diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b59666 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..fae246d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,82 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + version: + description: Exact semver to publish + required: true + type: string + dist_tag: + description: npm dist-tag (auto derives latest/rc/next) + required: true + default: auto + type: choice + options: [auto, latest, rc, next, preview] + +permissions: + contents: write + id-token: write + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - id: release + run: >- + node scripts/resolve-release.mjs + --version=${{ inputs.version }} + --tag=${{ inputs.dist_tag }} + --branch=${{ github.ref_name }} + - name: Verify npm authentication and unused version + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + if [ -n "${NODE_AUTH_TOKEN:-}" ]; then npm whoami; else npm ping; fi + if npm view "@rivet-dev/workflows@${{ steps.release.outputs.version }}" version >/dev/null 2>&1; then + echo "@rivet-dev/workflows@${{ steps.release.outputs.version }} already exists" >&2 + exit 1 + fi + - name: Set ephemeral package version + run: npm pkg set "version=${{ steps.release.outputs.version }}" --prefix packages/workflows + - run: pnpm run ci + - name: Pack verified release artifact + run: | + mkdir -p .pack + npm pack ./packages/workflows --silent --pack-destination .pack + - name: Publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: >- + npm publish + .pack/rivet-dev-workflows-${{ steps.release.outputs.version }}.tgz + --access public + --provenance + --tag ${{ steps.release.outputs.npm_tag }} + - name: Tag and release + if: steps.release.outputs.real_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + git tag "v$VERSION" + git push origin "v$VERSION" + gh release create "v$VERSION" --generate-notes --title "v$VERSION" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..232a025 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.artifacts/ +*.tgz + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7fe613 --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Rivet Gaming, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md index 2e7a9ce..2f572c7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,26 @@ # Rivet Workflows -Durable, replayable multi-step operations. +Durable, replayable multi-step operations for Rivet Actors. **[Documentation](https://rivet.dev/workflows/docs)** · **[Website](https://rivet.dev/workflows)** · **[Discord](https://rivet.dev/discord)** + +```sh +pnpm add @rivet-dev/workflows rivetkit +``` + +```ts +import { actor } from "rivetkit"; +import { workflow } from "@rivet-dev/workflows"; + +export const report = actor({ + run: workflow(async (ctx) => { + await ctx.step("generate", async (step) => { + step.log.info("generating report"); + }); + }), +}); +``` + +The package preserves the existing workflow history encoding and uses only +RivetKit's public workflow-host capabilities. RivetKit continues to own the +internal SQLite schema and its migrations. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b0825be --- /dev/null +++ b/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "files": { + "includes": ["**/*.{ts,js,mjs,json}", "!**/dist", "!**/node_modules"], + "ignoreUnknown": true + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "formatter": { + "enabled": true, + "useEditorconfig": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noUselessElse": "off", + "useNodejsImportProtocol": "error" + }, + "correctness": { + "noUnusedImports": "warn" + }, + "suspicious": { + "noExplicitAny": "off", + "noControlCharactersInRegex": "off" + }, + "performance": { + "useTopLevelRegex": "off" + }, + "nursery": { + "noFloatingPromises": "error", + "noMisusedPromises": "off" + } + } + } +} diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 100644 index 2d6ae04..0000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1,125 +0,0 @@ -# Docs Bundle CLAUDE.md - -Rules for the docs in this repo. These pages are **not** rendered here — they are -published on [rivet.dev](https://rivet.dev) by the -[rivet-website](https://github.com/rivet-dev/website) repo, which symlinks -this directory in. Everything below exists so a page written here renders -correctly there. - -## Layout - -``` -docs/ - sidebar.json navigation for the two tabs - content/ - docs/**.mdx -> /{product}/docs/... - tutorials/**.mdx -> /{product}/tutorials/... -``` - -The website links `docs/content` into its content collection, so **only real -pages belong under `content/`**. Anything else (scripts, fixtures, notes) goes -elsewhere in the repo or it will be published as a docs page. - -## Frontmatter - -Every page needs `title` and `description`. Both are used for SEO and the -sidebar falls back to `title` when a sidebar entry omits one. - -```mdx ---- -title: "In-Memory State" -description: "Actors store state in memory for instant reads and writes." ---- -``` - -## sidebar.json - -Navigation for this product's Documentation and Tutorials tabs. Icons travel as -Font Awesome **export names**, not objects, so this repo needs no dependency on -the website's icon package. - -```json -{ - "docs": [ - { "title": "General", "pages": [ - { "title": "Introduction", "href": "/actors/docs", "icon": "faSquareInfo" } - ]} - ], - "tutorials": [] -} -``` - -- `href` is the full site path, including the product segment. -- Adding a page to `content/` does not add it to the nav. Add it here too. -- The Self-Host tab is **not** in this file. It is generated by the website. - -## Code - -- **Never inline a fenced TypeScript block.** Real examples live in `examples/` - and are embedded with ``, so they are type-checked and cannot rot. - A snippet that fails to compile fails the website build. -- Snippet paths are relative to **this repo's root**, so the same path works both - here and on rivet.dev: - ```mdx - - ``` -- Embed part of a file with `region="name"`, delimited in the source by - `// docs:start name` / `// docs:end name`. -- Shell commands, YAML, Dockerfiles, and terminal output **may** be inline fenced - blocks. The no-inline rule exists for type checking, which only applies to - TypeScript. -- Every TypeScript snippet must include its imports and define everything it - references. Use `@nocheck` only for API that does not exist on this branch yet. -- Use `` for examples spanning multiple files, with each - file as its own ``. - -## What does not belong here - -- **Marketing pages.** They live in the website repo. -- **Deploy and self-hosting guides.** They are written once in the website repo - and templated across every product. Do not write a per-product copy. -- **Website components.** Do not import from the website by relative path or - alias; a page must render from the components the site already provides. - -## Terminology - -Applies to everything published on the website. - -- The service that routes, schedules, and persists is the **control plane**. - Never "engine", "server", or "orchestrator". -- A process running user code with the Rivet SDK is a **worker**. Never "envoy", - "runner", "node", "compute", or "data plane". -- **Never use "agent" as a deployment noun.** Rivet ships agentOS and Actors is - "where agents live"; the collision is unrecoverable. -- **"envoy" never appears in docs.** Envoy Proxy is a top-tier CNCF project. - Internal code keeps its own names. -- **"Rivet Compute" is retired.** Where prose must name the managed offering it - is **Rivet Cloud**, and it links to . -- Spell the product `agentOS`, never `AgentOS`. Capitalize **Rivet Actor** as a - proper noun, lowercase generic "actor". -- Always `rivet.dev`, never `rivet.gg`. - -## Writing - -- Write comments and prose as complete sentences. **Never use em dashes**; use - periods instead. -- Do not document deltas. A reader who never saw the old version gains nothing - from "this was renamed". - -## Previewing locally - -Clone the website next to this repo and run it. It detects the sibling -automatically and serves this directory's pages live: - -```sh -git clone https://github.com/rivet-dev/website -cd rivet-website && pnpm install && pnpm dev -``` - -`pnpm assemble` prints which checkout each product resolved to. To point at a -different checkout, repoint the symlink; it is gitignored and assemble leaves an -existing one alone: - -```sh -ln -sfn /path/to/this/repo/docs/content src/content/docs/ -``` diff --git a/docs/content/use-cases/index.mdx b/docs/content/use-cases/index.mdx deleted file mode 100644 index b68ed29..0000000 --- a/docs/content/use-cases/index.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: "Use Cases" -description: "What people build with Workflows." ---- - - -**TODO.** Overview of what Workflows is used for, one section per use case, each -linking to the guide that shows how to build it. - diff --git a/justfile b/justfile new file mode 100644 index 0000000..1568576 --- /dev/null +++ b/justfile @@ -0,0 +1,10 @@ +[group('release')] +release VERSION TAG='auto' REF='main': + gh workflow run .github/workflows/publish.yml \ + --ref "{{ REF }}" \ + --field version="{{ VERSION }}" \ + --field dist_tag="{{ TAG }}" + +[group('release')] +preview-publish VERSION REF: + just release "{{ VERSION }}" preview "{{ REF }}" diff --git a/package.json b/package.json new file mode 100644 index 0000000..42172c2 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "@rivet-dev/workflows-workspace", + "private": true, + "type": "module", + "packageManager": "pnpm@10.13.1", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "pnpm --filter @rivet-dev/workflows build", + "check-types": "pnpm --filter @rivet-dev/workflows check-types", + "test": "pnpm --filter @rivet-dev/workflows test", + "test:e2e": "pnpm --filter @rivet-dev/workflows test:e2e", + "lint": "biome lint .", + "format:check": "biome check packages/workflows/src/rivetkit packages/workflows/src/mod.ts packages/workflows/tests/rivetkit packages/workflows/tests/e2e packages/workflows/tests/compat packages/workflows/tests/fixtures packages/workflows/vitest.config.ts packages/workflows/vitest.e2e.config.ts scripts", + "check:boundaries": "tsx scripts/check-boundaries.ts", + "verify:pack": "tsx scripts/verify-pack.ts", + "ci": "pnpm lint && pnpm format:check && pnpm check:boundaries && pnpm check-types && pnpm test && pnpm test:e2e && pnpm build && pnpm verify:pack" + }, + "devDependencies": { + "@biomejs/biome": "2.3.11", + "tsx": "^4.20.6" + } +} diff --git a/packages/workflows/README.md b/packages/workflows/README.md new file mode 100644 index 0000000..30a83f7 --- /dev/null +++ b/packages/workflows/README.md @@ -0,0 +1,20 @@ +# @rivet-dev/workflows + +Durable, replayable workflows for Rivet Actors. + +[Documentation](https://rivet.dev/workflows/docs) + +```ts +import { actor } from "rivetkit"; +import { workflow } from "@rivet-dev/workflows"; + +export const example = actor({ + run: workflow(async (ctx) => { + await ctx.step("hello", async () => "world"); + }), +}); +``` + +The workflow storage format is owned and migrated by RivetKit. This package is +a format-compatible client and never creates or migrates RivetKit's internal +SQLite tables. diff --git a/packages/workflows/package.json b/packages/workflows/package.json new file mode 100644 index 0000000..662903e --- /dev/null +++ b/packages/workflows/package.json @@ -0,0 +1,76 @@ +{ + "name": "@rivet-dev/workflows", + "version": "2.3.10", + "description": "Durable, replayable workflows for Rivet Actors", + "license": "Apache-2.0", + "keywords": [ + "rivet", + "workflow", + "durable", + "reentrant", + "stateful" + ], + "files": [ + "dist", + "schemas", + "README.md", + "package.json" + ], + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/tsup/index.d.ts", + "default": "./dist/tsup/index.js" + }, + "require": { + "types": "./dist/tsup/index.d.cts", + "default": "./dist/tsup/index.cjs" + } + }, + "./testing": { + "import": { + "types": "./dist/tsup/testing.d.ts", + "default": "./dist/tsup/testing.js" + }, + "require": { + "types": "./dist/tsup/testing.d.cts", + "default": "./dist/tsup/testing.cjs" + } + } + }, + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "scripts": { + "build": "pnpm compile:bare && tsup", + "compile:bare": "tsx scripts/compile-bare.ts compile schemas/v1.bare -o dist/schemas/v1.ts", + "check-types": "pnpm compile:bare && tsc --noEmit", + "test": "pnpm compile:bare && vitest run", + "test:e2e": "pnpm compile:bare && vitest run --config vitest.e2e.config.ts", + "test:watch": "vitest" + }, + "dependencies": { + "@rivetkit/bare-ts": "^0.6.2", + "cbor-x": "^1.6.0", + "fdb-tuple": "^1.0.0", + "pino": "^9.6.0", + "vbare": "^0.0.4" + }, + "peerDependencies": { + "rivetkit": ">=2.4.0 <3" + }, + "devDependencies": { + "@bare-ts/tools": "^0.13.0", + "@types/node": "^22.13.1", + "commander": "^12.0.0", + "legacy-rivetkit": "npm:rivetkit@2.3.7", + "legacy-workflow-engine": "npm:@rivetkit/workflow-engine@2.3.7", + "rivetkit": "0.0.0-feat-workflows-public-host-apis.0ff6164", + "tsup": "^8.4.0", + "tsx": "^4.7.0", + "typescript": "^5.7.3", + "vitest": "^3.1.1" + } +} diff --git a/packages/workflows/schemas/serde.ts b/packages/workflows/schemas/serde.ts new file mode 100644 index 0000000..3566d9b --- /dev/null +++ b/packages/workflows/schemas/serde.ts @@ -0,0 +1,623 @@ +/** + * Serialization/deserialization utilities for converting between + * internal TypeScript types and BARE schema types. + */ + +import * as cbor from "cbor-x"; +import type * as v1 from "../dist/schemas/v1.js"; +import { + BranchStatusType as BareBranchStatusType, + EntryStatus as BareEntryStatus, + SleepState as BareSleepState, +} from "../dist/schemas/v1.js"; +import type { + BranchStatus as InternalBranchStatus, + BranchStatusType as InternalBranchStatusType, + Entry as InternalEntry, + EntryKind as InternalEntryKind, + EntryMetadata as InternalEntryMetadata, + EntryStatus as InternalEntryStatus, + Location as InternalLocation, + PathSegment as InternalPathSegment, + SleepState as InternalSleepState, + WorkflowState as InternalWorkflowState, +} from "../src/types.js"; +import { + CURRENT_VERSION, + ENTRY_METADATA_VERSIONED, + ENTRY_VERSIONED, +} from "./versioned.js"; + +// === Helper: ArrayBuffer to/from utilities === + +function bufferToArrayBuffer(buf: Uint8Array): ArrayBuffer { + // Create a new ArrayBuffer and copy the data to ensure it's not a SharedArrayBuffer + const arrayBuffer = new ArrayBuffer(buf.byteLength); + new Uint8Array(arrayBuffer).set(buf); + return arrayBuffer; +} + +function encodeCbor(value: unknown): ArrayBuffer { + return bufferToArrayBuffer(cbor.encode(value)); +} + +function decodeCbor(data: ArrayBuffer): T { + return cbor.decode(new Uint8Array(data)) as T; +} + +/** + * Validate that a value is a non-null object. + */ +function assertObject( + value: unknown, + context: string, +): asserts value is Record { + if (typeof value !== "object" || value === null) { + throw new Error(`${context}: expected object, got ${typeof value}`); + } +} + +/** + * Validate that a value is a string. + */ +function assertString( + value: unknown, + context: string, +): asserts value is string { + if (typeof value !== "string") { + throw new Error(`${context}: expected string, got ${typeof value}`); + } +} + +/** + * Validate that a value is a number. + */ +function _assertNumber( + value: unknown, + context: string, +): asserts value is number { + if (typeof value !== "number") { + throw new Error(`${context}: expected number, got ${typeof value}`); + } +} + +// === Entry Status Conversion === + +function entryStatusToBare(status: InternalEntryStatus): BareEntryStatus { + switch (status) { + case "pending": + return BareEntryStatus.PENDING; + case "running": + return BareEntryStatus.RUNNING; + case "completed": + return BareEntryStatus.COMPLETED; + case "failed": + return BareEntryStatus.FAILED; + case "exhausted": + return BareEntryStatus.EXHAUSTED; + } +} + +function entryStatusFromBare(status: BareEntryStatus): InternalEntryStatus { + switch (status) { + case BareEntryStatus.PENDING: + return "pending"; + case BareEntryStatus.RUNNING: + return "running"; + case BareEntryStatus.COMPLETED: + return "completed"; + case BareEntryStatus.FAILED: + return "failed"; + case BareEntryStatus.EXHAUSTED: + return "exhausted"; + } +} + +// === Sleep State Conversion === + +function sleepStateToBare(state: InternalSleepState): BareSleepState { + switch (state) { + case "pending": + return BareSleepState.PENDING; + case "completed": + return BareSleepState.COMPLETED; + case "interrupted": + return BareSleepState.INTERRUPTED; + } +} + +function sleepStateFromBare(state: BareSleepState): InternalSleepState { + switch (state) { + case BareSleepState.PENDING: + return "pending"; + case BareSleepState.COMPLETED: + return "completed"; + case BareSleepState.INTERRUPTED: + return "interrupted"; + } +} + +// === Branch Status Type Conversion === + +function branchStatusTypeToBare( + status: InternalBranchStatusType, +): BareBranchStatusType { + switch (status) { + case "pending": + return BareBranchStatusType.PENDING; + case "running": + return BareBranchStatusType.RUNNING; + case "completed": + return BareBranchStatusType.COMPLETED; + case "failed": + return BareBranchStatusType.FAILED; + case "cancelled": + return BareBranchStatusType.CANCELLED; + } +} + +function branchStatusTypeFromBare( + status: BareBranchStatusType, +): InternalBranchStatusType { + switch (status) { + case BareBranchStatusType.PENDING: + return "pending"; + case BareBranchStatusType.RUNNING: + return "running"; + case BareBranchStatusType.COMPLETED: + return "completed"; + case BareBranchStatusType.FAILED: + return "failed"; + case BareBranchStatusType.CANCELLED: + return "cancelled"; + } +} + +// === Location Conversion === + +function locationToBare(location: InternalLocation): v1.Location { + return location.map((segment): v1.PathSegment => { + if (typeof segment === "number") { + return { tag: "NameIndex", val: segment }; + } + return { + tag: "LoopIterationMarker", + val: { + loop: segment.loop, + iteration: segment.iteration, + }, + }; + }); +} + +function locationFromBare(location: v1.Location): InternalLocation { + return location.map((segment): InternalPathSegment => { + if (segment.tag === "NameIndex") { + return segment.val; + } + return { + loop: segment.val.loop, + iteration: segment.val.iteration, + }; + }); +} + +// === Branch Status Conversion === + +function branchStatusToBare(status: InternalBranchStatus): v1.BranchStatus { + return { + status: branchStatusTypeToBare(status.status), + output: status.output !== undefined ? encodeCbor(status.output) : null, + error: status.error ?? null, + }; +} + +function branchStatusFromBare(status: v1.BranchStatus): InternalBranchStatus { + return { + status: branchStatusTypeFromBare(status.status), + output: status.output !== null ? decodeCbor(status.output) : undefined, + error: status.error ?? undefined, + }; +} + +// === Entry Kind Conversion === + +function entryKindToBare(kind: InternalEntryKind): v1.EntryKind { + switch (kind.type) { + case "step": + return { + tag: "StepEntry", + val: { + output: + kind.data.output !== undefined + ? encodeCbor(kind.data.output) + : null, + error: kind.data.error ?? null, + }, + }; + case "loop": + return { + tag: "LoopEntry", + val: { + state: encodeCbor(kind.data.state), + iteration: kind.data.iteration, + output: + kind.data.output !== undefined + ? encodeCbor(kind.data.output) + : null, + }, + }; + case "sleep": + return { + tag: "SleepEntry", + val: { + deadline: BigInt(kind.data.deadline), + state: sleepStateToBare(kind.data.state), + }, + }; + case "message": + return { + tag: "MessageEntry", + val: { + name: kind.data.name, + messageData: encodeCbor(kind.data.data), + }, + }; + case "rollback_checkpoint": + return { + tag: "RollbackCheckpointEntry", + val: { + name: kind.data.name, + }, + }; + case "join": + return { + tag: "JoinEntry", + val: { + branches: new Map( + Object.entries(kind.data.branches).map( + ([name, status]) => [ + name, + branchStatusToBare(status), + ], + ), + ), + }, + }; + case "race": + return { + tag: "RaceEntry", + val: { + winner: kind.data.winner, + branches: new Map( + Object.entries(kind.data.branches).map( + ([name, status]) => [ + name, + branchStatusToBare(status), + ], + ), + ), + }, + }; + case "removed": + return { + tag: "RemovedEntry", + val: { + originalType: kind.data.originalType, + originalName: kind.data.originalName ?? null, + }, + }; + case "version_check": + return { + tag: "VersionCheckEntry", + val: { + resolved: kind.data.resolved, + latest: kind.data.latest, + }, + }; + } +} + +function entryKindFromBare(kind: v1.EntryKind): InternalEntryKind { + switch (kind.tag) { + case "StepEntry": + return { + type: "step", + data: { + output: + kind.val.output !== null + ? decodeCbor(kind.val.output) + : undefined, + error: kind.val.error ?? undefined, + }, + }; + case "LoopEntry": + return { + type: "loop", + data: { + state: decodeCbor(kind.val.state), + iteration: kind.val.iteration, + output: + kind.val.output !== null + ? decodeCbor(kind.val.output) + : undefined, + }, + }; + case "SleepEntry": + return { + type: "sleep", + data: { + deadline: Number(kind.val.deadline), + state: sleepStateFromBare(kind.val.state), + }, + }; + case "MessageEntry": + return { + type: "message", + data: { + name: kind.val.name, + data: decodeCbor(kind.val.messageData), + }, + }; + case "RollbackCheckpointEntry": + return { + type: "rollback_checkpoint", + data: { + name: kind.val.name, + }, + }; + case "JoinEntry": + return { + type: "join", + data: { + branches: Object.fromEntries( + Array.from(kind.val.branches.entries()).map( + ([name, status]) => [ + name, + branchStatusFromBare(status), + ], + ), + ), + }, + }; + case "RaceEntry": + return { + type: "race", + data: { + winner: kind.val.winner, + branches: Object.fromEntries( + Array.from(kind.val.branches.entries()).map( + ([name, status]) => [ + name, + branchStatusFromBare(status), + ], + ), + ), + }, + }; + case "RemovedEntry": + return { + type: "removed", + data: { + originalType: kind.val + .originalType as InternalEntryKind["type"], + originalName: kind.val.originalName ?? undefined, + }, + }; + case "VersionCheckEntry": + return { + type: "version_check", + data: { + resolved: kind.val.resolved, + latest: kind.val.latest, + }, + }; + default: + throw new Error( + `Unknown entry kind: ${(kind as { tag: string }).tag}`, + ); + } +} + +// === Entry Conversion & Serialization === + +function entryToBare(entry: InternalEntry): v1.Entry { + return { + id: entry.id, + location: locationToBare(entry.location), + kind: entryKindToBare(entry.kind), + }; +} + +function entryFromBare(bareEntry: v1.Entry): InternalEntry { + return { + id: bareEntry.id, + location: locationFromBare(bareEntry.location), + kind: entryKindFromBare(bareEntry.kind), + dirty: false, + }; +} + +export function serializeEntry(entry: InternalEntry): Uint8Array { + const bareEntry = entryToBare(entry); + return ENTRY_VERSIONED.serializeWithEmbeddedVersion( + bareEntry, + CURRENT_VERSION, + ); +} + +export function deserializeEntry(bytes: Uint8Array): InternalEntry { + const bareEntry = ENTRY_VERSIONED.deserializeWithEmbeddedVersion(bytes); + return entryFromBare(bareEntry); +} + +// === Entry Metadata Conversion & Serialization === + +function entryMetadataToBare( + metadata: InternalEntryMetadata, +): v1.EntryMetadata { + return { + status: entryStatusToBare(metadata.status), + error: metadata.error ?? null, + attempts: metadata.attempts, + lastAttemptAt: BigInt(metadata.lastAttemptAt), + createdAt: BigInt(metadata.createdAt), + completedAt: + metadata.completedAt !== undefined + ? BigInt(metadata.completedAt) + : null, + rollbackCompletedAt: + metadata.rollbackCompletedAt !== undefined + ? BigInt(metadata.rollbackCompletedAt) + : null, + rollbackError: metadata.rollbackError ?? null, + }; +} + +function entryMetadataFromBare( + bareMetadata: v1.EntryMetadata, +): InternalEntryMetadata { + return { + status: entryStatusFromBare(bareMetadata.status), + error: bareMetadata.error ?? undefined, + attempts: bareMetadata.attempts, + lastAttemptAt: Number(bareMetadata.lastAttemptAt), + createdAt: Number(bareMetadata.createdAt), + completedAt: + bareMetadata.completedAt !== null + ? Number(bareMetadata.completedAt) + : undefined, + rollbackCompletedAt: + bareMetadata.rollbackCompletedAt !== null + ? Number(bareMetadata.rollbackCompletedAt) + : undefined, + rollbackError: bareMetadata.rollbackError ?? undefined, + dirty: false, + }; +} + +export function serializeEntryMetadata( + metadata: InternalEntryMetadata, +): Uint8Array { + const bareMetadata = entryMetadataToBare(metadata); + return ENTRY_METADATA_VERSIONED.serializeWithEmbeddedVersion( + bareMetadata, + CURRENT_VERSION, + ); +} + +export function deserializeEntryMetadata( + bytes: Uint8Array, +): InternalEntryMetadata { + const bareMetadata = + ENTRY_METADATA_VERSIONED.deserializeWithEmbeddedVersion(bytes); + return entryMetadataFromBare(bareMetadata); +} + +// === Workflow Metadata Serialization === +// Note: These are used for reading/writing individual workflow fields + +export function serializeWorkflowState( + state: InternalWorkflowState, +): Uint8Array { + // For simple values, we can encode them directly without the full metadata struct + // Using a single byte for efficiency + const encoder = new TextEncoder(); + return encoder.encode(state); +} + +export function deserializeWorkflowState( + bytes: Uint8Array, +): InternalWorkflowState { + const decoder = new TextDecoder(); + const state = decoder.decode(bytes) as InternalWorkflowState; + const validStates: InternalWorkflowState[] = [ + "pending", + "running", + "sleeping", + "failed", + "completed", + "cancelled", + "rolling_back", + ]; + if (!validStates.includes(state)) { + throw new Error(`Invalid workflow state: ${state}`); + } + return state; +} + +export function serializeWorkflowOutput(output: unknown): Uint8Array { + return cbor.encode(output); +} + +export function deserializeWorkflowOutput(bytes: Uint8Array): T { + try { + return cbor.decode(bytes) as T; + } catch (error) { + throw new Error( + `Failed to deserialize workflow output: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Structured error type for serialization. + */ +interface SerializedWorkflowError { + name: string; + message: string; + stack?: string; + metadata?: Record; +} + +export function serializeWorkflowError( + error: SerializedWorkflowError, +): Uint8Array { + return cbor.encode(error); +} + +export function deserializeWorkflowError( + bytes: Uint8Array, +): SerializedWorkflowError { + const decoded = cbor.decode(bytes); + assertObject(decoded, "WorkflowError"); + // Validate required fields + const obj = decoded as Record; + assertString(obj.name, "WorkflowError.name"); + assertString(obj.message, "WorkflowError.message"); + return { + name: obj.name, + message: obj.message, + stack: typeof obj.stack === "string" ? obj.stack : undefined, + metadata: + typeof obj.metadata === "object" && obj.metadata !== null + ? (obj.metadata as Record) + : undefined, + }; +} + +export function serializeWorkflowInput(input: unknown): Uint8Array { + return cbor.encode(input); +} + +export function deserializeWorkflowInput(bytes: Uint8Array): T { + try { + return cbor.decode(bytes) as T; + } catch (error) { + throw new Error( + `Failed to deserialize workflow input: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +// === Name Registry Serialization === + +export function serializeName(name: string): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(name); +} + +export function deserializeName(bytes: Uint8Array): string { + const decoder = new TextDecoder(); + return decoder.decode(bytes); +} diff --git a/packages/workflows/schemas/v1.bare b/packages/workflows/schemas/v1.bare new file mode 100644 index 0000000..d319437 --- /dev/null +++ b/packages/workflows/schemas/v1.bare @@ -0,0 +1,212 @@ +# Workflow Engine BARE Schema v1 +# +# This schema defines the binary encoding for workflow engine persistence. +# Types marked with `data` are arbitrary binary blobs (for user-provided data). + +# Opaque user data (CBOR-encoded) +type Cbor data + +# MARK: Location +# Index into the entry name registry +type NameIndex u32 + +# Marker for a loop iteration in a location path +type LoopIterationMarker struct { + loop: NameIndex + iteration: u32 +} + +# A segment in a location path - either a name index or a loop iteration marker +type PathSegment union { + NameIndex | + LoopIterationMarker +} + +# Location identifies where an entry exists in the workflow execution tree +type Location list + +# MARK: Entry Status +type EntryStatus enum { + PENDING + RUNNING + COMPLETED + FAILED + EXHAUSTED +} + +# MARK: Sleep State +type SleepState enum { + PENDING + COMPLETED + INTERRUPTED +} + +# MARK: Branch Status +type BranchStatusType enum { + PENDING + RUNNING + COMPLETED + FAILED + CANCELLED +} + +# MARK: Step Entry +type StepEntry struct { + # Output value (CBOR-encoded arbitrary data) + output: optional + # Error message if step failed + error: optional +} + +# MARK: Loop Entry +type LoopEntry struct { + # Loop state (CBOR-encoded arbitrary data) + state: Cbor + # Current iteration number + iteration: u32 + # Output value if loop completed (CBOR-encoded arbitrary data) + output: optional +} + +# MARK: Sleep Entry +type SleepEntry struct { + # Deadline timestamp in milliseconds + deadline: u64 + # Current sleep state + state: SleepState +} + +# MARK: Message Entry + type MessageEntry struct { + # Message name + name: str + # Message data (CBOR-encoded arbitrary data) + messageData: Cbor + } + + # MARK: Rollback Checkpoint Entry + type RollbackCheckpointEntry struct { + # Checkpoint name + name: str + } + + # MARK: Branch Status + +type BranchStatus struct { + status: BranchStatusType + # Output value if completed (CBOR-encoded arbitrary data) + output: optional + # Error message if failed + error: optional +} + +# MARK: Join Entry +type JoinEntry struct { + # Map of branch name to status + branches: map +} + +# MARK: Race Entry +type RaceEntry struct { + # Name of the winning branch, or null if no winner yet + winner: optional + # Map of branch name to status + branches: map +} + +# MARK: Removed Entry +type RemovedEntry struct { + # Original entry type before removal + originalType: str + # Original entry name + originalName: optional +} + +# MARK: Version Check Entry +type VersionCheckEntry struct { + # The version this instance resolved to at this location + resolved: u32 + # The `latest` value seen when first resolved (diagnostics) + latest: u32 +} + +# MARK: Entry Kind +# Type-specific entry data +type EntryKind union { + StepEntry | + LoopEntry | + SleepEntry | + MessageEntry | + RollbackCheckpointEntry | + JoinEntry | + RaceEntry | + RemovedEntry | + VersionCheckEntry +} + +# MARK: Entry +# An entry in the workflow history +type Entry struct { + # Unique entry ID + id: str + # Location in the workflow tree + location: Location + # Entry kind and data + kind: EntryKind +} + +# MARK: Entry Metadata +# Metadata for an entry (stored separately, lazily loaded) +type EntryMetadata struct { + status: EntryStatus + # Error message if failed + error: optional + # Number of execution attempts + attempts: u32 + # Last attempt timestamp in milliseconds + lastAttemptAt: u64 + # Creation timestamp in milliseconds + createdAt: u64 + # Completion timestamp in milliseconds + completedAt: optional + # Rollback completion timestamp in milliseconds + rollbackCompletedAt: optional + # Rollback error message if failed + rollbackError: optional +} + +# MARK: Message +# A message in the queue +type Message struct { + # Unique message ID (used as KV key) + id: str + # Message name + name: str + # Message data (CBOR-encoded arbitrary data) + messageData: Cbor + # Timestamp when message was sent in milliseconds + sentAt: u64 +} + +# MARK: Workflow State +type WorkflowState enum { + PENDING + RUNNING + SLEEPING + FAILED + COMPLETED + ROLLING_BACK +} + +# MARK: Workflow Metadata +# Workflow-level metadata stored separately from entries +type WorkflowMetadata struct { + # Current workflow state + state: WorkflowState + # Workflow output if completed (CBOR-encoded arbitrary data) + output: optional + # Error message if failed + error: optional + # Workflow version hash for migration detection + version: optional +} diff --git a/packages/workflows/schemas/versioned.ts b/packages/workflows/schemas/versioned.ts new file mode 100644 index 0000000..f1698ad --- /dev/null +++ b/packages/workflows/schemas/versioned.ts @@ -0,0 +1,108 @@ +import { createVersionedDataHandler } from "vbare"; +import * as v1 from "../dist/schemas/v1.js"; + +export const CURRENT_VERSION = 1; + +// Re-export generated types for convenience +export type { + BranchStatus, + Entry, + EntryKind, + EntryMetadata, + JoinEntry, + Location, + LoopEntry, + LoopIterationMarker, + MessageEntry, + PathSegment, + RaceEntry, + RemovedEntry, + SleepEntry, + StepEntry, + VersionCheckEntry, + WorkflowMetadata, +} from "../dist/schemas/v1.js"; + +export { + BranchStatusType, + EntryStatus, + SleepState, + WorkflowState, +} from "../dist/schemas/v1.js"; + +// === Entry Handler === + +export const ENTRY_VERSIONED = createVersionedDataHandler({ + deserializeVersion: (bytes, version) => { + switch (version) { + case 1: + return v1.decodeEntry(bytes); + default: + throw new Error(`Unknown Entry version ${version}`); + } + }, + serializeVersion: (data, version) => { + switch (version) { + case 1: + return v1.encodeEntry(data as v1.Entry); + default: + throw new Error(`Unknown Entry version ${version}`); + } + }, + deserializeConverters: () => [], + serializeConverters: () => [], +}); + +// === Entry Metadata Handler === + +export const ENTRY_METADATA_VERSIONED = + createVersionedDataHandler({ + deserializeVersion: (bytes, version) => { + switch (version) { + case 1: + return v1.decodeEntryMetadata(bytes); + default: + throw new Error(`Unknown EntryMetadata version ${version}`); + } + }, + serializeVersion: (data, version) => { + switch (version) { + case 1: + return v1.encodeEntryMetadata(data as v1.EntryMetadata); + default: + throw new Error(`Unknown EntryMetadata version ${version}`); + } + }, + deserializeConverters: () => [], + serializeConverters: () => [], + }); + +// === Workflow Metadata Handler === + +export const WORKFLOW_METADATA_VERSIONED = + createVersionedDataHandler({ + deserializeVersion: (bytes, version) => { + switch (version) { + case 1: + return v1.decodeWorkflowMetadata(bytes); + default: + throw new Error( + `Unknown WorkflowMetadata version ${version}`, + ); + } + }, + serializeVersion: (data, version) => { + switch (version) { + case 1: + return v1.encodeWorkflowMetadata( + data as v1.WorkflowMetadata, + ); + default: + throw new Error( + `Unknown WorkflowMetadata version ${version}`, + ); + } + }, + deserializeConverters: () => [], + serializeConverters: () => [], + }); diff --git a/packages/workflows/scripts/compile-bare.ts b/packages/workflows/scripts/compile-bare.ts new file mode 100644 index 0000000..993a0f5 --- /dev/null +++ b/packages/workflows/scripts/compile-bare.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env -S tsx + +/** + * BARE schema compiler for TypeScript + * + * This script compiles .bare schema files to TypeScript using @bare-ts/tools, + * then post-processes the output to: + * 1. Replace @bare-ts/lib import with @rivetkit/bare-ts + * 2. Replace Node.js assert import with a custom assert function + * + * IMPORTANT: Keep the post-processing logic in sync with: + * engine/packages/runner-protocol/build.rs + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { type Config, transform } from "@bare-ts/tools"; +import { Command } from "commander"; + +const program = new Command(); + +program + .name("bare-compiler") + .description("Compile BARE schemas to TypeScript") + .version("0.0.1"); + +program + .command("compile") + .description("Compile a BARE schema file") + .argument("", "Input BARE schema file") + .option("-o, --output ", "Output file path") + .option("--pedantic", "Enable pedantic mode", false) + .option("--generator ", "Generator type (ts, js, dts, bare)", "ts") + .action(async (input: string, options) => { + try { + const schemaPath = path.resolve(input); + const outputPath = options.output + ? path.resolve(options.output) + : schemaPath.replace(/\.bare$/, ".ts"); + + await compileSchema({ + schemaPath, + outputPath, + config: { + pedantic: options.pedantic, + generator: options.generator, + }, + }); + + console.log(`Successfully compiled ${input} to ${outputPath}`); + } catch (error) { + console.error("Failed to compile schema:", error); + process.exit(1); + } + }); + +program.parse(); + +export interface CompileOptions { + schemaPath: string; + outputPath: string; + config?: Partial; +} + +export async function compileSchema(options: CompileOptions): Promise { + const { schemaPath, outputPath, config = {} } = options; + + const schema = await fs.readFile(schemaPath, "utf-8"); + const outputDir = path.dirname(outputPath); + + await fs.mkdir(outputDir, { recursive: true }); + + const defaultConfig: Partial = { + pedantic: true, + generator: "ts", + ...config, + }; + + let result = transform(schema, defaultConfig); + + result = postProcess(result); + + await fs.writeFile(outputPath, result); +} + +const POST_PROCESS_MARKER = + "// @generated - post-processed by compile-bare.ts\n"; + +const ASSERT_FUNCTION = ` +function assert(condition: boolean, message?: string): asserts condition { + if (!condition) throw new Error(message ?? "Assertion failed") +} +`; + +/** + * Post-process the generated TypeScript file to: + * 1. Replace @bare-ts/lib import with @rivetkit/bare-ts + * 2. Replace Node.js assert import with a custom assert function + * + * IMPORTANT: Keep this in sync with engine/packages/runner-protocol/build.rs + */ +function postProcess(code: string): string { + // Skip if already post-processed + if (code.startsWith(POST_PROCESS_MARKER)) { + return code; + } + + // Replace @bare-ts/lib with @rivetkit/bare-ts + code = code.replace(/@bare-ts\/lib/g, "@rivetkit/bare-ts"); + + // Remove Node.js assert import + code = code.replace(/^import assert from "assert"/m, ""); + + // Add marker and assert function + code = `${POST_PROCESS_MARKER + code}\n${ASSERT_FUNCTION}`; + + // Validate post-processing succeeded + if (code.includes("@bare-ts/lib")) { + throw new Error("Failed to replace @bare-ts/lib import"); + } + if (code.includes("import assert from")) { + throw new Error("Failed to remove Node.js assert import"); + } + + return code; +} + +export type { Config } from "@bare-ts/tools"; diff --git a/packages/workflows/src/context.ts b/packages/workflows/src/context.ts new file mode 100644 index 0000000..dc0dc21 --- /dev/null +++ b/packages/workflows/src/context.ts @@ -0,0 +1,2680 @@ +import type { Logger } from "pino"; +import type { EngineDriver } from "./driver.js"; +import { + extractErrorInfo, + getErrorEventTag, + markErrorReported, +} from "./error-utils.js"; +import { + CancelledError, + CriticalError, + EntryInProgressError, + EvictedError, + HistoryDivergedError, + JoinError, + MessageWaitError, + RaceError, + RollbackCheckpointError, + RollbackError, + RollbackStopError, + SleepError, + StepExhaustedError, + StepFailedError, +} from "./errors.js"; +import { buildEntryMetadataKey, buildLoopIterationRange } from "./keys.js"; +import { + appendLoopIteration, + appendName, + emptyLocation, + isLocationPrefix, + locationToKey, + registerName, +} from "./location.js"; +import { + createEntry, + deleteEntriesWithPrefix, + flush, + getOrCreateMetadata, + loadMetadata, + type PendingDeletions, + setEntry, +} from "./storage.js"; +import type { + BranchConfig, + BranchOutput, + Entry, + EntryKindType, + EntryMetadata, + Location, + LoopConfig, + LoopIterationResult, + LoopResult, + Message, + RollbackContextInterface, + StepConfig, + Storage, + TryBlockCatchKind, + TryBlockConfig, + TryBlockFailure, + TryBlockResult, + TryStepCatchKind, + TryStepConfig, + TryStepFailure, + TryStepResult, + WorkflowContextInterface, + WorkflowError, + WorkflowErrorEvent, + WorkflowErrorHandler, + WorkflowMessageDriver, + WorkflowQueue, + WorkflowQueueMessage, + WorkflowQueueNextBatchOptions, + WorkflowQueueNextOptions, +} from "./types.js"; + +/** + * Default values for step configuration. + * These are exported so users can reference them when overriding. + */ +export const DEFAULT_MAX_RETRIES = 3; +export const DEFAULT_RETRY_BACKOFF_BASE = 100; +export const DEFAULT_RETRY_BACKOFF_MAX = 30000; +export const DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL = 20; +export const DEFAULT_STEP_TIMEOUT = 30000; // 30 seconds +const DEFAULT_TRY_STEP_CATCH: readonly TryStepCatchKind[] = [ + "critical", + "timeout", + "exhausted", +]; +const DEFAULT_TRY_BLOCK_CATCH: readonly TryBlockCatchKind[] = [ + "step", + "join", + "race", +]; + +const QUEUE_HISTORY_MESSAGE_MARKER = "__rivetWorkflowQueueMessage"; +const TRY_STEP_FAILURE_SYMBOL = Symbol("workflow.try-step.failure"); +const TRY_BLOCK_FAILURE_SYMBOL = Symbol("workflow.try-block.failure"); + +/** + * Calculate backoff delay with exponential backoff. + * Uses deterministic calculation (no jitter) for replay consistency. + */ +function calculateBackoff(attempts: number, base: number, max: number): number { + // Exponential backoff without jitter for determinism + return Math.min(max, base * 2 ** attempts); +} + +/** + * Error thrown when a step times out. + */ +export class StepTimeoutError extends Error { + constructor( + public readonly stepName: string, + public readonly timeoutMs: number, + ) { + super(`Step "${stepName}" timed out after ${timeoutMs}ms`); + this.name = "StepTimeoutError"; + } +} + +type SchedulerYieldState = { + deadline?: number; + messageNames: Set; +}; + +type TryBlockFailureInfo = Pick; + +function attachTryStepFailure( + error: T, + failure: TryStepFailure, +): T { + ( + error as T & { + [TRY_STEP_FAILURE_SYMBOL]?: TryStepFailure; + } + )[TRY_STEP_FAILURE_SYMBOL] = failure; + return error; +} + +function readTryStepFailure(error: unknown): TryStepFailure | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + return ( + error as Error & { + [TRY_STEP_FAILURE_SYMBOL]?: TryStepFailure; + } + )[TRY_STEP_FAILURE_SYMBOL]; +} + +function attachTryBlockFailure( + error: T, + failure: TryBlockFailureInfo, +): T { + ( + error as T & { + [TRY_BLOCK_FAILURE_SYMBOL]?: TryBlockFailureInfo; + } + )[TRY_BLOCK_FAILURE_SYMBOL] = failure; + return error; +} + +function readTryBlockFailure(error: unknown): TryBlockFailureInfo | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + return ( + error as Error & { + [TRY_BLOCK_FAILURE_SYMBOL]?: TryBlockFailureInfo; + } + )[TRY_BLOCK_FAILURE_SYMBOL]; +} + +function shouldRethrowTryError(error: unknown): boolean { + return ( + error instanceof StepFailedError || + error instanceof SleepError || + error instanceof MessageWaitError || + error instanceof EvictedError || + error instanceof HistoryDivergedError || + error instanceof EntryInProgressError || + error instanceof RollbackCheckpointError || + error instanceof RollbackStopError + ); +} + +function shouldCatchTryStepFailure( + failure: TryStepFailure, + catchKinds?: readonly TryStepCatchKind[], +): boolean { + const effectiveCatch = catchKinds ?? DEFAULT_TRY_STEP_CATCH; + return effectiveCatch.includes(failure.kind); +} + +function shouldCatchTryBlockFailure( + failure: TryBlockFailure, + catchKinds?: readonly TryBlockCatchKind[], +): boolean { + const effectiveCatch = catchKinds ?? DEFAULT_TRY_BLOCK_CATCH; + + if (failure.source === "step") { + return failure.step?.kind === "rollback" + ? effectiveCatch.includes("rollback") + : effectiveCatch.includes("step"); + } + if (failure.source === "join") { + return effectiveCatch.includes("join"); + } + if (failure.source === "race") { + return effectiveCatch.includes("race"); + } + return effectiveCatch.includes("rollback"); +} + +function parseStoredWorkflowError(message: string | undefined): WorkflowError { + if (!message) { + return { + name: "Error", + message: "unknown error", + }; + } + + const match = /^([^:]+):\s*(.*)$/s.exec(message); + if (!match) { + return { + name: "Error", + message, + }; + } + + return { + name: match[1], + message: match[2], + }; +} + +function getTryStepFailureFromExhaustedError( + stepName: string, + attempts: number, + error: StepExhaustedError, +): TryStepFailure { + return { + kind: "exhausted", + stepName, + attempts, + error: parseStoredWorkflowError(error.lastError), + }; +} + +function mergeSchedulerYield( + state: SchedulerYieldState | undefined, + error: SleepError | MessageWaitError | StepFailedError, +): SchedulerYieldState { + const nextState: SchedulerYieldState = state ?? { + messageNames: new Set(), + }; + + if (error instanceof SleepError) { + nextState.deadline = + nextState.deadline === undefined + ? error.deadline + : Math.min(nextState.deadline, error.deadline); + for (const messageName of error.messageNames ?? []) { + nextState.messageNames.add(messageName); + } + return nextState; + } + + if (error instanceof MessageWaitError) { + for (const messageName of error.messageNames) { + nextState.messageNames.add(messageName); + } + return nextState; + } + + nextState.deadline = + nextState.deadline === undefined + ? error.retryAt + : Math.min(nextState.deadline, error.retryAt); + return nextState; +} + +function buildSchedulerYieldError( + state: SchedulerYieldState, +): SleepError | MessageWaitError { + const messageNames = [...state.messageNames]; + if (state.deadline !== undefined) { + return new SleepError( + state.deadline, + messageNames.length > 0 ? messageNames : undefined, + ); + } + return new MessageWaitError(messageNames); +} + +function controlFlowErrorPriority(error: Error): number { + if (error instanceof EvictedError) { + return 0; + } + if (error instanceof HistoryDivergedError) { + return 1; + } + if (error instanceof EntryInProgressError) { + return 2; + } + if (error instanceof RollbackCheckpointError) { + return 3; + } + if (error instanceof RollbackStopError) { + return 4; + } + return 5; +} + +function selectControlFlowError( + current: Error | undefined, + candidate: Error, +): Error { + if (!current) { + return candidate; + } + return controlFlowErrorPriority(candidate) < + controlFlowErrorPriority(current) + ? candidate + : current; +} + +/** + * Internal representation of a rollback handler. + */ +export interface RollbackAction { + entryId: string; + name: string; + output: T; + rollback: (ctx: RollbackContextInterface, output: T) => Promise; +} + +/** + * Internal implementation of WorkflowContext. + */ +export class WorkflowContextImpl implements WorkflowContextInterface { + private entryInProgress = false; + private abortController: AbortController; + private currentLocation: Location; + private visitedKeys: Set; + private mode: "forward" | "rollback"; + private rollbackActions?: RollbackAction[]; + private rollbackCheckpointSet: boolean; + /** Track names used in current execution to detect duplicates */ + private usedNamesInExecution = new Set(); + private pendingCompletableMessageIds = new Set(); + private historyNotifier?: () => void; + private onError?: WorkflowErrorHandler; + private logger?: Logger; + + constructor( + public readonly workflowId: string, + private storage: Storage, + private driver: EngineDriver, + private messageDriver: WorkflowMessageDriver, + location: Location = emptyLocation(), + abortController?: AbortController, + mode: "forward" | "rollback" = "forward", + rollbackActions?: RollbackAction[], + rollbackCheckpointSet = false, + historyNotifier?: () => void, + onError?: WorkflowErrorHandler, + logger?: Logger, + visitedKeys?: Set, + ) { + this.currentLocation = location; + this.abortController = abortController ?? new AbortController(); + this.mode = mode; + this.rollbackActions = rollbackActions; + this.rollbackCheckpointSet = rollbackCheckpointSet; + this.historyNotifier = historyNotifier; + this.onError = onError; + this.logger = logger; + this.visitedKeys = visitedKeys ?? new Set(); + } + + get abortSignal(): AbortSignal { + return this.abortController.signal; + } + + get queue(): WorkflowQueue { + return { + next: async (name, opts) => await this.queueNext(name, opts), + nextBatch: async (name, opts) => + await this.queueNextBatch(name, opts), + send: async (name, body) => await this.queueSend(name, body), + }; + } + + isEvicted(): boolean { + return this.abortSignal.aborted; + } + + private assertNotInProgress(): void { + if (this.entryInProgress) { + throw new EntryInProgressError(); + } + } + + private checkEvicted(): void { + if (this.abortSignal.aborted) { + throw new EvictedError(); + } + } + + private async flushStorage(): Promise { + await flush(this.storage, this.driver, this.historyNotifier); + } + + /** + * Create a new branch context for parallel/nested execution. + */ + createBranch( + location: Location, + abortController?: AbortController, + ): WorkflowContextImpl { + return new WorkflowContextImpl( + this.workflowId, + this.storage, + this.driver, + this.messageDriver, + location, + abortController ?? this.abortController, + this.mode, + this.rollbackActions, + this.rollbackCheckpointSet, + this.historyNotifier, + this.onError, + this.logger, + this.visitedKeys, + ); + } + + /** + * Log a debug message using the configured logger. + */ + private log( + level: "debug" | "info" | "warn" | "error", + data: Record, + ): void { + if (!this.logger) return; + this.logger[level](data); + } + + private async notifyError(event: WorkflowErrorEvent): Promise { + if (!this.onError) { + return; + } + + try { + await this.onError(event); + } catch (error) { + this.log("warn", { + msg: "workflow error hook failed", + hookEventType: getErrorEventTag(event), + error: extractErrorInfo(error), + }); + } + } + + private async notifyStepError( + config: StepConfig, + attempt: number, + error: unknown, + opts: { + willRetry: boolean; + retryDelay?: number; + retryAt?: number; + }, + ): Promise { + const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES; + await this.notifyError({ + step: { + workflowId: this.workflowId, + stepName: config.name, + attempt, + maxRetries, + remainingRetries: Math.max(0, maxRetries - (attempt - 1)), + willRetry: opts.willRetry, + retryDelay: opts.retryDelay, + retryAt: opts.retryAt, + error: extractErrorInfo(error), + }, + }); + } + + /** + * Mark a key as visited. + */ + private markVisited(key: string): void { + this.visitedKeys.add(key); + } + + /** + * Check if a name has already been used at the current location in this execution. + * Throws HistoryDivergedError if duplicate detected. + */ + private checkDuplicateName(name: string): void { + const fullKey = `${locationToKey(this.storage, this.currentLocation)}/${name}`; + if (this.usedNamesInExecution.has(fullKey)) { + throw new HistoryDivergedError( + `Duplicate entry name "${name}" at location "${locationToKey(this.storage, this.currentLocation)}". ` + + `Each step/loop/sleep/queue.next/join/race must have a unique name within its scope.`, + ); + } + this.usedNamesInExecution.add(fullKey); + } + + private stopRollback(): never { + throw new RollbackStopError(); + } + + private stopRollbackIfMissing(entry: Entry | undefined): void { + if (this.mode === "rollback" && !entry) { + this.stopRollback(); + } + } + + private stopRollbackIfIncomplete(condition: boolean): void { + if (this.mode === "rollback" && condition) { + this.stopRollback(); + } + } + + private registerRollbackAction( + config: StepConfig, + entryId: string, + output: T, + metadata: EntryMetadata, + ): void { + if (!config.rollback) { + return; + } + if (metadata.rollbackCompletedAt !== undefined) { + return; + } + this.rollbackActions?.push({ + entryId, + name: config.name, + output: output as unknown, + rollback: config.rollback as ( + ctx: RollbackContextInterface, + output: unknown, + ) => Promise, + }); + } + + /** + * Ensure a rollback checkpoint exists before registering rollback handlers. + */ + private ensureRollbackCheckpoint(config: StepConfig): void { + if (!config.rollback) { + return; + } + if (!this.rollbackCheckpointSet) { + throw new RollbackCheckpointError(); + } + } + + /** + * Validate that all expected entries in the branch were visited. + * Throws HistoryDivergedError if there are unvisited entries. + */ + validateComplete(): void { + const prefix = locationToKey(this.storage, this.currentLocation); + + for (const key of this.storage.history.entries.keys()) { + // Check if this key is under our current location prefix + // Handle root prefix (empty string) specially - all keys are under root + const isUnderPrefix = + prefix === "" + ? true // Root: all keys are children + : key.startsWith(`${prefix}/`) || key === prefix; + + if (isUnderPrefix) { + if (!this.visitedKeys.has(key)) { + // Entry exists in history but wasn't visited + // This means workflow code may have changed + throw new HistoryDivergedError( + `Entry "${key}" exists in history but was not visited. ` + + `Workflow code may have changed. Use ctx.removed() to handle migrations.`, + ); + } + } + } + } + + /** + * Evict the workflow. + */ + evict(): void { + this.abortController.abort(new EvictedError()); + } + + /** + * Wait for `ms`, rejecting early with EvictedError if the workflow is + * evicted. Both the timer and the abort listener are torn down on either + * outcome, so a completed sleep never leaves a dangling listener on the + * long-lived run abort signal. + */ + private async sleepOrEvict(ms: number): Promise { + if (this.abortSignal.aborted) { + throw new EvictedError(); + } + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + await new Promise((resolve, reject) => { + timer = setTimeout(resolve, ms); + onAbort = () => reject(new EvictedError()); + this.abortSignal.addEventListener("abort", onAbort, { + once: true, + }); + }); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + if (onAbort) { + this.abortSignal.removeEventListener("abort", onAbort); + } + } + } + + // === Step === + + async step( + nameOrConfig: string | StepConfig, + run?: () => Promise, + ): Promise { + this.assertNotInProgress(); + this.checkEvicted(); + + const config: StepConfig = + typeof nameOrConfig === "string" + ? { name: nameOrConfig, run: run! } + : nameOrConfig; + + this.entryInProgress = true; + try { + return await this.executeStep(config); + } finally { + this.entryInProgress = false; + } + } + + async tryStep( + nameOrConfig: string | TryStepConfig, + run?: () => Promise, + ): Promise> { + const config = + typeof nameOrConfig === "string" + ? ({ + name: nameOrConfig, + run: run!, + } satisfies TryStepConfig) + : nameOrConfig; + + try { + return { + ok: true, + value: await this.step(config), + }; + } catch (error) { + if (shouldRethrowTryError(error)) { + throw error; + } + + const failure = readTryStepFailure(error); + if (!failure || !shouldCatchTryStepFailure(failure, config.catch)) { + throw error; + } + + return { + ok: false, + failure, + }; + } + } + + async try( + nameOrConfig: string | TryBlockConfig, + run?: (ctx: WorkflowContextInterface) => Promise, + ): Promise> { + this.assertNotInProgress(); + this.checkEvicted(); + + const config = + typeof nameOrConfig === "string" + ? ({ + name: nameOrConfig, + run: run!, + } satisfies TryBlockConfig) + : nameOrConfig; + + this.entryInProgress = true; + try { + return await this.executeTry(config); + } finally { + this.entryInProgress = false; + } + } + + private async executeTry( + config: TryBlockConfig, + ): Promise> { + this.checkDuplicateName(config.name); + + const location = appendName( + this.storage, + this.currentLocation, + config.name, + ); + const blockCtx = this.createBranch(location); + + try { + const value = await config.run(blockCtx); + blockCtx.validateComplete(); + return { + ok: true, + value, + }; + } catch (error) { + if (shouldRethrowTryError(error)) { + throw error; + } + + const stepFailure = readTryStepFailure(error); + if (stepFailure) { + const failure: TryBlockFailure = { + source: "step", + name: stepFailure.stepName, + error: stepFailure.error, + step: stepFailure, + }; + if (!shouldCatchTryBlockFailure(failure, config.catch)) { + throw error; + } + return { + ok: false, + failure, + }; + } + + const operationFailure = readTryBlockFailure(error); + if (operationFailure) { + const failure: TryBlockFailure = { + ...operationFailure, + error: extractErrorInfo(error), + }; + if (!shouldCatchTryBlockFailure(failure, config.catch)) { + throw error; + } + return { + ok: false, + failure, + }; + } + + if (error instanceof RollbackError) { + const failure: TryBlockFailure = { + source: "block", + name: config.name, + error: extractErrorInfo(error), + }; + if (!shouldCatchTryBlockFailure(failure, config.catch)) { + throw error; + } + return { + ok: false, + failure, + }; + } + + throw error; + } + } + + private async executeStep(config: StepConfig): Promise { + this.ensureRollbackCheckpoint(config); + if (this.mode === "rollback") { + return await this.executeStepRollback(config); + } + + // Check for duplicate name in current execution + this.checkDuplicateName(config.name); + + const location = appendName( + this.storage, + this.currentLocation, + config.name, + ); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + if (existing) { + if (existing.kind.type !== "step") { + throw new HistoryDivergedError( + `Expected step "${config.name}" at ${key}, found ${existing.kind.type}`, + ); + } + + const stepData = existing.kind.data; + + const metadata = await loadMetadata( + this.storage, + this.driver, + existing.id, + ); + + // Replay successful result (including void steps). + if ( + metadata.status === "completed" || + stepData.output !== undefined + ) { + return stepData.output as T; + } + + // Check if we should retry + const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES; + + if (metadata.attempts > maxRetries) { + const lastError = metadata.error; + const exhaustedError = new StepExhaustedError( + config.name, + lastError, + ); + attachTryStepFailure( + exhaustedError, + getTryStepFailureFromExhaustedError( + config.name, + metadata.attempts, + exhaustedError, + ), + ); + markErrorReported(exhaustedError); + if (metadata.status !== "exhausted") { + metadata.status = "exhausted"; + metadata.dirty = true; + await this.flushStorage(); + await this.notifyStepError( + config, + metadata.attempts, + exhaustedError, + { willRetry: false }, + ); + } + throw exhaustedError; + } + + // Calculate backoff and yield to scheduler + // This allows the workflow to be evicted during backoff + const backoffDelay = calculateBackoff( + metadata.attempts, + config.retryBackoffBase ?? DEFAULT_RETRY_BACKOFF_BASE, + config.retryBackoffMax ?? DEFAULT_RETRY_BACKOFF_MAX, + ); + const retryAt = metadata.lastAttemptAt + backoffDelay; + const now = Date.now(); + + if (now < retryAt) { + // Yield to scheduler - will be woken up at retryAt + throw new SleepError(retryAt); + } + } + + // Execute the step + const entry = + existing ?? createEntry(location, { type: "step", data: {} }); + if (!existing) { + // New entry - register name + this.log("debug", { + msg: "executing new step", + step: config.name, + key, + }); + const nameIndex = registerName(this.storage, config.name); + entry.location = [...location]; + entry.location[entry.location.length - 1] = nameIndex; + setEntry(this.storage, location, entry); + } else { + this.log("debug", { msg: "retrying step", step: config.name, key }); + } + + const metadata = getOrCreateMetadata(this.storage, entry.id); + const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES; + const retryBackoffBase = + config.retryBackoffBase ?? DEFAULT_RETRY_BACKOFF_BASE; + const retryBackoffMax = + config.retryBackoffMax ?? DEFAULT_RETRY_BACKOFF_MAX; + metadata.status = "running"; + metadata.attempts++; + metadata.lastAttemptAt = Date.now(); + metadata.dirty = true; + + // Get timeout configuration + const timeout = config.timeout ?? DEFAULT_STEP_TIMEOUT; + + try { + // Execute with timeout + const output = await this.executeWithTimeout( + config.run(), + timeout, + config.name, + ); + + if (entry.kind.type === "step") { + entry.kind.data.output = output; + } + entry.dirty = true; + metadata.status = "completed"; + metadata.error = undefined; + metadata.completedAt = Date.now(); + + // Ephemeral steps don't trigger an immediate flush. This avoids the + // synchronous write overhead for transient operations. Note that the + // step's entry is still marked dirty and WILL be persisted on the + // next flush from a non-ephemeral operation. The purpose of ephemeral + // is to batch writes, not to avoid persistence entirely. + if (!config.ephemeral) { + this.log("debug", { + msg: "flushing step", + step: config.name, + key, + }); + await this.flushStorage(); + } + + this.log("debug", { + msg: "step completed", + step: config.name, + key, + }); + return output; + } catch (error) { + if (entry.kind.type === "step") { + entry.kind.data.error = String(error); + } + entry.dirty = true; + + // Timeout errors are treated as critical by default. Steps opt + // into retrying on timeout with retryOnTimeout: true. + if (error instanceof StepTimeoutError && !config.retryOnTimeout) { + metadata.status = "exhausted"; + metadata.error = String(error); + await this.notifyStepError(config, metadata.attempts, error, { + willRetry: false, + }); + throw markErrorReported( + attachTryStepFailure(new CriticalError(error.message), { + kind: "timeout", + stepName: config.name, + attempts: metadata.attempts, + error: extractErrorInfo(error), + }), + ); + } + + if ( + error instanceof CriticalError || + error instanceof RollbackError + ) { + metadata.status = "exhausted"; + metadata.error = String(error); + await this.notifyStepError(config, metadata.attempts, error, { + willRetry: false, + }); + throw markErrorReported( + attachTryStepFailure(error, { + kind: + error instanceof RollbackError + ? "rollback" + : "critical", + stepName: config.name, + attempts: metadata.attempts, + error: extractErrorInfo(error), + }), + ); + } + + const willRetry = metadata.attempts <= maxRetries; + metadata.status = willRetry ? "failed" : "exhausted"; + metadata.error = String(error); + + if (willRetry) { + const retryDelay = calculateBackoff( + metadata.attempts, + retryBackoffBase, + retryBackoffMax, + ); + const retryAt = metadata.lastAttemptAt + retryDelay; + await this.notifyStepError(config, metadata.attempts, error, { + willRetry: true, + retryDelay, + retryAt, + }); + throw new StepFailedError( + config.name, + error, + metadata.attempts, + retryAt, + ); + } + + const exhaustedError = markErrorReported( + attachTryStepFailure( + new StepExhaustedError(config.name, String(error)), + { + kind: + error instanceof StepTimeoutError + ? "timeout" + : "exhausted", + stepName: config.name, + attempts: metadata.attempts, + error: extractErrorInfo(error), + }, + ), + ); + await this.notifyStepError(config, metadata.attempts, error, { + willRetry: false, + }); + throw exhaustedError; + } + } + + /** + * Execute a promise with timeout. + * + * Note: This does NOT cancel the underlying operation. JavaScript Promises + * cannot be cancelled once started. When a timeout occurs: + * - The step is rejected with StepTimeoutError. By default this is treated + * as a critical failure with no retry. Set retryOnTimeout: true on the + * step config to retry timeouts like any other error. + * - The underlying async operation continues running in the background + * - Any side effects from the operation may still occur + * + * For cancellable operations, pass ctx.abortSignal to APIs that support AbortSignal: + * + * return fetch(url, { signal: ctx.abortSignal }); + + * }); + * + * Or check ctx.isEvicted() periodically in long-running loops. + */ + private async executeStepRollback(config: StepConfig): Promise { + this.checkDuplicateName(config.name); + this.ensureRollbackCheckpoint(config); + + const location = appendName( + this.storage, + this.currentLocation, + config.name, + ); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + this.markVisited(key); + + if (!existing || existing.kind.type !== "step") { + this.stopRollback(); + } + + const metadata = await loadMetadata( + this.storage, + this.driver, + existing.id, + ); + if (metadata.status !== "completed") { + this.stopRollback(); + } + + const output = existing.kind.data.output as T; + this.registerRollbackAction(config, existing.id, output, metadata); + + return output; + } + + private async executeWithTimeout( + promise: Promise, + timeoutMs: number, + stepName: string, + ): Promise { + if (timeoutMs <= 0) { + return promise; + } + + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new StepTimeoutError(stepName, timeoutMs)); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } + + // === Loop === + + async loop( + nameOrConfig: string | LoopConfig, + run?: ( + ctx: WorkflowContextInterface, + ) => LoopIterationResult, + ): Promise { + this.assertNotInProgress(); + this.checkEvicted(); + + const config: LoopConfig = + typeof nameOrConfig === "string" + ? { name: nameOrConfig, run: run as LoopConfig["run"] } + : nameOrConfig; + + this.entryInProgress = true; + try { + return await this.executeLoop(config); + } finally { + this.entryInProgress = false; + } + } + + private async executeLoop(config: LoopConfig): Promise { + // Check for duplicate name in current execution + this.checkDuplicateName(config.name); + + const location = appendName( + this.storage, + this.currentLocation, + config.name, + ); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + let entry: Entry; + let metadata: EntryMetadata | undefined; + let state: S; + let iteration: number; + let rollbackSingleIteration = false; + let rollbackIterationRan = false; + let rollbackOutput: T | undefined; + const rollbackMode = this.mode === "rollback"; + + if (existing) { + if (existing.kind.type !== "loop") { + throw new HistoryDivergedError( + `Expected loop "${config.name}" at ${key}, found ${existing.kind.type}`, + ); + } + + const loopData = existing.kind.data; + metadata = await loadMetadata( + this.storage, + this.driver, + existing.id, + ); + + if (rollbackMode) { + if (loopData.output !== undefined) { + return loopData.output as T; + } + rollbackSingleIteration = true; + rollbackIterationRan = false; + rollbackOutput = undefined; + } + + if (metadata.status === "completed") { + return loopData.output as T; + } + + // Loop already completed + if (loopData.output !== undefined) { + return loopData.output as T; + } + + // Resume from saved state + entry = existing; + state = loopData.state as S; + iteration = loopData.iteration; + if (rollbackMode) { + rollbackOutput = loopData.output as T | undefined; + rollbackIterationRan = rollbackOutput !== undefined; + } + } else { + this.stopRollbackIfIncomplete(true); + + // New loop + state = config.state as S; + iteration = 0; + entry = createEntry(location, { + type: "loop", + data: { state, iteration }, + }); + setEntry(this.storage, location, entry); + metadata = getOrCreateMetadata(this.storage, entry.id); + } + + if (metadata) { + metadata.status = "running"; + metadata.error = undefined; + metadata.dirty = true; + } + + const historyPruneInterval = + config.historyPruneInterval ?? + config.commitInterval ?? + config.historyEvery ?? + DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL; + const historySize = + config.historySize ?? config.historyKeep ?? historyPruneInterval; + + // Track the last iteration we pruned up to so we only delete + // newly-expired iterations instead of re-scanning from 0. + let lastPrunedUpTo = 0; + + // Deferred flush promise from the previous prune cycle. Awaited at the + // start of the next iteration so the flush runs in parallel with user code. + let deferredFlush: Promise | null = null; + + // Execute loop iterations + while (true) { + // Await any deferred flush from the previous prune cycle + if (deferredFlush) { + await deferredFlush; + deferredFlush = null; + } + + if (rollbackMode && rollbackSingleIteration) { + if (rollbackIterationRan) { + return rollbackOutput as T; + } + this.stopRollbackIfIncomplete(true); + } + this.checkEvicted(); + + // Create branch for this iteration + const iterationLocation = appendLoopIteration( + this.storage, + location, + config.name, + iteration, + ); + const branchCtx = this.createBranch(iterationLocation); + + // Execute iteration + const iterationResult = await config.run(branchCtx, state); + if (iterationResult === undefined && state !== undefined) { + throw new Error( + `Loop "${config.name}" returned undefined for a stateful iteration. Return Loop.continue(state) or Loop.break(value).`, + ); + } + const result = + iterationResult === undefined + ? ({ continue: true, state } as LoopResult) + : iterationResult; + + // Validate branch completed cleanly + branchCtx.validateComplete(); + + if ("break" in result && result.break) { + // Loop complete + if (entry.kind.type === "loop") { + entry.kind.data.output = result.value; + entry.kind.data.state = state; + entry.kind.data.iteration = iteration; + } + entry.dirty = true; + if (metadata) { + metadata.status = "completed"; + metadata.completedAt = Date.now(); + metadata.dirty = true; + } + + // Collect pruning deletions and flush + const deletions = this.collectLoopPruning( + location, + iteration + 1, + historySize, + lastPrunedUpTo, + ); + await this.flushStorageWithDeletions(deletions); + + if (rollbackMode && rollbackSingleIteration) { + rollbackOutput = result.value; + rollbackIterationRan = true; + continue; + } + + return result.value; + } + + // Continue with new state + if ("continue" in result && result.continue) { + state = result.state; + } + iteration++; + + if (!rollbackMode) { + if (entry.kind.type === "loop") { + entry.kind.data.state = state; + entry.kind.data.iteration = iteration; + } + entry.dirty = true; + } + + // Periodically defer the flush so the next iteration can overlap + // with loop pruning and any pending dirty state writes. + if (iteration % historyPruneInterval === 0) { + const deletions = this.collectLoopPruning( + location, + iteration, + historySize, + lastPrunedUpTo, + ); + lastPrunedUpTo = Math.max(0, iteration - historySize); + + // Defer the flush to run in parallel with the next iteration + deferredFlush = this.flushStorageWithDeletions(deletions); + } + } + } + + /** + * Collect pending deletions for loop history pruning. + * + * Only deletes iterations in the range [fromIteration, keepFrom) where + * keepFrom = currentIteration - historySize. This avoids re-scanning + * already-deleted iterations. + */ + private collectLoopPruning( + loopLocation: Location, + currentIteration: number, + historySize: number, + fromIteration: number, + ): PendingDeletions | undefined { + if (currentIteration <= historySize) { + return undefined; + } + + const keepFrom = Math.max(0, currentIteration - historySize); + if (fromIteration >= keepFrom) { + return undefined; + } + + const loopSegment = loopLocation[loopLocation.length - 1]; + if (typeof loopSegment !== "number") { + throw new Error("Expected loop location to end with a name index"); + } + + const range = buildLoopIterationRange( + loopLocation, + loopSegment, + fromIteration, + keepFrom, + ); + const metadataKeys: Uint8Array[] = []; + + for (const [key, entry] of this.storage.history.entries) { + if (!isLocationPrefix(loopLocation, entry.location)) { + continue; + } + + const iterationSegment = entry.location[loopLocation.length]; + if ( + !iterationSegment || + typeof iterationSegment === "number" || + iterationSegment.loop !== loopSegment || + iterationSegment.iteration < fromIteration || + iterationSegment.iteration >= keepFrom + ) { + continue; + } + + metadataKeys.push(buildEntryMetadataKey(entry.id)); + this.storage.entryMetadata.delete(entry.id); + this.storage.history.entries.delete(key); + } + + return { + prefixes: [], + keys: metadataKeys, + ranges: [range], + }; + } + + /** + * Flush storage with optional pending deletions so pruning + * happens alongside the state write. + */ + private async flushStorageWithDeletions( + deletions?: PendingDeletions, + ): Promise { + await flush(this.storage, this.driver, this.historyNotifier, deletions); + } + + // === Sleep === + + async sleep(name: string, durationMs: number): Promise { + const deadline = Date.now() + durationMs; + return this.sleepUntil(name, deadline); + } + + async sleepUntil(name: string, timestampMs: number): Promise { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + await this.executeSleep(name, timestampMs); + } finally { + this.entryInProgress = false; + } + } + + private async executeSleep(name: string, deadline: number): Promise { + // Check for duplicate name in current execution + this.checkDuplicateName(name); + + const location = appendName(this.storage, this.currentLocation, name); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + let entry: Entry; + + if (existing) { + if (existing.kind.type !== "sleep") { + throw new HistoryDivergedError( + `Expected sleep "${name}" at ${key}, found ${existing.kind.type}`, + ); + } + + const sleepData = existing.kind.data; + + if (this.mode === "rollback") { + this.stopRollbackIfIncomplete(sleepData.state === "pending"); + return; + } + + // Already completed or interrupted + if (sleepData.state !== "pending") { + return; + } + + // Use stored deadline + deadline = sleepData.deadline; + entry = existing; + } else { + this.stopRollbackIfIncomplete(true); + + entry = createEntry(location, { + type: "sleep", + data: { deadline, state: "pending" }, + }); + setEntry(this.storage, location, entry); + entry.dirty = true; + await this.flushStorage(); + } + + const now = Date.now(); + const remaining = deadline - now; + + if (remaining <= 0) { + // Deadline passed + if (entry.kind.type === "sleep") { + entry.kind.data.state = "completed"; + } + entry.dirty = true; + await this.flushStorage(); + return; + } + + // Short sleep: wait in memory + if (remaining < this.driver.workerPollInterval) { + await this.sleepOrEvict(remaining); + + this.checkEvicted(); + + if (entry.kind.type === "sleep") { + entry.kind.data.state = "completed"; + } + entry.dirty = true; + await this.flushStorage(); + return; + } + + // Long sleep: yield to scheduler + throw new SleepError(deadline); + } + + // === Rollback Checkpoint === + + async rollbackCheckpoint(name: string): Promise { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + await this.executeRollbackCheckpoint(name); + } finally { + this.entryInProgress = false; + } + } + + private async executeRollbackCheckpoint(name: string): Promise { + this.checkDuplicateName(name); + + const location = appendName(this.storage, this.currentLocation, name); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + this.markVisited(key); + + if (existing) { + if (existing.kind.type !== "rollback_checkpoint") { + throw new HistoryDivergedError( + `Expected rollback checkpoint "${name}" at ${key}, found ${existing.kind.type}`, + ); + } + this.rollbackCheckpointSet = true; + return; + } + + if (this.mode === "rollback") { + throw new HistoryDivergedError( + `Missing rollback checkpoint "${name}" at ${key}`, + ); + } + + const entry = createEntry(location, { + type: "rollback_checkpoint", + data: { name }, + }); + setEntry(this.storage, location, entry); + entry.dirty = true; + await this.flushStorage(); + + this.rollbackCheckpointSet = true; + } + + // === Queue === + + private async queueSend(name: string, body: unknown): Promise { + const message: Message = { + id: crypto.randomUUID(), + name, + data: body, + sentAt: Date.now(), + }; + await this.messageDriver.addMessage(message); + } + + private async queueNext( + name: string, + opts?: WorkflowQueueNextOptions, + ): Promise> { + const messages = await this.queueNextBatch(name, { + ...(opts ?? {}), + count: 1, + }); + const message = messages[0]; + if (!message) { + throw new Error( + `queue.next("${name}") timed out before receiving a message. Use queue.nextBatch(...) for optional/time-limited reads.`, + ); + } + return message; + } + + private async queueNextBatch( + name: string, + opts?: WorkflowQueueNextBatchOptions, + ): Promise>> { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + return await this.executeQueueNextBatch(name, opts); + } finally { + this.entryInProgress = false; + } + } + + private async executeQueueNextBatch( + name: string, + opts?: WorkflowQueueNextBatchOptions, + ): Promise>> { + if (this.pendingCompletableMessageIds.size > 0) { + throw new Error( + "Previous completable queue message is not completed. Call `message.complete(...)` before receiving the next message.", + ); + } + + const resolvedOpts = opts ?? {}; + const messageNames = this.normalizeQueueNames(resolvedOpts.names); + const messageNameLabel = this.messageNamesLabel(messageNames); + const count = Math.max(1, resolvedOpts.count ?? 1); + const completable = resolvedOpts.completable === true; + + this.checkDuplicateName(name); + + const countLocation = appendName( + this.storage, + this.currentLocation, + `${name}:count`, + ); + const countKey = locationToKey(this.storage, countLocation); + const existingCount = this.storage.history.entries.get(countKey); + this.markVisited(countKey); + this.stopRollbackIfMissing(existingCount); + + let deadline: number | undefined; + let deadlineEntry: Entry | undefined; + if (resolvedOpts.timeout !== undefined) { + const deadlineLocation = appendName( + this.storage, + this.currentLocation, + `${name}:deadline`, + ); + const deadlineKey = locationToKey(this.storage, deadlineLocation); + deadlineEntry = this.storage.history.entries.get(deadlineKey); + this.markVisited(deadlineKey); + this.stopRollbackIfMissing(deadlineEntry); + + if (deadlineEntry && deadlineEntry.kind.type === "sleep") { + deadline = deadlineEntry.kind.data.deadline; + } else { + deadline = Date.now() + resolvedOpts.timeout; + const created = createEntry(deadlineLocation, { + type: "sleep", + data: { deadline, state: "pending" }, + }); + setEntry(this.storage, deadlineLocation, created); + created.dirty = true; + await this.flushStorage(); + deadlineEntry = created; + } + } + + if (existingCount && existingCount.kind.type === "message") { + const replayCount = existingCount.kind.data.data as number; + return await this.readReplayQueueMessages( + name, + replayCount, + completable, + ); + } + + const now = Date.now(); + if (deadline !== undefined && now >= deadline) { + if (deadlineEntry && deadlineEntry.kind.type === "sleep") { + deadlineEntry.kind.data.state = "completed"; + deadlineEntry.dirty = true; + } + await this.recordQueueCountEntry( + countLocation, + `${messageNameLabel}:count`, + 0, + ); + return []; + } + + const received = await this.receiveMessagesNow( + messageNames, + count, + completable, + ); + if (received.length > 0) { + const historyMessages = received.map((message) => + this.toWorkflowQueueMessage(message), + ); + if (deadlineEntry && deadlineEntry.kind.type === "sleep") { + deadlineEntry.kind.data.state = "interrupted"; + deadlineEntry.dirty = true; + } + await this.recordQueueMessages( + name, + countLocation, + messageNames, + historyMessages, + ); + const queueMessages = received.map((message, index) => + this.createQueueMessage(message, completable, { + historyLocation: appendName( + this.storage, + this.currentLocation, + `${name}:${index}`, + ), + }), + ); + return queueMessages; + } + + if (deadline === undefined) { + throw new MessageWaitError(messageNames); + } + throw new SleepError(deadline, messageNames); + } + + private normalizeQueueNames(names?: readonly string[]): string[] { + if (!names || names.length === 0) { + return []; + } + const deduped: string[] = []; + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) { + continue; + } + seen.add(name); + deduped.push(name); + } + return deduped; + } + + private messageNamesLabel(messageNames: string[]): string { + if (messageNames.length === 0) { + return "*"; + } + return messageNames.length === 1 + ? messageNames[0] + : messageNames.join("|"); + } + + private async receiveMessagesNow( + messageNames: string[], + count: number, + completable: boolean, + ): Promise { + return await this.messageDriver.receiveMessages({ + names: messageNames.length > 0 ? messageNames : undefined, + count, + completable, + }); + } + + private async recordQueueMessages( + name: string, + countLocation: Location, + messageNames: string[], + messages: Array>, + ): Promise { + for (let i = 0; i < messages.length; i++) { + const messageLocation = appendName( + this.storage, + this.currentLocation, + `${name}:${i}`, + ); + const messageEntry = createEntry(messageLocation, { + type: "message", + data: { + name: messages[i].name, + data: this.toHistoryQueueMessage(messages[i]), + }, + }); + setEntry(this.storage, messageLocation, messageEntry); + this.markVisited(locationToKey(this.storage, messageLocation)); + } + await this.recordQueueCountEntry( + countLocation, + `${this.messageNamesLabel(messageNames)}:count`, + messages.length, + ); + } + + private async recordQueueCountEntry( + countLocation: Location, + countLabel: string, + count: number, + ): Promise { + const countEntry = createEntry(countLocation, { + type: "message", + data: { + name: countLabel, + data: count, + }, + }); + setEntry(this.storage, countLocation, countEntry); + await this.flushStorage(); + } + + private async readReplayQueueMessages( + name: string, + count: number, + completable: boolean, + ): Promise>> { + const results: Array> = []; + for (let i = 0; i < count; i++) { + const messageLocation = appendName( + this.storage, + this.currentLocation, + `${name}:${i}`, + ); + const messageKey = locationToKey(this.storage, messageLocation); + this.markVisited(messageKey); + const existingMessage = + this.storage.history.entries.get(messageKey); + if (!existingMessage || existingMessage.kind.type !== "message") { + throw new HistoryDivergedError( + `Expected queue message "${name}:${i}" in history`, + ); + } + const parsed = this.fromHistoryQueueMessage( + existingMessage.kind.data.name, + existingMessage.kind.data.data, + ); + results.push( + this.createQueueMessage(parsed.message, completable, { + historyLocation: messageLocation, + completed: parsed.completed, + replay: true, + }), + ); + } + return results; + } + + private toWorkflowQueueMessage( + message: Message, + ): WorkflowQueueMessage { + return { + id: message.id, + name: message.name, + body: message.data as T, + createdAt: message.sentAt, + }; + } + + private createQueueMessage( + message: Message, + completable: boolean, + opts?: { + historyLocation?: Location; + completed?: boolean; + replay?: boolean; + }, + ): WorkflowQueueMessage { + const queueMessage = this.toWorkflowQueueMessage(message); + if (!completable) { + return queueMessage; + } + + if (opts?.replay && opts.completed) { + return { + ...queueMessage, + complete: async () => { + // No-op: this message was already completed in a prior run. + }, + }; + } + + const messageId = message.id; + this.pendingCompletableMessageIds.add(messageId); + let completed = false; + + return { + ...queueMessage, + complete: async (response?: unknown) => { + if (completed) { + throw new Error("Queue message already completed"); + } + completed = true; + try { + await this.completeMessage(message, response); + await this.markQueueMessageCompleted(opts?.historyLocation); + this.pendingCompletableMessageIds.delete(messageId); + } catch (error) { + completed = false; + throw error; + } + }, + }; + } + + private async markQueueMessageCompleted( + historyLocation: Location | undefined, + ): Promise { + if (!historyLocation) { + return; + } + const key = locationToKey(this.storage, historyLocation); + const entry = this.storage.history.entries.get(key); + if (!entry || entry.kind.type !== "message") { + return; + } + const parsed = this.fromHistoryQueueMessage( + entry.kind.data.name, + entry.kind.data.data, + ); + entry.kind.data.data = this.toHistoryQueueMessage( + this.toWorkflowQueueMessage(parsed.message), + true, + ); + entry.dirty = true; + await this.flushStorage(); + } + + private async completeMessage( + message: Message, + response?: unknown, + ): Promise { + if (message.complete) { + await message.complete(response); + return; + } + await this.messageDriver.completeMessage( + { id: message.id, name: message.name }, + response, + ); + } + + private toHistoryQueueMessage( + message: WorkflowQueueMessage, + completed = false, + ): unknown { + return { + [QUEUE_HISTORY_MESSAGE_MARKER]: 1, + id: message.id, + name: message.name, + body: message.body, + createdAt: message.createdAt, + completed, + }; + } + + private fromHistoryQueueMessage( + name: string, + value: unknown, + ): { message: Message; completed: boolean } { + if ( + typeof value === "object" && + value !== null && + (value as Record)[QUEUE_HISTORY_MESSAGE_MARKER] === + 1 + ) { + const serialized = value as Record; + const id = typeof serialized.id === "string" ? serialized.id : ""; + const serializedName = + typeof serialized.name === "string" ? serialized.name : name; + const createdAt = + typeof serialized.createdAt === "number" + ? serialized.createdAt + : 0; + const completed = + typeof serialized.completed === "boolean" + ? serialized.completed + : false; + return { + message: { + id, + name: serializedName, + data: serialized.body, + sentAt: createdAt, + }, + completed, + }; + } + return { + message: { + id: "", + name, + data: value, + sentAt: 0, + }, + completed: false, + }; + } + + // === Join === + + async join>>( + name: string, + branches: T, + ): Promise<{ [K in keyof T]: BranchOutput }> { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + return await this.executeJoin(name, branches); + } finally { + this.entryInProgress = false; + } + } + + private async executeJoin>>( + name: string, + branches: T, + ): Promise<{ [K in keyof T]: BranchOutput }> { + // Check for duplicate name in current execution + this.checkDuplicateName(name); + + const location = appendName(this.storage, this.currentLocation, name); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + this.stopRollbackIfMissing(existing); + + let entry: Entry; + + if (existing) { + if (existing.kind.type !== "join") { + throw new HistoryDivergedError( + `Expected join "${name}" at ${key}, found ${existing.kind.type}`, + ); + } + entry = existing; + } else { + entry = createEntry(location, { + type: "join", + data: { + branches: Object.fromEntries( + Object.keys(branches).map((k) => [ + k, + { status: "pending" as const }, + ]), + ), + }, + }); + setEntry(this.storage, location, entry); + entry.dirty = true; + // Flush immediately to persist entry before branches execute + await this.flushStorage(); + } + + if (entry.kind.type !== "join") { + throw new HistoryDivergedError("Entry type mismatch"); + } + + this.stopRollbackIfIncomplete( + Object.values(entry.kind.data.branches).some( + (branch) => branch.status !== "completed", + ), + ); + + const joinData = entry.kind.data; + const results: Record = {}; + const errors: Record = {}; + let schedulerYieldState: SchedulerYieldState | undefined; + let propagatedError: Error | undefined; + + for (const [branchName, branchStatus] of Object.entries( + joinData.branches, + )) { + if (branchStatus.status === "completed") { + results[branchName] = branchStatus.output; + continue; + } + + if (branchStatus.status === "failed") { + errors[branchName] = new Error( + branchStatus.error ?? "branch failed", + ); + } + } + + // Execute all branches in parallel + const branchPromises = Object.entries(branches).map( + async ([branchName, config]) => { + const branchStatus = joinData.branches[branchName]; + if (!branchStatus) { + throw new HistoryDivergedError( + `Expected join branch "${branchName}" in "${name}"`, + ); + } + + // Already completed + if (branchStatus.status === "completed") { + results[branchName] = branchStatus.output; + return; + } + + // Already failed + if (branchStatus.status === "failed") { + errors[branchName] = new Error(branchStatus.error); + return; + } + + // Execute branch + const branchLocation = appendName( + this.storage, + location, + branchName, + ); + const branchCtx = this.createBranch(branchLocation); + + branchStatus.status = "running"; + branchStatus.error = undefined; + entry.dirty = true; + + try { + const output = await config.run(branchCtx); + branchCtx.validateComplete(); + + branchStatus.status = "completed"; + branchStatus.output = output; + branchStatus.error = undefined; + results[branchName] = output; + } catch (error) { + if ( + error instanceof SleepError || + error instanceof MessageWaitError || + error instanceof StepFailedError + ) { + schedulerYieldState = mergeSchedulerYield( + schedulerYieldState, + error, + ); + branchStatus.status = "running"; + branchStatus.error = undefined; + entry.dirty = true; + return; + } + + if ( + error instanceof EvictedError || + error instanceof HistoryDivergedError || + error instanceof EntryInProgressError || + error instanceof RollbackCheckpointError || + error instanceof RollbackStopError + ) { + propagatedError = selectControlFlowError( + propagatedError, + error, + ); + branchStatus.status = "running"; + branchStatus.error = undefined; + entry.dirty = true; + return; + } + + branchStatus.status = "failed"; + branchStatus.output = undefined; + branchStatus.error = String(error); + errors[branchName] = error as Error; + } + + entry.dirty = true; + }, + ); + + // Wait for ALL branches (no short-circuit on error) + await Promise.allSettled(branchPromises); + await this.flushStorage(); + + if (propagatedError) { + throw propagatedError; + } + + if ( + Object.values(joinData.branches).some( + (branch) => + branch.status === "pending" || branch.status === "running", + ) + ) { + if (!schedulerYieldState) { + throw new Error( + `Join "${name}" has pending branches without a scheduler yield`, + ); + } + throw buildSchedulerYieldError(schedulerYieldState); + } + + // Throw if any branches failed + if (Object.keys(errors).length > 0) { + throw attachTryBlockFailure(new JoinError(errors), { + source: "join", + name, + }); + } + + return results as { [K in keyof T]: BranchOutput }; + } + + // === Race === + + async race( + name: string, + branches: Array<{ + name: string; + run: (ctx: WorkflowContextInterface) => Promise; + }>, + ): Promise<{ winner: string; value: T }> { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + return await this.executeRace(name, branches); + } finally { + this.entryInProgress = false; + } + } + + private async executeRace( + name: string, + branches: Array<{ + name: string; + run: (ctx: WorkflowContextInterface) => Promise; + }>, + ): Promise<{ winner: string; value: T }> { + // Check for duplicate name in current execution + this.checkDuplicateName(name); + + const location = appendName(this.storage, this.currentLocation, name); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + this.stopRollbackIfMissing(existing); + + let entry: Entry; + + if (existing) { + if (existing.kind.type !== "race") { + throw new HistoryDivergedError( + `Expected race "${name}" at ${key}, found ${existing.kind.type}`, + ); + } + entry = existing; + + // Check if we already have a winner + const raceKind = existing.kind; + if (raceKind.data.winner !== null) { + const winnerStatus = + raceKind.data.branches[raceKind.data.winner]; + return { + winner: raceKind.data.winner, + value: winnerStatus.output as T, + }; + } + + this.stopRollbackIfIncomplete(true); + } else { + entry = createEntry(location, { + type: "race", + data: { + winner: null, + branches: Object.fromEntries( + branches.map((b) => [ + b.name, + { status: "pending" as const }, + ]), + ), + }, + }); + setEntry(this.storage, location, entry); + entry.dirty = true; + // Flush immediately to persist entry before branches execute + await this.flushStorage(); + } + + if (entry.kind.type !== "race") { + throw new HistoryDivergedError("Entry type mismatch"); + } + + const raceData = entry.kind.data; + + // Create abort controller for cancellation + const raceAbortController = new AbortController(); + + // Track all branch promises to wait for cleanup + const branchPromises: Promise[] = []; + + // Track winner info + let winnerName: string | null = null; + let winnerValue: T | undefined; + let hasWinner = false; + const errors: Record = {}; + const lateErrors: Array<{ name: string; error: string }> = []; + let schedulerYieldState: SchedulerYieldState | undefined; + let propagatedError: Error | undefined; + + // Check for replay winners first + for (const branch of branches) { + const branchStatus = raceData.branches[branch.name]; + if (!branchStatus) { + throw new HistoryDivergedError( + `Expected race branch "${branch.name}" in "${name}"`, + ); + } + if ( + branchStatus.status !== "pending" && + branchStatus.status !== "running" + ) { + if (branchStatus.status === "failed") { + errors[branch.name] = new Error( + branchStatus.error ?? "branch failed", + ); + } + if (branchStatus.status === "completed" && !hasWinner) { + hasWinner = true; + winnerName = branch.name; + winnerValue = branchStatus.output as T; + } + } + } + + // If we found a replay winner, return immediately + if (hasWinner && winnerName !== null) { + return { winner: winnerName, value: winnerValue as T }; + } + + // Execute branches that need to run + for (const branch of branches) { + const branchStatus = raceData.branches[branch.name]; + if (!branchStatus) { + throw new HistoryDivergedError( + `Expected race branch "${branch.name}" in "${name}"`, + ); + } + + // Skip already completed/cancelled + if ( + branchStatus.status !== "pending" && + branchStatus.status !== "running" + ) { + continue; + } + + const branchLocation = appendName( + this.storage, + location, + branch.name, + ); + const branchCtx = this.createBranch( + branchLocation, + raceAbortController, + ); + + branchStatus.status = "running"; + branchStatus.error = undefined; + entry.dirty = true; + + const branchPromise = branch.run(branchCtx).then( + async (output) => { + if (hasWinner) { + // This branch completed after a winner was determined + // Still record the completion for observability + branchStatus.status = "completed"; + branchStatus.output = output; + branchStatus.error = undefined; + entry.dirty = true; + return; + } + + if (propagatedError) { + branchStatus.status = "completed"; + branchStatus.output = output; + branchStatus.error = undefined; + entry.dirty = true; + return; + } + + hasWinner = true; + winnerName = branch.name; + winnerValue = output; + + branchCtx.validateComplete(); + + branchStatus.status = "completed"; + branchStatus.output = output; + branchStatus.error = undefined; + raceData.winner = branch.name; + entry.dirty = true; + + // Cancel other branches + raceAbortController.abort(); + }, + (error) => { + if (hasWinner) { + if ( + error instanceof CancelledError || + error instanceof EvictedError + ) { + branchStatus.status = "cancelled"; + } else { + lateErrors.push({ + name: branch.name, + error: String(error), + }); + } + entry.dirty = true; + return; + } + + if ( + error instanceof SleepError || + error instanceof MessageWaitError || + error instanceof StepFailedError + ) { + schedulerYieldState = mergeSchedulerYield( + schedulerYieldState, + error, + ); + branchStatus.status = "running"; + branchStatus.error = undefined; + entry.dirty = true; + return; + } + + if ( + error instanceof EvictedError || + error instanceof HistoryDivergedError || + error instanceof EntryInProgressError || + error instanceof RollbackCheckpointError || + error instanceof RollbackStopError + ) { + propagatedError = selectControlFlowError( + propagatedError, + error, + ); + branchStatus.status = "running"; + branchStatus.error = undefined; + entry.dirty = true; + return; + } + + if (error instanceof CancelledError) { + branchStatus.status = "cancelled"; + } else { + branchStatus.status = "failed"; + branchStatus.output = undefined; + branchStatus.error = String(error); + errors[branch.name] = error as Error; + } + entry.dirty = true; + }, + ); + + branchPromises.push(branchPromise); + } + + // Wait for all branches to complete or be cancelled + await Promise.allSettled(branchPromises); + + if (propagatedError) { + await this.flushStorage(); + throw propagatedError; + } + + if ( + !hasWinner && + Object.values(raceData.branches).some( + (branch) => + branch.status === "pending" || branch.status === "running", + ) + ) { + await this.flushStorage(); + if (!schedulerYieldState) { + throw new Error( + `Race "${name}" has pending branches without a scheduler yield`, + ); + } + throw buildSchedulerYieldError(schedulerYieldState); + } + + // Clean up entries from non-winning branches + if (hasWinner && winnerName !== null) { + for (const branch of branches) { + if (branch.name !== winnerName) { + const branchLocation = appendName( + this.storage, + location, + branch.name, + ); + await deleteEntriesWithPrefix( + this.storage, + this.driver, + branchLocation, + this.historyNotifier, + ); + } + } + } + + // Flush final state + await this.flushStorage(); + + // Log late errors if any (these occurred after a winner was determined) + if (lateErrors.length > 0) { + console.warn( + `Race "${name}" had ${lateErrors.length} branch(es) fail after winner was determined:`, + lateErrors, + ); + } + + // Return result or throw error + if (hasWinner && winnerName !== null) { + return { winner: winnerName, value: winnerValue as T }; + } + + // All branches failed + throw attachTryBlockFailure( + new RaceError( + "All branches failed", + Object.entries(errors).map(([branchName, error]) => ({ + name: branchName, + error: String(error), + })), + ), + { + source: "race", + name, + }, + ); + } + + // === Removed === + + async removed(name: string, originalType: EntryKindType): Promise { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + await this.executeRemoved(name, originalType); + } finally { + this.entryInProgress = false; + } + } + + private async executeRemoved( + name: string, + originalType: EntryKindType, + ): Promise { + // Check for duplicate name in current execution + this.checkDuplicateName(name); + + const location = appendName(this.storage, this.currentLocation, name); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + this.stopRollbackIfMissing(existing); + + if (existing) { + // Validate the existing entry matches what we expect + if ( + existing.kind.type !== "removed" && + existing.kind.type !== originalType + ) { + throw new HistoryDivergedError( + `Expected ${originalType} or removed at ${key}, found ${existing.kind.type}`, + ); + } + + // If it's not already marked as removed, we just skip it + return; + } + + // Create a removed entry placeholder + const entry = createEntry(location, { + type: "removed", + data: { originalType, originalName: name }, + }); + setEntry(this.storage, location, entry); + await this.flushStorage(); + } + + // === Version === + + async getVersion(name: string, latest: number): Promise { + this.assertNotInProgress(); + this.checkEvicted(); + + this.entryInProgress = true; + try { + return await this.executeGetVersion(name, latest); + } finally { + this.entryInProgress = false; + } + } + + private async executeGetVersion( + name: string, + latest: number, + ): Promise { + if (!Number.isInteger(latest) || latest < 1) { + throw new Error( + `getVersion("${name}", ${latest}): latest must be an integer >= 1`, + ); + } + + // Check for duplicate name in current execution + this.checkDuplicateName(name); + + const location = appendName(this.storage, this.currentLocation, name); + const key = locationToKey(this.storage, location); + const existing = this.storage.history.entries.get(key); + + // Mark this entry as visited for validateComplete + this.markVisited(key); + + this.stopRollbackIfMissing(existing); + + if (existing) { + if (existing.kind.type !== "version_check") { + throw new HistoryDivergedError( + `Expected version_check at ${key}, found ${existing.kind.type}`, + ); + } + // Pure replay: this instance is already pinned at this location. + return existing.kind.data.resolved; + } + + // No recorded version at this location. Decide whether this instance + // already executed past this point under older code (old in-flight) or + // is reaching it fresh at the live frontier. + // + // The discriminator is "is there any unvisited history entry under the + // current scope?". Entries created earlier in this same run are already + // marked visited, so the only unvisited entries under the scope are + // leftovers from a prior run, which proves this scope already executed + // under code that predates this gate. Such instances resolve to the + // implicit floor version 1 (old branch); fresh instances resolve to + // `latest`. + const resolved = this.hasUnvisitedUnderCurrentScope(key) ? 1 : latest; + + const entry = createEntry(location, { + type: "version_check", + data: { resolved, latest }, + }); + setEntry(this.storage, location, entry); + await this.flushStorage(); + + return resolved; + } + + /** + * Returns true if any history entry under the current location scope + * (other than `excludeKey`) has not yet been visited this run. Used by + * getVersion to detect an old in-flight instance whose scope was already + * executed by code that predates a version gate. + */ + private hasUnvisitedUnderCurrentScope(excludeKey: string): boolean { + const prefix = locationToKey(this.storage, this.currentLocation); + + for (const key of this.storage.history.entries.keys()) { + if (key === excludeKey) { + continue; + } + const isUnderPrefix = + prefix === "" + ? true + : key.startsWith(`${prefix}/`) || key === prefix; + if (isUnderPrefix && !this.visitedKeys.has(key)) { + return true; + } + } + return false; + } +} diff --git a/packages/workflows/src/driver.ts b/packages/workflows/src/driver.ts new file mode 100644 index 0000000..1df9977 --- /dev/null +++ b/packages/workflows/src/driver.ts @@ -0,0 +1,113 @@ +import type { WorkflowMessageDriver } from "./types.js"; + +/** + * A key-value entry returned from list operations. + */ +export interface KVEntry { + key: Uint8Array; + value: Uint8Array; +} + +/** + * A write operation for batch writes. + */ +export interface KVWrite { + key: Uint8Array; + value: Uint8Array; +} + +/** + * The engine driver provides the KV and scheduling interface. + * Implementations must provide these methods to integrate with different backends. + * + * IMPORTANT: Each workflow instance must have its own isolated driver/KV namespace. + * The workflow engine is the sole reader/writer of its KV during execution. + * KV operations do not include workflow IDs because isolation is provided externally + * by the host system (e.g., Cloudflare Durable Objects, dedicated actor processes). + * + * External systems may only enqueue messages through the configured message driver + * (via WorkflowHandle.message()). + */ +export interface EngineDriver { + /** + * Requires each logical storage flush to reach `batch` as one indivisible + * unit. The driver must reject an oversized unit instead of splitting it. + */ + readonly atomicBatch?: boolean; + + // === KV Operations === + + /** + * Get a value by key. + * Returns null if the key doesn't exist. + */ + get(key: Uint8Array): Promise; + + /** + * Set a value by key. + */ + set(key: Uint8Array, value: Uint8Array): Promise; + + /** + * Delete a key. + */ + delete(key: Uint8Array): Promise; + + /** + * Delete all keys with a given prefix. + */ + deletePrefix(prefix: Uint8Array): Promise; + + /** + * Delete all keys in the half-open range [start, end). + */ + deleteRange(start: Uint8Array, end: Uint8Array): Promise; + + /** + * List all key-value pairs with a given prefix. + * + * IMPORTANT: Results MUST be sorted by key in lexicographic byte order. + * The workflow engine relies on this ordering for deterministic history + * replay and name registry reconstruction. Failing to sort will cause + * non-deterministic replay behavior. + */ + list(prefix: Uint8Array): Promise; + + /** + * Batch write multiple key-value pairs. + * Should be atomic if possible. + */ + batch(writes: KVWrite[]): Promise; + + // === Scheduling === + + /** + * Set an alarm to wake the workflow at a specific time. + * @param workflowId The workflow to wake + * @param wakeAt Timestamp in milliseconds when to wake + */ + setAlarm(workflowId: string, wakeAt: number): Promise; + + /** + * Clear any pending alarm for a workflow. + */ + clearAlarm(workflowId: string): Promise; + + /** + * How often the worker polls for work (in milliseconds). + * Affects the threshold for in-memory vs scheduled sleeps. + */ + readonly workerPollInterval: number; + + /** Queue-backed message driver used for workflow messaging. */ + readonly messageDriver: WorkflowMessageDriver; + + /** + * Wait for incoming messages when running in live mode. + * Implementations should resolve when any of the specified message names are available. + */ + waitForMessages( + messageNames: string[], + abortSignal: AbortSignal, + ): Promise; +} diff --git a/packages/workflows/src/error-utils.ts b/packages/workflows/src/error-utils.ts new file mode 100644 index 0000000..ee704d4 --- /dev/null +++ b/packages/workflows/src/error-utils.ts @@ -0,0 +1,87 @@ +import type { WorkflowError, WorkflowErrorEvent } from "./types.js"; + +const WORKFLOW_ERROR_REPORTED_SYMBOL = Symbol("workflow.error.reported"); + +/** + * Extract structured error information from an error. + */ +export function extractErrorInfo(error: unknown): WorkflowError { + if (error instanceof Error) { + const result: WorkflowError = { + name: error.name, + message: error.message, + stack: error.stack, + }; + + const metadata: Record = {}; + for (const key of Object.keys(error)) { + if (key !== "name" && key !== "message" && key !== "stack") { + const value = (error as unknown as Record)[ + key + ]; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ) { + metadata[key] = value; + } + } + } + if (Object.keys(metadata).length > 0) { + result.metadata = metadata; + } + + return result; + } + + return { + name: "Error", + message: String(error), + }; +} + +/** + * Mark an error after it has been reported to the error hook. + */ +export function markErrorReported(error: T): T { + ( + error as T & { + [WORKFLOW_ERROR_REPORTED_SYMBOL]?: boolean; + } + )[WORKFLOW_ERROR_REPORTED_SYMBOL] = true; + return error; +} + +/** + * Check if an error was already reported to the error hook. + */ +export function isErrorReported(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return Boolean( + ( + error as Error & { + [WORKFLOW_ERROR_REPORTED_SYMBOL]?: boolean; + } + )[WORKFLOW_ERROR_REPORTED_SYMBOL], + ); +} + +/** + * Return the outer tag name for a workflow error event. + */ +export function getErrorEventTag( + event: WorkflowErrorEvent, +): "step" | "rollback" | "workflow" { + if ("step" in event) { + return "step"; + } + if ("rollback" in event) { + return "rollback"; + } + return "workflow"; +} diff --git a/packages/workflows/src/errors.ts b/packages/workflows/src/errors.ts new file mode 100644 index 0000000..5e1ef10 --- /dev/null +++ b/packages/workflows/src/errors.ts @@ -0,0 +1,171 @@ +/** + * Thrown from steps to prevent retry. + * Use this when an error is unrecoverable and retrying would be pointless. + */ +export class CriticalError extends Error { + constructor(message: string) { + super(message); + this.name = "CriticalError"; + } +} + +/** + * Thrown from steps to force rollback without retry. + */ +export class RollbackError extends Error { + constructor(message: string) { + super(message); + this.name = "RollbackError"; + } +} + +/** + * Thrown when rollback is used without a checkpoint. + */ +export class RollbackCheckpointError extends Error { + constructor() { + super("Rollback requires a checkpoint before any rollback step"); + this.name = "RollbackCheckpointError"; + } +} + +/** + * Internal: Workflow should sleep until deadline. + * This is thrown to yield control back to the scheduler. + * Optionally, the workflow can also wake early if certain messages arrive. + */ +export class SleepError extends Error { + constructor( + public readonly deadline: number, + public readonly messageNames?: string[], + ) { + super( + messageNames && messageNames.length > 0 + ? `Sleeping until ${deadline} or messages: ${messageNames.join(", ")}` + : `Sleeping until ${deadline}`, + ); + this.name = "SleepError"; + } +} + +/** + * Internal: Workflow is waiting for messages. + * This is thrown to yield control back to the scheduler. + */ +export class MessageWaitError extends Error { + constructor(public readonly messageNames: string[]) { + super(`Waiting for messages: ${messageNames.join(", ")}`); + this.name = "MessageWaitError"; + } +} + +/** + * Internal: Workflow was evicted. + * This is thrown when the workflow is being gracefully stopped. + */ +export class EvictedError extends Error { + constructor() { + super("Workflow evicted"); + this.name = "EvictedError"; + } +} + +/** + * Internal: Stop rollback traversal. + */ +export class RollbackStopError extends Error { + constructor() { + super("Rollback traversal halted"); + this.name = "RollbackStopError"; + } +} + +/** + * Workflow code changed incompatibly. + * Thrown when history doesn't match the current workflow code. + */ +export class HistoryDivergedError extends Error { + constructor(message: string) { + super(message); + this.name = "HistoryDivergedError"; + } +} + +/** + * Step exhausted all retries. + */ +export class StepExhaustedError extends Error { + constructor( + public readonly stepName: string, + public readonly lastError?: string, + ) { + super( + `Step "${stepName}" exhausted retries: ${lastError ?? "unknown error"}`, + ); + this.name = "StepExhaustedError"; + } +} + +/** + * Step failed (will be retried). + * Internal error used to trigger retry logic. + */ +export class StepFailedError extends Error { + constructor( + public readonly stepName: string, + public readonly originalError: unknown, + public readonly attempts: number, + public readonly retryAt: number, + ) { + super(`Step "${stepName}" failed (attempt ${attempts})`); + this.name = "StepFailedError"; + this.cause = originalError; + } +} + +/** + * Join had branch failures. + */ +export class JoinError extends Error { + constructor(public readonly errors: Record) { + super(`Join failed: ${Object.keys(errors).join(", ")}`); + this.name = "JoinError"; + } +} + +/** + * Race had all branches fail. + */ +export class RaceError extends Error { + constructor( + message: string, + public readonly errors: Array<{ name: string; error: string }>, + ) { + super(message); + this.name = "RaceError"; + } +} + +/** + * Branch was cancelled (used by race). + */ +export class CancelledError extends Error { + constructor() { + super("Branch cancelled"); + this.name = "CancelledError"; + } +} + +/** + * Entry is currently being processed. + * Thrown when user forgets to await a step. + */ +export class EntryInProgressError extends Error { + constructor() { + super( + "Cannot start a new workflow entry while another is in progress. " + + "Did you forget to await the previous step/loop/sleep?", + ); + this.name = "EntryInProgressError"; + } +} diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts new file mode 100644 index 0000000..8182a83 --- /dev/null +++ b/packages/workflows/src/index.ts @@ -0,0 +1,1096 @@ +import type { Logger } from "pino"; + +// Types + +// Context +export { + DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_BASE, + DEFAULT_RETRY_BACKOFF_MAX, + DEFAULT_STEP_TIMEOUT, + WorkflowContextImpl, +} from "./context.js"; +// Driver +export type { EngineDriver, KVEntry, KVWrite } from "./driver.js"; +export { extractErrorInfo } from "./error-utils.js"; +// Errors +export { + CancelledError, + CriticalError, + EntryInProgressError, + EvictedError, + HistoryDivergedError, + JoinError, + MessageWaitError, + RaceError, + RollbackCheckpointError, + RollbackError, + SleepError, + StepExhaustedError, + StepFailedError, +} from "./errors.js"; + +// Location utilities +export { + appendLoopIteration, + appendName, + emptyLocation, + isLocationPrefix, + isLoopIterationMarker, + locationsEqual, + locationToKey, + parentLocation, + registerName, + resolveName, +} from "./location.js"; + +// Storage utilities +export { + createEntry, + createHistorySnapshot, + createStorage, + deleteEntriesWithPrefix, + flush, + generateId, + getEntry, + getOrCreateMetadata, + loadMetadata, + loadStorage, + setEntry, +} from "./storage.js"; +export type { + BranchConfig, + BranchOutput, + BranchStatus, + BranchStatusType, + Entry, + EntryKind, + EntryKindType, + EntryMetadata, + EntryStatus, + History, + JoinEntry, + Location, + LoopConfig, + LoopEntry, + LoopIterationMarker, + LoopResult, + Message, + MessageEntry, + NameIndex, + PathSegment, + RaceEntry, + RemovedEntry, + RollbackCheckpointEntry, + RollbackContextInterface, + RunWorkflowOptions, + SleepEntry, + SleepState, + StepConfig, + StepEntry, + Storage, + TryBlockCatchKind, + TryBlockConfig, + TryBlockFailure, + TryBlockResult, + TryStepCatchKind, + TryStepConfig, + TryStepFailure, + TryStepResult, + WorkflowContextInterface, + WorkflowEntryMetadataSnapshot, + WorkflowError, + WorkflowErrorEvent, + WorkflowErrorHandler, + WorkflowFunction, + WorkflowHandle, + WorkflowHistoryEntry, + WorkflowHistorySnapshot, + WorkflowMessageDriver, + WorkflowMessageIdentity, + WorkflowQueue, + WorkflowQueueMessage, + WorkflowQueueNextBatchOptions, + WorkflowQueueNextOptions, + WorkflowResult, + WorkflowRollbackErrorEvent, + WorkflowRunErrorEvent, + WorkflowRunMode, + WorkflowState, + WorkflowStepErrorEvent, +} from "./types.js"; + +// Loop result helpers +export const Loop = { + continue: (state: S): { continue: true; state: S } => ({ + continue: true, + state, + }), + break: (value: T): { break: true; value: T } => ({ + break: true, + value, + }), +}; + +import { + deserializeEntryMetadata, + deserializeWorkflowInput, + deserializeWorkflowOutput, + deserializeWorkflowState, + serializeEntryMetadata, + serializeWorkflowInput, + serializeWorkflowState, +} from "../schemas/serde.js"; +import { type RollbackAction, WorkflowContextImpl } from "./context.js"; +// Main workflow runner +import type { EngineDriver } from "./driver.js"; +import { + extractErrorInfo, + getErrorEventTag, + isErrorReported, + markErrorReported, +} from "./error-utils.js"; +import { + CriticalError, + EvictedError, + MessageWaitError, + RollbackCheckpointError, + RollbackError, + RollbackStopError, + SleepError, + StepExhaustedError, + StepFailedError, +} from "./errors.js"; +import { + buildEntryMetadataKey, + buildEntryMetadataPrefix, + buildHistoryKey, + buildWorkflowErrorKey, + buildWorkflowInputKey, + buildWorkflowOutputKey, + buildWorkflowStateKey, +} from "./keys.js"; +import { isLocationPrefix } from "./location.js"; +import { + createHistorySnapshot, + flush, + generateId, + loadMetadata, + loadStorage, +} from "./storage.js"; +import type { + Entry, + EntryMetadata, + RollbackContextInterface, + RunWorkflowOptions, + Storage, + WorkflowErrorEvent, + WorkflowFunction, + WorkflowHandle, + WorkflowHistorySnapshot, + WorkflowMessageDriver, + WorkflowResult, + WorkflowRunMode, + WorkflowState, +} from "./types.js"; +import { setLongTimeout } from "./utils.js"; + +/** + * Run a workflow and return a handle for managing it. + * + * The workflow starts executing immediately. Use the returned handle to: + * - `handle.result` - Await workflow completion (or yield in `yield` mode) + * - `handle.message()` - Send messages to the workflow + * - `handle.wake()` - Wake the workflow early + * - `handle.evict()` - Request graceful shutdown + * - `handle.getOutput()` / `handle.getState()` - Query status + */ +interface LiveRuntime { + sleepWaiter?: () => void; + isSleeping: boolean; +} + +type HistoryNotifier = (() => void) | undefined; + +type ReplayEntryRecord = { + key: string; + entry: Entry; + metadata: EntryMetadata; +}; + +function createLiveRuntime(): LiveRuntime { + return { + isSleeping: false, + }; +} + +function createEvictionWait(signal: AbortSignal): { + promise: Promise; + cleanup: () => void; +} { + if (signal.aborted) { + return { + promise: Promise.reject(new EvictedError()), + cleanup: () => {}, + }; + } + + let onAbort: (() => void) | undefined; + const promise = new Promise((_, reject) => { + onAbort = () => { + reject(new EvictedError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + + return { + promise, + cleanup: () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + } + }, + }; +} + +function createRollbackContext( + workflowId: string, + abortController: AbortController, +): RollbackContextInterface { + return { + workflowId, + abortSignal: abortController.signal, + isEvicted: () => abortController.signal.aborted, + }; +} + +async function awaitWithEviction( + promise: Promise, + abortSignal: AbortSignal, +): Promise { + const { promise: evictionPromise, cleanup } = + createEvictionWait(abortSignal); + try { + return await Promise.race([promise, evictionPromise]); + } finally { + cleanup(); + } +} + +async function executeRollback( + workflowId: string, + workflowFn: WorkflowFunction, + input: TInput, + driver: EngineDriver, + messageDriver: WorkflowMessageDriver, + abortController: AbortController, + storage: Storage, + historyNotifier?: HistoryNotifier, + onError?: RunWorkflowOptions["onError"], + logger?: Logger, +): Promise { + const rollbackActions: RollbackAction[] = []; + const ctx = new WorkflowContextImpl( + workflowId, + storage, + driver, + messageDriver, + undefined, + abortController, + "rollback", + rollbackActions, + false, + historyNotifier, + onError, + logger, + ); + + try { + await workflowFn(ctx, input); + } catch (error) { + if (error instanceof EvictedError) { + throw error; + } + if (error instanceof RollbackStopError) { + // Stop replay once we hit incomplete history during rollback. + } else { + // Ignore workflow errors during rollback replay. + } + } + + if (rollbackActions.length === 0) { + return; + } + + const rollbackContext = createRollbackContext(workflowId, abortController); + + for (let i = rollbackActions.length - 1; i >= 0; i--) { + if (abortController.signal.aborted) { + throw new EvictedError(); + } + + const action = rollbackActions[i]; + const metadata = await loadMetadata(storage, driver, action.entryId); + if (metadata.rollbackCompletedAt !== undefined) { + continue; + } + + let rollbackEvent: WorkflowErrorEvent | undefined; + try { + await awaitWithEviction( + action.rollback(rollbackContext, action.output), + abortController.signal, + ); + metadata.rollbackCompletedAt = Date.now(); + metadata.rollbackError = undefined; + } catch (error) { + if (error instanceof EvictedError) { + throw error; + } + metadata.rollbackError = + error instanceof Error ? error.message : String(error); + if (onError) { + rollbackEvent = { + rollback: { + workflowId, + stepName: action.name, + error: extractErrorInfo(error), + }, + }; + } + if (error instanceof Error) { + markErrorReported(error); + } + throw error; + } finally { + metadata.dirty = true; + await flush(storage, driver, historyNotifier); + if (rollbackEvent && onError) { + await notifyError(onError, logger, rollbackEvent); + } + } + } +} + +async function notifyError( + onError: NonNullable, + logger: Logger | undefined, + event: WorkflowErrorEvent, +): Promise { + try { + await onError(event); + } catch (error) { + logger?.warn({ + msg: "workflow error hook failed", + hookEventType: getErrorEventTag(event), + error: extractErrorInfo(error), + }); + } +} + +async function setSleepState( + storage: Storage, + driver: EngineDriver, + workflowId: string, + deadline: number, + messageNames?: string[], + historyNotifier?: HistoryNotifier, +): Promise> { + storage.state = "sleeping"; + await flush(storage, driver, historyNotifier); + await driver.setAlarm(workflowId, deadline); + + return { + state: "sleeping", + sleepUntil: deadline, + waitingForMessages: messageNames, + }; +} + +async function setMessageWaitState( + storage: Storage, + driver: EngineDriver, + messageNames: string[], + historyNotifier?: HistoryNotifier, +): Promise> { + storage.state = "sleeping"; + await flush(storage, driver, historyNotifier); + + return { state: "sleeping", waitingForMessages: messageNames }; +} + +async function setEvictedState( + storage: Storage, + driver: EngineDriver, + historyNotifier?: HistoryNotifier, +): Promise> { + await flush(storage, driver, historyNotifier); + return { state: storage.state }; +} + +async function setRetryState( + storage: Storage, + driver: EngineDriver, + workflowId: string, + retryAt: number, + historyNotifier?: HistoryNotifier, +): Promise> { + storage.state = "sleeping"; + await flush(storage, driver, historyNotifier); + + await driver.setAlarm(workflowId, retryAt); + + return { state: "sleeping", sleepUntil: retryAt }; +} + +async function setFailedState( + storage: Storage, + driver: EngineDriver, + error: unknown, + historyNotifier?: HistoryNotifier, +): Promise { + storage.state = "failed"; + storage.error = extractErrorInfo(error); + await flush(storage, driver, historyNotifier); +} + +async function waitForSleep( + runtime: LiveRuntime, + deadline: number, + abortSignal: AbortSignal, +): Promise { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return; + } + + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = setLongTimeout(resolve, remaining); + }); + + const wakePromise = new Promise((resolve) => { + runtime.sleepWaiter = resolve; + }); + runtime.isSleeping = true; + + try { + await awaitWithEviction( + Promise.race([timeoutPromise, wakePromise]), + abortSignal, + ); + } finally { + runtime.isSleeping = false; + runtime.sleepWaiter = undefined; + timeoutHandle?.abort(); + } + + if (abortSignal.aborted) { + throw new EvictedError(); + } + + if (Date.now() >= deadline) { + return; + } + } +} + +async function executeLiveWorkflow( + workflowId: string, + workflowFn: WorkflowFunction, + input: TInput, + driver: EngineDriver, + messageDriver: WorkflowMessageDriver, + abortController: AbortController, + runtime: LiveRuntime, + onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void, + onError?: RunWorkflowOptions["onError"], + logger?: Logger, +): Promise> { + let lastResult: WorkflowResult | undefined; + + while (true) { + const result = await executeWorkflow( + workflowId, + workflowFn, + input, + driver, + messageDriver, + abortController, + onHistoryUpdated, + onError, + logger, + ); + lastResult = result; + + if (result.state !== "sleeping") { + return result; + } + + const hasMessages = result.waitingForMessages !== undefined; + const hasDeadline = result.sleepUntil !== undefined; + + if (hasMessages && hasDeadline) { + // Wait for EITHER a message OR the deadline (for queue.next timeout). + // Scope both waiters to a per-iteration controller chained to the run + // signal so that whichever loses the race is cancelled immediately. + // Otherwise the loser (a message waiter or an armed sleep timer) stays + // registered on the long-lived run signal and leaks once per cycle. + const iterationAbort = new AbortController(); + const onRunAbort = () => iterationAbort.abort(); + if (abortController.signal.aborted) { + iterationAbort.abort(); + } else { + abortController.signal.addEventListener("abort", onRunAbort, { + once: true, + }); + } + const messagePromise = awaitWithEviction( + driver.waitForMessages( + result.waitingForMessages!, + iterationAbort.signal, + ), + iterationAbort.signal, + ); + const sleepPromise = waitForSleep( + runtime, + result.sleepUntil!, + iterationAbort.signal, + ); + // Swallow the loser's rejection once it is aborted below so it does + // not surface as an unhandled rejection. + messagePromise.catch(() => {}); + sleepPromise.catch(() => {}); + try { + await Promise.race([messagePromise, sleepPromise]); + } catch (error) { + if (error instanceof EvictedError) { + return lastResult; + } + throw error; + } finally { + iterationAbort.abort(); + abortController.signal.removeEventListener("abort", onRunAbort); + } + continue; + } + + if (hasMessages) { + try { + await awaitWithEviction( + driver.waitForMessages( + result.waitingForMessages!, + abortController.signal, + ), + abortController.signal, + ); + } catch (error) { + if (error instanceof EvictedError) { + return lastResult; + } + throw error; + } + continue; + } + + if (hasDeadline) { + try { + await waitForSleep( + runtime, + result.sleepUntil!, + abortController.signal, + ); + } catch (error) { + if (error instanceof EvictedError) { + return lastResult; + } + throw error; + } + continue; + } + + return result; + } +} + +export function runWorkflow( + workflowId: string, + workflowFn: WorkflowFunction, + input: TInput, + driver: EngineDriver, + options: RunWorkflowOptions = {}, +): WorkflowHandle { + const messageDriver = driver.messageDriver; + const abortController = new AbortController(); + const mode: WorkflowRunMode = options.mode ?? "yield"; + const liveRuntime = mode === "live" ? createLiveRuntime() : undefined; + + const logger = options.logger; + + const resultPromise = + mode === "live" && liveRuntime + ? executeLiveWorkflow( + workflowId, + workflowFn, + input, + driver, + messageDriver, + abortController, + liveRuntime, + options.onHistoryUpdated, + options.onError, + logger, + ) + : executeWorkflow( + workflowId, + workflowFn, + input, + driver, + messageDriver, + abortController, + options.onHistoryUpdated, + options.onError, + logger, + ); + + return { + workflowId, + result: resultPromise, + + async message(name: string, data: unknown): Promise { + const messageId = generateId(); + await messageDriver.addMessage({ + id: messageId, + name, + data, + sentAt: Date.now(), + }); + }, + + async wake(): Promise { + if (liveRuntime) { + if (liveRuntime.isSleeping && liveRuntime.sleepWaiter) { + liveRuntime.sleepWaiter(); + } + return; + } + await driver.setAlarm(workflowId, Date.now()); + }, + + async recover(): Promise { + const stateValue = await driver.get(buildWorkflowStateKey()); + const state = stateValue + ? deserializeWorkflowState(stateValue) + : "pending"; + + if (state !== "failed") { + return; + } + + const metadataEntries = await driver.list( + buildEntryMetadataPrefix(), + ); + const writes: { key: Uint8Array; value: Uint8Array }[] = []; + + for (const entry of metadataEntries) { + const metadata = deserializeEntryMetadata(entry.value); + if ( + metadata.status !== "failed" && + metadata.status !== "exhausted" + ) { + continue; + } + + metadata.status = "pending"; + metadata.attempts = 0; + metadata.lastAttemptAt = 0; + metadata.error = undefined; + metadata.dirty = false; + + writes.push({ + key: entry.key, + value: serializeEntryMetadata(metadata), + }); + } + + if (writes.length > 0) { + await driver.batch(writes); + } + + await driver.delete(buildWorkflowErrorKey()); + await driver.set( + buildWorkflowStateKey(), + serializeWorkflowState("sleeping"), + ); + + if (liveRuntime) { + if (liveRuntime.isSleeping && liveRuntime.sleepWaiter) { + liveRuntime.sleepWaiter(); + } + return; + } + + await driver.setAlarm(workflowId, Date.now()); + }, + + evict(): void { + abortController.abort(new EvictedError()); + }, + + async cancel(): Promise { + abortController.abort(new EvictedError()); + + await driver.set( + buildWorkflowStateKey(), + serializeWorkflowState("cancelled"), + ); + + await driver.clearAlarm(workflowId); + }, + + async getOutput(): Promise { + const value = await driver.get(buildWorkflowOutputKey()); + if (!value) { + return undefined; + } + return deserializeWorkflowOutput(value); + }, + + async getState(): Promise { + const value = await driver.get(buildWorkflowStateKey()); + if (!value) { + return "pending"; + } + return deserializeWorkflowState(value); + }, + }; +} + +/** + * Remove a step and every later workflow entry, then schedule the workflow to + * start again immediately. Omitting `entryId` replays the workflow from the + * beginning. + */ +export async function replayWorkflowFromStep( + workflowId: string, + driver: EngineDriver, + entryId?: string, + options?: { + scheduleAlarm?: boolean; + }, +): Promise { + const storage = await loadStorage(driver); + const entries = await Promise.all( + Array.from(storage.history.entries.entries()).map( + async ([key, entry]) => ({ + key, + entry, + metadata: await loadMetadata(storage, driver, entry.id), + }), + ), + ); + const ordered = [...entries].sort((a, b) => { + if (a.metadata.createdAt !== b.metadata.createdAt) { + return a.metadata.createdAt - b.metadata.createdAt; + } + return a.key.localeCompare(b.key); + }); + + let entriesToDelete = ordered; + if (entryId !== undefined) { + const target = entries.find(({ entry }) => entry.id === entryId); + if (!target) { + throw new Error(`Workflow step not found: ${entryId}`); + } + if (target.entry.kind.type !== "step") { + throw new Error("Workflow replay target must be a step"); + } + + const replayBoundary = findReplayBoundaryEntry(entries, target); + const targetIndex = ordered.findIndex( + ({ entry }) => entry.id === replayBoundary.entry.id, + ); + entriesToDelete = ordered.slice(targetIndex); + } + + const entryIdsToDelete = new Set( + entriesToDelete.map(({ entry }) => entry.id), + ); + if ( + entries.some( + ({ entry, metadata }) => + metadata.status === "running" && + !entryIdsToDelete.has(entry.id), + ) + ) { + throw new Error( + "Cannot replay a workflow while a step is currently running", + ); + } + + await Promise.all( + entriesToDelete.flatMap(({ entry }) => [ + driver.delete(buildHistoryKey(entry.location)), + driver.delete(buildEntryMetadataKey(entry.id)), + ]), + ); + + for (const { key, entry } of entriesToDelete) { + storage.history.entries.delete(key); + storage.entryMetadata.delete(entry.id); + } + + storage.output = undefined; + storage.flushedOutput = undefined; + storage.error = undefined; + storage.flushedError = undefined; + storage.state = "sleeping"; + storage.flushedState = "sleeping"; + + await Promise.all([ + driver.delete(buildWorkflowOutputKey()), + driver.delete(buildWorkflowErrorKey()), + driver.set(buildWorkflowStateKey(), serializeWorkflowState("sleeping")), + ]); + if (options?.scheduleAlarm ?? true) { + await driver.setAlarm(workflowId, Date.now()); + } + + return createHistorySnapshot(storage); +} + +function findReplayBoundaryEntry( + entries: ReplayEntryRecord[], + target: ReplayEntryRecord, +): ReplayEntryRecord { + let boundary = target; + let boundaryDepth = -1; + + for (const candidate of entries) { + if (candidate.entry.kind.type !== "loop") { + continue; + } + if ( + candidate.entry.location.length >= target.entry.location.length || + !isLocationPrefix(candidate.entry.location, target.entry.location) + ) { + continue; + } + if (candidate.entry.location.length > boundaryDepth) { + boundary = candidate; + boundaryDepth = candidate.entry.location.length; + } + } + + return boundary; +} + +/** + * Internal: Execute the workflow and return the result. + */ +async function executeWorkflow( + workflowId: string, + workflowFn: WorkflowFunction, + input: TInput, + driver: EngineDriver, + messageDriver: WorkflowMessageDriver, + abortController: AbortController, + onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void, + onError?: RunWorkflowOptions["onError"], + logger?: Logger, +): Promise> { + const storage = await loadStorage(driver); + const historyNotifier: HistoryNotifier = onHistoryUpdated + ? () => onHistoryUpdated(createHistorySnapshot(storage)) + : undefined; + if (historyNotifier) { + historyNotifier(); + } + + if (logger) { + const entryKeys = Array.from(storage.history.entries.keys()); + logger.debug({ + msg: "loaded workflow storage", + state: storage.state, + entryCount: entryKeys.length, + entries: entryKeys.slice(0, 10), + nameRegistry: storage.nameRegistry, + }); + } + + // Check if workflow was cancelled + if (storage.state === "cancelled") { + throw new EvictedError(); + } + + // Input persistence: store on first run, use stored input on resume + const storedInputBytes = await driver.get(buildWorkflowInputKey()); + let effectiveInput: TInput; + + if (storedInputBytes) { + // Resume: use stored input for deterministic replay + effectiveInput = deserializeWorkflowInput(storedInputBytes); + } else { + // First run: store the input + effectiveInput = input; + await driver.set( + buildWorkflowInputKey(), + serializeWorkflowInput(input), + ); + } + + if (storage.state === "rolling_back") { + try { + await executeRollback( + workflowId, + workflowFn, + effectiveInput, + driver, + messageDriver, + abortController, + storage, + historyNotifier, + onError, + logger, + ); + } catch (error) { + if (error instanceof EvictedError) { + return { state: storage.state }; + } + throw error; + } + + storage.state = "failed"; + await flush(storage, driver, historyNotifier); + + const storedError = storage.error + ? new Error(storage.error.message) + : new Error("Workflow failed"); + if (storage.error?.name) { + storedError.name = storage.error.name; + } + throw storedError; + } + + const ctx = new WorkflowContextImpl( + workflowId, + storage, + driver, + messageDriver, + undefined, + abortController, + "forward", + undefined, + false, + historyNotifier, + onError, + logger, + ); + + storage.state = "running"; + + try { + const output = await workflowFn(ctx, effectiveInput); + + storage.state = "completed"; + storage.output = output; + await flush(storage, driver, historyNotifier); + await driver.clearAlarm(workflowId); + + return { state: "completed", output }; + } catch (error) { + if (error instanceof SleepError) { + return await setSleepState( + storage, + driver, + workflowId, + error.deadline, + error.messageNames, + historyNotifier, + ); + } + + if (error instanceof MessageWaitError) { + return await setMessageWaitState( + storage, + driver, + error.messageNames, + historyNotifier, + ); + } + + if (error instanceof EvictedError) { + return await setEvictedState(storage, driver, historyNotifier); + } + + if (error instanceof StepFailedError) { + return await setRetryState( + storage, + driver, + workflowId, + error.retryAt, + historyNotifier, + ); + } + + if (error instanceof RollbackCheckpointError) { + await setFailedState(storage, driver, error, historyNotifier); + if (onError && !isErrorReported(error)) { + await notifyError(onError, logger, { + workflow: { + workflowId, + error: extractErrorInfo(error), + }, + }); + } + throw error; + } + + // Unrecoverable error + storage.error = extractErrorInfo(error); + storage.state = "rolling_back"; + await flush(storage, driver, historyNotifier); + + try { + await executeRollback( + workflowId, + workflowFn, + effectiveInput, + driver, + messageDriver, + abortController, + storage, + historyNotifier, + onError, + logger, + ); + } catch (rollbackError) { + if (rollbackError instanceof EvictedError) { + return { state: storage.state }; + } + throw rollbackError; + } + + storage.state = "failed"; + await flush(storage, driver, historyNotifier); + if (onError && !isErrorReported(error)) { + await notifyError(onError, logger, { + workflow: { + workflowId, + error: extractErrorInfo(error), + }, + }); + if ( + error instanceof CriticalError || + error instanceof RollbackError || + error instanceof StepExhaustedError + ) { + markErrorReported(error); + } + } + + throw error; + } +} diff --git a/packages/workflows/src/keys.ts b/packages/workflows/src/keys.ts new file mode 100644 index 0000000..d5e5b83 --- /dev/null +++ b/packages/workflows/src/keys.ts @@ -0,0 +1,303 @@ +/** + * Binary key encoding/decoding using fdb-tuple. + * All keys are encoded as tuples with integer prefixes for proper sorting. + */ + +import * as tuple from "fdb-tuple"; +import type { Location, LoopIterationMarker, PathSegment } from "./types.js"; + +// === Key Prefixes === +// Using integers for compact encoding and proper sorting + +export const KEY_PREFIX = { + NAMES: 1, // Name registry: [1, index] + HISTORY: 2, // History entries: [2, ...locationSegments] + WORKFLOW: 3, // Workflow metadata: [3, field] + ENTRY_METADATA: 4, // Entry metadata: [4, entryId] +} as const; + +// Workflow metadata field identifiers +export const WORKFLOW_FIELD = { + STATE: 1, + OUTPUT: 2, + ERROR: 3, + INPUT: 4, +} as const; + +// === Type Definitions === + +// fdb-tuple's TupleItem type - we use a subset +type TupleItem = string | number | boolean | null | TupleItem[]; + +// === Location Segment Encoding === + +/** + * Convert a path segment to tuple elements. + * - NameIndex (number) → just the number + * - LoopIterationMarker → nested tuple [loopIdx, iteration] + */ +function segmentToTuple(segment: PathSegment): TupleItem { + if (typeof segment === "number") { + return segment; + } + // LoopIterationMarker + return [segment.loop, segment.iteration]; +} + +/** + * Convert tuple elements back to a path segment. + */ +function tupleToSegment(element: TupleItem): PathSegment { + if (typeof element === "number") { + return element; + } + if (Array.isArray(element) && element.length === 2) { + const [loop, iteration] = element; + if (typeof loop === "number" && typeof iteration === "number") { + return { loop, iteration } as LoopIterationMarker; + } + } + throw new Error( + `Invalid path segment tuple element: ${JSON.stringify(element)}`, + ); +} + +/** + * Convert a location to tuple elements. + */ +function locationToTupleElements(location: Location): TupleItem[] { + return location.map(segmentToTuple); +} + +/** + * Convert tuple elements back to a location. + */ +function tupleElementsToLocation(elements: TupleItem[]): Location { + return elements.map(tupleToSegment); +} + +// === Helper Functions === + +/** + * Convert Buffer to Uint8Array. + */ +function bufferToUint8Array(buf: Buffer): Uint8Array { + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +} + +/** + * Convert Uint8Array to Buffer. + */ +function uint8ArrayToBuffer(arr: Uint8Array): Buffer { + return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength); +} + +/** + * Pack tuple items and return as Uint8Array. + */ +function pack(items: TupleItem | TupleItem[]): Uint8Array { + const buf = tuple.pack(items); + return bufferToUint8Array(buf); +} + +/** + * Unpack a Uint8Array and return tuple items. + */ +function unpack(data: Uint8Array): TupleItem[] { + const buf = uint8ArrayToBuffer(data); + return tuple.unpack(buf) as TupleItem[]; +} + +// === Key Builders === + +/** + * Build a key for the name registry. + * Key: [1, index] + */ +export function buildNameKey(index: number): Uint8Array { + return pack([KEY_PREFIX.NAMES, index]); +} + +/** + * Build a prefix for listing all names. + * Prefix: [1] + */ +export function buildNamePrefix(): Uint8Array { + return pack([KEY_PREFIX.NAMES]); +} + +/** + * Build a key for a history entry. + * Key: [2, ...locationSegments] + */ +export function buildHistoryKey(location: Location): Uint8Array { + return pack([KEY_PREFIX.HISTORY, ...locationToTupleElements(location)]); +} + +/** + * Build a prefix for listing history entries under a location. + * Prefix: [2, ...locationSegments] + */ +export function buildHistoryPrefix(location: Location): Uint8Array { + return pack([KEY_PREFIX.HISTORY, ...locationToTupleElements(location)]); +} + +/** + * Build a key range for loop iteration history entries. + * Range: [2, ...locationSegments, [loopSegment, fromIteration]] to + * [2, ...locationSegments, [loopSegment, toIteration]]) + */ +export function buildLoopIterationRange( + loopLocation: Location, + loopSegment: number, + fromIteration: number, + toIteration: number, +): { start: Uint8Array; end: Uint8Array } { + const loopLocationSegments = locationToTupleElements(loopLocation); + return { + start: pack([ + KEY_PREFIX.HISTORY, + ...loopLocationSegments, + [loopSegment, fromIteration], + ]), + end: pack([ + KEY_PREFIX.HISTORY, + ...loopLocationSegments, + [loopSegment, toIteration], + ]), + }; +} + +/** + * Build a prefix for listing all history entries. + * Prefix: [2] + */ +export function buildHistoryPrefixAll(): Uint8Array { + return pack([KEY_PREFIX.HISTORY]); +} + +/** + * Build a key for workflow state. + * Key: [3, 1] + */ +export function buildWorkflowStateKey(): Uint8Array { + return pack([KEY_PREFIX.WORKFLOW, WORKFLOW_FIELD.STATE]); +} + +/** + * Build a key for workflow output. + * Key: [3, 2] + */ +export function buildWorkflowOutputKey(): Uint8Array { + return pack([KEY_PREFIX.WORKFLOW, WORKFLOW_FIELD.OUTPUT]); +} + +/** + * Build a key for workflow error. + * Key: [3, 3] + */ +export function buildWorkflowErrorKey(): Uint8Array { + return pack([KEY_PREFIX.WORKFLOW, WORKFLOW_FIELD.ERROR]); +} + +/** + * Build a key for workflow input. + * Key: [3, 4] + */ +export function buildWorkflowInputKey(): Uint8Array { + return pack([KEY_PREFIX.WORKFLOW, WORKFLOW_FIELD.INPUT]); +} + +/** + * Build a key for entry metadata. + * Key: [4, entryId] + */ +export function buildEntryMetadataKey(entryId: string): Uint8Array { + return pack([KEY_PREFIX.ENTRY_METADATA, entryId]); +} + +/** + * Build a prefix for listing all entry metadata. + * Prefix: [4] + */ +export function buildEntryMetadataPrefix(): Uint8Array { + return pack([KEY_PREFIX.ENTRY_METADATA]); +} + +// === Key Parsers === + +/** + * Parse a name key and return the index. + * Key: [1, index] → index + */ +export function parseNameKey(key: Uint8Array): number { + const elements = unpack(key); + if (elements.length !== 2 || elements[0] !== KEY_PREFIX.NAMES) { + throw new Error("Invalid name key"); + } + return elements[1] as number; +} + +/** + * Parse a history key and return the location. + * Key: [2, ...segments] → Location + */ +export function parseHistoryKey(key: Uint8Array): Location { + const elements = unpack(key); + if (elements.length < 1 || elements[0] !== KEY_PREFIX.HISTORY) { + throw new Error("Invalid history key"); + } + return tupleElementsToLocation(elements.slice(1)); +} + +/** + * Parse an entry metadata key and return the entry ID. + * Key: [4, entryId] → entryId + */ +export function parseEntryMetadataKey(key: Uint8Array): string { + const elements = unpack(key); + if (elements.length !== 2 || elements[0] !== KEY_PREFIX.ENTRY_METADATA) { + throw new Error("Invalid entry metadata key"); + } + return elements[1] as string; +} + +// === Key Comparison Utilities === + +/** + * Check if a key starts with a prefix. + */ +export function keyStartsWith(key: Uint8Array, prefix: Uint8Array): boolean { + if (key.length < prefix.length) { + return false; + } + for (let i = 0; i < prefix.length; i++) { + if (key[i] !== prefix[i]) { + return false; + } + } + return true; +} + +/** + * Compare two keys lexicographically. + * Returns negative if a < b, 0 if a === b, positive if a > b. + */ +export function compareKeys(a: Uint8Array, b: Uint8Array): number { + const minLen = Math.min(a.length, b.length); + for (let i = 0; i < minLen; i++) { + if (a[i] !== b[i]) { + return a[i] - b[i]; + } + } + return a.length - b.length; +} + +/** + * Convert a key to a hex string for debugging. + */ +export function keyToHex(key: Uint8Array): string { + return Array.from(key) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/packages/workflows/src/location.ts b/packages/workflows/src/location.ts new file mode 100644 index 0000000..d3547ab --- /dev/null +++ b/packages/workflows/src/location.ts @@ -0,0 +1,171 @@ +import type { + Location, + LoopIterationMarker, + NameIndex, + PathSegment, + Storage, +} from "./types.js"; + +/** + * Check if a path segment is a loop iteration marker. + */ +export function isLoopIterationMarker( + segment: PathSegment, +): segment is LoopIterationMarker { + return typeof segment === "object" && "loop" in segment; +} + +/** + * Register a name in the registry and return its index. + * If the name already exists, returns the existing index. + */ +export function registerName(storage: Storage, name: string): NameIndex { + const existing = storage.nameRegistry.indexOf(name); + if (existing !== -1) { + return existing; + } + storage.nameRegistry.push(name); + return storage.nameRegistry.length - 1; +} + +/** + * Resolve a name index to its string value. + */ +export function resolveName(storage: Storage, index: NameIndex): string { + const name = storage.nameRegistry[index]; + if (name === undefined) { + throw new Error(`Name index ${index} not found in registry`); + } + return name; +} + +/** + * Convert a location to a KV key string. + * Named entries use their string name, loop iterations use ~N format. + */ +export function locationToKey(storage: Storage, location: Location): string { + return location + .map((segment) => { + if (typeof segment === "number") { + return resolveName(storage, segment); + } + return `~${segment.iteration}`; + }) + .join("/"); +} + +/** + * Append a named segment to a location. + */ +export function appendName( + storage: Storage, + location: Location, + name: string, +): Location { + const nameIndex = registerName(storage, name); + return [...location, nameIndex]; +} + +/** + * Append a loop iteration segment to a location. + */ +export function appendLoopIteration( + storage: Storage, + location: Location, + loopName: string, + iteration: number, +): Location { + const loopIndex = registerName(storage, loopName); + return [...location, { loop: loopIndex, iteration }]; +} + +/** + * Create an empty location (root). + */ +export function emptyLocation(): Location { + return []; +} + +/** + * Get the parent location (all segments except the last). + */ +export function parentLocation(location: Location): Location { + return location.slice(0, -1); +} + +/** + * Check if one location is a prefix of another. + */ +export function isLocationPrefix( + prefix: Location, + location: Location, +): boolean { + if (prefix.length > location.length) { + return false; + } + for (let i = 0; i < prefix.length; i++) { + const prefixSegment = prefix[i]; + const locationSegment = location[i]; + + if ( + typeof prefixSegment === "number" && + typeof locationSegment === "number" + ) { + if (prefixSegment !== locationSegment) { + return false; + } + } else if ( + isLoopIterationMarker(prefixSegment) && + isLoopIterationMarker(locationSegment) + ) { + if ( + prefixSegment.loop !== locationSegment.loop || + prefixSegment.iteration !== locationSegment.iteration + ) { + return false; + } + } else { + return false; + } + } + return true; +} + +/** + * Compare two locations for equality. + */ +export function locationsEqual(a: Location, b: Location): boolean { + if (a.length !== b.length) { + return false; + } + return isLocationPrefix(a, b); +} + +/** + * Get all entry keys that are children of a given location. + * + * Note: Returns a map of key → entry for convenience, not key → location. + * The location can be retrieved from the entry itself via entry.location. + */ +export function getChildEntries( + storage: Storage, + parentLoc: Location, +): Map { + const parentKey = locationToKey(storage, parentLoc); + const children = new Map(); + + for (const [key, entry] of storage.history.entries) { + // Handle empty parent (root) - all entries are children + const isChild = + parentKey === "" + ? true + : key.startsWith(`${parentKey}/`) || key === parentKey; + + if (isChild) { + // Return the actual entry's location, not the parent location + children.set(key, entry.location); + } + } + + return children; +} diff --git a/packages/workflows/src/mod.ts b/packages/workflows/src/mod.ts new file mode 100644 index 0000000..7c76c49 --- /dev/null +++ b/packages/workflows/src/mod.ts @@ -0,0 +1,2 @@ +export * from "./index.js"; +export * from "./rivetkit/mod.js"; diff --git a/packages/workflows/src/rivetkit/context.ts b/packages/workflows/src/rivetkit/context.ts new file mode 100644 index 0000000..d824200 --- /dev/null +++ b/packages/workflows/src/rivetkit/context.ts @@ -0,0 +1,1086 @@ +import type { + AnyActorDefinition, + BaseActorDefinition, + Client, + EventSchemaConfig, + InferEventArgs, + InferSchemaMap, + QueueFilterName, + QueueNextBatchOptions, + QueueNextOptions, + QueueResultMessageForName, + QueueSchemaConfig, + Registry, + RunContext, +} from "rivetkit"; +import type { AnyDatabaseProvider, InferDatabaseClient } from "rivetkit/db"; +import type { + BranchConfig, + BranchOutput, + EntryKindType, + LoopConfig, + LoopResult, + StepConfig, + TryBlockConfig, + TryBlockResult, + TryStepConfig, + TryStepResult, + WorkflowContextInterface, + WorkflowQueueMessage, +} from "../index.js"; + +type WorkflowActorQueueNextOptions< + TName extends string, + TCompletable extends boolean, +> = Omit, "signal">; + +type WorkflowActorQueueNextOptionsFallback = Omit< + QueueNextOptions, + "signal" +>; + +type WorkflowActorQueueNextBatchOptions< + TName extends string, + TCompletable extends boolean, +> = Omit, "signal">; + +type WorkflowActorQueueNextBatchOptionsFallback = + Omit, "signal">; + +// Step run callbacks receive a WorkflowStepContext, which is the only place +// actor data (state/db/vars/client) may be touched. +type WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = ( + step: WorkflowStepContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, +) => Promise; + +// Step rollback callbacks compensate a committed step, so they also run with a +// WorkflowStepContext to mutate actor data. +type WorkflowStepRollback< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = ( + step: WorkflowStepContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + output: T, +) => Promise; + +// Orchestration callbacks (try/loop/race/join) receive a WorkflowContext, +// because inside them you sequence further steps rather than touch actor data. +type WorkflowContextRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = ( + ctx: WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, +) => Promise; + +export type WorkflowStepConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = Omit, "run" | "rollback"> & { + run: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; + rollback?: WorkflowStepRollback< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; +}; + +export type WorkflowTryStepConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = Omit, "run" | "rollback"> & { + run: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; + rollback?: WorkflowStepRollback< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; +}; + +export type WorkflowTryConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = Omit, "run"> & { + run: WorkflowContextRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; +}; + +export type WorkflowLoopConfig< + S, + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = Omit, "run"> & { + run: ( + ctx: WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + state: S, + ) => Promise< + LoopResult | (S extends undefined ? undefined | void : never) + >; +}; + +export type WorkflowBranchConfig< + TOutput, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = { + run: WorkflowContextRun< + TOutput, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; +}; + +// Marks a step context inactive once its step has finished. Module-private so it +// never appears on the public surface. +const DEACTIVATE_STEP = Symbol("workflow.step.deactivate"); + +/** + * The context handed to a workflow step (`step` / `tryStep` callbacks). This is + * the only scope where actor data (state, vars, db, client) and side effects + * (broadcast, queue.send) are reachable. It is valid only while its step is + * executing; using it after the step resolves throws. + */ +export class WorkflowStepContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig = Record, + TQueues extends QueueSchemaConfig = Record, +> { + #runCtx: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; + #active = true; + + constructor( + runCtx: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ) { + this.#runCtx = runCtx; + } + + [DEACTIVATE_STEP](): void { + this.#active = false; + } + + #ensureActive(feature: string): void { + if (!this.#active) { + throw new Error(`${feature} is only available inside workflow steps`); + } + } + + get actorId(): string { + return this.#runCtx.actorId; + } + + get name(): string { + return this.#runCtx.name; + } + + get key(): string[] { + return this.#runCtx.key; + } + + get log() { + return this.#runCtx.log; + } + + get abortSignal(): AbortSignal { + return this.#runCtx.abortSignal; + } + + get state(): TState extends never ? never : TState { + this.#ensureActive("state"); + return this.#runCtx.state as TState extends never ? never : TState; + } + + get vars(): TVars extends never ? never : TVars { + this.#ensureActive("vars"); + return this.#runCtx.vars as TVars extends never ? never : TVars; + } + + get db(): TDatabase extends never ? never : InferDatabaseClient { + this.#ensureActive("db"); + return this.#runCtx.db as TDatabase extends never + ? never + : InferDatabaseClient; + } + + client = Registry>(): Client { + this.#ensureActive("client"); + return this.#runCtx.client(); + } + + broadcast( + name: K, + ...args: InferEventArgs[K]> + ): void; + broadcast( + name: keyof TEvents extends never ? string : never, + ...args: Array + ): void; + broadcast(name: string, ...args: Array): void { + this.#ensureActive("broadcast"); + this.#runCtx.broadcast(name as never, ...(args as unknown[] as never[])); + } + + get queue() { + const self = this; + function send( + name: K, + body: InferSchemaMap[K], + ): Promise; + function send( + name: keyof TQueues extends never ? string : never, + body: unknown, + ): Promise; + async function send(name: string, body: unknown): Promise { + self.#ensureActive("queue.send"); + await self.#runCtx.queue.send(name as never, body as never); + } + return { send }; + } + + /** + * Holds the actor awake for the duration of the provided promise. The actor + * cannot idle-sleep or finalize the sleep grace period until the promise + * settles. + */ + keepAwake(promise: Promise): Promise { + this.#ensureActive("keepAwake"); + return this.#runCtx.keepAwake(promise); + } + + /** + * Registers a promise that the sleep grace period will wait on. Use this for + * best-effort flush/cleanup work that may complete inside the grace window. + */ + waitUntil(promise: Promise): void { + this.#ensureActive("waitUntil"); + this.#runCtx.waitUntil(promise); + } + + destroy(): void { + this.#ensureActive("destroy"); + this.#runCtx.destroy(); + } +} + +/** + * The context handed to the workflow function and to orchestration callbacks + * (`try` / `loop` / `race` / `join`). It exposes the deterministic, replayable + * workflow primitives (step, sleep, queue waits, control flow). It deliberately + * does NOT expose actor data; reach `state`, `db`, `vars`, `client`, and + * `broadcast` through the step context passed to `step` / `tryStep`. + */ +export class WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig = Record, + TQueues extends QueueSchemaConfig = Record, +> { + #inner: WorkflowContextInterface; + #runCtx: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; + constructor( + inner: WorkflowContextInterface, + runCtx: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ) { + this.#inner = inner; + this.#runCtx = runCtx; + } + + get workflowId(): string { + return this.#inner.workflowId; + } + + get abortSignal(): AbortSignal { + return this.#inner.abortSignal; + } + + get actorId(): string { + return this.#runCtx.actorId; + } + + get name(): string { + return this.#runCtx.name; + } + + get key(): string[] { + return this.#runCtx.key; + } + + get log() { + return this.#runCtx.log; + } + + get queue() { + const self = this; + function next< + const TName extends QueueFilterName, + const TCompletable extends boolean = false, + >( + name: string, + opts?: WorkflowActorQueueNextOptions, + ): Promise>; + function next( + name: string, + opts?: WorkflowActorQueueNextOptionsFallback, + ): Promise< + QueueResultMessageForName, TCompletable> + >; + // The implementation signature stays broad so the schema-typed public + // overloads above remain compatible with it. + async function next(name: string, opts?: any): Promise { + const message = await self.#inner.queue.next(name, opts); + return self.#toActorQueueMessage(message); + } + + function nextBatch< + const TName extends QueueFilterName, + const TCompletable extends boolean = false, + >( + name: string, + opts?: WorkflowActorQueueNextBatchOptions, + ): Promise>>; + function nextBatch( + name: string, + opts?: WorkflowActorQueueNextBatchOptionsFallback, + ): Promise< + Array< + QueueResultMessageForName< + TQueues, + QueueFilterName, + TCompletable + > + > + >; + async function nextBatch(name: string, opts?: any): Promise { + const messages = await self.#inner.queue.nextBatch(name, opts); + return messages.map((message) => self.#toActorQueueMessage(message)); + } + + return { + next, + nextBatch, + }; + } + + step( + name: string, + run: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise; + step( + config: WorkflowStepConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise; + async step( + nameOrConfig: + | string + | WorkflowStepConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + run?: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise { + if (typeof nameOrConfig === "string") { + if (!run) { + throw new Error("Step run function missing"); + } + const stepRun = run; + return await this.#wrapActive(() => + this.#inner.step(nameOrConfig, () => this.#runStep(stepRun)), + ); + } + const stepConfig = nameOrConfig; + const rollback = stepConfig.rollback; + const config: StepConfig = { + ...stepConfig, + run: () => this.#runStep(stepConfig.run), + rollback: rollback + ? (_ctx, output) => this.#runRollback(rollback, output) + : undefined, + }; + return await this.#wrapActive(() => this.#inner.step(config)); + } + + tryStep( + name: string, + run: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise>; + tryStep( + config: WorkflowTryStepConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise>; + async tryStep( + nameOrConfig: + | string + | WorkflowTryStepConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + run?: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise> { + if (typeof nameOrConfig === "string") { + if (!run) { + throw new Error("Step run function missing"); + } + const stepRun = run; + return await this.#wrapActive(() => + this.#inner.tryStep(nameOrConfig, () => this.#runStep(stepRun)), + ); + } + const stepConfig = nameOrConfig; + const rollback = stepConfig.rollback; + const config: TryStepConfig = { + ...stepConfig, + run: () => this.#runStep(stepConfig.run), + rollback: rollback + ? (_ctx, output) => this.#runRollback(rollback, output) + : undefined, + }; + return await this.#wrapActive(() => this.#inner.tryStep(config)); + } + + try( + name: string, + run: WorkflowContextRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise>; + try( + config: WorkflowTryConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise>; + async try( + nameOrConfig: + | string + | WorkflowTryConfig< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + run?: WorkflowContextRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise> { + if (typeof nameOrConfig === "string") { + if (!run) { + throw new Error("Try run function missing"); + } + const tryRun = run; + return await this.#wrapActive(() => + this.#inner.try(nameOrConfig, async (ctx) => + tryRun(this.#createChildContext(ctx)), + ), + ); + } + const tryConfig = nameOrConfig; + const config: TryBlockConfig = { + ...tryConfig, + run: async (ctx) => tryConfig.run(this.#createChildContext(ctx)), + }; + return await this.#wrapActive(() => this.#inner.try(config)); + } + + loop( + name: string, + run: ( + ctx: WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ) => Promise | undefined | void>, + ): Promise; + loop( + config: WorkflowLoopConfig< + S, + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise; + async loop( + nameOrConfig: + | string + | WorkflowLoopConfig< + any, + any, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + run?: ( + ctx: WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ) => Promise | undefined | void>, + ): Promise { + if (typeof nameOrConfig === "string") { + if (!run) { + throw new Error("Loop run function missing"); + } + const loopRun = run; + return await this.#wrapActive(() => + this.#inner.loop( + nameOrConfig, + // A void return (no explicit Loop result) is undefined at + // runtime, which the engine treats as continue. + async (ctx): Promise | undefined> => + (await loopRun(this.#createChildContext(ctx))) ?? undefined, + ), + ); + } + const loopConfig = nameOrConfig; + const wrapped: LoopConfig = { + ...loopConfig, + run: (async (ctx, state) => + (await loopConfig.run(this.#createChildContext(ctx), state)) ?? + undefined) as LoopConfig["run"], + }; + return await this.#wrapActive(() => this.#inner.loop(wrapped)); + } + + sleep(name: string, durationMs: number): Promise { + return this.#inner.sleep(name, durationMs); + } + + sleepUntil(name: string, timestampMs: number): Promise { + return this.#inner.sleepUntil(name, timestampMs); + } + + async rollbackCheckpoint(name: string): Promise { + await this.#wrapActive(() => this.#inner.rollbackCheckpoint(name)); + } + + async join< + T extends Record< + string, + WorkflowBranchConfig< + unknown, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + > + >, + >( + name: string, + branches: T, + ): Promise<{ [K in keyof T]: Awaited> }>; + async join>>( + name: string, + branches: T, + ): Promise<{ [K in keyof T]: BranchOutput }>; + async join( + name: string, + branches: Record Promise }>, + ) { + const wrappedBranches = Object.fromEntries( + Object.entries(branches).map(([key, branch]) => [ + key, + { + run: async (ctx: WorkflowContextInterface) => + branch.run(this.#createChildContext(ctx)), + }, + ]), + ) as Record>; + return await this.#wrapActive(() => + this.#inner.join(name, wrappedBranches), + ); + } + + async race( + name: string, + branches: Array<{ + name: string; + run: WorkflowContextRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >; + }>, + ): Promise<{ winner: string; value: T }> { + const wrappedBranches = branches.map((branch) => ({ + name: branch.name, + run: (ctx: WorkflowContextInterface) => + branch.run(this.#createChildContext(ctx)), + })); + return (await this.#wrapActive(() => + this.#inner.race(name, wrappedBranches), + )) as { winner: string; value: T }; + } + + async removed(name: string, originalType: EntryKindType): Promise { + await this.#wrapActive(() => this.#inner.removed(name, originalType)); + } + + getVersion(name: string, latest: number): Promise { + return this.#wrapActive(() => this.#inner.getVersion(name, latest)); + } + + isEvicted(): boolean { + return this.#inner.isEvicted(); + } + + // Runs a user step body inside a fresh step context and deactivates the step + // context once the body settles. Workflows are roll-forward only, so state and + // vars mutations made before a failure always remain visible. + async #runStep( + run: WorkflowStepRun< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise { + const stepCtx = new WorkflowStepContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >(this.#runCtx); + + try { + return await run(stepCtx); + } finally { + stepCtx[DEACTIVATE_STEP](); + } + } + + #toActorQueueMessage( + message: WorkflowQueueMessage, + ): WorkflowQueueMessage & { id: bigint } { + let id: bigint; + try { + id = BigInt(message.id); + } catch { + throw new Error(`Invalid queue message id "${message.id}"`); + } + return { + id, + name: message.name, + body: message.body, + createdAt: message.createdAt, + ...(message.complete ? { complete: message.complete } : {}), + }; + } + + // Runs a step rollback compensation with an active step context. + async #runRollback( + rollback: WorkflowStepRollback< + T, + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + output: T, + ): Promise { + const stepCtx = new WorkflowStepContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >(this.#runCtx); + try { + await rollback(stepCtx, output); + } finally { + stepCtx[DEACTIVATE_STEP](); + } + } + + async #wrapActive(run: () => Promise): Promise { + const promise = run(); + const tracked = promise.then( + () => undefined, + () => undefined, + ); + this.#runCtx.waitUntil(tracked); + return await promise; + } + + #createChildContext( + ctx: WorkflowContextInterface, + ): WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + > { + return new WorkflowContext(ctx, this.#runCtx); + } +} + +export type WorkflowContextOf = + AD extends BaseActorDefinition< + infer S, + infer CP, + infer CS, + infer V, + infer I, + infer DB extends AnyDatabaseProvider, + infer E extends EventSchemaConfig, + infer Q extends QueueSchemaConfig, + any + > + ? WorkflowContext + : never; + +export type WorkflowLoopContextOf = + WorkflowContextOf; + +export type WorkflowBranchContextOf = + WorkflowContextOf; + +export type WorkflowStepContextOf = + AD extends BaseActorDefinition< + infer S, + infer CP, + infer CS, + infer V, + infer I, + infer DB extends AnyDatabaseProvider, + infer E extends EventSchemaConfig, + infer Q extends QueueSchemaConfig, + any + > + ? WorkflowStepContext + : never; diff --git a/packages/workflows/src/rivetkit/driver.ts b/packages/workflows/src/rivetkit/driver.ts new file mode 100644 index 0000000..e5856c2 --- /dev/null +++ b/packages/workflows/src/rivetkit/driver.ts @@ -0,0 +1,241 @@ +import type { ActorQueue, ActorRun, RunContext } from "rivetkit"; +import { + WORKFLOW_STORAGE_V1, + type WorkflowStorageHandle, +} from "rivetkit/storage"; +import type { + EngineDriver, + KVEntry, + KVWrite, + Message, + WorkflowMessageDriver, + WorkflowMessageIdentity, +} from "../index.js"; + +function track( + runCtx: RunContext, + promise: Promise, +): Promise { + runCtx.waitUntil( + promise.then( + () => undefined, + () => undefined, + ), + ); + return promise; +} + +class ActorWorkflowMessageDriver implements WorkflowMessageDriver { + #runCtx: RunContext; + #queue: ActorQueue; + + constructor(runCtx: RunContext) { + this.#runCtx = runCtx; + this.#queue = runCtx.queue; + } + + async addMessage(message: Message): Promise { + await track(this.#runCtx, this.#queue.send(message.name, message.data)); + } + + async receiveMessages(opts: { + names?: readonly string[]; + count: number; + completable: boolean; + }): Promise { + const messages = await track( + this.#runCtx, + this.#queue.tryNextBatch({ + names: + opts.names && opts.names.length > 0 ? [...opts.names] : undefined, + count: opts.count, + completable: opts.completable, + }), + ); + return messages.map((message) => ({ + id: message.id.toString(), + name: message.name, + data: message.body, + sentAt: message.createdAt, + ...(opts.completable + ? { + complete: async (response?: unknown) => { + if (!message.complete) { + throw new Error( + "RivetKit returned a non-completable queue message", + ); + } + await track(this.#runCtx, message.complete(response)); + }, + } + : {}), + })); + } + + async completeMessage( + message: WorkflowMessageIdentity, + response?: unknown, + ): Promise { + let parsedId: bigint; + try { + parsedId = BigInt(message.id); + } catch { + return; + } + + await track( + this.#runCtx, + this.#queue.complete({ id: parsedId, name: message.name }, response), + ); + } +} + +export class ActorWorkflowDriver implements EngineDriver { + readonly atomicBatch = true; + readonly workerPollInterval = 100; + readonly messageDriver: WorkflowMessageDriver; + #runCtx: RunContext; + #storage: WorkflowStorageHandle; + #queue: ActorQueue; + #run: ActorRun; + + constructor(runCtx: RunContext) { + this.#runCtx = runCtx; + this.messageDriver = new ActorWorkflowMessageDriver(runCtx); + this.#queue = runCtx.queue; + this.#storage = runCtx.storage.open(WORKFLOW_STORAGE_V1); + this.#run = runCtx.run; + } + + async get(key: Uint8Array): Promise { + return await track(this.#runCtx, this.#storage.get(key)); + } + + async set(key: Uint8Array, value: Uint8Array): Promise { + await track(this.#runCtx, this.#storage.set(key, value)); + } + + async delete(key: Uint8Array): Promise { + await track(this.#runCtx, this.#storage.delete(key)); + } + + async deletePrefix(prefix: Uint8Array): Promise { + await track(this.#runCtx, this.#storage.deletePrefix(prefix)); + } + + async deleteRange(start: Uint8Array, end: Uint8Array): Promise { + await track(this.#runCtx, this.#storage.deleteRange(start, end)); + } + + async list(prefix: Uint8Array): Promise { + return await track(this.#runCtx, this.#storage.list(prefix)); + } + + async batch(writes: KVWrite[]): Promise { + if (writes.length === 0) return; + + await track(this.#runCtx, this.#storage.flushWithState(writes)); + } + + async setAlarm(_workflowId: string, wakeAt: number): Promise { + await track(this.#runCtx, this.#run.setWakeAt(wakeAt)); + } + + async clearAlarm(_workflowId: string): Promise { + await track(this.#runCtx, this.#run.setWakeAt(null)); + } + + waitForMessages( + messageNames: string[], + abortSignal: AbortSignal, + ): Promise { + return track( + this.#runCtx, + this.#queue.waitForAvailable( + messageNames.length > 0 ? messageNames : undefined, + { signal: abortSignal }, + ), + ); + } +} + +class NoopWorkflowMessageDriver implements WorkflowMessageDriver { + async addMessage(_message: Message): Promise { + throw new Error("Workflow control driver does not support messages"); + } + + async receiveMessages(_opts: { + names?: readonly string[]; + count: number; + completable: boolean; + }): Promise { + throw new Error("Workflow control driver does not support messages"); + } + + async completeMessage( + _message: WorkflowMessageIdentity, + _response?: unknown, + ): Promise { + throw new Error("Workflow control driver does not support messages"); + } +} + +export class ActorWorkflowControlDriver implements EngineDriver { + readonly workerPollInterval = 100; + readonly messageDriver: WorkflowMessageDriver = + new NoopWorkflowMessageDriver(); + #storage: WorkflowStorageHandle; + #run: ActorRun; + + constructor(runCtx: RunContext) { + this.#storage = runCtx.storage.open(WORKFLOW_STORAGE_V1); + this.#run = runCtx.run; + } + + async get(key: Uint8Array): Promise { + return await this.#storage.get(key); + } + + async set(key: Uint8Array, value: Uint8Array): Promise { + await this.#storage.set(key, value); + } + + async delete(key: Uint8Array): Promise { + await this.#storage.delete(key); + } + + async deletePrefix(prefix: Uint8Array): Promise { + await this.#storage.deletePrefix(prefix); + } + + async deleteRange(start: Uint8Array, end: Uint8Array): Promise { + await this.#storage.deleteRange(start, end); + } + + async list(prefix: Uint8Array): Promise { + return await this.#storage.list(prefix); + } + + async batch(writes: KVWrite[]): Promise { + if (writes.length === 0) { + return; + } + + await this.#storage.batch(writes); + } + + async setAlarm(_workflowId: string, wakeAt: number): Promise { + await this.#run.setWakeAt(wakeAt); + } + + async clearAlarm(_workflowId: string): Promise { + await this.#run.setWakeAt(null); + } + + waitForMessages( + _messageNames: string[], + _abortSignal: AbortSignal, + ): Promise { + throw new Error("Workflow control driver does not support messages"); + } +} diff --git a/packages/workflows/src/rivetkit/inspector.ts b/packages/workflows/src/rivetkit/inspector.ts new file mode 100644 index 0000000..0055452 --- /dev/null +++ b/packages/workflows/src/rivetkit/inspector.ts @@ -0,0 +1,311 @@ +import * as transport from "rivetkit/inspector/workflow"; +import { + encodeWorkflowHistoryTransport, + encodeWorkflowInspectorValue, + type WorkflowInspectorAdapter, +} from "rivetkit/inspector/workflow"; +import type { + BranchStatus, + BranchStatusType, + EntryKind, + EntryStatus, + Location, + SleepState, + WorkflowEntryMetadataSnapshot, + WorkflowHistoryEntry, + WorkflowHistorySnapshot, + WorkflowState, +} from "../index.js"; + +function assertUnreachable(value: never): never { + throw new Error(`Unexpected workflow Inspector value: ${String(value)}`); +} + +type HistoryListener = (history: ArrayBuffer) => void; + +function createHistoryEmitter() { + const listeners = new Set(); + + return { + on: (listener: HistoryListener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit: (history: ArrayBuffer) => { + for (const listener of listeners) { + listener(history); + } + }, + }; +} + +export function createWorkflowInspectorAdapter(): { + adapter: WorkflowInspectorAdapter; + update: (snapshot: WorkflowHistorySnapshot) => void; + setGetState: (fn: () => Promise) => void; + setReplayFromStep: ( + fn: (entryId?: string) => Promise, + ) => void; +} { + const emitter = createHistoryEmitter(); + let history: ArrayBuffer | null = null; + let getState: () => Promise = async () => null; + let replayFromStep: (entryId?: string) => Promise = + async () => { + throw new Error("Workflow replay controls are not initialized"); + }; + + const adapter: WorkflowInspectorAdapter = { + getHistory: () => history, + getState: async () => await getState(), + onHistoryUpdated: (listener) => emitter.on(listener), + replayFromStep: async (entryId) => await replayFromStep(entryId), + }; + + const update = (snapshot: WorkflowHistorySnapshot) => { + const transportHistory = toWorkflowHistory(snapshot); + const next = encodeWorkflowHistoryTransport(transportHistory); + history = next; + emitter.emit(next); + }; + + return { + adapter, + update, + setGetState: (fn) => { + getState = fn; + }, + setReplayFromStep: (fn) => { + replayFromStep = fn; + }, + }; +} + +function encodeCbor(value: unknown): ArrayBuffer { + return encodeWorkflowInspectorValue(value); +} + +function encodeOptionalCbor(value: unknown): ArrayBuffer | null { + if (value === undefined) { + return null; + } + return encodeCbor(value); +} + +function toU64(value: number): bigint { + return BigInt(Math.max(0, Math.floor(value))); +} + +function toWorkflowLocation(location: Location): transport.WorkflowLocation { + return location.map((segment) => { + if (typeof segment === "number") { + return { tag: "WorkflowNameIndex", val: segment }; + } + return { + tag: "WorkflowLoopIterationMarker", + val: { + loop: segment.loop, + iteration: segment.iteration, + }, + }; + }); +} + +function toWorkflowEntryKind(kind: EntryKind): transport.WorkflowEntryKind { + switch (kind.type) { + case "step": + return { + tag: "WorkflowStepEntry", + val: { + output: encodeOptionalCbor(kind.data.output), + error: kind.data.error ?? null, + }, + }; + case "loop": + return { + tag: "WorkflowLoopEntry", + val: { + state: encodeCbor(kind.data.state), + iteration: kind.data.iteration, + output: encodeOptionalCbor(kind.data.output), + }, + }; + case "sleep": + return { + tag: "WorkflowSleepEntry", + val: { + deadline: toU64(kind.data.deadline), + state: toWorkflowSleepState(kind.data.state), + }, + }; + case "message": + return { + tag: "WorkflowMessageEntry", + val: { + name: kind.data.name, + messageData: encodeCbor(kind.data.data), + }, + }; + case "rollback_checkpoint": + return { + tag: "WorkflowRollbackCheckpointEntry", + val: { name: kind.data.name }, + }; + case "join": + return { + tag: "WorkflowJoinEntry", + val: { + branches: toWorkflowBranchStatusMap(kind.data.branches), + }, + }; + case "race": + return { + tag: "WorkflowRaceEntry", + val: { + winner: kind.data.winner ?? null, + branches: toWorkflowBranchStatusMap(kind.data.branches), + }, + }; + case "removed": + return { + tag: "WorkflowRemovedEntry", + val: { + originalType: kind.data.originalType, + originalName: kind.data.originalName ?? null, + }, + }; + case "version_check": + return { + tag: "WorkflowVersionCheckEntry", + val: { + resolved: kind.data.resolved, + latest: kind.data.latest, + }, + }; + default: + return assertUnreachable(kind as never); + } +} + +function toWorkflowEntry(entry: WorkflowHistoryEntry): transport.WorkflowEntry { + return { + id: entry.id, + location: toWorkflowLocation(entry.location), + kind: toWorkflowEntryKind(entry.kind), + }; +} + +function toWorkflowEntryStatus( + status: EntryStatus, +): transport.WorkflowEntryStatus { + switch (status) { + case "pending": + return transport.WorkflowEntryStatus.PENDING; + case "running": + return transport.WorkflowEntryStatus.RUNNING; + case "completed": + return transport.WorkflowEntryStatus.COMPLETED; + case "failed": + return transport.WorkflowEntryStatus.FAILED; + case "exhausted": + return transport.WorkflowEntryStatus.EXHAUSTED; + default: + return assertUnreachable(status as never); + } +} + +function toWorkflowSleepState(state: SleepState): transport.WorkflowSleepState { + switch (state) { + case "pending": + return transport.WorkflowSleepState.PENDING; + case "completed": + return transport.WorkflowSleepState.COMPLETED; + case "interrupted": + return transport.WorkflowSleepState.INTERRUPTED; + default: + return assertUnreachable(state as never); + } +} + +function toWorkflowBranchStatusType( + status: BranchStatusType, +): transport.WorkflowBranchStatusType { + switch (status) { + case "pending": + return transport.WorkflowBranchStatusType.PENDING; + case "running": + return transport.WorkflowBranchStatusType.RUNNING; + case "completed": + return transport.WorkflowBranchStatusType.COMPLETED; + case "failed": + return transport.WorkflowBranchStatusType.FAILED; + case "cancelled": + return transport.WorkflowBranchStatusType.CANCELLED; + default: + return assertUnreachable(status as never); + } +} + +function toWorkflowBranchStatus( + status: BranchStatus, +): transport.WorkflowBranchStatus { + return { + status: toWorkflowBranchStatusType(status.status), + output: encodeOptionalCbor(status.output), + error: status.error ?? null, + }; +} + +function toWorkflowBranchStatusMap( + branches: Record, +): ReadonlyMap { + return new Map( + Object.entries(branches).map(([name, status]) => [ + name, + toWorkflowBranchStatus(status), + ]), + ); +} + +function toWorkflowEntryMetadata( + metadata: WorkflowEntryMetadataSnapshot, +): transport.WorkflowEntryMetadata { + const rollbackCompletedAt = ( + metadata as WorkflowEntryMetadataSnapshot & { + rollbackCompletedAt?: number; + } + ).rollbackCompletedAt; + const rollbackError = ( + metadata as WorkflowEntryMetadataSnapshot & { + rollbackError?: string | null; + } + ).rollbackError; + + return { + status: toWorkflowEntryStatus(metadata.status), + error: metadata.error ?? null, + attempts: metadata.attempts, + lastAttemptAt: toU64(metadata.lastAttemptAt), + createdAt: toU64(metadata.createdAt), + completedAt: + metadata.completedAt === undefined ? null : toU64(metadata.completedAt), + rollbackCompletedAt: + rollbackCompletedAt === undefined ? null : toU64(rollbackCompletedAt), + rollbackError: rollbackError ?? null, + }; +} + +function toWorkflowHistory( + snapshot: WorkflowHistorySnapshot, +): transport.WorkflowHistory { + const entryMetadata = new Map(); + for (const [id, metadata] of snapshot.entryMetadata) { + entryMetadata.set(id, toWorkflowEntryMetadata(metadata)); + } + + return { + nameRegistry: [...snapshot.nameRegistry], + entries: snapshot.entries.map(toWorkflowEntry), + entryMetadata, + }; +} diff --git a/packages/workflows/src/rivetkit/mod.ts b/packages/workflows/src/rivetkit/mod.ts new file mode 100644 index 0000000..e424f71 --- /dev/null +++ b/packages/workflows/src/rivetkit/mod.ts @@ -0,0 +1,317 @@ +import { + defineRunHandler, + type EventSchemaConfig, + type QueueSchemaConfig, + RivetError, + type RunContext, + type RunControl, +} from "rivetkit"; +import type { AnyDatabaseProvider } from "rivetkit/db"; +import { isActorAbortedError } from "rivetkit/errors"; +import { stringifyError } from "rivetkit/utils"; +import { + CriticalError, + EntryInProgressError, + HistoryDivergedError, + JoinError, + RaceError, + RollbackCheckpointError, + RollbackError, + type RunWorkflowOptions, + replayWorkflowFromStep, + runWorkflow, + StepExhaustedError, + type WorkflowErrorEvent, +} from "../index.js"; +import { WorkflowContext } from "./context"; +import { ActorWorkflowControlDriver, ActorWorkflowDriver } from "./driver"; +import { createWorkflowInspectorAdapter } from "./inspector"; + +export type { + TryBlockCatchKind, + TryBlockConfig, + TryBlockFailure, + TryBlockResult, + TryStepCatchKind, + TryStepConfig, + TryStepFailure, + TryStepResult, + WorkflowError, + WorkflowErrorEvent, +} from "../index.js"; +export { Loop } from "../index.js"; +export { + type WorkflowBranchConfig, + type WorkflowBranchContextOf, + WorkflowContext, + type WorkflowContextOf, + type WorkflowLoopConfig, + type WorkflowLoopContextOf, + type WorkflowStepConfig, + WorkflowStepContext, + type WorkflowStepContextOf, + type WorkflowTryConfig, + type WorkflowTryStepConfig, +} from "./context"; + +function shouldRethrowWorkflowError(error: unknown): boolean { + if ( + error instanceof CriticalError || + error instanceof JoinError || + error instanceof RaceError || + error instanceof RollbackError || + error instanceof StepExhaustedError + ) { + return false; + } + + if ( + error instanceof EntryInProgressError || + error instanceof HistoryDivergedError || + error instanceof RollbackCheckpointError + ) { + return true; + } + + return true; +} + +function workflowReplayInFlightError(): RivetError { + return new RivetError( + "actor", + "workflow_in_flight", + "Workflow replay is unavailable while the workflow is currently in flight.", + { + public: true, + statusCode: 409, + }, + ); +} + +function isWorkflowReplayBlockedByRunningEntry(error: unknown): boolean { + return ( + error instanceof Error && + error.message === + "Cannot replay a workflow while a step is currently running" + ); +} + +function isRunHandlerUnavailable(error: unknown): boolean { + return ( + error instanceof RivetError && + error.group === "actor" && + error.code === "run_handler_unavailable" + ); +} + +export interface WorkflowOptions< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig = Record, + TQueues extends QueueSchemaConfig = Record, +> { + onError?: ( + ctx: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + event: WorkflowErrorEvent, + ) => void | Promise; +} + +export function workflow< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase extends AnyDatabaseProvider, + TEvents extends EventSchemaConfig = Record, + TQueues extends QueueSchemaConfig = Record, +>( + fn: ( + ctx: WorkflowContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ) => Promise, + options: WorkflowOptions< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + > = {}, +): ( + c: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, +) => Promise { + const onError = options.onError; + const workflowInspectors = new Map< + string, + ReturnType + >(); + const workflowControls = new Map(); + + function getWorkflowInspector(actorId: string) { + let workflowInspector = workflowInspectors.get(actorId); + if (!workflowInspector) { + workflowInspector = createWorkflowInspectorAdapter(); + workflowInspectors.set(actorId, workflowInspector); + } + return workflowInspector; + } + + async function run( + runCtx: RunContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + ): Promise { + const workflowInspector = getWorkflowInspector(runCtx.actorId); + + const driver = new ActorWorkflowDriver(runCtx); + const controlDriver = new ActorWorkflowControlDriver(runCtx); + workflowInspector.setReplayFromStep(async (entryId) => { + const control = workflowControls.get(runCtx.actorId); + if (!control) { + throw new Error("Workflow Inspector control is not initialized"); + } + try { + return await control.run.withInactive( + { restartOnSuccess: true }, + async () => { + const workflowState = await workflowInspector.adapter.getState(); + if (workflowState === "pending" || workflowState === "running") { + throw workflowReplayInFlightError(); + } + + const snapshot = await replayWorkflowFromStep( + runCtx.actorId, + controlDriver, + entryId, + { scheduleAlarm: false }, + ); + workflowInspector.update(snapshot); + return workflowInspector.adapter.getHistory(); + }, + ); + } catch (error) { + if ( + isWorkflowReplayBlockedByRunningEntry(error) || + isRunHandlerUnavailable(error) + ) { + throw workflowReplayInFlightError(); + } + throw error; + } + }); + + const handle = runWorkflow( + runCtx.actorId, + async (ctx) => await fn(new WorkflowContext(ctx, runCtx)), + undefined, + driver, + { + mode: "live", + // The actor logger and the engine's pino logger are runtime + // compatible but not structurally assignable. + logger: runCtx.log as RunWorkflowOptions["logger"], + onHistoryUpdated: workflowInspector.update, + onError: onError + ? async (event) => await onError(runCtx, event) + : undefined, + }, + ); + workflowInspector.setGetState(async () => await handle.getState()); + + const onAbort = () => { + handle.evict(); + }; + if (runCtx.abortSignal.aborted) { + onAbort(); + } else { + runCtx.abortSignal.addEventListener("abort", onAbort, { + once: true, + }); + } + + try { + await handle.result; + } catch (error) { + // `abortSignal.aborted` is delivered on a separate async hop and + // races the rejection, so detect the sleep abort structurally too. + if (runCtx.abortSignal.aborted || isActorAbortedError(error)) { + return; + } + + if (shouldRethrowWorkflowError(error)) { + runCtx.log.error({ + msg: "workflow run failed", + error: stringifyError(error), + }); + throw error; + } + + runCtx.log.warn({ + msg: "workflow failed and will sleep until woken", + error: stringifyError(error), + }); + } finally { + runCtx.abortSignal.removeEventListener("abort", onAbort); + } + } + + return defineRunHandler(run, { + icon: "diagram-project", + inspectorKind: "workflow", + createInspector: ({ actorId, control }) => { + workflowControls.set(actorId, control); + const workflowInspector = getWorkflowInspector(actorId); + return { + inspector: { + workflow: workflowInspector.adapter, + }, + dispose: () => { + if (workflowInspectors.get(actorId) === workflowInspector) { + workflowControls.delete(actorId); + workflowInspectors.delete(actorId); + } + }, + }; + }, + }); +} diff --git a/packages/workflows/src/storage.ts b/packages/workflows/src/storage.ts new file mode 100644 index 0000000..3073c55 --- /dev/null +++ b/packages/workflows/src/storage.ts @@ -0,0 +1,468 @@ +import { + deserializeEntry, + deserializeEntryMetadata, + deserializeName, + deserializeWorkflowError, + deserializeWorkflowOutput, + deserializeWorkflowState, + serializeEntry, + serializeEntryMetadata, + serializeName, + serializeWorkflowError, + serializeWorkflowOutput, + serializeWorkflowState, +} from "../schemas/serde.js"; +import type { EngineDriver, KVWrite } from "./driver.js"; +import { + buildEntryMetadataKey, + buildEntryMetadataPrefix, + buildHistoryKey, + buildHistoryPrefix, + buildHistoryPrefixAll, + buildNameKey, + buildNamePrefix, + buildWorkflowErrorKey, + buildWorkflowOutputKey, + buildWorkflowStateKey, + compareKeys, + parseEntryMetadataKey, + parseNameKey, +} from "./keys.js"; +import { isLocationPrefix, locationToKey } from "./location.js"; +import type { + Entry, + EntryKind, + EntryMetadata, + Location, + Storage, + WorkflowEntryMetadataSnapshot, + WorkflowHistoryEntry, + WorkflowHistorySnapshot, +} from "./types.js"; + +export const MAX_KV_BATCH_ENTRIES = 128; +export const MAX_KV_BATCH_PAYLOAD_BYTES = 976 * 1024; + +/** + * Create an empty storage instance. + */ +export function createStorage(): Storage { + return { + nameRegistry: [], + flushedNameCount: 0, + history: { entries: new Map() }, + entryMetadata: new Map(), + output: undefined, + state: "pending", + flushedState: undefined, + error: undefined, + flushedError: undefined, + flushedOutput: undefined, + }; +} + +/** + * Create a snapshot of workflow history for observers. + */ +export function createHistorySnapshot( + storage: Storage, +): WorkflowHistorySnapshot { + const entryMetadata = new Map(); + for (const [id, metadata] of storage.entryMetadata) { + const { dirty, ...rest } = metadata; + entryMetadata.set(id, rest); + } + + const entries: WorkflowHistoryEntry[] = []; + const entryKeys = Array.from(storage.history.entries.keys()).sort(); + for (const key of entryKeys) { + const entry = storage.history.entries.get(key); + if (!entry) continue; + const { dirty, ...rest } = entry; + entries.push(rest); + } + + return { + nameRegistry: [...storage.nameRegistry], + entries, + entryMetadata, + }; +} + +/** + * Generate a UUID v4. + */ +export function generateId(): string { + return crypto.randomUUID(); +} + +/** + * Create a new entry. + */ +export function createEntry(location: Location, kind: EntryKind): Entry { + return { + id: generateId(), + location, + kind, + dirty: true, + }; +} + +/** + * Create or get metadata for an entry. + */ +export function getOrCreateMetadata( + storage: Storage, + entryId: string, +): EntryMetadata { + let metadata = storage.entryMetadata.get(entryId); + if (!metadata) { + metadata = { + status: "pending", + attempts: 0, + lastAttemptAt: 0, + createdAt: Date.now(), + rollbackCompletedAt: undefined, + rollbackError: undefined, + dirty: true, + }; + storage.entryMetadata.set(entryId, metadata); + } + return metadata; +} + +/** + * Load storage from the driver. + */ +export async function loadStorage(driver: EngineDriver): Promise { + const storage = createStorage(); + + // Load name registry + const nameEntries = await driver.list(buildNamePrefix()); + // Sort by index to ensure correct order + nameEntries.sort((a, b) => compareKeys(a.key, b.key)); + for (const entry of nameEntries) { + const index = parseNameKey(entry.key); + storage.nameRegistry[index] = deserializeName(entry.value); + } + // Track how many names are already persisted + storage.flushedNameCount = storage.nameRegistry.length; + + // Load history entries + const historyEntries = await driver.list(buildHistoryPrefixAll()); + for (const entry of historyEntries) { + const parsed = deserializeEntry(entry.value); + parsed.dirty = false; + // Use locationToKey to match how context.ts looks up entries + const key = locationToKey(storage, parsed.location); + storage.history.entries.set(key, parsed); + } + + // Load entry metadata so observers can reconstruct workflow state after + // the actor wakes and rebuilds storage from persisted history. + const metadataEntries = await driver.list(buildEntryMetadataPrefix()); + for (const entry of metadataEntries) { + const entryId = parseEntryMetadataKey(entry.key); + const metadata = deserializeEntryMetadata(entry.value); + metadata.dirty = false; + storage.entryMetadata.set(entryId, metadata); + } + + // Load workflow state + const stateValue = await driver.get(buildWorkflowStateKey()); + if (stateValue) { + storage.state = deserializeWorkflowState(stateValue); + storage.flushedState = storage.state; + } + + // Load output if present + const outputValue = await driver.get(buildWorkflowOutputKey()); + if (outputValue) { + storage.output = deserializeWorkflowOutput(outputValue); + storage.flushedOutput = storage.output; + } + + // Load error if present + const errorValue = await driver.get(buildWorkflowErrorKey()); + if (errorValue) { + storage.error = deserializeWorkflowError(errorValue); + storage.flushedError = storage.error; + } + + return storage; +} + +/** + * Load metadata for an entry (lazy loading). + */ +export async function loadMetadata( + storage: Storage, + driver: EngineDriver, + entryId: string, +): Promise { + // Check if already loaded + const existing = storage.entryMetadata.get(entryId); + if (existing) { + return existing; + } + + // Load from driver + const value = await driver.get(buildEntryMetadataKey(entryId)); + if (value) { + const metadata = deserializeEntryMetadata(value); + metadata.dirty = false; + storage.entryMetadata.set(entryId, metadata); + return metadata; + } + + // Create new metadata + return getOrCreateMetadata(storage, entryId); +} + +/** + * Pending deletions collected by collectLoopPruning to be included + * in the next flush alongside the state write. + */ +export interface PendingDeletions { + prefixes: Uint8Array[]; + keys: Uint8Array[]; + ranges: { start: Uint8Array; end: Uint8Array }[]; +} + +/** + * Flush all dirty data to the driver. Optionally includes pending + * deletions so that history pruning happens alongside the + * state write. + */ +export async function flush( + storage: Storage, + driver: EngineDriver, + onHistoryUpdated?: () => void, + pendingDeletions?: PendingDeletions, +): Promise { + const writes: KVWrite[] = []; + const dirtyEntries: Entry[] = []; + const dirtyMetadata: EntryMetadata[] = []; + let historyUpdated = false; + + // Flush only new names (those added since last flush) + for ( + let i = storage.flushedNameCount; + i < storage.nameRegistry.length; + i++ + ) { + const name = storage.nameRegistry[i]; + if (name !== undefined) { + writes.push({ + key: buildNameKey(i), + value: serializeName(name), + }); + historyUpdated = true; + } + } + + // Flush dirty entries + for (const [, entry] of storage.history.entries) { + if (entry.dirty) { + writes.push({ + key: buildHistoryKey(entry.location), + value: serializeEntry(entry), + }); + dirtyEntries.push(entry); + historyUpdated = true; + } + } + + // Flush dirty metadata + for (const [id, metadata] of storage.entryMetadata) { + if (metadata.dirty) { + writes.push({ + key: buildEntryMetadataKey(id), + value: serializeEntryMetadata(metadata), + }); + dirtyMetadata.push(metadata); + historyUpdated = true; + } + } + + // Flush workflow state if changed + if (storage.state !== storage.flushedState) { + writes.push({ + key: buildWorkflowStateKey(), + value: serializeWorkflowState(storage.state), + }); + } + + // Flush output if changed + if ( + storage.output !== undefined && + storage.output !== storage.flushedOutput + ) { + writes.push({ + key: buildWorkflowOutputKey(), + value: serializeWorkflowOutput(storage.output), + }); + } + + // Flush error if changed (compare by message since objects aren't reference-equal) + const errorChanged = + storage.error !== undefined && + (storage.flushedError === undefined || + storage.error.name !== storage.flushedError.name || + storage.error.message !== storage.flushedError.message); + if (errorChanged) { + writes.push({ + key: buildWorkflowErrorKey(), + value: serializeWorkflowError(storage.error!), + }); + } + + if (writes.length > 0) { + if (driver.atomicBatch) { + await driver.batch(writes); + } else { + for (const chunk of splitBatchWrites(writes)) { + await driver.batch(chunk); + } + } + } + + // Apply pending deletions after the batch write. These are collected + // by collectLoopPruning so pruning happens alongside the state write. + if (pendingDeletions) { + const deleteOps: Promise[] = []; + for (const prefix of pendingDeletions.prefixes) { + deleteOps.push(driver.deletePrefix(prefix)); + } + for (const range of pendingDeletions.ranges) { + deleteOps.push(driver.deleteRange(range.start, range.end)); + } + for (const key of pendingDeletions.keys) { + deleteOps.push(driver.delete(key)); + } + if (deleteOps.length > 0) { + await Promise.all(deleteOps); + historyUpdated = true; + } + } + + // Update flushed tracking after successful write + for (const entry of dirtyEntries) { + entry.dirty = false; + } + for (const metadata of dirtyMetadata) { + metadata.dirty = false; + } + storage.flushedNameCount = storage.nameRegistry.length; + storage.flushedState = storage.state; + storage.flushedOutput = storage.output; + storage.flushedError = storage.error; + + if (historyUpdated && onHistoryUpdated) { + onHistoryUpdated(); + } +} + +function splitBatchWrites(writes: KVWrite[]): KVWrite[][] { + const chunks: KVWrite[][] = []; + let chunk: KVWrite[] = []; + let chunkBytes = 0; + + for (const write of writes) { + const writeBytes = write.key.byteLength + write.value.byteLength; + if (writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES) { + throw new Error( + `KV batch write is ${writeBytes} bytes, exceeding the ${MAX_KV_BATCH_PAYLOAD_BYTES} byte limit`, + ); + } + + if ( + chunk.length >= MAX_KV_BATCH_ENTRIES || + (chunk.length > 0 && + chunkBytes + writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES) + ) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(write); + chunkBytes += writeBytes; + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} + +/** + * Delete entries with a given location prefix (used for loop forgetting). + * Also cleans up associated metadata from both memory and driver. + */ +export async function deleteEntriesWithPrefix( + storage: Storage, + driver: EngineDriver, + prefixLocation: Location, + onHistoryUpdated?: () => void, +): Promise { + const deletions = collectDeletionsForPrefix(storage, prefixLocation); + + // Apply deletions to driver + await driver.deletePrefix(deletions.prefixes[0]!); + await Promise.all(deletions.keys.map((key) => driver.delete(key))); + + if (deletions.keys.length > 0 && onHistoryUpdated) { + onHistoryUpdated(); + } +} + +/** + * Remove entries matching a location prefix from memory and collect + * the driver-level deletion operations. The returned PendingDeletions + * can be applied immediately or batched with a flush. + */ +export function collectDeletionsForPrefix( + storage: Storage, + prefixLocation: Location, +): PendingDeletions { + const pending: PendingDeletions = { + prefixes: [buildHistoryPrefix(prefixLocation)], + keys: [], + ranges: [], + }; + + for (const [key, entry] of storage.history.entries) { + if (isLocationPrefix(prefixLocation, entry.location)) { + pending.keys.push(buildEntryMetadataKey(entry.id)); + storage.entryMetadata.delete(entry.id); + storage.history.entries.delete(key); + } + } + + return pending; +} + +/** + * Get an entry by location. + */ +export function getEntry( + storage: Storage, + location: Location, +): Entry | undefined { + const key = locationToKey(storage, location); + return storage.history.entries.get(key); +} + +/** + * Set an entry by location. + */ +export function setEntry( + storage: Storage, + location: Location, + entry: Entry, +): void { + const key = locationToKey(storage, location); + storage.history.entries.set(key, entry); +} diff --git a/packages/workflows/src/testing.ts b/packages/workflows/src/testing.ts new file mode 100644 index 0000000..8dbc696 --- /dev/null +++ b/packages/workflows/src/testing.ts @@ -0,0 +1,317 @@ +import type { EngineDriver, KVEntry, KVWrite } from "./driver.js"; +import { EvictedError } from "./errors.js"; +import { compareKeys, keyStartsWith, keyToHex } from "./keys.js"; +import type { Message, WorkflowMessageDriver } from "./types.js"; +import { sleep } from "./utils.js"; + +interface Waiter { + nameSet?: Set; + resolve: () => void; + reject: (error: Error) => void; + abortSignal: AbortSignal; + onAbort: () => void; +} + +class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver { + #messages: Message[] = []; + #waiters = new Set(); + + async addMessage(message: Message): Promise { + this.#messages.push(message); + this.#notifyWaiters(message.name); + } + + async receiveMessages(opts: { + names?: readonly string[]; + count: number; + completable: boolean; + }): Promise { + const limitedCount = Math.max(1, opts.count); + const nameSet = + opts.names && opts.names.length > 0 + ? new Set(opts.names) + : undefined; + const selected: Array<{ message: Message; index: number }> = []; + + for ( + let i = 0; + i < this.#messages.length && selected.length < limitedCount; + i++ + ) { + const message = this.#messages[i]; + if (nameSet && !nameSet.has(message.name)) { + continue; + } + selected.push({ message, index: i }); + } + + if (selected.length === 0) { + return []; + } + + if (!opts.completable) { + for (let i = selected.length - 1; i >= 0; i--) { + this.#messages.splice(selected[i].index, 1); + } + return selected.map((entry) => entry.message); + } + + return selected.map((entry) => { + const { message } = entry; + return { + ...message, + complete: async () => { + await this.completeMessage({ + id: message.id, + name: message.name, + }); + }, + }; + }); + } + + async completeMessage(message: { id: string; name: string }): Promise { + const index = this.#messages.findIndex( + (candidate) => + candidate.id === message.id && candidate.name === message.name, + ); + if (index !== -1) { + this.#messages.splice(index, 1); + } + } + + async waitForMessages( + messageNames: string[], + abortSignal: AbortSignal, + ): Promise { + if (abortSignal.aborted) { + throw new EvictedError(); + } + + const nameSet = + messageNames.length > 0 ? new Set(messageNames) : undefined; + if ( + this.#messages.some((message) => + nameSet ? nameSet.has(message.name) : true, + ) + ) { + return; + } + + await new Promise((resolve, reject) => { + const waiter: Waiter = { + nameSet, + resolve: () => { + this.#removeWaiter(waiter); + resolve(); + }, + reject: (error) => { + this.#removeWaiter(waiter); + reject(error); + }, + abortSignal, + onAbort: () => { + waiter.reject(new EvictedError()); + }, + }; + abortSignal.addEventListener("abort", waiter.onAbort, { + once: true, + }); + this.#waiters.add(waiter); + }); + } + + #removeWaiter(waiter: Waiter): void { + if (this.#waiters.delete(waiter)) { + waiter.abortSignal.removeEventListener("abort", waiter.onAbort); + } + } + + #notifyWaiters(name: string): void { + for (const waiter of [...this.#waiters]) { + if (waiter.nameSet && !waiter.nameSet.has(name)) { + continue; + } + waiter.resolve(); + } + } + + clear(): void { + this.#messages = []; + for (const waiter of [...this.#waiters]) { + waiter.reject(new Error("cleared")); + } + } +} + +/** + * In-memory implementation of EngineDriver for testing. + * Uses binary keys (Uint8Array) with hex encoding for internal Map storage. + */ +export class InMemoryDriver implements EngineDriver { + // Map from hex-encoded key to { originalKey, value } + private kv = new Map(); + private alarms = new Map(); + #inMemoryMessageDriver = new InMemoryWorkflowMessageDriver(); + + /** Simulated latency per operation (ms) */ + latency = 10; + + /** How often the worker polls for work */ + workerPollInterval = 100; + messageDriver: WorkflowMessageDriver = this.#inMemoryMessageDriver; + + async get(key: Uint8Array): Promise { + await sleep(this.latency); + const entry = this.kv.get(keyToHex(key)); + return entry?.value ?? null; + } + + async set(key: Uint8Array, value: Uint8Array): Promise { + await sleep(this.latency); + this.kv.set(keyToHex(key), { key, value }); + } + + async delete(key: Uint8Array): Promise { + await sleep(this.latency); + this.kv.delete(keyToHex(key)); + } + + async deletePrefix(prefix: Uint8Array): Promise { + await sleep(this.latency); + for (const [hexKey, entry] of this.kv) { + if (keyStartsWith(entry.key, prefix)) { + this.kv.delete(hexKey); + } + } + } + + async deleteRange(start: Uint8Array, end: Uint8Array): Promise { + await sleep(this.latency); + for (const [hexKey, entry] of this.kv) { + if ( + compareKeys(entry.key, start) >= 0 && + compareKeys(entry.key, end) < 0 + ) { + this.kv.delete(hexKey); + } + } + } + + async list(prefix: Uint8Array): Promise { + await sleep(this.latency); + const results: KVEntry[] = []; + for (const entry of this.kv.values()) { + if (keyStartsWith(entry.key, prefix)) { + results.push({ key: entry.key, value: entry.value }); + } + } + // Sort by key lexicographically + return results.sort((a, b) => compareKeys(a.key, b.key)); + } + + async batch(writes: KVWrite[]): Promise { + await sleep(this.latency); + for (const { key, value } of writes) { + this.kv.set(keyToHex(key), { key, value }); + } + } + + async setAlarm(workflowId: string, wakeAt: number): Promise { + await sleep(this.latency); + this.alarms.set(workflowId, wakeAt); + } + + async clearAlarm(workflowId: string): Promise { + await sleep(this.latency); + this.alarms.delete(workflowId); + } + + async waitForMessages( + messageNames: string[], + abortSignal: AbortSignal, + ): Promise { + const driver = this.messageDriver as WorkflowMessageDriver & { + waitForMessages?: ( + messageNames: string[], + abortSignal: AbortSignal, + ) => Promise; + }; + if (driver.waitForMessages) { + await driver.waitForMessages(messageNames, abortSignal); + return; + } + + while (true) { + if (abortSignal.aborted) { + throw new EvictedError(); + } + const messages = await this.messageDriver.receiveMessages({ + names: messageNames.length > 0 ? messageNames : undefined, + count: 1, + completable: true, + }); + if (messages.length > 0) { + return; + } + await sleep(Math.max(1, this.latency)); + } + } + + /** + * Get the alarm time for a workflow (for testing). + */ + getAlarm(workflowId: string): number | undefined { + return this.alarms.get(workflowId); + } + + /** + * Check if any alarms are due and return their workflow IDs. + */ + getDueAlarms(): string[] { + const now = Date.now(); + const due: string[] = []; + for (const [workflowId, wakeAt] of this.alarms) { + if (wakeAt <= now) { + due.push(workflowId); + } + } + return due; + } + + /** + * Clear all data (for testing). + */ + clear(): void { + this.kv.clear(); + this.alarms.clear(); + this.#inMemoryMessageDriver.clear(); + } + + /** + * Get a snapshot of all data (for testing/debugging). + */ + snapshot(): { + kv: Record; + alarms: Record; + } { + const kvSnapshot: Record = {}; + for (const [hexKey, entry] of this.kv) { + kvSnapshot[hexKey] = entry.value; + } + return { + kv: kvSnapshot, + alarms: Object.fromEntries(this.alarms), + }; + } + + /** + * Get all hex-encoded keys (for testing). + */ + keys(): string[] { + return [...this.kv.keys()]; + } +} + +// Re-export main exports for convenience +export * from "./index.js"; diff --git a/packages/workflows/src/types.ts b/packages/workflows/src/types.ts new file mode 100644 index 0000000..5b5cb18 --- /dev/null +++ b/packages/workflows/src/types.ts @@ -0,0 +1,651 @@ +import type { Logger } from "pino"; + +/** + * Index into the entry name registry. + * Names are stored once and referenced by this index to avoid repetition. + */ +export type NameIndex = number; + +/** + * A segment in a location path. + * Either a name index (for named entries) or a loop iteration marker. + */ +export type PathSegment = NameIndex | LoopIterationMarker; + +/** + * Marker for a loop iteration in a location path. + */ +export interface LoopIterationMarker { + loop: NameIndex; + iteration: number; +} + +/** + * Location identifies where an entry exists in the workflow execution tree. + * It forms a path from the root through loops, joins, and branches. + */ +export type Location = PathSegment[]; + +/** + * Current state of a sleep entry. + */ +export type SleepState = "pending" | "completed" | "interrupted"; + +/** + * Status of an entry in the workflow. + */ +export type EntryStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "exhausted"; + +/** + * Status of a branch in join/race. + */ +export type BranchStatusType = + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled"; + +/** + * Current state of the workflow. + */ +export type WorkflowState = + | "pending" + | "running" + | "sleeping" + | "failed" + | "completed" + | "cancelled" + | "rolling_back"; + +/** + * Step entry data. + */ +export interface StepEntry { + output?: unknown; + error?: string; +} + +/** + * Loop entry data. + */ +export interface LoopEntry { + state: unknown; + iteration: number; + output?: unknown; +} + +/** + * Sleep entry data. + */ +export interface SleepEntry { + deadline: number; + state: SleepState; +} + +/** + * Message entry data. + */ +export interface MessageEntry { + name: string; + data: unknown; +} + +/** + * Rollback checkpoint entry data. + */ +export interface RollbackCheckpointEntry { + name: string; +} + +/** + * Branch status for join/race entries. + */ +export interface BranchStatus { + status: BranchStatusType; + output?: unknown; + error?: string; +} + +/** + * Join entry data. + */ +export interface JoinEntry { + branches: Record; +} + +/** + * Race entry data. + */ +export interface RaceEntry { + winner: string | null; + branches: Record; +} + +/** + * Removed entry data - placeholder for removed steps in workflow migrations. + */ +export interface RemovedEntry { + originalType: EntryKindType; + originalName?: string; +} + +/** + * Version check entry data - records which version of a code path this + * workflow instance is pinned to at a given location. + */ +export interface VersionCheckEntry { + /** The version this instance resolved to at this location. */ + resolved: number; + /** The `latest` value seen when this entry was first resolved (diagnostics). */ + latest: number; +} + +/** + * All possible entry kind types. + */ +export type EntryKindType = + | "step" + | "loop" + | "sleep" + | "message" + | "rollback_checkpoint" + | "join" + | "race" + | "removed" + | "version_check"; + +/** + * Type-specific entry data. + */ +export type EntryKind = + | { type: "step"; data: StepEntry } + | { type: "loop"; data: LoopEntry } + | { type: "sleep"; data: SleepEntry } + | { type: "message"; data: MessageEntry } + | { type: "rollback_checkpoint"; data: RollbackCheckpointEntry } + | { type: "join"; data: JoinEntry } + | { type: "race"; data: RaceEntry } + | { type: "removed"; data: RemovedEntry } + | { type: "version_check"; data: VersionCheckEntry }; + +/** + * An entry in the workflow history. + */ +export interface Entry { + id: string; + location: Location; + kind: EntryKind; + dirty: boolean; +} + +/** + * Metadata for an entry (stored separately, lazily loaded). + */ +export interface EntryMetadata { + status: EntryStatus; + error?: string; + attempts: number; + lastAttemptAt: number; + createdAt: number; + completedAt?: number; + rollbackCompletedAt?: number; + rollbackError?: string; + dirty: boolean; +} + +/** + * A message in the queue. + */ +export interface Message { + /** Unique message ID. */ + id: string; + name: string; + data: unknown; + sentAt: number; + /** + * Optional completion callback for queue-backed drivers. + * + * This is runtime-only and is not persisted. + */ + complete?: (response?: unknown) => Promise; +} + +/** Stable identity used to complete a persisted queue message after restart. */ +export interface WorkflowMessageIdentity { + id: string; + name: string; +} + +/** + * Options for receiving queue messages in workflows. + */ +export interface WorkflowQueueNextOptions { + /** + * Queue names to receive from. + * If omitted, receives from all queue names. + */ + names?: readonly string[]; + /** + * Timeout in milliseconds. + * Omit to wait indefinitely. + */ + timeout?: number; + /** Whether returned messages must be manually completed. */ + completable?: boolean; +} + +/** + * Options for receiving a batch of queue messages in workflows. + */ +export interface WorkflowQueueNextBatchOptions + extends WorkflowQueueNextOptions { + /** Maximum number of messages to receive. Defaults to 1. */ + count?: number; +} + +/** + * Message returned by workflow queue operations. + */ +export interface WorkflowQueueMessage { + id: string | bigint; + name: string; + body: TBody; + createdAt: number; + complete?(response?: unknown): Promise; +} + +/** + * Workflow queue interface. + */ +export interface WorkflowQueue { + next( + name: string, + opts?: WorkflowQueueNextOptions, + ): Promise>; + nextBatch( + name: string, + opts?: WorkflowQueueNextBatchOptions, + ): Promise>>; + send(name: string, body: unknown): Promise; +} + +/** + * Workflow history - maps location keys to entries. + */ +export interface History { + entries: Map; +} + +/** + * History entry snapshot without internal dirty flags. + */ +export type WorkflowHistoryEntry = Omit; + +/** + * Entry metadata snapshot without internal dirty flags. + */ +export type WorkflowEntryMetadataSnapshot = Omit; + +/** + * Snapshot of workflow history for observers. + */ +export interface WorkflowHistorySnapshot { + nameRegistry: string[]; + entries: WorkflowHistoryEntry[]; + entryMetadata: ReadonlyMap; +} + +/** + * Structured error information for workflow failures. + */ +export interface WorkflowError { + /** Error name/type (e.g., "TypeError", "CriticalError") */ + name: string; + /** Error message */ + message: string; + /** Stack trace if available */ + stack?: string; + /** Custom error properties (for structured errors) */ + metadata?: Record; +} + +/** + * Error event emitted while a workflow is running. + */ +export interface WorkflowStepErrorEvent { + workflowId: string; + stepName: string; + attempt: number; + maxRetries: number; + remainingRetries: number; + willRetry: boolean; + retryDelay?: number; + retryAt?: number; + error: WorkflowError; +} + +/** + * Error event emitted when a rollback handler fails. + */ +export interface WorkflowRollbackErrorEvent { + workflowId: string; + stepName: string; + error: WorkflowError; +} + +/** + * Error event emitted for workflow-level failures outside individual steps. + */ +export interface WorkflowRunErrorEvent { + workflowId: string; + error: WorkflowError; +} + +export type WorkflowErrorEvent = + | { step: WorkflowStepErrorEvent } + | { rollback: WorkflowRollbackErrorEvent } + | { workflow: WorkflowRunErrorEvent }; + +export type WorkflowErrorHandler = ( + event: WorkflowErrorEvent, +) => void | Promise; + +/** + * Complete storage state for a workflow. + */ +export interface Storage { + nameRegistry: string[]; + flushedNameCount: number; + history: History; + entryMetadata: Map; + output?: unknown; + state: WorkflowState; + flushedState?: WorkflowState; + error?: WorkflowError; + flushedError?: WorkflowError; + flushedOutput?: unknown; +} + +/** + * Driver interface for workflow message persistence. + */ +export interface WorkflowMessageDriver { + addMessage(message: Message): Promise; + /** + * Receive messages directly from the host queue implementation. + * The operation must be non-blocking and return immediately. + */ + receiveMessages(opts: { + names?: readonly string[]; + count: number; + completable: boolean; + }): Promise; + /** + * Complete a previously consumed message with an optional response payload. + */ + completeMessage( + message: WorkflowMessageIdentity, + response?: unknown, + ): Promise; +} + +/** + * Context available to rollback handlers. + */ +export interface RollbackContextInterface { + readonly workflowId: string; + readonly abortSignal: AbortSignal; + isEvicted(): boolean; +} + +/** + * Configuration for a step. + */ +export interface StepConfig { + name: string; + run: () => Promise; + rollback?: (ctx: RollbackContextInterface, output: T) => Promise; + /** If true, step result is not persisted (use for idempotent operations). */ + ephemeral?: boolean; + /** Maximum number of retry attempts (default: 3). */ + maxRetries?: number; + /** Base delay in ms for exponential backoff (default: 100). */ + retryBackoffBase?: number; + /** Maximum delay in ms for exponential backoff (default: 30000). */ + retryBackoffMax?: number; + /** Timeout in ms for step execution (default: 30000). Set to 0 to disable. */ + timeout?: number; + /** If true, step timeouts retry like any other error instead of failing immediately as critical. Default: false. */ + retryOnTimeout?: boolean; +} + +export type TryStepCatchKind = + | "critical" + | "timeout" + | "exhausted" + | "rollback"; + +export interface TryStepFailure { + kind: TryStepCatchKind; + stepName: string; + attempts: number; + error: WorkflowError; +} + +export type TryStepResult = + | { ok: true; value: T } + | { ok: false; failure: TryStepFailure }; + +export interface TryStepConfig extends StepConfig { + catch?: readonly TryStepCatchKind[]; +} + +export type TryBlockCatchKind = "step" | "join" | "race" | "rollback"; + +export interface TryBlockFailure { + source: "step" | "join" | "race" | "block"; + name: string; + error: WorkflowError; + step?: TryStepFailure; +} + +export type TryBlockResult = + | { ok: true; value: T } + | { ok: false; failure: TryBlockFailure }; + +export interface TryBlockConfig { + name: string; + run: (ctx: WorkflowContextInterface) => Promise; + catch?: readonly TryBlockCatchKind[]; +} + +/** + * Result from a loop iteration. + */ +export type LoopResult = + | { continue: true; state: S } + | { break: true; value: T }; + +/** + * Return type for a loop iteration callback. + * + * Stateless loops (state = undefined) may return void/undefined, which is treated as + * `Loop.continue(undefined)`. + */ +export type LoopIterationResult = Promise< + LoopResult | (S extends undefined ? undefined : never) +>; + +/** + * Configuration for a loop. + */ +export interface LoopConfig { + name: string; + state?: S; + run: (ctx: WorkflowContextInterface, state: S) => LoopIterationResult; + /** Prune old loop iterations every N iterations. Default: 20. */ + historyPruneInterval?: number; + /** Number of past iterations to retain when pruning. Defaults to historyPruneInterval. */ + historySize?: number; + /** @deprecated Use historyPruneInterval. */ + commitInterval?: number; + /** @deprecated Use historyPruneInterval. */ + historyEvery?: number; + /** @deprecated Use historySize. */ + historyKeep?: number; +} + +/** + * Configuration for a branch in join/race. + */ +export interface BranchConfig { + run: (ctx: WorkflowContextInterface) => Promise; +} + +/** + * Extract the output type from a BranchConfig. + */ +export type BranchOutput = T extends BranchConfig ? O : never; + +/** + * The workflow context interface exposed to workflow functions. + */ +export interface WorkflowContextInterface { + readonly workflowId: string; + readonly abortSignal: AbortSignal; + readonly queue: WorkflowQueue; + + step(name: string, run: () => Promise): Promise; + step(config: StepConfig): Promise; + + tryStep(name: string, run: () => Promise): Promise>; + tryStep(config: TryStepConfig): Promise>; + + try( + name: string, + run: (ctx: WorkflowContextInterface) => Promise, + ): Promise>; + try(config: TryBlockConfig): Promise>; + + loop( + name: string, + run: ( + ctx: WorkflowContextInterface, + ) => LoopIterationResult, + ): Promise; + loop(config: LoopConfig): Promise; + + sleep(name: string, durationMs: number): Promise; + sleepUntil(name: string, timestampMs: number): Promise; + + rollbackCheckpoint(name: string): Promise; + + join>>( + name: string, + branches: T, + ): Promise<{ [K in keyof T]: BranchOutput }>; + + race( + name: string, + branches: Array<{ + name: string; + run: (ctx: WorkflowContextInterface) => Promise; + }>, + ): Promise<{ winner: string; value: T }>; + + removed(name: string, originalType: EntryKindType): Promise; + + getVersion(name: string, latest: number): Promise; + + isEvicted(): boolean; +} + +/** + * Workflow function type. + */ +export type WorkflowRunMode = "yield" | "live"; + +export interface RunWorkflowOptions { + mode?: WorkflowRunMode; + logger?: Logger; + onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void; + onError?: WorkflowErrorHandler; +} + +export type WorkflowFunction = ( + ctx: WorkflowContextInterface, + input: TInput, +) => Promise; + +/** + * Result returned when a workflow run completes or yields. + */ +export interface WorkflowResult { + state: WorkflowState; + output?: TOutput; + sleepUntil?: number; + waitingForMessages?: string[]; +} + +/** + * Handle for managing a running workflow. + * + * Returned by `runWorkflow()`. The workflow starts executing immediately. + * Use `.result` to await completion, and other methods to interact with + * the running workflow. + */ +export interface WorkflowHandle { + readonly workflowId: string; + + /** + * Promise that resolves when the workflow completes or yields. + */ + readonly result: Promise>; + + /** + * Send a message to the workflow. + * The message is delegated to the runtime message driver. + * In live mode, this wakes workflows waiting on queue messages. + */ + message(name: string, data: unknown): Promise; + + /** + * Wake the workflow immediately by setting an alarm for now. + */ + wake(): Promise; + + /** + * Reset exhausted retries and schedule the workflow to run again. + */ + recover(): Promise; + + /** + * Request the workflow to stop gracefully. + * The workflow will throw EvictedError at its next yield point, + * flush its state, and resolve the result promise. + */ + evict(): void; + + /** + * Cancel the workflow permanently. + * Sets the workflow state to "cancelled" and clears any pending alarms. + * Unlike evict(), this marks the workflow as permanently stopped. + */ + cancel(): Promise; + + /** + * Get the workflow output if completed. + */ + getOutput(): Promise; + + /** + * Get the current workflow state. + */ + getState(): Promise; +} diff --git a/packages/workflows/src/utils.ts b/packages/workflows/src/utils.ts new file mode 100644 index 0000000..16de15f --- /dev/null +++ b/packages/workflows/src/utils.ts @@ -0,0 +1,48 @@ +/** + * Sleep for a given number of milliseconds. + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const TIMEOUT_MAX = 2147483647; + +export type LongTimeoutHandle = { abort: () => void }; + +export function setLongTimeout( + listener: () => void, + after: number, +): LongTimeoutHandle { + let timeout: ReturnType | undefined; + + function start(remaining: number) { + if (remaining <= TIMEOUT_MAX) { + timeout = setTimeout(listener, remaining); + } else { + timeout = setTimeout(() => { + start(remaining - TIMEOUT_MAX); + }, TIMEOUT_MAX); + } + } + + start(after); + + return { + abort: () => { + if (timeout !== undefined) clearTimeout(timeout); + }, + }; +} + +/** + * Safely parse JSON with a meaningful error message. + */ +export function safeJsonParse(value: string, context: string): T { + try { + return JSON.parse(value) as T; + } catch (error) { + throw new Error( + `Failed to parse ${context}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/workflows/tests/compat/fixture-codec.ts b/packages/workflows/tests/compat/fixture-codec.ts new file mode 100644 index 0000000..27bb8ef --- /dev/null +++ b/packages/workflows/tests/compat/fixture-codec.ts @@ -0,0 +1,36 @@ +import { createVersionedDataHandler } from "vbare"; +import { + decodeWorkflowFixture, + encodeWorkflowFixture, + type WorkflowFixture, +} from "./generated/rivetkit-workflow-fixture-v1"; + +export type { WorkflowFixture }; + +const FIXTURE_VERSION = 1; +const FIXTURE_CODEC = createVersionedDataHandler({ + deserializeVersion: (bytes, version) => { + if (version !== FIXTURE_VERSION) { + throw new Error(`Unsupported workflow fixture version ${version}`); + } + return decodeWorkflowFixture(bytes); + }, + serializeVersion: (fixture, version) => { + if (version !== FIXTURE_VERSION) { + throw new Error(`Unsupported workflow fixture version ${version}`); + } + return encodeWorkflowFixture(fixture); + }, + deserializeConverters: () => [], + serializeConverters: () => [], +}); + +export function encodeFixture(fixture: WorkflowFixture): Uint8Array { + return FIXTURE_CODEC.serializeWithEmbeddedVersion(fixture, FIXTURE_VERSION); +} + +export function decodeFixture(bytes: Uint8Array): WorkflowFixture { + // Node's `Buffer#slice()` aliases its backing allocation, while the BARE data + // decoder expects `Uint8Array#slice()` copy semantics for each data field. + return FIXTURE_CODEC.deserializeWithEmbeddedVersion(Uint8Array.from(bytes)); +} diff --git a/packages/workflows/tests/compat/fixture-driver.ts b/packages/workflows/tests/compat/fixture-driver.ts new file mode 100644 index 0000000..2882488 --- /dev/null +++ b/packages/workflows/tests/compat/fixture-driver.ts @@ -0,0 +1,378 @@ +import * as bare from "@rivetkit/bare-ts"; +import * as cbor from "cbor-x"; +import type { WorkflowFixture } from "./fixture-codec"; + +const WORKFLOW_NAMESPACE = new Uint8Array([6, 1]); +const RUN_WAKE_META_KEY = "run_wake_at"; +const SCHEMA_VERSION_META_KEY = "schema_version"; +const fixtureBareConfig = bare.Config({}); + +interface WorkflowMessage { + id: string; + name: string; + data: unknown; + sentAt: number; + complete?: (response?: unknown) => Promise; +} + +interface QueueMessageState extends Omit { + numericId: bigint; +} + +interface Waiter { + names?: Set; + resolve: () => void; + reject: (error: unknown) => void; + signal: AbortSignal; + onAbort: () => void; +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +function compareBytes(left: Uint8Array, right: Uint8Array): number { + for (let index = 0; index < Math.min(left.length, right.length); index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return left.length - right.length; +} + +function startsWith(key: Uint8Array, prefix: Uint8Array): boolean { + return prefix.every((byte, index) => key[index] === byte); +} + +function keyString(key: Uint8Array): string { + return Buffer.from(key).toString("hex"); +} + +export class CompatibilityDriver { + readonly atomicBatch = true; + workerPollInterval = 0; + readonly completions: Array<{ + id: string; + name: string; + response: unknown; + }> = []; + readonly messageDriver; + actorState: Uint8Array; + scheduleEvents: WorkflowFixture["scheduleEvents"]; + scheduleHistory: WorkflowFixture["scheduleHistory"]; + #rows = new Map(); + #alarms = new Map(); + #messages: QueueMessageState[] = []; + #waiters = new Set(); + #logicalRunWakeAt: bigint | null = null; + #persistRunWakeMetadata: boolean; + #existingRunWakeValue: ArrayBuffer | undefined; + + constructor( + fixture?: WorkflowFixture, + options: { persistRunWakeMetadata?: boolean } = {}, + ) { + this.#persistRunWakeMetadata = options.persistRunWakeMetadata ?? false; + this.actorState = fixture?.actorState + ? new Uint8Array(fixture.actorState) + : cbor.encode({ mutations: 0 }); + this.scheduleEvents = fixture?.scheduleEvents ?? []; + this.scheduleHistory = fixture?.scheduleHistory ?? []; + + for (const row of fixture?.workflowRows ?? []) { + const key = new Uint8Array(row.key); + if (!startsWith(key, WORKFLOW_NAMESPACE)) { + throw new Error("Workflow fixture row escaped the [6, 1] namespace"); + } + const logicalKey = key.slice(WORKFLOW_NAMESPACE.length); + this.#rows.set(keyString(logicalKey), { + key: logicalKey, + value: new Uint8Array(row.value), + }); + } + const runWakeAt = fixture?.metaRows.find( + (row) => row.key === RUN_WAKE_META_KEY, + ); + const lastPushedAlarm = fixture?.runtime?.lastPushedAlarm; + if (fixture && runWakeAt) { + this.#existingRunWakeValue = runWakeAt.value; + this.#logicalRunWakeAt = decodeRunWakeAt(new Uint8Array(runWakeAt.value)); + } + if (fixture && this.#logicalRunWakeAt != null) { + this.#alarms.set( + fixture.metadata.actorId, + Number(this.#logicalRunWakeAt), + ); + } else if (fixture && lastPushedAlarm != null) { + this.#alarms.set(fixture.metadata.actorId, Number(lastPushedAlarm)); + } + this.#messages = (fixture?.queueRows ?? []).map((row) => ({ + id: row.id.toString(), + numericId: row.id, + name: row.name, + data: cbor.decode(new Uint8Array(row.body)), + sentAt: Number(row.createdAt), + })); + + this.messageDriver = { + addMessage: async (message: WorkflowMessage) => { + const highestId = this.#messages.reduce( + (max, candidate) => + candidate.numericId > max ? candidate.numericId : max, + 0n, + ); + const parsed = /^\d+$/.test(message.id) + ? BigInt(message.id) + : highestId + 1n; + this.#messages.push({ ...message, numericId: parsed }); + this.#notify(message.name); + }, + receiveMessages: async (options: { + names?: readonly string[]; + count: number; + completable: boolean; + }) => { + const nameSet = options.names?.length + ? new Set(options.names) + : undefined; + const selected = this.#messages + .filter((message) => !nameSet || nameSet.has(message.name)) + .slice(0, Math.max(1, options.count)); + if (!options.completable) { + for (const message of selected) this.#remove(message); + return selected; + } + return selected.map((message) => ({ + ...message, + complete: async (response?: unknown) => { + this.#complete(message, response); + }, + })); + }, + completeMessage: async ( + identity: { id: string; name: string }, + response?: unknown, + ) => { + const message = this.#messages.find( + (candidate) => + candidate.id === identity.id && candidate.name === identity.name, + ); + if (message) this.#complete(message, response); + }, + }; + } + + async get(key: Uint8Array): Promise { + return this.#rows.get(keyString(key))?.value ?? null; + } + + async set(key: Uint8Array, value: Uint8Array): Promise { + this.#rows.set(keyString(key), { key, value }); + } + + async delete(key: Uint8Array): Promise { + this.#rows.delete(keyString(key)); + } + + async deletePrefix(prefix: Uint8Array): Promise { + for (const [encoded, row] of this.#rows) { + if (startsWith(row.key, prefix)) this.#rows.delete(encoded); + } + } + + async deleteRange(start: Uint8Array, end: Uint8Array): Promise { + for (const [encoded, row] of this.#rows) { + if (compareBytes(row.key, start) >= 0 && compareBytes(row.key, end) < 0) { + this.#rows.delete(encoded); + } + } + } + + async list(prefix: Uint8Array) { + return [...this.#rows.values()] + .filter((row) => startsWith(row.key, prefix)) + .sort((left, right) => compareBytes(left.key, right.key)); + } + + async batch(writes: Array<{ key: Uint8Array; value: Uint8Array }>) { + for (const write of writes) await this.set(write.key, write.value); + } + + async setAlarm(workflowId: string, wakeAt: number): Promise { + this.#alarms.set(workflowId, wakeAt); + this.#logicalRunWakeAt = BigInt(wakeAt); + } + + async clearAlarm(workflowId: string): Promise { + this.#alarms.delete(workflowId); + this.#logicalRunWakeAt = null; + } + + async waitForMessages(names: string[], signal: AbortSignal): Promise { + if ( + this.#messages.some( + (message) => names.length === 0 || names.includes(message.name), + ) + ) { + return; + } + await new Promise((resolve, reject) => { + const waiter: Waiter = { + names: names.length ? new Set(names) : undefined, + resolve: () => { + this.#removeWaiter(waiter); + resolve(); + }, + reject, + signal, + onAbort: () => { + this.#removeWaiter(waiter); + reject(new Error("workflow fixture wait aborted")); + }, + }; + signal.addEventListener("abort", waiter.onAbort, { once: true }); + this.#waiters.add(waiter); + }); + } + + workflowAlarm(actorId: string): number | undefined { + return this.#alarms.get(actorId); + } + + queueSize(): number { + return this.#messages.length; + } + + toFixture( + metadata: WorkflowFixture["metadata"], + options: { + runWakeAt?: bigint | null; + lastPushedAlarm?: bigint | null; + } = {}, + ): WorkflowFixture { + const workflowRows = [...this.#rows.values()] + .sort((left, right) => compareBytes(left.key, right.key)) + .map((row) => ({ + key: toArrayBuffer(new Uint8Array([...WORKFLOW_NAMESPACE, ...row.key])), + value: toArrayBuffer(row.value), + })); + const queueRows = [...this.#messages] + .sort((left, right) => + left.numericId < right.numericId + ? -1 + : left.numericId > right.numericId + ? 1 + : 0, + ) + .map((message) => ({ + id: message.numericId, + name: message.name, + body: toArrayBuffer(cbor.encode(message.data)), + createdAt: BigInt(message.sentAt), + })); + const alarm = this.#alarms.get(metadata.actorId); + if (options.runWakeAt !== undefined) { + this.#logicalRunWakeAt = options.runWakeAt; + } + const metaRows: Array = [ + { + key: SCHEMA_VERSION_META_KEY, + value: toArrayBuffer( + encodeSchemaVersion(metadata.internalSchemaVersion), + ), + }, + ]; + if (this.#persistRunWakeMetadata) { + metaRows.push({ + key: RUN_WAKE_META_KEY, + value: toArrayBuffer(encodeRunWakeAt(this.#logicalRunWakeAt)), + }); + } else if (this.#existingRunWakeValue) { + metaRows.push({ + key: RUN_WAKE_META_KEY, + value: this.#existingRunWakeValue, + }); + } + metaRows.sort((left, right) => left.key.localeCompare(right.key)); + return { + metadata, + metaRows, + runtime: { + lastPushedAlarm: + options.lastPushedAlarm ?? + (alarm === undefined ? null : BigInt(alarm)), + inspectorToken: null, + queueNextId: + queueRows.reduce( + (max, row) => (row.id >= max ? row.id + 1n : max), + 0n, + ) || 1n, + }, + actor: { hasInitialized: 1n, input: null }, + actorState: toArrayBuffer(this.actorState), + workflowRows, + queueRows, + scheduleEvents: this.scheduleEvents, + scheduleHistory: this.scheduleHistory, + }; + } + + #remove(message: QueueMessageState): void { + const index = this.#messages.indexOf(message); + if (index !== -1) this.#messages.splice(index, 1); + } + + #complete(message: QueueMessageState, response: unknown): void { + this.completions.push({ + id: message.id, + name: message.name, + response, + }); + this.#remove(message); + } + + #notify(name: string): void { + for (const waiter of [...this.#waiters]) { + if (!waiter.names || waiter.names.has(name)) waiter.resolve(); + } + } + + #removeWaiter(waiter: Waiter): void { + if (this.#waiters.delete(waiter)) { + waiter.signal.removeEventListener("abort", waiter.onAbort); + } + } +} + +export function encodeActorState(value: unknown): Uint8Array { + return cbor.encode(value); +} + +export function decodeActorState(bytes: Uint8Array): T { + return cbor.decode(bytes) as T; +} + +function encodeSchemaVersion(version: bigint): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigInt64(0, version, true); + return bytes; +} + +function encodeRunWakeAt(value: bigint | null): Uint8Array { + const cursor = new bare.ByteCursor( + new Uint8Array(fixtureBareConfig.initialBufferLength), + fixtureBareConfig, + ); + bare.writeBool(cursor, value !== null); + if (value !== null) bare.writeI64(cursor, value); + return new Uint8Array([1, 0, ...cursor.bytes.slice(0, cursor.offset)]); +} + +function decodeRunWakeAt(bytes: Uint8Array): bigint | null { + if (bytes[0] !== 1 || bytes[1] !== 0) { + throw new Error("Unsupported logical run-wake fixture version"); + } + const payload = bytes.slice(2); + const cursor = new bare.ByteCursor(payload, fixtureBareConfig); + return bare.readBool(cursor) ? bare.readI64(cursor) : null; +} diff --git a/packages/workflows/tests/compat/fixtures/pending-message.vbare b/packages/workflows/tests/compat/fixtures/pending-message.vbare new file mode 100644 index 0000000..a150598 Binary files /dev/null and b/packages/workflows/tests/compat/fixtures/pending-message.vbare differ diff --git a/packages/workflows/tests/compat/fixtures/pending-sleep.vbare b/packages/workflows/tests/compat/fixtures/pending-sleep.vbare new file mode 100644 index 0000000..d72c002 Binary files /dev/null and b/packages/workflows/tests/compat/fixtures/pending-sleep.vbare differ diff --git a/packages/workflows/tests/compat/fixtures/replay-retry.vbare b/packages/workflows/tests/compat/fixtures/replay-retry.vbare new file mode 100644 index 0000000..003ee8e Binary files /dev/null and b/packages/workflows/tests/compat/fixtures/replay-retry.vbare differ diff --git a/packages/workflows/tests/compat/generated/rivetkit-workflow-fixture-v1.ts b/packages/workflows/tests/compat/generated/rivetkit-workflow-fixture-v1.ts new file mode 100644 index 0000000..ce2fb63 --- /dev/null +++ b/packages/workflows/tests/compat/generated/rivetkit-workflow-fixture-v1.ts @@ -0,0 +1,448 @@ +// @generated - post-processed by compile-bare.ts +import * as bare from "@rivetkit/bare-ts"; + +const config = /* @__PURE__ */ bare.Config({}); + +export type i64 = bigint; +export type u64 = bigint; + +export type FixtureMetadata = { + readonly fixtureName: string; + readonly sourceRivetkitVersion: string; + readonly sourceWorkflowVersion: string; + readonly sourceRevision: string; + readonly actorId: string; + readonly registryKey: string; + readonly internalSchemaVersion: i64; + readonly fakeClockSeed: u64; + readonly generatedIdSeed: u64; +}; + +export function readFixtureMetadata(bc: bare.ByteCursor): FixtureMetadata { + return { + fixtureName: bare.readString(bc), + sourceRivetkitVersion: bare.readString(bc), + sourceWorkflowVersion: bare.readString(bc), + sourceRevision: bare.readString(bc), + actorId: bare.readString(bc), + registryKey: bare.readString(bc), + internalSchemaVersion: bare.readI64(bc), + fakeClockSeed: bare.readU64(bc), + generatedIdSeed: bare.readU64(bc), + }; +} + +export function writeFixtureMetadata( + bc: bare.ByteCursor, + x: FixtureMetadata, +): void { + bare.writeString(bc, x.fixtureName); + bare.writeString(bc, x.sourceRivetkitVersion); + bare.writeString(bc, x.sourceWorkflowVersion); + bare.writeString(bc, x.sourceRevision); + bare.writeString(bc, x.actorId); + bare.writeString(bc, x.registryKey); + bare.writeI64(bc, x.internalSchemaVersion); + bare.writeU64(bc, x.fakeClockSeed); + bare.writeU64(bc, x.generatedIdSeed); +} + +function read0(bc: bare.ByteCursor): i64 | null { + return bare.readBool(bc) ? bare.readI64(bc) : null; +} + +function write0(bc: bare.ByteCursor, x: i64 | null): void { + bare.writeBool(bc, x !== null); + if (x !== null) { + bare.writeI64(bc, x); + } +} + +function read1(bc: bare.ByteCursor): string | null { + return bare.readBool(bc) ? bare.readString(bc) : null; +} + +function write1(bc: bare.ByteCursor, x: string | null): void { + bare.writeBool(bc, x !== null); + if (x !== null) { + bare.writeString(bc, x); + } +} + +export type RuntimeRow = { + readonly lastPushedAlarm: i64 | null; + readonly inspectorToken: string | null; + readonly queueNextId: i64; +}; + +export function readRuntimeRow(bc: bare.ByteCursor): RuntimeRow { + return { + lastPushedAlarm: read0(bc), + inspectorToken: read1(bc), + queueNextId: bare.readI64(bc), + }; +} + +export function writeRuntimeRow(bc: bare.ByteCursor, x: RuntimeRow): void { + write0(bc, x.lastPushedAlarm); + write1(bc, x.inspectorToken); + bare.writeI64(bc, x.queueNextId); +} + +export type MetaRow = { + readonly key: string; + readonly value: ArrayBuffer; +}; + +export function readMetaRow(bc: bare.ByteCursor): MetaRow { + return { + key: bare.readString(bc), + value: bare.readData(bc), + }; +} + +export function writeMetaRow(bc: bare.ByteCursor, x: MetaRow): void { + bare.writeString(bc, x.key); + bare.writeData(bc, x.value); +} + +function read2(bc: bare.ByteCursor): ArrayBuffer | null { + return bare.readBool(bc) ? bare.readData(bc) : null; +} + +function write2(bc: bare.ByteCursor, x: ArrayBuffer | null): void { + bare.writeBool(bc, x !== null); + if (x !== null) { + bare.writeData(bc, x); + } +} + +export type ActorRow = { + readonly hasInitialized: i64; + readonly input: ArrayBuffer | null; +}; + +export function readActorRow(bc: bare.ByteCursor): ActorRow { + return { + hasInitialized: bare.readI64(bc), + input: read2(bc), + }; +} + +export function writeActorRow(bc: bare.ByteCursor, x: ActorRow): void { + bare.writeI64(bc, x.hasInitialized); + write2(bc, x.input); +} + +export type WorkflowRow = { + readonly key: ArrayBuffer; + readonly value: ArrayBuffer; +}; + +export function readWorkflowRow(bc: bare.ByteCursor): WorkflowRow { + return { + key: bare.readData(bc), + value: bare.readData(bc), + }; +} + +export function writeWorkflowRow(bc: bare.ByteCursor, x: WorkflowRow): void { + bare.writeData(bc, x.key); + bare.writeData(bc, x.value); +} + +export type QueueRow = { + readonly id: i64; + readonly name: string; + readonly body: ArrayBuffer; + readonly createdAt: i64; +}; + +export function readQueueRow(bc: bare.ByteCursor): QueueRow { + return { + id: bare.readI64(bc), + name: bare.readString(bc), + body: bare.readData(bc), + createdAt: bare.readI64(bc), + }; +} + +export function writeQueueRow(bc: bare.ByteCursor, x: QueueRow): void { + bare.writeI64(bc, x.id); + bare.writeString(bc, x.name); + bare.writeData(bc, x.body); + bare.writeI64(bc, x.createdAt); +} + +export type ScheduleEventRow = { + readonly eventId: string; + readonly triggerAt: i64; + readonly action: string; + readonly args: ArrayBuffer | null; + readonly kind: i64; + readonly cronExpression: string | null; + readonly timezone: string | null; + readonly intervalMs: i64 | null; + readonly lastStartedAt: i64 | null; + readonly maxHistory: i64; +}; + +export function readScheduleEventRow(bc: bare.ByteCursor): ScheduleEventRow { + return { + eventId: bare.readString(bc), + triggerAt: bare.readI64(bc), + action: bare.readString(bc), + args: read2(bc), + kind: bare.readI64(bc), + cronExpression: read1(bc), + timezone: read1(bc), + intervalMs: read0(bc), + lastStartedAt: read0(bc), + maxHistory: bare.readI64(bc), + }; +} + +export function writeScheduleEventRow( + bc: bare.ByteCursor, + x: ScheduleEventRow, +): void { + bare.writeString(bc, x.eventId); + bare.writeI64(bc, x.triggerAt); + bare.writeString(bc, x.action); + write2(bc, x.args); + bare.writeI64(bc, x.kind); + write1(bc, x.cronExpression); + write1(bc, x.timezone); + write0(bc, x.intervalMs); + write0(bc, x.lastStartedAt); + bare.writeI64(bc, x.maxHistory); +} + +export type ScheduleHistoryRow = { + readonly id: i64; + readonly scheduleId: string; + readonly action: string; + readonly scheduledAt: i64; + readonly firedAt: i64; + readonly finishedAt: i64 | null; + readonly result: i64; + readonly errorGroup: string | null; + readonly errorCode: string | null; + readonly errorMessage: string | null; + readonly errorMetadata: ArrayBuffer | null; +}; + +export function readScheduleHistoryRow( + bc: bare.ByteCursor, +): ScheduleHistoryRow { + return { + id: bare.readI64(bc), + scheduleId: bare.readString(bc), + action: bare.readString(bc), + scheduledAt: bare.readI64(bc), + firedAt: bare.readI64(bc), + finishedAt: read0(bc), + result: bare.readI64(bc), + errorGroup: read1(bc), + errorCode: read1(bc), + errorMessage: read1(bc), + errorMetadata: read2(bc), + }; +} + +export function writeScheduleHistoryRow( + bc: bare.ByteCursor, + x: ScheduleHistoryRow, +): void { + bare.writeI64(bc, x.id); + bare.writeString(bc, x.scheduleId); + bare.writeString(bc, x.action); + bare.writeI64(bc, x.scheduledAt); + bare.writeI64(bc, x.firedAt); + write0(bc, x.finishedAt); + bare.writeI64(bc, x.result); + write1(bc, x.errorGroup); + write1(bc, x.errorCode); + write1(bc, x.errorMessage); + write2(bc, x.errorMetadata); +} + +function read3(bc: bare.ByteCursor): readonly MetaRow[] { + const len = bare.readUintSafe(bc); + if (len === 0) { + return []; + } + const result = [readMetaRow(bc)]; + for (let i = 1; i < len; i++) { + result[i] = readMetaRow(bc); + } + return result; +} + +function write3(bc: bare.ByteCursor, x: readonly MetaRow[]): void { + bare.writeUintSafe(bc, x.length); + for (let i = 0; i < x.length; i++) { + writeMetaRow(bc, x[i]); + } +} + +function read4(bc: bare.ByteCursor): RuntimeRow | null { + return bare.readBool(bc) ? readRuntimeRow(bc) : null; +} + +function write4(bc: bare.ByteCursor, x: RuntimeRow | null): void { + bare.writeBool(bc, x !== null); + if (x !== null) { + writeRuntimeRow(bc, x); + } +} + +function read5(bc: bare.ByteCursor): ActorRow | null { + return bare.readBool(bc) ? readActorRow(bc) : null; +} + +function write5(bc: bare.ByteCursor, x: ActorRow | null): void { + bare.writeBool(bc, x !== null); + if (x !== null) { + writeActorRow(bc, x); + } +} + +function read6(bc: bare.ByteCursor): readonly WorkflowRow[] { + const len = bare.readUintSafe(bc); + if (len === 0) { + return []; + } + const result = [readWorkflowRow(bc)]; + for (let i = 1; i < len; i++) { + result[i] = readWorkflowRow(bc); + } + return result; +} + +function write6(bc: bare.ByteCursor, x: readonly WorkflowRow[]): void { + bare.writeUintSafe(bc, x.length); + for (let i = 0; i < x.length; i++) { + writeWorkflowRow(bc, x[i]); + } +} + +function read7(bc: bare.ByteCursor): readonly QueueRow[] { + const len = bare.readUintSafe(bc); + if (len === 0) { + return []; + } + const result = [readQueueRow(bc)]; + for (let i = 1; i < len; i++) { + result[i] = readQueueRow(bc); + } + return result; +} + +function write7(bc: bare.ByteCursor, x: readonly QueueRow[]): void { + bare.writeUintSafe(bc, x.length); + for (let i = 0; i < x.length; i++) { + writeQueueRow(bc, x[i]); + } +} + +function read8(bc: bare.ByteCursor): readonly ScheduleEventRow[] { + const len = bare.readUintSafe(bc); + if (len === 0) { + return []; + } + const result = [readScheduleEventRow(bc)]; + for (let i = 1; i < len; i++) { + result[i] = readScheduleEventRow(bc); + } + return result; +} + +function write8(bc: bare.ByteCursor, x: readonly ScheduleEventRow[]): void { + bare.writeUintSafe(bc, x.length); + for (let i = 0; i < x.length; i++) { + writeScheduleEventRow(bc, x[i]); + } +} + +function read9(bc: bare.ByteCursor): readonly ScheduleHistoryRow[] { + const len = bare.readUintSafe(bc); + if (len === 0) { + return []; + } + const result = [readScheduleHistoryRow(bc)]; + for (let i = 1; i < len; i++) { + result[i] = readScheduleHistoryRow(bc); + } + return result; +} + +function write9(bc: bare.ByteCursor, x: readonly ScheduleHistoryRow[]): void { + bare.writeUintSafe(bc, x.length); + for (let i = 0; i < x.length; i++) { + writeScheduleHistoryRow(bc, x[i]); + } +} + +export type WorkflowFixture = { + readonly metadata: FixtureMetadata; + readonly metaRows: readonly MetaRow[]; + readonly runtime: RuntimeRow | null; + readonly actor: ActorRow | null; + readonly actorState: ArrayBuffer | null; + readonly workflowRows: readonly WorkflowRow[]; + readonly queueRows: readonly QueueRow[]; + readonly scheduleEvents: readonly ScheduleEventRow[]; + readonly scheduleHistory: readonly ScheduleHistoryRow[]; +}; + +export function readWorkflowFixture(bc: bare.ByteCursor): WorkflowFixture { + return { + metadata: readFixtureMetadata(bc), + metaRows: read3(bc), + runtime: read4(bc), + actor: read5(bc), + actorState: read2(bc), + workflowRows: read6(bc), + queueRows: read7(bc), + scheduleEvents: read8(bc), + scheduleHistory: read9(bc), + }; +} + +export function writeWorkflowFixture( + bc: bare.ByteCursor, + x: WorkflowFixture, +): void { + writeFixtureMetadata(bc, x.metadata); + write3(bc, x.metaRows); + write4(bc, x.runtime); + write5(bc, x.actor); + write2(bc, x.actorState); + write6(bc, x.workflowRows); + write7(bc, x.queueRows); + write8(bc, x.scheduleEvents); + write9(bc, x.scheduleHistory); +} + +export function encodeWorkflowFixture(x: WorkflowFixture): Uint8Array { + const bc = new bare.ByteCursor( + new Uint8Array(config.initialBufferLength), + config, + ); + writeWorkflowFixture(bc, x); + return new Uint8Array(bc.view.buffer, bc.view.byteOffset, bc.offset); +} + +export function decodeWorkflowFixture(bytes: Uint8Array): WorkflowFixture { + const bc = new bare.ByteCursor(bytes, config); + const result = readWorkflowFixture(bc); + if (bc.offset < bc.view.byteLength) { + throw new bare.BareError(bc.offset, "remaining bytes"); + } + return result; +} + +function _assert(condition: boolean, message?: string): asserts condition { + if (!condition) throw new Error(message ?? "Assertion failed"); +} diff --git a/packages/workflows/tests/compat/runner.ts b/packages/workflows/tests/compat/runner.ts new file mode 100644 index 0000000..5c703b1 --- /dev/null +++ b/packages/workflows/tests/compat/runner.ts @@ -0,0 +1,504 @@ +import { webcrypto } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import * as cbor from "cbor-x"; +import type { WorkflowContextInterface } from "../../src/index.js"; +import { + decodeFixture, + encodeFixture, + type WorkflowFixture, +} from "./fixture-codec"; +import { + CompatibilityDriver, + decodeActorState, + encodeActorState, +} from "./fixture-driver"; + +type Engine = typeof import("../../src/index.js"); +type Implementation = "old" | "new"; +type Operation = "generate" | "resume"; +type Scenario = "pending-sleep" | "pending-message" | "replay-retry"; + +const LEGACY_REVISION = "b70c717c1a8eef2fe018f803a4ba160547296432"; +const FIXED_CLOCKS: Record = { + "pending-sleep": 1_723_456_789_000, + "pending-message": 1_723_556_789_000, + "replay-retry": 1_723_656_789_000, +}; +const ID_SEEDS: Record = { + "pending-sleep": 100, + "pending-message": 200, + "replay-retry": 300, +}; + +function readArg(name: string): string { + const index = process.argv.indexOf(`--${name}`); + const value = process.argv[index + 1]; + if (index === -1 || !value) throw new Error(`Missing --${name}`); + return value; +} + +async function loadEngine(implementation: Implementation): Promise { + if (implementation === "old") { + return (await import("legacy-workflow-engine")) as unknown as Engine; + } + return await import("../../src/index.js"); +} + +function installDeterminism(scenario: Scenario): { + setNow(value: number): void; +} { + let now = FIXED_CLOCKS[scenario]; + Date.now = () => now++; + let nextId = ID_SEEDS[scenario]; + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: Object.create(webcrypto, { + randomUUID: { + configurable: true, + value: () => { + const suffix = String(nextId++).padStart(12, "0"); + return `00000000-0000-4000-8000-${suffix}`; + }, + }, + }), + }); + return { + setNow(value: number) { + now = value; + }, + }; +} + +function metadata( + implementation: Implementation, + scenario: Scenario, +): WorkflowFixture["metadata"] { + return { + fixtureName: scenario, + sourceRivetkitVersion: implementation === "old" ? "2.3.7" : "2.4.0-local", + sourceWorkflowVersion: implementation === "old" ? "2.3.7" : "2.3.10-local", + sourceRevision: + implementation === "old" ? LEGACY_REVISION : "local-working-copy", + actorId: `compat-${scenario}`, + registryKey: "workflowCompatibilityFixture", + internalSchemaVersion: 1n, + fakeClockSeed: BigInt(FIXED_CLOCKS[scenario]), + generatedIdSeed: BigInt(ID_SEEDS[scenario]), + }; +} + +function fixtureRows(fixture: WorkflowFixture): string[] { + return fixture.workflowRows.map( + (row) => + `${Buffer.from(row.key).toString("hex")}:${Buffer.from(row.value).toString("hex")}`, + ); +} + +async function generateSleep( + engine: Engine, + implementation: Implementation, +): Promise<{ fixture: WorkflowFixture; summary: object }> { + const scenario = "pending-sleep"; + const actorId = `compat-${scenario}`; + const driver = new CompatibilityDriver(undefined, { + persistRunWakeMetadata: implementation === "new", + }); + driver.workerPollInterval = 0; + let mutations = 0; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("mutate-once", async () => { + mutations++; + driver.actorState = encodeActorState({ mutations }); + return mutations; + }); + await ctx.sleep("pending-sleep", 10_000); + return "woke"; + }; + const result = await engine.runWorkflow( + actorId, + workflow, + undefined, + driver, + { mode: "yield" }, + ).result; + if (result.state !== "sleeping" || result.sleepUntil === undefined) { + throw new Error(`Expected sleeping fixture, received ${result.state}`); + } + const scheduledAt = result.sleepUntil + 5_000; + driver.scheduleEvents = [ + { + eventId: "unrelated-schedule", + triggerAt: BigInt(scheduledAt), + action: "tick", + args: null, + kind: 0n, + cronExpression: null, + timezone: null, + intervalMs: null, + lastStartedAt: null, + maxHistory: 0n, + }, + ]; + const fixture = driver.toFixture(metadata(implementation, scenario), { + runWakeAt: implementation === "new" ? BigInt(result.sleepUntil) : null, + lastPushedAlarm: BigInt(Math.min(result.sleepUntil, scheduledAt)), + }); + return { + fixture, + summary: { + state: result.state, + mutations, + workflowDeadline: result.sleepUntil, + scheduledDeadline: scheduledAt, + effectiveDeadline: Number(fixture.runtime?.lastPushedAlarm), + rows: fixtureRows(fixture), + }, + }; +} + +async function resumeSleep( + engine: Engine, + implementation: Implementation, + fixture: WorkflowFixture, +) { + const driver = new CompatibilityDriver(fixture, { + persistRunWakeMetadata: implementation === "new", + }); + const state = decodeActorState<{ mutations: number }>(driver.actorState); + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("mutate-once", async () => { + throw new Error("completed state-mutating step executed twice"); + }); + await ctx.sleep("pending-sleep", 10_000); + return "woke"; + }; + const result = await engine.runWorkflow( + fixture.metadata.actorId, + workflow, + undefined, + driver, + { mode: "yield" }, + ).result; + const storage = await engine.loadStorage(driver); + return { + fixture: driver.toFixture(fixture.metadata), + summary: { + state: result.state, + output: result.output, + mutations: state.mutations, + historyEntries: storage.history.entries.size, + rows: fixtureRows(driver.toFixture(fixture.metadata)), + }, + }; +} + +async function generateMessage( + engine: Engine, + implementation: Implementation, +): Promise<{ fixture: WorkflowFixture; summary: object }> { + const scenario = "pending-message"; + const actorId = `compat-${scenario}`; + const driver = new CompatibilityDriver(undefined, { + persistRunWakeMetadata: implementation === "new", + }); + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait-for-signal", { + names: ["signal"], + }); + return message.body; + }; + const result = await engine.runWorkflow( + actorId, + workflow, + undefined, + driver, + { mode: "yield" }, + ).result; + await driver.messageDriver.addMessage({ + id: "7", + name: "approval", + data: { request: "release" }, + sentAt: FIXED_CLOCKS[scenario] + 1, + }); + const fixture = driver.toFixture(metadata(implementation, scenario)); + return { + fixture, + summary: { + state: result.state, + waitingForMessages: result.waitingForMessages, + queueSize: driver.queueSize(), + rows: fixtureRows(fixture), + }, + }; +} + +async function resumeMessage( + engine: Engine, + implementation: Implementation, + fixture: WorkflowFixture, +) { + const driver = new CompatibilityDriver(fixture, { + persistRunWakeMetadata: implementation === "new", + }); + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait-for-signal", { + names: ["signal"], + }); + return message.body; + }; + await driver.messageDriver.addMessage({ + id: "8", + name: "signal", + data: "continue", + sentAt: Number(fixture.metadata.fakeClockSeed) + 10, + }); + const result = await engine.runWorkflow( + fixture.metadata.actorId, + workflow, + undefined, + driver, + { mode: "yield" }, + ).result; + const [approval] = (await driver.messageDriver.receiveMessages({ + names: ["approval"], + count: 1, + completable: true, + })) as Array<{ complete?: (response?: unknown) => Promise }>; + if (!approval?.complete) throw new Error("Missing persisted completable row"); + await approval.complete({ accepted: true }); + const storage = await engine.loadStorage(driver); + return { + fixture: driver.toFixture(fixture.metadata), + summary: { + state: result.state, + output: result.output, + completion: driver.completions[0], + queueSize: driver.queueSize(), + historyEntries: storage.history.entries.size, + rows: fixtureRows(driver.toFixture(fixture.metadata)), + }, + }; +} + +function replayWorkflow( + engine: Engine, + options: { + replaying: boolean; + attempts: { value: number }; + timeline: string[]; + }, +) { + return async (ctx: WorkflowContextInterface) => { + await ctx.loop({ + name: "loop", + state: 0, + run: async (loopCtx, state) => { + await loopCtx.step(`loop-${state}`, async () => { + if (options.replaying) { + throw new Error("history before replay boundary reran"); + } + options.timeline.push(`loop-${state}`); + }); + return state === 1 + ? engine.Loop.break(undefined) + : engine.Loop.continue(1); + }, + }); + await ctx.join("branches", { + left: { + run: async (branchCtx) => + await branchCtx.step("left-step", async () => { + options.timeline.push("left-step"); + return "left"; + }), + }, + right: { + run: async (branchCtx) => + await branchCtx.step("right-step", async () => { + options.timeline.push("right-step"); + return "right"; + }), + }, + }); + await ctx.step({ + name: "flaky-step", + maxRetries: 2, + retryBackoffBase: 100, + retryBackoffMax: 100, + run: async () => { + options.attempts.value++; + options.timeline.push(`flaky-${options.attempts.value}`); + if (!options.replaying && options.attempts.value === 1) { + throw new Error("fixture retry"); + } + return "recovered"; + }, + }); + await ctx.step("after", async () => { + options.timeline.push("after"); + return "complete"; + }); + return "complete"; + }; +} + +function findStepId( + storage: Awaited>, + name: string, +) { + const nameIndex = storage.nameRegistry.indexOf(name); + return [...storage.history.entries.values()].find( + (entry) => + entry.kind.type === "step" && + entry.location[entry.location.length - 1] === nameIndex, + )?.id; +} + +async function generateReplay( + engine: Engine, + implementation: Implementation, + clock: { setNow(value: number): void }, +): Promise<{ fixture: WorkflowFixture; summary: object }> { + const scenario = "replay-retry"; + const actorId = `compat-${scenario}`; + const driver = new CompatibilityDriver(undefined, { + persistRunWakeMetadata: implementation === "new", + }); + const options = { + replaying: false, + attempts: { value: 0 }, + timeline: [] as string[], + }; + const first = await engine.runWorkflow( + actorId, + replayWorkflow(engine, options), + undefined, + driver, + { mode: "yield" }, + ).result; + if (first.state !== "sleeping") { + throw new Error(`Expected retry sleep, received ${first.state}`); + } + clock.setNow(FIXED_CLOCKS[scenario] + 1_000); + const second = await engine.runWorkflow( + actorId, + replayWorkflow(engine, options), + undefined, + driver, + { mode: "yield" }, + ).result; + if (second.state !== "completed") { + throw new Error( + `Expected completed retry fixture, received ${second.state}`, + ); + } + const storage = await engine.loadStorage(driver); + const targetStepId = findStepId(storage, "flaky-step"); + if (!targetStepId) throw new Error("Missing replay target step"); + driver.scheduleHistory = [ + { + id: 1n, + scheduleId: "fixture-history", + action: "retry-observer", + scheduledAt: BigInt(FIXED_CLOCKS[scenario]), + firedAt: BigInt(FIXED_CLOCKS[scenario] + 1), + finishedAt: BigInt(FIXED_CLOCKS[scenario] + 2), + result: 1n, + errorGroup: "workflow", + errorCode: "fixture_retry", + errorMessage: "fixture retry", + errorMetadata: Uint8Array.from(cbor.encode({ attempt: 1 })).buffer, + }, + ]; + const fixture = driver.toFixture(metadata(implementation, scenario)); + return { + fixture, + summary: { + state: second.state, + attempts: options.attempts.value, + timeline: options.timeline, + replayTarget: targetStepId, + historyEntries: storage.history.entries.size, + rows: fixtureRows(fixture), + }, + }; +} + +async function resumeReplay( + engine: Engine, + implementation: Implementation, + fixture: WorkflowFixture, +) { + const driver = new CompatibilityDriver(fixture, { + persistRunWakeMetadata: implementation === "new", + }); + const storage = await engine.loadStorage(driver); + const targetStepId = findStepId(storage, "flaky-step"); + if (!targetStepId) + throw new Error("Missing replay target in restored fixture"); + await engine.replayWorkflowFromStep( + fixture.metadata.actorId, + driver, + targetStepId, + { scheduleAlarm: false }, + ); + const options = { + replaying: true, + attempts: { value: 0 }, + timeline: [] as string[], + }; + const result = await engine.runWorkflow( + fixture.metadata.actorId, + replayWorkflow(engine, options), + undefined, + driver, + { mode: "yield" }, + ).result; + const replayed = await engine.loadStorage(driver); + return { + fixture: driver.toFixture(fixture.metadata), + summary: { + state: result.state, + output: result.output, + timeline: options.timeline, + historyEntries: replayed.history.entries.size, + rows: fixtureRows(driver.toFixture(fixture.metadata)), + }, + }; +} + +async function main() { + const implementation = readArg("implementation") as Implementation; + const operation = readArg("operation") as Operation; + const scenario = readArg("scenario") as Scenario; + const output = readArg("output"); + const clock = installDeterminism(scenario); + const engine = await loadEngine(implementation); + let result: { fixture: WorkflowFixture; summary: object }; + + if (operation === "generate") { + if (scenario === "pending-sleep") { + result = await generateSleep(engine, implementation); + } else if (scenario === "pending-message") { + result = await generateMessage(engine, implementation); + } else { + result = await generateReplay(engine, implementation, clock); + } + } else { + const input = readArg("input"); + const fixture = decodeFixture(await readFile(input)); + clock.setNow(Number(fixture.metadata.fakeClockSeed) + 20_000); + if (scenario === "pending-sleep") { + result = await resumeSleep(engine, implementation, fixture); + } else if (scenario === "pending-message") { + result = await resumeMessage(engine, implementation, fixture); + } else { + result = await resumeReplay(engine, implementation, fixture); + } + } + + await writeFile(output, encodeFixture(result.fixture)); + process.stdout.write(`RESULT ${JSON.stringify(result.summary)}\n`); +} + +await main(); diff --git a/packages/workflows/tests/compat/upgrade.test.ts b/packages/workflows/tests/compat/upgrade.test.ts new file mode 100644 index 0000000..23c919b --- /dev/null +++ b/packages/workflows/tests/compat/upgrade.test.ts @@ -0,0 +1,222 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import * as cbor from "cbor-x"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { decodeFixture } from "./fixture-codec"; + +const execFileAsync = promisify(execFile); +const here = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = join(here, "../../../.."); +const runner = join(here, "runner.ts"); +const tsx = join(workspaceRoot, "node_modules/.bin/tsx"); +const fixtureDirectory = join(here, "fixtures"); +const scenarios = ["pending-sleep", "pending-message", "replay-retry"] as const; +type Scenario = (typeof scenarios)[number]; +type Implementation = "old" | "new"; + +let scratchDirectory: string; + +beforeAll(async () => { + scratchDirectory = await mkdtemp(join(tmpdir(), "rivet-workflow-compat-")); +}); + +afterAll(async () => { + await rm(scratchDirectory, { recursive: true, force: true }); +}); + +async function runFixtureProcess(options: { + implementation: Implementation; + operation: "generate" | "resume"; + scenario: Scenario; + input?: string; +}): Promise<{ output: string; summary: Record }> { + const output = join( + scratchDirectory, + `${options.scenario}-${options.implementation}-${options.operation}-${crypto.randomUUID()}.vbare`, + ); + const args = [ + runner, + "--implementation", + options.implementation, + "--operation", + options.operation, + "--scenario", + options.scenario, + "--output", + output, + ]; + if (options.input) args.push("--input", options.input); + const { stdout, stderr } = await execFileAsync(tsx, args, { + cwd: workspaceRoot, + env: { ...process.env, NO_COLOR: "1" }, + maxBuffer: 8 * 1024 * 1024, + }); + if (stderr.trim()) throw new Error(stderr); + const resultLine = stdout + .trim() + .split("\n") + .findLast((line) => line.startsWith("RESULT ")); + if (!resultLine) throw new Error(`Missing runner result:\n${stdout}`); + return { + output, + summary: JSON.parse(resultLine.slice("RESULT ".length)), + }; +} + +describe.sequential("persisted workflow package compatibility", () => { + for (const scenario of scenarios) { + test(`${scenario}: old write -> new resume and new write -> old resume`, async () => { + const committedPath = join(fixtureDirectory, `${scenario}.vbare`); + const oldGenerated = await runFixtureProcess({ + implementation: "old", + operation: "generate", + scenario, + }); + expect(await readFile(oldGenerated.output)).toEqual( + await readFile(committedPath), + ); + + const legacyFixture = decodeFixture(await readFile(committedPath)); + expect([ + ...new Uint8Array(await readFile(committedPath)).slice(0, 2), + ]).toEqual([1, 0]); + expect(legacyFixture.metadata).toMatchObject({ + fixtureName: scenario, + sourceRivetkitVersion: "2.3.7", + sourceWorkflowVersion: "2.3.7", + sourceRevision: "b70c717c1a8eef2fe018f803a4ba160547296432", + registryKey: "workflowCompatibilityFixture", + internalSchemaVersion: 1n, + }); + for (const row of legacyFixture.workflowRows) { + expect([...new Uint8Array(row.key).slice(0, 2)]).toEqual([6, 1]); + } + + const newResumed = await runFixtureProcess({ + implementation: "new", + operation: "resume", + scenario, + input: committedPath, + }); + expect(newResumed.summary.state).toBe("completed"); + + const newGenerated = await runFixtureProcess({ + implementation: "new", + operation: "generate", + scenario, + }); + expect(newGenerated.summary.rows).toEqual(oldGenerated.summary.rows); + const newFixture = decodeFixture(await readFile(newGenerated.output)); + expect(newFixture.metadata.internalSchemaVersion).toBe(1n); + const newRunWake = newFixture.metaRows.find( + (row) => row.key === "run_wake_at", + ); + if (!newRunWake) throw new Error("Missing new logical run-wake row"); + expect([...new Uint8Array(newRunWake.value).slice(0, 2)]).toEqual([1, 0]); + const oldResumed = await runFixtureProcess({ + implementation: "old", + operation: "resume", + scenario, + input: newGenerated.output, + }); + expect(oldResumed.summary.state).toBe("completed"); + const oldResultFixture = decodeFixture(await readFile(oldResumed.output)); + expect( + oldResultFixture.metaRows.find((row) => row.key === "run_wake_at") + ?.value, + ).toEqual(newRunWake?.value); + + if (scenario === "pending-sleep") { + expect(oldGenerated.summary).toMatchObject({ + state: "sleeping", + mutations: 1, + }); + expect(oldGenerated.summary.effectiveDeadline).toBe( + oldGenerated.summary.workflowDeadline, + ); + expect(Number(oldGenerated.summary.scheduledDeadline)).toBeGreaterThan( + Number(oldGenerated.summary.workflowDeadline), + ); + expect(newResumed.summary).toMatchObject({ + output: "woke", + mutations: 1, + }); + expect(oldResumed.summary).toMatchObject({ + output: "woke", + mutations: 1, + }); + if (!legacyFixture.actorState) { + throw new Error("Missing persisted actor state"); + } + expect(cbor.decode(new Uint8Array(legacyFixture.actorState))).toEqual({ + mutations: 1, + }); + expect(legacyFixture.scheduleEvents[0]?.triggerAt).toBe( + BigInt(oldGenerated.summary.scheduledDeadline as number), + ); + } + + if (scenario === "pending-message") { + expect(oldGenerated.summary).toMatchObject({ + state: "sleeping", + waitingForMessages: ["signal"], + queueSize: 1, + }); + expect(legacyFixture.queueRows).toHaveLength(1); + expect(legacyFixture.queueRows[0]).toMatchObject({ + id: 7n, + name: "approval", + }); + expect( + cbor.decode(new Uint8Array(legacyFixture.queueRows[0].body)), + ).toEqual({ request: "release" }); + expect(newResumed.summary).toMatchObject({ + output: "continue", + queueSize: 0, + completion: { + id: "7", + name: "approval", + response: { accepted: true }, + }, + }); + expect(oldResumed.summary).toMatchObject({ + output: "continue", + queueSize: 0, + }); + } + + if (scenario === "replay-retry") { + expect(oldGenerated.summary).toMatchObject({ + state: "completed", + attempts: 2, + timeline: [ + "loop-0", + "loop-1", + "left-step", + "right-step", + "flaky-1", + "flaky-2", + "after", + ], + }); + expect(legacyFixture.scheduleHistory[0]).toMatchObject({ + errorGroup: "workflow", + errorCode: "fixture_retry", + errorMessage: "fixture retry", + }); + expect(newResumed.summary).toMatchObject({ + output: "complete", + timeline: ["flaky-1", "after"], + }); + expect(oldResumed.summary).toMatchObject({ + output: "complete", + timeline: ["flaky-1", "after"], + }); + } + }, 30_000); + } +}); diff --git a/packages/workflows/tests/driver-kv.test.ts b/packages/workflows/tests/driver-kv.test.ts new file mode 100644 index 0000000..eef035e --- /dev/null +++ b/packages/workflows/tests/driver-kv.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + buildNameKey, + buildNamePrefix, + buildWorkflowStateKey, + parseNameKey, +} from "../src/keys.js"; +import { InMemoryDriver } from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +const encoder = new TextEncoder(); + +function encode(value: string): Uint8Array { + return encoder.encode(value); +} + +for (const mode of modes) { + describe( + `Workflow Engine Driver KV (${mode})`, + { + sequential: true, + }, + () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should set and get values", async () => { + const key = encode("key-a"); + const value = encode("value-a"); + + await driver.set(key, value); + + const result = await driver.get(key); + expect(result).toEqual(value); + }); + + it("should return null for missing keys", async () => { + const result = await driver.get(encode("missing")); + expect(result).toBeNull(); + }); + + it("should overwrite existing keys", async () => { + const key = encode("key-b"); + await driver.set(key, encode("first")); + await driver.set(key, encode("second")); + + const result = await driver.get(key); + expect(result).toEqual(encode("second")); + }); + + it("should delete keys", async () => { + const key = encode("key-c"); + await driver.set(key, encode("value")); + await driver.delete(key); + + const result = await driver.get(key); + expect(result).toBeNull(); + }); + + it("should list keys by prefix", async () => { + await driver.set(buildNameKey(0), encode("one")); + await driver.set(buildNameKey(1), encode("two")); + await driver.set(buildWorkflowStateKey(), encode("state")); + + const entries = await driver.list(buildNamePrefix()); + const indices = entries.map((entry) => parseNameKey(entry.key)); + + expect(indices).toEqual([0, 1]); + }); + + it("should delete only keys with a prefix", async () => { + const firstNameKey = buildNameKey(0); + const stateKey = buildWorkflowStateKey(); + + await driver.set(firstNameKey, encode("name")); + await driver.set(stateKey, encode("state")); + + await driver.deletePrefix(buildNamePrefix()); + + expect(await driver.get(firstNameKey)).toBeNull(); + expect(await driver.get(stateKey)).not.toBeNull(); + }); + + it("should delete only keys within a half-open range", async () => { + const keyA = encode("range-0"); + const keyB = encode("range-1"); + const keyBChild = encode("range-1-child"); + const keyC = encode("range-2"); + + await driver.set(keyA, encode("a")); + await driver.set(keyB, encode("b")); + await driver.set(keyBChild, encode("b-child")); + await driver.set(keyC, encode("c")); + + await driver.deleteRange(keyB, keyC); + + expect(await driver.get(keyA)).toEqual(encode("a")); + expect(await driver.get(keyB)).toBeNull(); + expect(await driver.get(keyBChild)).toBeNull(); + expect(await driver.get(keyC)).toEqual(encode("c")); + }); + + it("should list name keys in sorted order", async () => { + await driver.set(buildNameKey(1), encode("two")); + await driver.set(buildNameKey(0), encode("one")); + + const entries = await driver.list(buildNamePrefix()); + const indices = entries.map((entry) => parseNameKey(entry.key)); + + expect(indices).toEqual([0, 1]); + }); + + it("should batch writes", async () => { + const keyA = encode("batch-a"); + const keyB = encode("batch-b"); + + await driver.batch([ + { key: keyA, value: encode("one") }, + { key: keyB, value: encode("two") }, + ]); + + expect(await driver.get(keyA)).toEqual(encode("one")); + expect(await driver.get(keyB)).toEqual(encode("two")); + }); + + it("should batch overwrite existing keys", async () => { + const key = encode("batch-c"); + await driver.set(key, encode("old")); + + await driver.batch([{ key, value: encode("new") }]); + + expect(await driver.get(key)).toEqual(encode("new")); + }); + }, + ); +} diff --git a/packages/workflows/tests/driver-scheduling.test.ts b/packages/workflows/tests/driver-scheduling.test.ts new file mode 100644 index 0000000..804ad4a --- /dev/null +++ b/packages/workflows/tests/driver-scheduling.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { InMemoryDriver } from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe( + `Workflow Engine Driver Scheduling (${mode})`, + { + sequential: true, + }, + () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should set and clear alarms", async () => { + const wakeAt = Date.now() + 1000; + + await driver.setAlarm("wf-1", wakeAt); + expect(driver.getAlarm("wf-1")).toBe(wakeAt); + + await driver.clearAlarm("wf-1"); + expect(driver.getAlarm("wf-1")).toBeUndefined(); + }); + + it("should return due alarms", async () => { + await driver.setAlarm("wf-due", Date.now() - 1); + await driver.setAlarm("wf-later", Date.now() + 1000); + + const due = driver.getDueAlarms(); + expect(due).toContain("wf-due"); + expect(due).not.toContain("wf-later"); + }); + + it("should expose worker poll interval", async () => { + expect(driver.workerPollInterval).toBeGreaterThan(0); + }); + }, + ); +} diff --git a/packages/workflows/tests/e2e/preview-runtime.test.ts b/packages/workflows/tests/e2e/preview-runtime.test.ts new file mode 100644 index 0000000..2543061 --- /dev/null +++ b/packages/workflows/tests/e2e/preview-runtime.test.ts @@ -0,0 +1,42 @@ +import { actor, setup } from "rivetkit"; +import { setupTest } from "rivetkit/test"; +import { expect, test } from "vitest"; +import { workflow } from "../../src/rivetkit/mod"; + +const sleepAcrossWake = actor({ + state: { completed: [] as string[] }, + run: workflow(async (ctx) => { + await ctx.step("before-sleep", async (step) => { + step.state.completed.push("before-sleep"); + }); + await ctx.sleep("sleep", 100); + await ctx.step("after-sleep", async (step) => { + step.state.completed.push("after-sleep"); + }); + }), + actions: { + getCompleted: (ctx) => ctx.state.completed, + }, + options: { + sleepTimeout: 20, + }, +}); + +const registry = setup({ use: { sleepAcrossWake } }); + +test("published runtime resumes a sleeping workflow exactly once", async (context) => { + const { client } = await setupTest(context, registry); + const handle = client.sleepAcrossWake.getOrCreate(["preview-e2e"]); + const deadline = Date.now() + 10_000; + let completed: string[] = []; + + while (Date.now() < deadline) { + completed = await handle.getCompleted(); + if (completed.includes("after-sleep")) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + expect(completed).toEqual(["before-sleep", "after-sleep"]); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(await handle.getCompleted()).toEqual(["before-sleep", "after-sleep"]); +}); diff --git a/packages/workflows/tests/eviction-cancel.test.ts b/packages/workflows/tests/eviction-cancel.test.ts new file mode 100644 index 0000000..5e566db --- /dev/null +++ b/packages/workflows/tests/eviction-cancel.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + EvictedError, + InMemoryDriver, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe( + `Workflow Engine Eviction and Cancellation (${mode})`, + { + sequential: true, + }, + () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should surface eviction through the abort event", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener( + "abort", + () => resolve(), + { + once: true, + }, + ); + }); + return ctx.isEvicted(); + }; + + const handle = runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ); + handle.evict(); + + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toBe(true); + }); + + it("should cancel workflow and clear alarms", async () => { + const workflow = async (_ctx: WorkflowContextInterface) => { + return "done"; + }; + + const handle = runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ); + await driver.setAlarm("wf-1", Date.now() + 1000); + + await handle.cancel(); + + await expect(handle.result).rejects.toThrow(EvictedError); + expect(await handle.getState()).toBe("cancelled"); + expect(driver.getAlarm("wf-1")).toBeUndefined(); + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(EvictedError); + }); + }, + ); +} diff --git a/packages/workflows/tests/fixtures/rivetkit-inspector-workflow.ts b/packages/workflows/tests/fixtures/rivetkit-inspector-workflow.ts new file mode 100644 index 0000000..f6a097c --- /dev/null +++ b/packages/workflows/tests/fixtures/rivetkit-inspector-workflow.ts @@ -0,0 +1,51 @@ +export const WorkflowEntryStatus = { + PENDING: "PENDING", + RUNNING: "RUNNING", + COMPLETED: "COMPLETED", + FAILED: "FAILED", + EXHAUSTED: "EXHAUSTED", +} as const; + +export const WorkflowSleepState = { + PENDING: "PENDING", + COMPLETED: "COMPLETED", + INTERRUPTED: "INTERRUPTED", +} as const; + +export const WorkflowBranchStatusType = { + PENDING: "PENDING", + RUNNING: "RUNNING", + COMPLETED: "COMPLETED", + FAILED: "FAILED", + CANCELLED: "CANCELLED", +} as const; + +export type WorkflowLocation = unknown[]; +export type WorkflowEntryKind = unknown; +export type WorkflowEntry = unknown; +export type WorkflowEntryStatus = string; +export type WorkflowSleepState = string; +export type WorkflowBranchStatusType = string; +export type WorkflowBranchStatus = unknown; +export type WorkflowEntryMetadata = unknown; +export type WorkflowHistory = unknown; +export interface WorkflowInspectorAdapter { + getHistory(): ArrayBuffer | null; + getState(): Promise; + onHistoryUpdated(listener: (history: ArrayBuffer) => void): () => void; + replayFromStep(entryId?: string): Promise; +} + +export function encodeWorkflowHistoryTransport(value: unknown): ArrayBuffer { + const json = JSON.stringify(value, (_key, item) => { + if (typeof item === "bigint") return item.toString(); + if (item instanceof Map) return [...item.entries()]; + if (item instanceof ArrayBuffer) return [...new Uint8Array(item)]; + return item; + }); + return new TextEncoder().encode(json).buffer; +} + +export function encodeWorkflowInspectorValue(value: unknown): ArrayBuffer { + return new TextEncoder().encode(JSON.stringify(value)).buffer; +} diff --git a/packages/workflows/tests/fixtures/rivetkit-storage.ts b/packages/workflows/tests/fixtures/rivetkit-storage.ts new file mode 100644 index 0000000..91d7ecc --- /dev/null +++ b/packages/workflows/tests/fixtures/rivetkit-storage.ts @@ -0,0 +1 @@ +export const WORKFLOW_STORAGE_V1 = "rivetkit.workflow-storage.v1"; diff --git a/packages/workflows/tests/fixtures/rivetkit.ts b/packages/workflows/tests/fixtures/rivetkit.ts new file mode 100644 index 0000000..b0c9dc5 --- /dev/null +++ b/packages/workflows/tests/fixtures/rivetkit.ts @@ -0,0 +1,43 @@ +type AnyFunction = (...args: any[]) => any; + +const optionsByRun = new WeakMap(); + +export class RivetError extends Error { + group: string; + code: string; + public?: boolean; + statusCode?: number; + + constructor( + group: string, + code: string, + message: string, + options: { public?: boolean; statusCode?: number } = {}, + ) { + super(message); + this.group = group; + this.code = code; + this.public = options.public; + this.statusCode = options.statusCode; + } +} + +export function isActorAbortedError(error: unknown): boolean { + return ( + error instanceof RivetError && + error.group === "actor" && + error.code === "aborted" + ); +} + +export function defineRunHandler( + run: TRun, + options: unknown, +): TRun { + optionsByRun.set(run, options); + return run; +} + +export function getDefinedRunHandlerOptions(run: AnyFunction): any { + return optionsByRun.get(run); +} diff --git a/packages/workflows/tests/handle.test.ts b/packages/workflows/tests/handle.test.ts new file mode 100644 index 0000000..0cb6655 --- /dev/null +++ b/packages/workflows/tests/handle.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Handle (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should send messages via handle", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait", { + names: ["message-name"], + }); + return message.body; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + if (mode === "yield") { + await handle.result; + await handle.message("message-name", "payload"); + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("payload"); + return; + } + + await handle.message("message-name", "payload"); + const result = await handle.result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("payload"); + }); + + it("should set alarms with wake", async () => { + const workflow = async (_ctx: WorkflowContextInterface) => { + return "done"; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + await handle.wake(); + + const alarm = driver.getAlarm("wf-1"); + if (mode === "yield") { + expect(alarm).toBeDefined(); + expect(alarm).toBeLessThanOrEqual(Date.now()); + } else { + expect(alarm).toBeUndefined(); + } + }); + + it("should read output and state", async () => { + const workflow = async (_ctx: WorkflowContextInterface) => { + return "finished"; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + const result = await handle.result; + + expect(result.state).toBe("completed"); + expect(await handle.getOutput()).toBe("finished"); + expect(await handle.getState()).toBe("completed"); + }); + }); +} diff --git a/packages/workflows/tests/history-updates.test.ts b/packages/workflows/tests/history-updates.test.ts new file mode 100644 index 0000000..93de7e4 --- /dev/null +++ b/packages/workflows/tests/history-updates.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + runWorkflow, + type WorkflowContextInterface, + type WorkflowHistorySnapshot, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow History Updates (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("emits history snapshots when entries change", async () => { + const updates: WorkflowHistorySnapshot[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("hello", async () => "world"); + }; + + const result = await runWorkflow( + "wf-history-1", + workflow, + undefined, + driver, + { + mode, + onHistoryUpdated: (snapshot) => { + updates.push(snapshot); + }, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(updates.length).toBeGreaterThan(0); + + const last = updates[updates.length - 1]; + expect( + last.entries.some((entry) => entry.kind.type === "step"), + ).toBe(true); + expect( + Array.from(last.entryMetadata.values()).some( + (meta) => meta.status === "completed", + ), + ).toBe(true); + }); + }); +} diff --git a/packages/workflows/tests/join.test.ts b/packages/workflows/tests/join.test.ts new file mode 100644 index 0000000..e9d3b0a --- /dev/null +++ b/packages/workflows/tests/join.test.ts @@ -0,0 +1,312 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + JoinError, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Join (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should execute branches in parallel", async () => { + const order: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + const results = await ctx.join("parallel", { + a: { + run: async (ctx) => { + order.push("a-start"); + const val = await ctx.step("step-a", async () => 1); + order.push("a-end"); + return val; + }, + }, + b: { + run: async (ctx) => { + order.push("b-start"); + const val = await ctx.step("step-b", async () => 2); + order.push("b-end"); + return val; + }, + }, + }); + + return results.a + results.b; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(3); + expect(order.indexOf("a-start")).toBeLessThan( + order.indexOf("b-end"), + ); + expect(order.indexOf("b-start")).toBeLessThan( + order.indexOf("a-end"), + ); + }); + + it("should wait for all branches even on error", async () => { + let bCompleted = false; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.join("parallel", { + a: { + run: async () => { + throw new Error("A failed"); + }, + }, + b: { + run: async (ctx) => { + await ctx.step("step-b", async () => { + bCompleted = true; + return "b"; + }); + return "b"; + }, + }, + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(); + + expect(bCompleted).toBe(true); + }); + + it("should surface join errors per branch", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.join("parallel", { + good: { + run: async () => "ok", + }, + bad: { + run: async () => { + throw new Error("boom"); + }, + }, + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(JoinError); + + try { + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + } catch (error) { + const joinError = error as JoinError; + expect(Object.keys(joinError.errors)).toEqual(["bad"]); + } + }); + + it("should replay join results", async () => { + let callCount = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + const result = await ctx.join("replay", { + one: { + run: async () => { + callCount += 1; + return "one"; + }, + }, + two: { + run: async () => "two", + }, + }); + + return result.one; + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + expect(callCount).toBe(1); + }); + + it("should support nested joins inside branches", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const results = await ctx.join("outer", { + nested: { + run: async (ctx) => { + const inner = await ctx.join("inner", { + left: { + run: async (ctx) => + await ctx.step( + "left-step", + async () => 1, + ), + }, + right: { + run: async (ctx) => + await ctx.step( + "right-step", + async () => 2, + ), + }, + }); + + return inner.left + inner.right; + }, + }, + plain: { + run: async (ctx) => + await ctx.step("plain-step", async () => 3), + }, + }); + + return results.nested + results.plain; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(6); + }); + + it("should support nested races inside join branches", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const results = await ctx.join("outer", { + raced: { + run: async (ctx) => { + const nested = await ctx.race("inner-race", [ + { + name: "fast", + run: async (ctx) => + await ctx.step( + "fast-step", + async () => "winner", + ), + }, + { + name: "slow", + run: async (ctx) => { + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener( + "abort", + () => resolve(), + { once: true }, + ); + }); + return "loser"; + }, + }, + ]); + + return nested.value; + }, + }, + plain: { + run: async (ctx) => + await ctx.step("plain-step", async () => "plain"), + }, + }); + + return `${results.raced}:${results.plain}`; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("winner:plain"); + }); + + it("should preserve branch retries instead of failing the join", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + const result = await ctx.join("retrying-join", { + flaky: { + run: async (branchCtx) => { + return await branchCtx.step({ + name: "flaky-step", + maxRetries: 1, + retryBackoffBase: 5, + retryBackoffMax: 5, + run: async () => { + attempts += 1; + if (attempts === 1) { + throw new Error("retry"); + } + return "a"; + }, + }); + }, + }, + stable: { + run: async () => "b", + }, + }); + + return result.flaky + result.stable; + }; + + const firstResult = await runWorkflow( + "wf-join-retry", + workflow, + undefined, + driver, + { mode }, + ).result; + + if (mode === "yield") { + expect(firstResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const secondResult = await runWorkflow( + "wf-join-retry", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(secondResult.state).toBe("completed"); + expect(secondResult.output).toBe("ab"); + } else { + expect(firstResult.state).toBe("completed"); + expect(firstResult.output).toBe("ab"); + } + + expect(attempts).toBe(2); + }); + }); +} diff --git a/packages/workflows/tests/loops.test.ts b/packages/workflows/tests/loops.test.ts new file mode 100644 index 0000000..2a18dbe --- /dev/null +++ b/packages/workflows/tests/loops.test.ts @@ -0,0 +1,736 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + Loop, + loadStorage, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +function isLoopIteration(segment: unknown): segment is { iteration: number } { + return ( + typeof segment === "object" && + segment !== null && + "iteration" in segment + ); +} + +for (const mode of modes) { + describe(`Workflow Engine Loops (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should execute a simple loop", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "count-loop", + state: { count: 0 }, + run: async (ctx, state) => { + if (state.count >= 3) { + return Loop.break(state.count); + } + await ctx.step(`step-${state.count}`, async () => {}); + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(3); + }); + + it("should run a stateless loop", async () => { + let iteration = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop("stateless", async () => { + iteration += 1; + if (iteration >= 3) { + return Loop.break("done"); + } + return Loop.continue(undefined); + }); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + }); + + it("should treat undefined return as continue in stateless loops", async () => { + let iteration = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop( + "stateless-implicit-continue", + async () => { + iteration += 1; + if (iteration >= 3) { + return Loop.break("done"); + } + return undefined; + }, + ); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + }); + + it("should replay void loop output on restart", async () => { + let callCount = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop("void-output", async () => { + callCount++; + return Loop.break(undefined); + }); + }; + + const result1 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result1.state).toBe("completed"); + expect(result1.output).toBeUndefined(); + expect(callCount).toBe(1); + + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result2.state).toBe("completed"); + expect(result2.output).toBeUndefined(); + expect(callCount).toBe(1); + }); + + it("should resume nested sub-loops across parent loop iterations", async () => { + const processed: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop("command-loop", async (loopCtx) => { + const message = await loopCtx.queue.next<{ + items: string[]; + }>("next", { + names: ["work"], + completable: true, + }); + + let itemIndex = 0; + await loopCtx.loop("process-items", async (subLoopCtx) => { + const item = message.body.items[itemIndex]; + if (item === undefined) { + return Loop.break(undefined); + } + + await subLoopCtx.step( + `process-item-${itemIndex}`, + async () => { + processed.push(item); + }, + ); + itemIndex += 1; + return Loop.continue(undefined); + }); + + await message.complete?.({ ok: true }); + + if (processed.length >= 3) { + return Loop.break([...processed]); + } + + return Loop.continue(undefined); + }); + }; + + await driver.messageDriver.addMessage({ + id: "msg-1", + name: "work", + data: { items: ["a", "b"] }, + sentAt: Date.now(), + }); + + if (mode === "yield") { + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(firstRun.state).toBe("sleeping"); + expect(processed).toEqual(["a", "b"]); + + await driver.messageDriver.addMessage({ + id: "msg-2", + name: "work", + data: { items: ["c"] }, + sentAt: Date.now(), + }); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual(["a", "b", "c"]); + return; + } + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await handle.message("work", { items: ["c"] }); + + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["a", "b", "c"]); + }); + + it("should resume nested joins across parent loop iterations", async () => { + const processed: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop("command-loop", async (loopCtx) => { + const message = await loopCtx.queue.next<{ + items: string[]; + }>("next", { + names: ["work"], + completable: true, + }); + + const branches = Object.fromEntries( + message.body.items.map((item, index) => [ + `item-${index}`, + { + run: async ( + branchCtx: WorkflowContextInterface, + ) => + await branchCtx.step( + `process-item-${index}`, + async () => { + processed.push(item); + return item; + }, + ), + }, + ]), + ); + + await loopCtx.join("process-items", branches); + await message.complete?.({ ok: true }); + + if (processed.length >= 3) { + return Loop.break([...processed]); + } + + return Loop.continue(undefined); + }); + }; + + await driver.messageDriver.addMessage({ + id: "msg-1", + name: "work", + data: { items: ["a", "b"] }, + sentAt: Date.now(), + }); + + if (mode === "yield") { + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(firstRun.state).toBe("sleeping"); + expect(processed).toEqual(["a", "b"]); + + await driver.messageDriver.addMessage({ + id: "msg-2", + name: "work", + data: { items: ["c"] }, + sentAt: Date.now(), + }); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual(["a", "b", "c"]); + return; + } + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await handle.message("work", { items: ["c"] }); + + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["a", "b", "c"]); + }); + + it("should resume nested races across parent loop iterations", async () => { + const processed: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop("command-loop", async (loopCtx) => { + const message = await loopCtx.queue.next<{ + items: string[]; + }>("next", { + names: ["work"], + completable: true, + }); + + const nested = await loopCtx.race("process-item", [ + { + name: "fast", + run: async (raceCtx) => + await raceCtx.step("process-fast", async () => { + const item = message.body.items[0]!; + processed.push(item); + return item; + }), + }, + { + name: "slow", + run: async (raceCtx) => { + await new Promise((resolve) => { + if (raceCtx.abortSignal.aborted) { + resolve(); + return; + } + raceCtx.abortSignal.addEventListener( + "abort", + () => resolve(), + { once: true }, + ); + }); + return "slow"; + }, + }, + ]); + + expect(nested.value).toBe(message.body.items[0]); + await message.complete?.({ ok: true }); + + if (processed.length >= 2) { + return Loop.break([...processed]); + } + + return Loop.continue(undefined); + }); + }; + + await driver.messageDriver.addMessage({ + id: "msg-1", + name: "work", + data: { items: ["a"] }, + sentAt: Date.now(), + }); + + if (mode === "yield") { + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(firstRun.state).toBe("sleeping"); + expect(processed).toEqual(["a"]); + + await driver.messageDriver.addMessage({ + id: "msg-2", + name: "work", + data: { items: ["b"] }, + sentAt: Date.now(), + }); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual(["a", "b"]); + return; + } + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await handle.message("work", { items: ["b"] }); + + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["a", "b"]); + }); + + it("should resume loop from saved state", async () => { + let iteration = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "resume-loop", + state: { count: 0 }, + historyPruneInterval: 2, + run: async (_ctx, state) => { + iteration++; + + if (state.count >= 5) { + return Loop.break(state.count); + } + + if (state.count === 2 && iteration === 3) { + throw new Error("Simulated crash"); + } + + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + try { + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + } catch {} + + iteration = 0; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(5); + }); + + it("should prune old iterations at each prune interval", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "cleanup-loop", + state: { count: 0 }, + historyPruneInterval: 2, + run: async (ctx, state) => { + await ctx.step(`step-${state.count}`, async () => {}); + if (state.count >= 4) { + return Loop.break(state.count); + } + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + const storage = await loadStorage(driver); + const iterations = [...storage.history.entries.values()] + .flatMap((entry) => entry.location) + .flatMap((segment) => + isLoopIteration(segment) ? [segment] : [], + ) + .map((segment) => segment.iteration); + + const minIteration = Math.min(...iterations); + expect(minIteration).toBeGreaterThanOrEqual(2); + }); + + it("should not re-delete already-pruned iterations", async () => { + let deleteRangeCallCount = 0; + const originalDeleteRange = driver.deleteRange.bind(driver); + driver.deleteRange = async (start: Uint8Array, end: Uint8Array) => { + deleteRangeCallCount++; + return originalDeleteRange(start, end); + }; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "efficient-cleanup", + state: { count: 0 }, + historyPruneInterval: 3, + run: async (ctx, state) => { + await ctx.step(`step-${state.count}`, async () => {}); + if (state.count >= 8) { + return Loop.break(state.count); + } + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + deleteRangeCallCount = 0; + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + // With historyPruneInterval=3 and 9 iterations (0-8), prune runs at + // iterations 3 and 6, plus final break at iteration 8. + // At prune 3: no deletions (3 - 3 = 0, nothing to delete) + // At prune 6: one range delete for iterations 0-2 + // At break (iteration 9): one range delete for iterations 3-5 + expect(deleteRangeCallCount).toBe(2); + }); + + it("should prune history on break even without reaching historyPruneInterval", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "break-prune", + state: { count: 0 }, + historyPruneInterval: 3, + run: async (ctx, state) => { + await ctx.step(`step-${state.count}`, async () => {}); + if (state.count >= 5) { + return Loop.break(state.count); + } + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + const storage = await loadStorage(driver); + const iterations = [...storage.history.entries.values()] + .flatMap((entry) => entry.location) + .flatMap((segment) => + isLoopIteration(segment) ? [segment] : [], + ) + .map((segment) => segment.iteration); + + // With historyPruneInterval=3 and break at iteration 5 (6 total iterations): + // At prune 3: no deletions (3 - 3 = 0) + // At break (6 iterations total): prune iterations 0-2 + // So iterations 3-5 should remain + if (iterations.length > 0) { + const minIteration = Math.min(...iterations); + expect(minIteration).toBeGreaterThanOrEqual(3); + } + }); + + it("should resume from saved state after crash in later iteration", async () => { + let firstRun = true; + let iterationsExecuted: number[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "deferred-crash", + state: { count: 0 }, + historyPruneInterval: 2, + run: async (ctx, state) => { + iterationsExecuted.push(state.count); + + await ctx.step( + `step-${state.count}`, + async () => state.count, + ); + + if (state.count >= 5) { + return Loop.break(state.count); + } + + // Crash at iteration 3 during first run. State was + // persisted at the end of iteration 2 after + // Loop.continue, so state should be saved. + if (state.count === 3 && firstRun) { + throw new Error("Crash after state save"); + } + + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + try { + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + } catch {} + + // Reset tracking for second run + firstRun = false; + iterationsExecuted = []; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(5); + + // Should resume from saved state at iteration 3, not from 0 + expect(iterationsExecuted[0]).toBe(3); + }); + + it("should handle loop that breaks before first prune interval", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "early-break", + state: { count: 0 }, + historyPruneInterval: 10, + run: async (ctx, state) => { + await ctx.step(`step-${state.count}`, async () => {}); + if (state.count >= 2) { + return Loop.break(state.count); + } + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(2); + + // No pruning should occur since we never reached historyPruneInterval + const storage = await loadStorage(driver); + const iterations = [...storage.history.entries.values()] + .flatMap((entry) => entry.location) + .flatMap((segment) => + isLoopIteration(segment) ? [segment] : [], + ) + .map((segment) => segment.iteration); + + // All iterations should still be present + expect(iterations).toContain(0); + expect(iterations).toContain(1); + expect(iterations).toContain(2); + }); + + it("should propagate loop errors", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "error-loop", + state: { count: 0 }, + run: async () => { + throw new Error("loop failure"); + }, + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow("loop failure"); + }); + + it("should handle historyPruneInterval of 1", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "frequent-commit", + state: { count: 0 }, + historyPruneInterval: 1, + run: async (ctx, state) => { + await ctx.step(`step-${state.count}`, async () => {}); + if (state.count >= 3) { + return Loop.break(state.count); + } + return Loop.continue({ count: state.count + 1 }); + }, + }); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(3); + + // With historyPruneInterval=1, only the most recent iteration should remain + const storage = await loadStorage(driver); + const iterations = [...storage.history.entries.values()] + .flatMap((entry) => entry.location) + .flatMap((segment) => + isLoopIteration(segment) ? [segment] : [], + ) + .map((segment) => segment.iteration); + + if (iterations.length > 0) { + const minIteration = Math.min(...iterations); + expect(minIteration).toBeGreaterThanOrEqual(2); + } + }); + }); +} diff --git a/packages/workflows/tests/messages.test.ts b/packages/workflows/tests/messages.test.ts new file mode 100644 index 0000000..3f0d2d1 --- /dev/null +++ b/packages/workflows/tests/messages.test.ts @@ -0,0 +1,675 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + generateId, + InMemoryDriver, + Loop, + type Message, + runWorkflow, + type WorkflowContextInterface, + type WorkflowMessageDriver, +} from "../src/testing.js"; + +async function queueMessage( + driver: InMemoryDriver, + name: string, + data: unknown, + id = generateId(), +): Promise { + await driver.messageDriver.addMessage({ + id, + name, + data, + sentAt: Date.now(), + }); +} + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Messages (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should wait for messages", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait-message", { + names: ["my-message"], + }); + return message.body; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + if (mode === "yield") { + const result1 = await handle.result; + expect(result1.state).toBe("sleeping"); + expect(result1.waitingForMessages).toContain("my-message"); + return; + } + + await handle.message("my-message", "payload"); + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toBe("payload"); + }); + + it("should wait for any message in a name set", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait-many", { + names: ["first", "second"], + }); + return { name: message.name, body: message.body }; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + if (mode === "yield") { + const result1 = await handle.result; + expect(result1.state).toBe("sleeping"); + expect(result1.waitingForMessages).toEqual(["first", "second"]); + + await handle.message("second", "payload"); + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result2.state).toBe("completed"); + expect(result2.output).toEqual({ + name: "second", + body: "payload", + }); + return; + } + + await handle.message("second", "payload"); + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual({ + name: "second", + body: "payload", + }); + }); + + it("should consume pending messages", async () => { + await queueMessage(driver, "my-message", "hello"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait-message", { + names: ["my-message"], + }); + return message.body; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("hello"); + }); + + it("queue.next should return completable messages", async () => { + const completions: Array<{ id: string; response?: unknown }> = []; + const pending: Message[] = [ + { + id: "msg-1", + name: "my-message", + data: "hello", + sentAt: Date.now(), + }, + ]; + + const messageDriver: WorkflowMessageDriver = { + async addMessage(message) { + pending.push(message); + }, + async receiveMessages(opts) { + const nameSet = + opts.names && opts.names.length > 0 + ? new Set(opts.names) + : undefined; + const selected: Array<{ message: Message; index: number }> = + []; + for ( + let i = 0; + i < pending.length && selected.length < opts.count; + i++ + ) { + const message = pending[i]; + if (nameSet && !nameSet.has(message.name)) { + continue; + } + selected.push({ message, index: i }); + } + if (!opts.completable) { + for (let i = selected.length - 1; i >= 0; i--) { + pending.splice(selected[i].index, 1); + } + return selected.map((entry) => entry.message); + } + return selected.map((entry) => { + const { message } = entry; + return { + ...message, + complete: async (response?: unknown) => { + completions.push({ id: message.id, response }); + const index = pending.findIndex( + (m) => m.id === message.id, + ); + if (index !== -1) { + pending.splice(index, 1); + } + }, + }; + }); + }, + async completeMessage(messageIdentity, response) { + completions.push({ id: messageIdentity.id, response }); + const index = pending.findIndex( + (message) => + message.id === messageIdentity.id && + message.name === messageIdentity.name, + ); + if (index !== -1) { + pending.splice(index, 1); + } + }, + }; + driver.messageDriver = messageDriver; + + const workflow = async (ctx: WorkflowContextInterface) => { + const message = await ctx.queue.next("wait-message", { + names: ["my-message"], + completable: true, + }); + if (!message.complete) { + throw new Error("Expected completable message"); + } + await message.complete({ ok: true }); + return message.body; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("hello"); + expect(completions).toEqual([ + { id: "msg-1", response: { ok: true } }, + ]); + }); + + it("replay should not block the next completable queue.next", async () => { + if (mode !== "yield") { + return; + } + + await queueMessage(driver, "my-message", "one", "msg-1"); + await queueMessage(driver, "my-message", "two", "msg-2"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const first = await ctx.queue.next("wait-first", { + names: ["my-message"], + completable: true, + }); + if (!first.complete) { + throw new Error("Expected first completable message"); + } + const completeFirst = first.complete; + await ctx.step("complete-first", async () => { + await completeFirst({ ok: "first" }); + return first.body; + }); + + await ctx.sleep("between", 120); + + const second = await ctx.queue.next("wait-second", { + names: ["my-message"], + completable: true, + }); + if (!second.complete) { + throw new Error("Expected second completable message"); + } + const completeSecond = second.complete; + await ctx.step("complete-second", async () => { + await completeSecond({ ok: "second" }); + return second.body; + }); + + return [first.body, second.body] as const; + }; + + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstRun.state).toBe("sleeping"); + + await new Promise((resolve) => setTimeout(resolve, 140)); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual(["one", "two"]); + }); + + it("replay should keep blocking if completable message was not completed", async () => { + if (mode !== "yield") { + return; + } + + await queueMessage(driver, "my-message", "one", "msg-1"); + await queueMessage(driver, "my-message", "two", "msg-2"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const first = await ctx.queue.next("wait-first", { + names: ["my-message"], + completable: true, + }); + if (!first.complete) { + throw new Error("Expected first completable message"); + } + + // Intentionally do not complete the message. + await ctx.sleep("between", 120); + + await ctx.queue.next("wait-second", { + names: ["my-message"], + completable: true, + }); + + return first.body; + }; + + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstRun.state).toBe("sleeping"); + + await new Promise((resolve) => setTimeout(resolve, 140)); + + const secondRunHandle = runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ); + await expect(secondRunHandle.result).rejects.toThrow( + "Previous completable queue message is not completed.", + ); + + const queued = await driver.messageDriver.receiveMessages({ + names: ["my-message"], + count: 10, + completable: true, + }); + expect(queued.map((message) => String(message.id)).sort()).toEqual([ + "msg-1", + "msg-2", + ]); + }); + + it("should collect multiple messages with queue.next count", async () => { + await queueMessage(driver, "batch", "a", "1"); + await queueMessage(driver, "batch", "b", "2"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const messages = await ctx.queue.nextBatch( + "batch-wait", + { + names: ["batch"], + count: 2, + }, + ); + return messages.map((message) => message.body); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["a", "b"]); + }); + + it("should time out queue.next", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const messages = await ctx.queue.nextBatch("timeout", { + names: ["missing"], + timeout: 50, + }); + return messages[0]?.body ?? null; + }; + + if (mode === "yield") { + const result1 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result1.state).toBe("sleeping"); + + await new Promise((r) => setTimeout(r, 80)); + + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result2.state).toBe("completed"); + expect(result2.output).toBeNull(); + return; + } + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toBeNull(); + }); + + it("should return a message before queue.next timeout", async () => { + await queueMessage(driver, "deadline", "data"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const messages = await ctx.queue.nextBatch("deadline", { + names: ["deadline"], + timeout: 1000, + }); + return messages[0]?.body ?? null; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("data"); + }); + + it("should wait for queue.next timeout messages", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const messages = await ctx.queue.nextBatch("batch", { + names: ["batch"], + count: 2, + timeout: 5000, + }); + return messages.map((message) => message.body); + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + if (mode === "yield") { + const result1 = await handle.result; + expect(result1.state).toBe("sleeping"); + + await handle.message("batch", "first"); + + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result2.state).toBe("completed"); + expect(result2.output).toEqual(["first"]); + return; + } + + await handle.message("batch", "first"); + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["first"]); + }); + + it("should respect limits and FIFO ordering in queue.next", async () => { + await queueMessage(driver, "fifo", "first", "1"); + await queueMessage(driver, "fifo", "second", "2"); + await queueMessage(driver, "fifo", "third", "3"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const messages = await ctx.queue.nextBatch("fifo", { + names: ["fifo"], + count: 2, + timeout: 1000, + }); + return messages.map((message) => message.body); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["first", "second"]); + }); + + it("should consume messages sent during execution", async () => { + let resolveGate: (() => void) | null = null; + let resolveStarted: (() => void) | null = null; + const gate = new Promise((resolve) => { + resolveGate = () => resolve(); + }); + const started = new Promise((resolve) => { + resolveStarted = () => resolve(); + }); + + const workflow = async (ctx: WorkflowContextInterface) => { + resolveStarted?.(); + await ctx.step("gate", async () => { + await gate; + return "ready"; + }); + + const message = await ctx.queue.next("wait", { + names: ["mid"], + }); + return message.body; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + await started; + await handle.message("mid", "value"); + if (resolveGate) { + (resolveGate as () => void)(); + } + + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(result.output).toBe("value"); + }); + + it("should not replay the previous loop message after queue.next timeout", async () => { + await queueMessage(driver, "loop", "first", "msg-1"); + const seen: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop("drain", async (loopCtx) => { + const messages = await loopCtx.queue.nextBatch( + "wait", + { + names: ["loop"], + timeout: 50, + }, + ); + const message = messages[0]; + if (!message) { + return Loop.break([...seen]); + } + seen.push(message.body); + return Loop.continue(undefined); + }); + }; + + if (mode === "yield") { + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstRun.state).toBe("sleeping"); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual(["first"]); + expect(seen).toEqual(["first"]); + return; + } + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["first"]); + expect(seen).toEqual(["first"]); + }); + + it("should not reproduce the timeout replay issue without a loop", async () => { + await queueMessage(driver, "plain", "first", "msg-1"); + + const workflow = async (ctx: WorkflowContextInterface) => { + const first = await ctx.queue.next("first", { + names: ["plain"], + }); + const timed = await ctx.queue.nextBatch("second", { + names: ["plain"], + timeout: 50, + }); + return { + first: first.body, + second: timed[0]?.body ?? null, + }; + }; + + if (mode === "yield") { + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstRun.state).toBe("sleeping"); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual({ + first: "first", + second: null, + }); + return; + } + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual({ + first: "first", + second: null, + }); + }); + }); +} diff --git a/packages/workflows/tests/race.test.ts b/packages/workflows/tests/race.test.ts new file mode 100644 index 0000000..76fac99 --- /dev/null +++ b/packages/workflows/tests/race.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + RaceError, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Race (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should return first completed branch", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.race("race", [ + { + name: "fast", + run: async (ctx) => { + return await ctx.step( + "fast-step", + async () => "fast", + ); + }, + }, + { + name: "slow", + run: async (ctx) => { + return await ctx.step( + "slow-step", + async () => "slow", + ); + }, + }, + ]); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output?.winner).toBe("fast"); + expect(result.output?.value).toBe("fast"); + }); + + it("should cancel losing branches", async () => { + let aborted = false; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.race("cancel", [ + { + name: "fast", + run: async () => "winner", + }, + { + name: "slow", + run: async (ctx) => { + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + aborted = true; + resolve(); + return; + } + ctx.abortSignal.addEventListener( + "abort", + () => { + aborted = true; + resolve(); + }, + { once: true }, + ); + }); + return "aborted"; + }, + }, + ]); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(aborted).toBe(true); + }); + + it("should surface errors when all branches fail", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.race("fail", [ + { + name: "one", + run: async () => { + throw new Error("fail one"); + }, + }, + { + name: "two", + run: async () => { + throw new Error("fail two"); + }, + }, + ]); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(RaceError); + }); + + it("should replay race winner", async () => { + let runs = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + const result = await ctx.race("replay", [ + { + name: "first", + run: async () => { + runs += 1; + return "winner"; + }, + }, + { + name: "second", + run: async () => "loser", + }, + ]); + + return result.value; + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + expect(runs).toBe(1); + }); + + it("should support nested joins in the winning branch", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.race("outer-race", [ + { + name: "fast", + run: async (ctx) => { + const nested = await ctx.join("inner-join", { + left: { + run: async (ctx) => + await ctx.step( + "left-step", + async () => 1, + ), + }, + right: { + run: async (ctx) => + await ctx.step( + "right-step", + async () => 2, + ), + }, + }); + + return nested.left + nested.right; + }, + }, + { + name: "slow", + run: async (ctx) => { + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener( + "abort", + () => resolve(), + { once: true }, + ); + }); + return 0; + }, + }, + ]); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toEqual({ + winner: "fast", + value: 3, + }); + }); + + it("should support nested races in the winning branch", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.race("outer-race", [ + { + name: "fast", + run: async (ctx) => { + const nested = await ctx.race("inner-race", [ + { + name: "nested-fast", + run: async (ctx) => + await ctx.step( + "nested-fast-step", + async () => "nested-fast", + ), + }, + { + name: "nested-slow", + run: async (ctx) => { + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener( + "abort", + () => resolve(), + { once: true }, + ); + }); + return "nested-slow"; + }, + }, + ]); + + return nested.value; + }, + }, + { + name: "slow", + run: async (ctx) => { + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener( + "abort", + () => resolve(), + { once: true }, + ); + }); + return "slow"; + }, + }, + ]); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toEqual({ + winner: "fast", + value: "nested-fast", + }); + }); + + it("should preserve branch retries instead of failing the race", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.race("retrying-race", [ + { + name: "flaky", + run: async (branchCtx) => { + return await branchCtx.step({ + name: "flaky-step", + maxRetries: 1, + retryBackoffBase: 5, + retryBackoffMax: 5, + run: async () => { + attempts += 1; + if (attempts === 1) { + throw new Error("retry"); + } + return "winner"; + }, + }); + }, + }, + { + name: "slow", + run: async (branchCtx) => { + await branchCtx.sleep("slow-wait", 200); + return "slow"; + }, + }, + ]); + }; + + const firstResult = await runWorkflow( + "wf-race-retry", + workflow, + undefined, + driver, + { mode }, + ).result; + + if (mode === "yield") { + expect(firstResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const secondResult = await runWorkflow( + "wf-race-retry", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(secondResult.state).toBe("completed"); + expect(secondResult.output).toEqual({ + winner: "flaky", + value: "winner", + }); + } else { + expect(firstResult.state).toBe("completed"); + expect(firstResult.output).toEqual({ + winner: "flaky", + value: "winner", + }); + } + + expect(attempts).toBe(2); + }); + }); +} diff --git a/packages/workflows/tests/removals.test.ts b/packages/workflows/tests/removals.test.ts new file mode 100644 index 0000000..1413640 --- /dev/null +++ b/packages/workflows/tests/removals.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + Loop, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe( + `Workflow Engine Removed Entries (${mode})`, + { + sequential: true, + }, + () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should skip removed entries of all kinds", async () => { + const workflow1 = async (ctx: WorkflowContextInterface) => { + await ctx.step("old-step", async () => "old"); + await ctx.loop({ + name: "old-loop", + state: { count: 0 }, + run: async (_ctx, state) => { + if (state.count >= 1) { + return Loop.break("done"); + } + return Loop.continue({ count: state.count + 1 }); + }, + }); + await ctx.queue.send("old-message", "message-data"); + await ctx.sleep("old-sleep", 0); + await ctx.queue.next("old-listen", { + names: ["old-message"], + }); + await ctx.join("old-join", { + branch: { + run: async () => "ok", + }, + }); + await ctx.race("old-race", [ + { + name: "fast", + run: async () => "fast", + }, + ]); + return "done"; + }; + + await runWorkflow("wf-1", workflow1, undefined, driver, { + mode, + }).result; + + const workflow2 = async (ctx: WorkflowContextInterface) => { + await ctx.removed("old-step", "step"); + await ctx.removed("old-loop", "loop"); + await ctx.removed("old-sleep", "sleep"); + await ctx.removed("old-listen", "message"); + await ctx.removed("old-join", "join"); + await ctx.removed("old-race", "race"); + return "updated"; + }; + + const result = await runWorkflow( + "wf-1", + workflow2, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("updated"); + }); + }, + ); +} diff --git a/packages/workflows/tests/replay.test.ts b/packages/workflows/tests/replay.test.ts new file mode 100644 index 0000000..d4185e7 --- /dev/null +++ b/packages/workflows/tests/replay.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + Loop, + loadStorage, + replayWorkflowFromStep, + runWorkflow, + type WorkflowContextInterface, +} from "../src/index.js"; +import { InMemoryDriver } from "../src/testing.js"; + +function findStepIdByName( + storage: Awaited>, + name: string, +): string | undefined { + const nameIndex = storage.nameRegistry.indexOf(name); + if (nameIndex === -1) { + return undefined; + } + + return Array.from(storage.history.entries.values()).find( + (entry) => + entry.kind.type === "step" && + entry.location[entry.location.length - 1] === nameIndex, + )?.id; +} + +describe("replayWorkflowFromStep", () => { + it("replays from the requested step", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + + const timeline: string[] = []; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("one", async () => { + timeline.push("one"); + }); + await ctx.step("two", async () => { + timeline.push("two"); + }); + await ctx.step("three", async () => { + timeline.push("three"); + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver).result; + timeline.length = 0; + + const storage = await loadStorage(driver); + const stepTwoIndex = storage.nameRegistry.indexOf("two"); + const stepTwo = Array.from(storage.history.entries.values()).find( + (entry) => + entry.kind.type === "step" && + entry.location[entry.location.length - 1] === stepTwoIndex, + ); + expect(stepTwo).toBeDefined(); + + const snapshot = await replayWorkflowFromStep( + "wf-1", + driver, + stepTwo?.id, + ); + + expect(snapshot.entries.map((entry) => entry.id)).toHaveLength(1); + + await runWorkflow("wf-1", workflow, undefined, driver).result; + expect(timeline).toEqual(["two", "three"]); + }); + + it("replays from the beginning when the target step is omitted", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + + const timeline: string[] = []; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("one", async () => { + timeline.push("one"); + }); + await ctx.step("two", async () => { + timeline.push("two"); + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver).result; + timeline.length = 0; + + await replayWorkflowFromStep("wf-1", driver); + await runWorkflow("wf-1", workflow, undefined, driver).result; + + expect(timeline).toEqual(["one", "two"]); + }); + + it("rewinds the enclosing loop when replaying a nested loop step", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + + const timeline: string[] = []; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("before", async () => { + timeline.push("before"); + }); + await ctx.loop({ + name: "repeat", + state: 0, + run: async (loopCtx, state) => { + await loopCtx.step(`loop-step-${state}`, async () => { + timeline.push(`loop-step-${state}`); + }); + + if (state >= 1) { + return Loop.break(state); + } + + return Loop.continue(state + 1); + }, + }); + await ctx.step("after", async () => { + timeline.push("after"); + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver).result; + timeline.length = 0; + + const storage = await loadStorage(driver); + const targetStepId = findStepIdByName(storage, "loop-step-1"); + expect(targetStepId).toBeDefined(); + + await replayWorkflowFromStep("wf-1", driver, targetStepId); + await runWorkflow("wf-1", workflow, undefined, driver).result; + + expect(timeline).toEqual(["loop-step-0", "loop-step-1", "after"]); + }); + + it("allows replay from a step inside a sleeping loop", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + driver.workerPollInterval = 0; + + const timeline: string[] = []; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("before", async () => { + timeline.push("before"); + }); + await ctx.loop({ + name: "repeat", + state: 0, + run: async (loopCtx, state) => { + if (state === 0) { + await loopCtx.step("loop-step-0", async () => { + timeline.push("loop-step-0"); + }); + return Loop.continue(1); + } + + await loopCtx.step("loop-step-1", async () => { + timeline.push("loop-step-1"); + }); + await loopCtx.sleep("pause", 20); + return Loop.break("done"); + }, + }); + await ctx.step("after", async () => { + timeline.push("after"); + }); + }; + + const firstRun = await runWorkflow("wf-1", workflow, undefined, driver) + .result; + expect(firstRun.state).toBe("sleeping"); + timeline.length = 0; + + const storage = await loadStorage(driver); + const targetStepId = findStepIdByName(storage, "loop-step-1"); + expect(targetStepId).toBeDefined(); + + await expect( + replayWorkflowFromStep("wf-1", driver, targetStepId), + ).resolves.toBeDefined(); + + const replayRun = await runWorkflow("wf-1", workflow, undefined, driver) + .result; + expect(replayRun.state).toBe("sleeping"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const resumedRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + ).result; + expect(resumedRun.state).toBe("completed"); + expect(resumedRun.output).toBeUndefined(); + expect(timeline).toEqual(["loop-step-0", "loop-step-1", "after"]); + }); +}); diff --git a/packages/workflows/tests/rivetkit/driver.test.ts b/packages/workflows/tests/rivetkit/driver.test.ts new file mode 100644 index 0000000..daf6128 --- /dev/null +++ b/packages/workflows/tests/rivetkit/driver.test.ts @@ -0,0 +1,106 @@ +import { WORKFLOW_STORAGE_V1 } from "rivetkit/storage"; +import { describe, expect, test, vi } from "vitest"; +import { ActorWorkflowDriver } from "../../src/rivetkit/driver"; + +function write(key = 1, value = 2) { + return { + key: new Uint8Array([key]), + value: new Uint8Array([value]), + }; +} + +function createSubject() { + const storage = { + get: vi.fn(async () => null), + set: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + deletePrefix: vi.fn(async () => {}), + deleteRange: vi.fn(async () => {}), + list: vi.fn(async () => []), + batch: vi.fn(async () => {}), + flushWithState: vi.fn(async () => {}), + }; + const waitUntil: Promise[] = []; + const queue = { + send: vi.fn(async () => {}), + tryNextBatch: vi.fn(async () => []), + complete: vi.fn(async () => {}), + waitForAvailable: vi.fn(async () => {}), + }; + const run = { setWakeAt: vi.fn(async () => {}) }; + const open = vi.fn(() => storage); + const ctx = { + storage: { open }, + queue, + run, + waitUntil: (promise: Promise) => waitUntil.push(promise), + }; + return { + driver: new ActorWorkflowDriver(ctx as never), + storage, + queue, + run, + open, + waitUntil, + }; +} + +describe("RivetKit workflow driver", () => { + test("opens only the opaque workflow storage capability", () => { + const { open } = createSubject(); + expect(open).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledWith(WORKFLOW_STORAGE_V1); + }); + + test("flushes actor state and the full workflow batch atomically", async () => { + const { driver, storage } = createSubject(); + const writes = [write(), write(3, 4)]; + await driver.batch(writes); + expect(storage.flushWithState).toHaveBeenCalledWith(writes); + expect(storage.batch).not.toHaveBeenCalled(); + }); + + test("does not flush an empty batch", async () => { + const { driver, storage } = createSubject(); + await driver.batch([]); + expect(storage.flushWithState).not.toHaveBeenCalled(); + }); + + test("uses the logical run wake source for set and clear", async () => { + const { driver, run } = createSubject(); + await driver.setAlarm("actor", 1234); + await driver.clearAlarm("actor"); + expect(run.setWakeAt.mock.calls).toEqual([[1234], [null]]); + }); + + test("waits for queue availability without consuming", async () => { + const { driver, queue } = createSubject(); + const abort = new AbortController(); + await driver.waitForMessages(["ready"], abort.signal); + expect(queue.waitForAvailable).toHaveBeenCalledWith(["ready"], { + signal: abort.signal, + }); + expect(queue.tryNextBatch).not.toHaveBeenCalled(); + }); + + test("completes a persisted message with both its id and name", async () => { + const { driver, queue } = createSubject(); + await driver.messageDriver.completeMessage( + { id: "42", name: "task" }, + { ok: true }, + ); + expect(queue.complete).toHaveBeenCalledWith( + { id: 42n, name: "task" }, + { ok: true }, + ); + }); + + test("tracks host operations with outcome-swallowed waitUntil promises", async () => { + const { driver, storage, waitUntil } = createSubject(); + storage.get.mockRejectedValueOnce(new Error("read failed")); + await expect(driver.get(new Uint8Array([1]))).rejects.toThrow( + "read failed", + ); + await expect(Promise.all(waitUntil)).resolves.toEqual([undefined]); + }); +}); diff --git a/packages/workflows/tests/rivetkit/inspector.test.ts b/packages/workflows/tests/rivetkit/inspector.test.ts new file mode 100644 index 0000000..38f44c0 --- /dev/null +++ b/packages/workflows/tests/rivetkit/inspector.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test, vi } from "vitest"; +import { createWorkflowInspectorAdapter } from "../../src/rivetkit/inspector"; + +describe("workflow Inspector adapter", () => { + test("retains, publishes, and unsubscribes encoded history", () => { + const inspector = createWorkflowInspectorAdapter(); + const listener = vi.fn(); + const unsubscribe = inspector.adapter.onHistoryUpdated(listener); + const snapshot = { + nameRegistry: [], + entries: [], + entryMetadata: new Map(), + }; + + inspector.update(snapshot); + expect(listener).toHaveBeenCalledOnce(); + expect(inspector.adapter.getHistory()).toBeInstanceOf(ArrayBuffer); + + unsubscribe(); + inspector.update(snapshot); + expect(listener).toHaveBeenCalledOnce(); + }); + + test("delegates state and replay through actor-bound callbacks", async () => { + const inspector = createWorkflowInspectorAdapter(); + const history = new Uint8Array([1, 2, 3]).buffer; + const replay = vi.fn(async () => history); + inspector.setGetState(async () => "sleeping"); + inspector.setReplayFromStep(replay); + + await expect(inspector.adapter.getState()).resolves.toBe("sleeping"); + await expect(inspector.adapter.replayFromStep("step-id")).resolves.toBe( + history, + ); + expect(replay).toHaveBeenCalledWith("step-id"); + }); +}); diff --git a/packages/workflows/tests/rivetkit/workflow.test.ts b/packages/workflows/tests/rivetkit/workflow.test.ts new file mode 100644 index 0000000..4a49c84 --- /dev/null +++ b/packages/workflows/tests/rivetkit/workflow.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test, vi } from "vitest"; +import { workflow } from "../../src/rivetkit/mod"; +import { getDefinedRunHandlerOptions } from "../fixtures/rivetkit"; + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + for (let index = 0; index < Math.min(a.length, b.length); index++) { + if (a[index] !== b[index]) return a[index] - b[index]; + } + return a.length - b.length; +} + +function startsWith(key: Uint8Array, prefix: Uint8Array): boolean { + return prefix.every((byte, index) => key[index] === byte); +} + +type Write = { key: Uint8Array; value: Uint8Array }; + +function createRunContext() { + const rows = new Map(); + const keyOf = (key: Uint8Array) => Buffer.from(key).toString("hex"); + const apply = (writes: Write[]) => { + for (const write of writes) { + rows.set(keyOf(write.key), write); + } + }; + const storage = { + get: async (key: Uint8Array) => rows.get(keyOf(key))?.value ?? null, + set: async (key: Uint8Array, value: Uint8Array) => apply([{ key, value }]), + delete: async (key: Uint8Array) => { + rows.delete(keyOf(key)); + }, + deletePrefix: async (prefix: Uint8Array) => { + for (const [key, row] of rows) { + if (startsWith(row.key, prefix)) rows.delete(key); + } + }, + deleteRange: async (start: Uint8Array, end: Uint8Array) => { + for (const [key, row] of rows) { + if ( + compareBytes(row.key, start) >= 0 && + compareBytes(row.key, end) < 0 + ) { + rows.delete(key); + } + } + }, + list: async (prefix: Uint8Array) => + [...rows.values()] + .filter((row) => startsWith(row.key, prefix)) + .sort((a, b) => compareBytes(a.key, b.key)), + batch: async (writes: Write[]) => apply(writes), + flushWithState: async (writes: Write[]) => apply(writes), + }; + const waitUntil: Promise[] = []; + const setWakeAt = vi.fn(async () => {}); + return { + ctx: { + actorId: "actor-1", + name: "example", + key: ["one"], + log: { + fatal: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + child: () => undefined, + }, + abortSignal: new AbortController().signal, + storage: { open: () => storage }, + run: { setWakeAt }, + queue: { + send: async () => {}, + tryNextBatch: async () => [], + complete: async () => {}, + waitForAvailable: async () => {}, + }, + waitUntil: (promise: Promise) => waitUntil.push(promise), + }, + rows, + setWakeAt, + waitUntil, + }; +} + +describe("workflow RivetKit integration", () => { + test("publishes static Inspector metadata and disposes actor state", async () => { + const step = vi.fn(async () => "done"); + const run = workflow(async (ctx) => { + await ctx.step("once", step); + }); + const options = getDefinedRunHandlerOptions(run); + expect(options.inspectorKind).toBe("workflow"); + + const withInactive = vi.fn(async (_options, callback) => await callback()); + const registration = options.createInspector({ + actorId: "actor-1", + control: { run: { withInactive } }, + }); + const firstAdapter = registration.inspector.workflow; + const { ctx, rows, setWakeAt, waitUntil } = createRunContext(); + await run(ctx as never); + await Promise.all(waitUntil); + + expect(step).toHaveBeenCalledOnce(); + expect(rows.size).toBeGreaterThan(0); + expect(setWakeAt).toHaveBeenLastCalledWith(null); + await expect(firstAdapter.getState()).resolves.toBe("completed"); + expect(firstAdapter.getHistory()).toBeInstanceOf(ArrayBuffer); + + await firstAdapter.replayFromStep(); + expect(withInactive).toHaveBeenCalledWith( + { restartOnSuccess: true }, + expect.any(Function), + ); + + registration.dispose(); + const nextRegistration = options.createInspector({ + actorId: "actor-1", + control: { run: { withInactive } }, + }); + expect(nextRegistration.inspector.workflow).not.toBe(firstAdapter); + }); +}); diff --git a/packages/workflows/tests/rollback.test.ts b/packages/workflows/tests/rollback.test.ts new file mode 100644 index 0000000..5b7314d --- /dev/null +++ b/packages/workflows/tests/rollback.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + Loop, + loadStorage, + RollbackCheckpointError, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Rollback (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should execute rollback steps in reverse order", async () => { + const rollbacks: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.rollbackCheckpoint("checkpoint"); + await ctx.step({ + name: "first", + run: async () => "one", + rollback: async (_ctx, output) => { + rollbacks.push(`first:${output}`); + }, + }); + await ctx.step({ + name: "second", + run: async () => "two", + rollback: async (_ctx, output) => { + rollbacks.push(`second:${output}`); + }, + }); + throw new Error("boom"); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow("boom"); + + expect(rollbacks).toEqual(["second:two", "first:one"]); + }); + + it("should error if rollback checkpoint missing", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step({ + name: "missing-checkpoint", + run: async () => "value", + rollback: async () => { + return; + }, + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(RollbackCheckpointError); + }); + + it("should resume rollback after eviction", async () => { + const rollbacks: string[] = []; + let unblockRollback: (() => void) | undefined; + let startRollback: (() => void) | undefined; + + const rollbackGate = new Promise((resolve) => { + unblockRollback = () => resolve(); + }); + const rollbackStarted = new Promise((resolve) => { + startRollback = () => resolve(); + }); + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.rollbackCheckpoint("checkpoint"); + await ctx.step({ + name: "first", + run: async () => "one", + rollback: async () => { + rollbacks.push("first"); + }, + }); + await ctx.step({ + name: "second", + run: async () => "two", + rollback: async (rollbackCtx) => { + if (startRollback) { + startRollback(); + } + await rollbackGate; + if (rollbackCtx.abortSignal.aborted) { + return; + } + rollbacks.push("second"); + }, + }); + await ctx.step({ + name: "third", + run: async () => "three", + rollback: async () => { + rollbacks.push("third"); + }, + }); + throw new Error("boom"); + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + await rollbackStarted; + handle.evict(); + + const result = await handle.result; + expect(result.state).toBe("rolling_back"); + expect(rollbacks).toEqual(["third"]); + + if (unblockRollback) { + unblockRollback(); + } + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow("boom"); + + expect(rollbacks).toEqual(["third", "second", "first"]); + }); + + it("should not persist loop progress while replaying rollback", async () => { + const rollbacks: string[] = []; + let shouldFail = true; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.rollbackCheckpoint("checkpoint"); + await ctx.step({ + name: "outside", + run: async () => "outside", + rollback: async () => { + rollbacks.push("outside"); + }, + }); + await ctx.loop({ + name: "loop", + state: 0, + run: async (loopCtx, state) => { + await loopCtx.step({ + name: `step-${state}`, + run: async () => `step-${state}`, + rollback: async () => { + rollbacks.push(`loop-${state}`); + }, + }); + + if (state === 0) { + return Loop.continue(1); + } + + if (shouldFail) { + shouldFail = false; + throw new Error("boom"); + } + + return Loop.continue(2); + }, + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow("boom"); + + const storage = await loadStorage(driver); + const loopEntry = [...storage.history.entries.values()].find( + (entry) => entry.kind.type === "loop", + ); + + expect(loopEntry?.kind.type).toBe("loop"); + if (loopEntry?.kind.type !== "loop") { + throw new Error("Expected loop entry"); + } + + expect(loopEntry.kind.data.iteration).toBe(1); + expect(rollbacks).toContain("outside"); + }); + }); +} diff --git a/packages/workflows/tests/sleep.test.ts b/packages/workflows/tests/sleep.test.ts new file mode 100644 index 0000000..daba2c6 --- /dev/null +++ b/packages/workflows/tests/sleep.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + InMemoryDriver, + Loop, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Sleep (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should yield on long sleep", async () => { + const durationMs = mode === "live" ? 150 : 10000; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.sleep("my-sleep", durationMs); + return "done"; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + if (mode === "yield") { + expect(result.state).toBe("sleeping"); + expect(result.sleepUntil).toBeDefined(); + expect(result.sleepUntil).toBeGreaterThan(Date.now()); + return; + } + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + }); + + it("should complete short sleep in memory", async () => { + driver.workerPollInterval = 1000; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.sleep("short-sleep", 10); + return "done"; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + expect(driver.getAlarm("wf-1")).toBeUndefined(); + }); + + it("should resume after sleep deadline", async () => { + driver.workerPollInterval = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.sleep("my-sleep", 20); + return "done"; + }; + + if (mode === "yield") { + const result1 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result1.state).toBe("sleeping"); + + await new Promise((r) => setTimeout(r, 30)); + + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result2.state).toBe("completed"); + expect(result2.output).toBe("done"); + return; + } + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + }); + + it("should complete sleepUntil with past timestamp", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.sleepUntil("past", Date.now() - 1); + return "done"; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + }); + + it("should keep short sleeps in memory near poll interval", async () => { + driver.workerPollInterval = 50; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.sleep("near-poll", 25); + return "done"; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + }, + ).result; + + expect(result.state).toBe("completed"); + expect(driver.getAlarm("wf-1")).toBeUndefined(); + }); + + it("should schedule and clear alarms for long sleep", async () => { + driver.workerPollInterval = 1; + const durationMs = mode === "live" ? 200 : 20; + + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.sleep("alarm-sleep", durationMs); + return "done"; + }; + + const handle = runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }); + + if (mode === "yield") { + const result1 = await handle.result; + expect(result1.state).toBe("sleeping"); + expect(driver.getAlarm("wf-1")).toBe(result1.sleepUntil); + + await new Promise((r) => setTimeout(r, 30)); + + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result2.state).toBe("completed"); + expect(driver.getAlarm("wf-1")).toBeUndefined(); + return; + } + + const alarm = await new Promise((resolve) => { + const start = Date.now(); + const check = () => { + const value = driver.getAlarm("wf-1"); + if (value !== undefined || Date.now() - start > 50) { + resolve(value); + return; + } + setTimeout(check, 1); + }; + check(); + }); + expect(alarm).toBeDefined(); + + const result = await handle.result; + expect(result.state).toBe("completed"); + expect(driver.getAlarm("wf-1")).toBeUndefined(); + }); + + it("should not replay the previous loop iteration after sleep", async () => { + driver.workerPollInterval = 0; + const seen: string[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "drain", + state: 0, + run: async (loopCtx, state) => { + if (state === 0) { + seen.push("first"); + return Loop.continue(1); + } + + await loopCtx.sleep("pause", 20); + return Loop.break([...seen]); + }, + }); + }; + + if (mode === "yield") { + const firstRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstRun.state).toBe("sleeping"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const secondRun = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(secondRun.state).toBe("completed"); + expect(secondRun.output).toEqual(["first"]); + expect(seen).toEqual(["first"]); + return; + } + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toEqual(["first"]); + expect(seen).toEqual(["first"]); + }); + }); +} diff --git a/packages/workflows/tests/steps.test.ts b/packages/workflows/tests/steps.test.ts new file mode 100644 index 0000000..94aa419 --- /dev/null +++ b/packages/workflows/tests/steps.test.ts @@ -0,0 +1,659 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { deserializeEntry } from "../schemas/serde.js"; +import { buildHistoryPrefixAll } from "../src/keys.js"; +import { + CriticalError, + EntryInProgressError, + HistoryDivergedError, + InMemoryDriver, + RollbackError, + runWorkflow, + StepExhaustedError, + type WorkflowContextInterface, + type WorkflowErrorEvent, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +class CountingDriver extends InMemoryDriver { + batchCalls = 0; + + async batch( + writes: { key: Uint8Array; value: Uint8Array }[], + ): Promise { + this.batchCalls += 1; + await super.batch(writes); + } +} + +for (const mode of modes) { + describe(`Workflow Engine Steps (${mode})`, { sequential: true }, () => { + let driver: CountingDriver; + + beforeEach(() => { + driver = new CountingDriver(); + driver.latency = 0; + }); + + it("should execute a simple step", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const result = await ctx.step("my-step", async () => { + return "hello world"; + }); + return result; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("hello world"); + }); + + it("should replay step on restart", async () => { + let callCount = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + const result = await ctx.step("my-step", async () => { + callCount++; + return "hello"; + }); + return result; + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + expect(callCount).toBe(1); + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + expect(callCount).toBe(1); + }); + + it("should replay void step on restart", async () => { + let callCount = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + const result = await ctx.step("void-step", async () => { + callCount++; + }); + return result; + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + expect(callCount).toBe(1); + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + expect(callCount).toBe(1); + }); + + it("should execute multiple steps in sequence", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const a = await ctx.step("step-a", async () => 1); + const b = await ctx.step("step-b", async () => 2); + const c = await ctx.step("step-c", async () => 3); + return a + b + c; + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe(6); + }); + + it("should retry failed steps", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "flaky-step", + maxRetries: 3, + retryBackoffBase: 1, + retryBackoffMax: 10, + run: async () => { + attempts++; + if (attempts < 3) { + throw new Error("Transient failure"); + } + return "success"; + }, + }); + }; + + try { + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + } catch {} + + try { + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + } catch {} + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("success"); + expect(attempts).toBe(3); + }); + + it("should report retryable step failures to onError", async () => { + let attempts = 0; + const events: WorkflowErrorEvent[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "flaky-step", + maxRetries: 3, + retryBackoffBase: 5, + retryBackoffMax: 5, + run: async () => { + attempts++; + if (attempts === 1) { + throw new Error("Transient failure"); + } + return "ok"; + }, + }); + }; + + const firstResult = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + onError: (event) => { + events.push(event); + }, + }, + ).result; + + if (mode === "yield") { + expect(firstResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + const secondResult = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { + mode, + onError: (event) => { + events.push(event); + }, + }, + ).result; + expect(secondResult.state).toBe("completed"); + expect(secondResult.output).toBe("ok"); + } else { + expect(firstResult.state).toBe("completed"); + expect(firstResult.output).toBe("ok"); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + step: { + stepName: "flaky-step", + attempt: 1, + maxRetries: 3, + remainingRetries: 3, + willRetry: true, + retryDelay: 5, + error: { + name: "Error", + message: "Transient failure", + }, + }, + }); + expect("step" in events[0] && events[0].step.retryAt).toBeTypeOf( + "number", + ); + }); + + it("should yield during backoff retries", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "always-fails", + maxRetries: 3, + retryBackoffBase: 50, + retryBackoffMax: 100, + run: async () => { + attempts++; + throw new Error("Failure"); + }, + }); + }; + + if (mode === "yield") { + const result1 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result1.state).toBe("sleeping"); + expect(result1.sleepUntil).toBeDefined(); + + const result2 = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result2.state).toBe("sleeping"); + expect(result2.sleepUntil).toBeDefined(); + expect(result2.sleepUntil).toBeGreaterThan(Date.now()); + expect(driver.getAlarm("wf-1")).toBe(result2.sleepUntil); + expect(attempts).toBe(1); + return; + } + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(StepExhaustedError); + expect(attempts).toBe(4); + }); + + it("should not retry CriticalError", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step("critical-step", async () => { + attempts++; + throw new CriticalError("Unrecoverable"); + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(CriticalError); + + expect(attempts).toBe(1); + }); + + it("should report terminal step failures to onError", async () => { + const events: WorkflowErrorEvent[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step("critical-step", async () => { + throw new CriticalError("Unrecoverable"); + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { + mode, + onError: (event) => { + events.push(event); + }, + }).result, + ).rejects.toThrow(CriticalError); + + expect(events).toEqual([ + expect.objectContaining({ + step: expect.objectContaining({ + stepName: "critical-step", + attempt: 1, + willRetry: false, + error: expect.objectContaining({ + name: "CriticalError", + message: "Unrecoverable", + }), + }), + }), + ]); + }); + + it("should not re-report exhausted step failures on rerun", async () => { + const events: WorkflowErrorEvent[] = []; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "always-fails", + maxRetries: 0, + retryBackoffBase: 0, + retryBackoffMax: 0, + run: async () => { + throw new Error("boom"); + }, + }); + }; + + const runOnce = async () => + await runWorkflow("wf-1", workflow, undefined, driver, { + mode, + onError: (event) => { + events.push(event); + }, + }).result; + + await expect(runOnce()).rejects.toThrow(StepExhaustedError); + await expect(runOnce()).rejects.toThrow(StepExhaustedError); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + step: { + stepName: "always-fails", + attempt: 1, + willRetry: false, + error: { + name: "Error", + message: "boom", + }, + }, + }); + }); + + it("should report workflow-level failures to onError", async () => { + const events: WorkflowErrorEvent[] = []; + + const workflow = async (_ctx: WorkflowContextInterface) => { + throw new Error("workflow failed"); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { + mode, + onError: (event) => { + events.push(event); + }, + }).result, + ).rejects.toThrow("workflow failed"); + + expect(events).toEqual([ + expect.objectContaining({ + workflow: expect.objectContaining({ + error: expect.objectContaining({ + name: "Error", + message: "workflow failed", + }), + }), + }), + ]); + }); + + it("should not retry RollbackError", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step("rollback-step", async () => { + attempts++; + throw new RollbackError("Rollback now"); + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(RollbackError); + + expect(attempts).toBe(1); + }); + + it("should exhaust retries", async () => { + let _attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "always-fails", + maxRetries: 2, + retryBackoffBase: 1, + run: async () => { + _attempts++; + throw new Error("Always fails"); + }, + }); + }; + + if (mode === "yield") { + const firstResult = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const secondResult = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(secondResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(StepExhaustedError); + }); + + it("should recover exhausted retries", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "recoverable", + maxRetries: 1, + retryBackoffBase: 1, + run: async () => { + attempts++; + throw new Error("Always fails"); + }, + }); + }; + + const runOnce = () => + runWorkflow("wf-1", workflow, undefined, driver, { mode }); + + if (mode === "yield") { + const firstResult = await runOnce().result; + expect(firstResult.state).toBe("sleeping"); + } + + const exhaustedHandle = runOnce(); + await expect(exhaustedHandle.result).rejects.toThrow( + StepExhaustedError, + ); + + const attemptsAfterExhaust = attempts; + await exhaustedHandle.recover(); + + if (mode === "yield") { + const resumedResult = await runOnce().result; + expect(resumedResult.state).toBe("sleeping"); + } + + await expect(runOnce().result).rejects.toThrow(StepExhaustedError); + + expect(attempts).toBeGreaterThan(attemptsAfterExhaust); + }); + + it("should fail steps that exceed timeout", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "timeout-step", + timeout: 5, + run: async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + return "late"; + }, + }); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(CriticalError); + }); + + it("should retry steps that exceed timeout when retryOnTimeout is set", async () => { + let attempts = 0; + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "timeout-step", + timeout: 5, + retryOnTimeout: true, + maxRetries: 2, + retryBackoffBase: 1, + retryBackoffMax: 1, + run: async () => { + attempts++; + await new Promise((resolve) => setTimeout(resolve, 25)); + return "late"; + }, + }); + }; + + if (mode === "yield") { + const firstResult = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(firstResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const secondResult = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(secondResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(StepExhaustedError); + + expect(attempts).toBe(3); + }); + + it("should fail when a step is not awaited", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const first = ctx.step("step-a", async () => "a"); + await ctx.step("step-b", async () => "b"); + return await first; + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(EntryInProgressError); + }); + + it("should reject duplicate entry names", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("dup", async () => "first"); + await ctx.step("dup", async () => "second"); + return "done"; + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow(HistoryDivergedError); + }); + + it("should batch ephemeral steps until a durable flush", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step({ + name: "ephemeral-a", + ephemeral: true, + run: async () => "a", + }); + await ctx.step({ + name: "ephemeral-b", + ephemeral: true, + run: async () => "b", + }); + return await ctx.step("durable", async () => "done"); + }; + + const result = await runWorkflow( + "wf-1", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toBe("done"); + expect(driver.batchCalls).toBe(2); + }); + + it("should commit error but not output to step entry on failure", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step({ + name: "fail-once", + maxRetries: 3, + retryBackoffBase: 0, + retryBackoffMax: 0, + run: async () => { + throw new Error("step failed"); + }, + }); + }; + + // Run once so the step fails and state is flushed + try { + await runWorkflow("wf-1", workflow, undefined, driver, { + mode, + }).result; + } catch {} + + // Inspect all history entries in KV + const historyPrefix = buildHistoryPrefixAll(); + const entries = await driver.list(historyPrefix); + + for (const kv of entries) { + const entry = deserializeEntry(kv.value); + if (entry.kind.type === "step") { + // The step entry should have no output committed on failure. + expect(entry.kind.data.output).toBeUndefined(); + // The error should be committed for inspection. + expect(entry.kind.data.error).toBe("Error: step failed"); + } + } + }); + }); +} diff --git a/packages/workflows/tests/storage.test.ts b/packages/workflows/tests/storage.test.ts new file mode 100644 index 0000000..41ea3ae --- /dev/null +++ b/packages/workflows/tests/storage.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { KVWrite } from "../src/driver.js"; +import { + MAX_KV_BATCH_ENTRIES, + MAX_KV_BATCH_PAYLOAD_BYTES, +} from "../src/storage.js"; +import { + appendName, + createEntry, + createStorage, + emptyLocation, + flush, + getOrCreateMetadata, + InMemoryDriver, + loadMetadata, + loadStorage, + runWorkflow, + setEntry, + type WorkflowContextInterface, +} from "../src/testing.js"; + +class RecordingDriver extends InMemoryDriver { + batches: KVWrite[][] = []; + failOnBatch?: number; + + override async batch(writes: KVWrite[]): Promise { + this.batches.push(writes); + if ( + this.failOnBatch !== undefined && + this.batches.length === this.failOnBatch + ) { + throw new Error("injected batch failure"); + } + await super.batch(writes); + } +} + +class AtomicRecordingDriver extends RecordingDriver { + readonly atomicBatch = true; +} + +function batchPayloadBytes(writes: KVWrite[]): number { + return writes.reduce( + (total, write) => total + write.key.byteLength + write.value.byteLength, + 0, + ); +} + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Storage (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should persist workflow output and state", async () => { + const workflow = async (_ctx: WorkflowContextInterface) => { + return "value"; + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + const storage = await loadStorage(driver); + expect(storage.state).toBe("completed"); + expect(storage.output).toBe("value"); + }); + + it("should persist workflow errors", async () => { + const workflow = async (_ctx: WorkflowContextInterface) => { + throw new Error("boom"); + }; + + await expect( + runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result, + ).rejects.toThrow("boom"); + + const storage = await loadStorage(driver); + expect(storage.state).toBe("failed"); + expect(storage.error?.message).toBe("boom"); + }); + + it("should persist entry metadata and names", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.step("named-step", async () => "ok"); + }; + + await runWorkflow("wf-1", workflow, undefined, driver, { mode }) + .result; + + const storage = await loadStorage(driver); + expect(storage.nameRegistry).toContain("named-step"); + + const entry = [...storage.history.entries.values()][0]; + const metadata = await loadMetadata(storage, driver, entry.id); + expect(metadata.status).toBe("completed"); + expect(metadata.attempts).toBe(1); + }); + }); +} + +describe("Workflow Engine Storage flush", () => { + it("does not split a driver-declared atomic flush", async () => { + const driver = new AtomicRecordingDriver(); + driver.latency = 0; + const storage = createStorage(); + storage.flushedState = storage.state; + storage.nameRegistry = Array.from( + { length: MAX_KV_BATCH_ENTRIES + 1 }, + (_, i) => `step-${i}`, + ); + + await flush(storage, driver); + + expect(driver.batches).toHaveLength(1); + expect(driver.batches[0]).toHaveLength(MAX_KV_BATCH_ENTRIES + 1); + }); + + it("splits writes into KV-sized batches", async () => { + const driver = new RecordingDriver(); + driver.latency = 0; + const storage = createStorage(); + storage.flushedState = storage.state; + storage.nameRegistry = Array.from( + { length: MAX_KV_BATCH_ENTRIES + 1 }, + (_, i) => `step-${i}`, + ); + + await flush(storage, driver); + + expect(driver.batches).toHaveLength(2); + expect(driver.batches.map((batch) => batch.length)).toEqual([ + MAX_KV_BATCH_ENTRIES, + 1, + ]); + for (const batch of driver.batches) { + expect(batchPayloadBytes(batch)).toBeLessThanOrEqual( + MAX_KV_BATCH_PAYLOAD_BYTES, + ); + } + + const reloaded = await loadStorage(driver); + expect(reloaded.nameRegistry).toEqual(storage.nameRegistry); + }); + + it("splits writes by KV batch payload size", async () => { + const driver = new RecordingDriver(); + driver.latency = 0; + const storage = createStorage(); + storage.flushedState = storage.state; + storage.nameRegistry = Array.from( + { length: 9 }, + (_, i) => `${i}-${"x".repeat(120 * 1024)}`, + ); + + await flush(storage, driver); + + expect(driver.batches.length).toBeGreaterThan(1); + for (const batch of driver.batches) { + expect(batch.length).toBeLessThanOrEqual(MAX_KV_BATCH_ENTRIES); + expect(batchPayloadBytes(batch)).toBeLessThanOrEqual( + MAX_KV_BATCH_PAYLOAD_BYTES, + ); + } + + const reloaded = await loadStorage(driver); + expect(reloaded.nameRegistry).toEqual(storage.nameRegistry); + }); + + it("keeps dirty markers when a batch write fails", async () => { + const driver = new RecordingDriver(); + driver.latency = 0; + driver.failOnBatch = 1; + const storage = createStorage(); + const location = appendName(storage, emptyLocation(), "step"); + const entry = createEntry(location, { + type: "step", + data: { output: "ok" }, + }); + setEntry(storage, location, entry); + const metadata = getOrCreateMetadata(storage, entry.id); + metadata.status = "completed"; + + await expect(flush(storage, driver)).rejects.toThrow( + "injected batch failure", + ); + + expect(entry.dirty).toBe(true); + expect(metadata.dirty).toBe(true); + expect(storage.flushedNameCount).toBe(0); + + driver.failOnBatch = undefined; + await flush(storage, driver); + + expect(entry.dirty).toBe(false); + expect(metadata.dirty).toBe(false); + }); +}); diff --git a/packages/workflows/tests/try.test.ts b/packages/workflows/tests/try.test.ts new file mode 100644 index 0000000..a910d20 --- /dev/null +++ b/packages/workflows/tests/try.test.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + CriticalError, + InMemoryDriver, + RollbackError, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe(`Workflow Engine Try (${mode})`, { sequential: true }, () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("should return handled critical failures from tryStep", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.tryStep("critical-step", async () => { + throw new CriticalError("stop"); + }); + }; + + const result = await runWorkflow( + "wf-try-step-critical", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toMatchObject({ + ok: false, + failure: { + kind: "critical", + stepName: "critical-step", + attempts: 1, + error: { + name: "CriticalError", + message: "stop", + }, + }, + }); + }); + + it("should preserve retries before returning exhausted tryStep failures", async () => { + let attempts = 0; + + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.tryStep({ + name: "flaky-step", + maxRetries: 1, + retryBackoffBase: 5, + retryBackoffMax: 5, + run: async () => { + attempts += 1; + throw new Error("boom"); + }, + }); + }; + + const firstResult = await runWorkflow( + "wf-try-step-exhausted", + workflow, + undefined, + driver, + { mode }, + ).result; + + if (mode === "yield") { + expect(firstResult.state).toBe("sleeping"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const secondResult = await runWorkflow( + "wf-try-step-exhausted", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(secondResult.state).toBe("completed"); + expect(secondResult.output).toMatchObject({ + ok: false, + failure: { + kind: "exhausted", + stepName: "flaky-step", + attempts: 2, + error: { + message: "boom", + }, + }, + }); + } else { + expect(firstResult.state).toBe("completed"); + expect(firstResult.output).toMatchObject({ + ok: false, + failure: { + kind: "exhausted", + stepName: "flaky-step", + attempts: 2, + error: { + message: "boom", + }, + }, + }); + } + + expect(attempts).toBe(2); + }); + + it("should not catch rollback in tryStep by default", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.tryStep("rollback-step", async () => { + throw new RollbackError("rollback"); + }); + }; + + await expect( + runWorkflow( + "wf-try-step-rollback-default", + workflow, + undefined, + driver, + { mode }, + ).result, + ).rejects.toThrow(RollbackError); + }); + + it("should catch rollback in tryStep when configured", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.tryStep({ + name: "rollback-step", + catch: ["rollback"], + run: async () => { + throw new RollbackError("rollback"); + }, + }); + }; + + const result = await runWorkflow( + "wf-try-step-rollback-caught", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toMatchObject({ + ok: false, + failure: { + kind: "rollback", + stepName: "rollback-step", + attempts: 1, + error: { + name: "RollbackError", + message: "rollback", + }, + }, + }); + }); + + it("should catch terminal step failures inside try blocks", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.try("payment-flow", async (blockCtx) => { + return await blockCtx.step({ + name: "charge", + maxRetries: 0, + run: async () => { + throw new CriticalError("declined"); + }, + }); + }); + }; + + const result = await runWorkflow( + "wf-try-block-step", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toMatchObject({ + ok: false, + failure: { + source: "step", + name: "charge", + error: { + name: "CriticalError", + message: "declined", + }, + step: { + kind: "critical", + stepName: "charge", + attempts: 1, + error: { + name: "CriticalError", + message: "declined", + }, + }, + }, + }); + }); + + it("should catch join failures inside try blocks", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.try("parallel-flow", async (blockCtx) => { + return await blockCtx.join("parallel", { + good: { + run: async () => "ok", + }, + bad: { + run: async () => { + throw new Error("boom"); + }, + }, + }); + }); + }; + + const result = await runWorkflow( + "wf-try-block-join", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toMatchObject({ + ok: false, + failure: { + source: "join", + name: "parallel", + error: { + name: "JoinError", + }, + }, + }); + }); + + it("should catch race failures inside try blocks", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.try("race-flow", async (blockCtx) => { + return await blockCtx.race("contest", [ + { + name: "one", + run: async () => { + throw new Error("one failed"); + }, + }, + { + name: "two", + run: async () => { + throw new Error("two failed"); + }, + }, + ]); + }); + }; + + const result = await runWorkflow( + "wf-try-block-race", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toMatchObject({ + ok: false, + failure: { + source: "race", + name: "contest", + error: { + name: "RaceError", + }, + }, + }); + }); + + it("should not catch rollback in try blocks by default", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.try("rollback-flow", async () => { + throw new RollbackError("rollback"); + }); + }; + + await expect( + runWorkflow( + "wf-try-block-rollback-default", + workflow, + undefined, + driver, + { mode }, + ).result, + ).rejects.toThrow(RollbackError); + }); + + it("should catch direct rollback in try blocks when configured", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + return await ctx.try({ + name: "rollback-flow", + catch: ["rollback"], + run: async () => { + throw new RollbackError("rollback"); + }, + }); + }; + + const result = await runWorkflow( + "wf-try-block-rollback-caught", + workflow, + undefined, + driver, + { mode }, + ).result; + + expect(result.state).toBe("completed"); + expect(result.output).toMatchObject({ + ok: false, + failure: { + source: "block", + name: "rollback-flow", + error: { + name: "RollbackError", + message: "rollback", + }, + }, + }); + }); + }); +} diff --git a/packages/workflows/tests/version.test.ts b/packages/workflows/tests/version.test.ts new file mode 100644 index 0000000..40d355f --- /dev/null +++ b/packages/workflows/tests/version.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + HistoryDivergedError, + InMemoryDriver, + Loop, + runWorkflow, + type WorkflowContextInterface, +} from "../src/testing.js"; + +const modes = ["yield", "live"] as const; + +for (const mode of modes) { + describe( + `Workflow Engine getVersion (${mode})`, + { sequential: true }, + () => { + let driver: InMemoryDriver; + + beforeEach(() => { + driver = new InMemoryDriver(); + driver.latency = 0; + }); + + it("resolves a fresh instance to latest and pins it on replay", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + const v = await ctx.getVersion("gate", 2); + await ctx.step("record", async () => v); + return v; + }; + + const first = await runWorkflow( + "wf-fresh", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(first.state).toBe("completed"); + expect(first.output).toBe(2); + + // Re-running replays the recorded version_check and returns the + // same pinned value. + const second = await runWorkflow( + "wf-fresh", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(second.state).toBe("completed"); + expect(second.output).toBe(2); + }); + + it("resolves an old in-flight instance to floor 1 and replays the old step's value", async () => { + // v1 code: records a step, no version gate. + const v1 = async (ctx: WorkflowContextInterface) => { + await ctx.step("work", async () => "did-work"); + return "v1-done"; + }; + await runWorkflow("wf-old", v1, undefined, driver, { mode }) + .result; + + // v2 code: adds a gate before the step. The old instance already + // ran `work`, so the gate must resolve to 1 (old branch) and the + // replayed step must return the originally recorded value. + const v2 = async (ctx: WorkflowContextInterface) => { + const v = await ctx.getVersion("gate", 2); + if (v === 1) { + const w = await ctx.step( + "work", + async () => "should-not-run", + ); + return `old:${w}`; + } + return "new"; + }; + const result = await runWorkflow( + "wf-old", + v2, + undefined, + driver, + { mode }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toBe("old:did-work"); + }); + + it("resolves to latest when the gate is not the first call in its scope (fresh run)", async () => { + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("a", async () => "a"); + return await ctx.getVersion("gate", 3); + }; + const result = await runWorkflow( + "wf-notfirst", + workflow, + undefined, + driver, + { mode }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toBe(3); + }); + + it("can be retired with removed() once a migration is finished", async () => { + const withGate = async (ctx: WorkflowContextInterface) => { + const v = await ctx.getVersion("gate", 2); + return `v${v}`; + }; + const gated = await runWorkflow( + "wf-retire", + withGate, + undefined, + driver, + { mode }, + ).result; + expect(gated.output).toBe("v2"); + + const removeGate = async (ctx: WorkflowContextInterface) => { + await ctx.removed("gate", "version_check"); + return "cleaned"; + }; + const result = await runWorkflow( + "wf-retire", + removeGate, + undefined, + driver, + { mode }, + ).result; + expect(result.state).toBe("completed"); + expect(result.output).toBe("cleaned"); + }); + + it("throws HistoryDiverged when a non-version entry occupies the gate location", async () => { + const asStep = async (ctx: WorkflowContextInterface) => { + await ctx.step("gate", async () => "x"); + return "step"; + }; + await runWorkflow("wf-diverge", asStep, undefined, driver, { + mode, + }).result; + + const asVersion = async (ctx: WorkflowContextInterface) => { + await ctx.getVersion("gate", 2); + return "version"; + }; + await expect( + runWorkflow("wf-diverge", asVersion, undefined, driver, { + mode, + }).result, + ).rejects.toThrow(HistoryDivergedError); + }); + }, + ); +} + +describe( + "Workflow Engine getVersion per-iteration cutover", + { sequential: true }, + () => { + it("resolves each loop iteration independently across a redeploy", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + const mode = "yield" as const; + + // v1 loop: records a `pre` step before waiting for a message, so an + // iteration can be left suspended mid-body with history already present. + const v1 = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "consume", + state: { i: 0, processed: 0 }, + run: async (lctx, state) => { + await lctx.step("pre", async () => `pre-${state.i}`); + const msg = await lctx.queue.next("in", { + names: ["work"], + }); + await lctx.step("post", async () => `post:${msg.body}`); + const processed = state.processed + 1; + if (processed >= 3) { + return Loop.break(processed); + } + return Loop.continue({ i: state.i + 1, processed }); + }, + }); + }; + + // First message is processed in iteration 0; iteration 1 records `pre` + // then suspends at queue.next (in-flight under v1). + await driver.messageDriver.addMessage({ + id: "m1", + name: "work", + data: "one", + sentAt: Date.now(), + }); + const r1 = await runWorkflow("wf-loop", v1, undefined, driver, { + mode, + }).result; + expect(r1.state).toBe("sleeping"); + + // Redeploy v2: adds a gate at the top of the loop body. + const seenByIter = new Map(); + const v2 = async (ctx: WorkflowContextInterface) => { + return await ctx.loop({ + name: "consume", + state: { i: 0, processed: 0 }, + run: async (lctx, state) => { + const v = await lctx.getVersion("gate", 2); + seenByIter.set(state.i, v); + await lctx.step("pre", async () => `pre-${state.i}`); + const msg = await lctx.queue.next("in", { + names: ["work"], + }); + await lctx.step("post", async () => `post:${msg.body}`); + const processed = state.processed + 1; + if (processed >= 3) { + return Loop.break(processed); + } + return Loop.continue({ i: state.i + 1, processed }); + }, + }); + }; + + // Second message resumes the in-flight iteration 1 (whose `pre` step + // predates the gate) and lets iteration 2 begin fresh. + await driver.messageDriver.addMessage({ + id: "m2", + name: "work", + data: "two", + sentAt: Date.now(), + }); + const r2 = await runWorkflow("wf-loop", v2, undefined, driver, { + mode, + }).result; + expect(r2.state).toBe("sleeping"); + + // Third message completes iteration 2. + await driver.messageDriver.addMessage({ + id: "m3", + name: "work", + data: "three", + sentAt: Date.now(), + }); + const r3 = await runWorkflow("wf-loop", v2, undefined, driver, { + mode, + }).result; + expect(r3.state).toBe("completed"); + + // Iteration 1 was in-flight under v1 (its `pre` step predates the gate), + // so it resolves to floor version 1. Iteration 2 is fresh, so it + // resolves to latest (2). + expect(seenByIter.get(1)).toBe(1); + expect(seenByIter.get(2)).toBe(2); + }); + }, +); diff --git a/packages/workflows/tsconfig.json b/packages/workflows/tsconfig.json new file mode 100644 index 0000000..27f927c --- /dev/null +++ b/packages/workflows/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "outDir": "./dist" + }, + "include": ["src/**/*", "schemas/**/*", "dist/schemas/**/*", "tests/**/*"] +} + diff --git a/packages/workflows/tsup.config.ts b/packages/workflows/tsup.config.ts new file mode 100644 index 0000000..aeeb697 --- /dev/null +++ b/packages/workflows/tsup.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/mod.ts", + testing: "src/testing.ts", + }, + target: "node18", + platform: "node", + format: ["cjs", "esm"], + sourcemap: true, + clean: true, + dts: { + compilerOptions: { + skipLibCheck: true, + resolveJsonModule: true, + }, + }, + minify: false, + splitting: true, + skipNodeModulesBundle: true, + external: [/^node:.*/, /^rivetkit(?:\/.*)?$/], + shims: true, + outDir: "dist/tsup", +}); diff --git a/packages/workflows/vitest.config.ts b/packages/workflows/vitest.config.ts new file mode 100644 index 0000000..757dd74 --- /dev/null +++ b/packages/workflows/vitest.config.ts @@ -0,0 +1,47 @@ +import { availableParallelism, cpus } from "node:os"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const maxConcurrency = (() => { + try { + return availableParallelism(); + } catch { + return cpus().length; + } +})(); + +export default defineConfig({ + resolve: { + alias: [ + { + find: /^rivetkit$/, + replacement: fileURLToPath( + new URL("./tests/fixtures/rivetkit.ts", import.meta.url), + ), + }, + { + find: "rivetkit/storage", + replacement: fileURLToPath( + new URL("./tests/fixtures/rivetkit-storage.ts", import.meta.url), + ), + }, + { + find: "rivetkit/inspector/workflow", + replacement: fileURLToPath( + new URL( + "./tests/fixtures/rivetkit-inspector-workflow.ts", + import.meta.url, + ), + ), + }, + ], + }, + test: { + include: ["tests/**/*.test.ts"], + exclude: ["tests/e2e/**"], + testTimeout: 10_000, + hookTimeout: 10_000, + maxConcurrency, + sequence: { concurrent: true }, + }, +}); diff --git a/packages/workflows/vitest.e2e.config.ts b/packages/workflows/vitest.e2e.config.ts new file mode 100644 index 0000000..0fb1b17 --- /dev/null +++ b/packages/workflows/vitest.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/e2e/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + pool: "forks", + fileParallelism: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..3ed8865 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4055 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: 2.3.11 + version: 2.3.11 + tsx: + specifier: ^4.20.6 + version: 4.23.12 + + packages/workflows: + dependencies: + '@rivetkit/bare-ts': + specifier: ^0.6.2 + version: 0.6.2 + cbor-x: + specifier: ^1.6.0 + version: 1.6.5 + fdb-tuple: + specifier: ^1.0.0 + version: 1.0.0 + pino: + specifier: ^9.6.0 + version: 9.14.0 + vbare: + specifier: ^0.0.4 + version: 0.0.4 + devDependencies: + '@bare-ts/tools': + specifier: ^0.13.0 + version: 0.13.0(@bare-ts/lib@0.3.0) + '@types/node': + specifier: ^22.13.1 + version: 22.20.1 + commander: + specifier: ^12.0.0 + version: 12.1.0 + legacy-rivetkit: + specifier: npm:rivetkit@2.3.7 + version: rivetkit@2.3.7(better-sqlite3@12.11.1) + legacy-workflow-engine: + specifier: npm:@rivetkit/workflow-engine@2.3.7 + version: '@rivetkit/workflow-engine@2.3.7' + rivetkit: + specifier: 0.0.0-feat-workflows-public-host-apis.0ff6164 + version: 0.0.0-feat-workflows-public-host-apis.0ff6164(better-sqlite3@12.11.1) + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.26)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) + tsx: + specifier: ^4.7.0 + version: 4.23.12 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.7(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + +packages: + + '@asteasolutions/zod-to-openapi@9.1.0': + resolution: {integrity: sha512-pLMeRgRYS7/vZIgAAkOe2P6+XGTifR1MT9pqDoxZ11EmcJJ+DPmdPqLN8ZZF4U8R9KHCCQCS9c2wtuE8wSrfkw==} + peerDependencies: + zod: ^4.0.0 + + '@bare-ts/lib@0.3.0': + resolution: {integrity: sha512-0JlIu0R26CTMdEnmmKIuFvAS9Cd554MvH4BjI6iL+74TDYBCHhaCWIDJXU6+VuYPu0+/JDLV/KXQstK9Sxn+9A==} + engines: {node: '>= 12'} + + '@bare-ts/tools@0.13.0': + resolution: {integrity: sha512-53fjq3/B3HeFJG6FygJbbdAoqvVnexdWjoi2O95p4karcyXUzest+AJ4ruf9ppk4bl28Jn0jkVegsJgeNZt4OQ==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@bare-ts/lib': ~0.3.0 + + '@biomejs/biome@2.3.11': + resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.11': + resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.11': + resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.11': + resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.11': + resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/zod-openapi@1.6.0': + resolution: {integrity: sha512-42HXUBIaGmQh+hZGLw3Hy5piOreIXgnBnUSs55mtn2gnW/xWtU9nQwEUtRRCk5to8Vm2XbfO0J83eUCwCf5eTg==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.10.0' + zod: ^4.0.0 + + '@hono/zod-validator@0.9.0': + resolution: {integrity: sha512-n0ZSXmCiHVIp4Y5wlOOyZCeTd/rsawA/qW1cipB8QOYKZ9N8Tk0nZUZCXho9cu374AN4JpDNKioNKBJ/W+LBug==} + peerDependencies: + hono: '>=4.11.2' + zod: ^3.25.0 || ^4.0.0 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rivet-dev/agent-os-core@0.1.1': + resolution: {integrity: sha512-Uw5jr+gUXDY7TDUFqlypjGe1BD2KL9kTHPNo/f1iNS1R+l9IWuvT+FF/MXsLOEkc3fB06OPVu2ZvUuNwp9MpLQ==} + + '@rivet-dev/agent-os-posix@0.1.0': + resolution: {integrity: sha512-NIrI7cCb9x6jdmzRPPx7dAeXoTF/YCqf93ydEzYFA2zshIelLW9Rp5KtgP/2hM6fP0ly4+vVnOeavxJW0wYtcA==} + + '@rivet-dev/agent-os-python@0.1.0': + resolution: {integrity: sha512-1tH1beMf1ceSpicQKwN/a6h+NmJrmfuT4GStiRDZmvN/UWfZhkxuy7HR5VPTQpE/feUZJ01FdtBS3Em/Qoxb2Q==} + peerDependencies: + pyodide: '>=0.28.0' + peerDependenciesMeta: + pyodide: + optional: true + + '@rivetkit/bare-ts@0.6.2': + resolution: {integrity: sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@rivetkit/engine-cli-darwin-arm64@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-hb/GOOgEPKEZicdBVhnkjIalYZwIx+x0AL3YORl2nXFpMxqWaUtOCGzQB3E2IveSfnPw9SJMKO2xkBe0Z6pjxg==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [darwin] + + '@rivetkit/engine-cli-darwin-arm64@2.3.7': + resolution: {integrity: sha512-F3yGSDIaW94BZzuAm3erQRT/Ki55wHJ/b6tOaE7d9tRmTAZw/XqHmjNdUYjOvFUQKLb+SSsOittM4dINrAANQQ==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [darwin] + + '@rivetkit/engine-cli-darwin-x64@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-qt128grSxT3p2nSK3uudl1ALh79lzXuQ9IbwIkqXQV8i7TFSF7N9jY98U8FvDI2360whr3qq1lvWmFTGDnJ6/A==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [darwin] + + '@rivetkit/engine-cli-darwin-x64@2.3.7': + resolution: {integrity: sha512-P8BbQsamIvv8U/cEoJUFr+uAzwjjl9cG+ZZxjDX9OJxbuD11ESpHvDLgge54ijynO3IjoP8cIzjIXat6mr2Gkw==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [darwin] + + '@rivetkit/engine-cli-linux-arm64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-J9xWBqUvy1D1IWsWpIdkujlg83GqSBdTOTC1DBGPhoQ8It8BSZlRGYvrFLSAFPYqgb1vMv6eFPtipPLPTXecUg==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/engine-cli-linux-arm64-musl@2.3.7': + resolution: {integrity: sha512-G3s76BHfIs6R9rm6JYI13sOjPLy08+C8WvVoFwSdMqsibEaqlfo36wVkp/x1g0sEZUek/dBxVrKqMMR7c4/gug==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/engine-cli-linux-x64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-ImXsYTJnmxwrlOa0DSKVy4+2Dp39a2/FVUzi9jgjz4KWVfR/QafGc/gVntZDIkVSvfTUkG/WW6pp8qS7qrz1oA==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/engine-cli-linux-x64-musl@2.3.7': + resolution: {integrity: sha512-LWhrUVzULcsZ4ufCLsx23YfrdMW2INd9g0ANBac9+PzGwL8qYfVIXnDpR3587lapu68EbSEPZcconf1tBlvJxQ==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/engine-cli-win32-x64@2.3.7': + resolution: {integrity: sha512-jyA5PlHivHXyyq2ehnuXwxd5TUSbB4emomnph9EovsJo7qIdmSNaaCO13NYBT483fd0uXiNI0MK/lkJa9ePY5A==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [win32] + + '@rivetkit/engine-cli@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-EZE/e48WufHAJFm/0qKzTXY5LKNH0+YAlD26haDhwyucW2OgomdVSdmP2i+1a1wTwqSdtRsQRN7O4DmWSbDRTg==} + engines: {node: '>= 20.0.0'} + + '@rivetkit/engine-cli@2.3.7': + resolution: {integrity: sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg==} + engines: {node: '>= 20.0.0'} + + '@rivetkit/engine-envoy-protocol@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-ZEzFU2dtHD+IzM57NlmKH5YXl6sHJTPLYBWYwgfGH1fJsYRdk8zY6yCSLPkzsCcKC8418XIY01mgaymd0vRDPA==} + + '@rivetkit/engine-envoy-protocol@2.3.7': + resolution: {integrity: sha512-lsJM3ERwozQCebaOMKJzqIQlcLbbN3LXurRB0+7LsM6FUUL2l6hMoq5I2De/G35DJ6OWDva9AmzfvzI+5f1GPg==} + + '@rivetkit/on-change@6.0.1': + resolution: {integrity: sha512-QBN/KRBXLJdCgN4gBTL3XAc/zKm58atSnieXWMOyFSPmo6F1/yIVV/LTRdvAktfCttrGx7W6c32i/lwqCHWnsQ==} + engines: {node: '>=20'} + + '@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-XHnxDGrdzZGfNdo7kLBUcXua84bqJ/6v/GhZF1s4sajFO1hkWzBGrAq2R3+t8g3tUuimoLxMGfCtO1ctXUBOlg==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [darwin] + + '@rivetkit/rivetkit-napi-darwin-arm64@2.3.7': + resolution: {integrity: sha512-gI1wMe6z85VDeR4K/wnzevVakXTg5UCfvjlDOp1+23nyU3NlInhN156dEWO8EsfRMrs+1bYoGdc4rcoN+4PUrw==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [darwin] + + '@rivetkit/rivetkit-napi-darwin-x64@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-ni79/MJRNR/jO/blbvjW+lPhHGFtp6otDSoWmxW/QkeUvFF6wvwBTeNcvR7Bx+GohgU9JC7Y3BOXJGuYrCRbAA==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [darwin] + + '@rivetkit/rivetkit-napi-darwin-x64@2.3.7': + resolution: {integrity: sha512-H5tUwvCbKwTIBiyQG01ikK5mdc9ISg+bv8gPUw41mYOKq8rb9nBLkcl2TKcreFHac8KaGxtnBZzYcjbUV3NcwQ==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [darwin] + + '@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-uUHF7vF5N8pegVtluIzix3xYBTik25cL9izjMhwAzoL2/+2UvQaqMg47hqgO1hwr17ImY7UV/WcwOyNODzEJHg==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.7': + resolution: {integrity: sha512-MpOeiO5M737e93hM/FjcH+zv8xBEX1tgBd/59K/Uw7qlRbWW7JGz8WGjavdxv211LRqxsn8EUlGRxcADDsXnBA==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-7/BCrybtr1Tl3GJ1JXyG9MxxsCmfREgpVYDm0H4MZdgu8Lckc7V2CQWWwuXK1bgSewCPcD1QLsVMVj6qhBdxyQ==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.7': + resolution: {integrity: sha512-0GsUgd/HHvIq6v92kp0H175mGm2Exvs4krBLih0MIpVAkqJk8EIcKI6wJhCwY04Fr0MS8sHkUhjuHXa7QcmlYA==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-SjoGcPPM6L4LFEmc9JvdqCJWMUTBMtkaZDquIiu6kBU2bB7V///fqRtMTX06oASdZoU4P7CTSyEGg1DPS2RACg==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.7': + resolution: {integrity: sha512-8wCD1FuDpM+yiKRG8ro1K8ILVpJXzCNKViJ1axq1b4Nyqgod/nyEvvKDaT4qhA5+Om+W5i6YRO1uddjC6QjJSQ==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-yfVy21Fcw6heDNBbI6vICAm4aFQUbs2jSMUexxEXFh5huHjVCHeTLDh9d6TRBB82iDm94i8URU7q/UYYJRzqBQ==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-x64-musl@2.3.7': + resolution: {integrity: sha512-8RGzXD33GH1/DWufFzCZRI+npF5IL/9z7Ja3pthsuYgrL+axGG0iqfAjbwj5HHnB/ceUGQVT+uCKzoLnG+odtw==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/rivetkit-napi-win32-x64-msvc@2.3.7': + resolution: {integrity: sha512-n1by/coKxWOb2FZDGLM2qmULxcLSqx8hxJuk7Hsb1gl4uNHGviUzYr+r7WAiOzonK/Dgy477u3OKf5yBie743A==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [win32] + + '@rivetkit/rivetkit-napi@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-JNsbSqJNqNBT4Meh/CaHA5kJjKokAGfDC0k1E4N2faysbt1zn6LYYQoM12irwj/6++Wr5RpNgOiiDT8npB9d2g==} + engines: {node: '>= 20.0.0'} + + '@rivetkit/rivetkit-napi@2.3.7': + resolution: {integrity: sha512-1d3HtNKzJwdkznWDWSpCwg1rBn+nVwUfcKrApINTfiEhfDyVeznwbVFkfHWqOBHsjU17vvWFfjjTtf8QsjLwUQ==} + engines: {node: '>= 20.0.0'} + + '@rivetkit/rivetkit-wasm@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-sajvCywErQwBxeIKDbNKp83hjlfUAe8JsFjGz17AiBV+Cgobc6DZOZBLrnItrccJjBtL3Iq0XdOYd60JAF/LkA==} + + '@rivetkit/rivetkit-wasm@2.3.7': + resolution: {integrity: sha512-o7QBtFOJrajyPVBIDQ32k8sDNFsUc8v2ETfNpr+Ch374ydmZMzCv3XKJU9vubc9bZa7WpCHSzf5ztc8b8i/Xpg==} + + '@rivetkit/traces@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-TRffcxSyfJE6mooduSxVfnY0JP26ealeLcTeB5MnkqhbbiTY3UV2YG4eJ/mbUKmNWTYQCAtddHGwN42geLzIgQ==} + engines: {node: '>=18.0.0'} + + '@rivetkit/traces@2.3.7': + resolution: {integrity: sha512-B7QYjFP2HPxyfPsVjfcNIV7FPLWgGWGypmlT/dZM1i1Cf1WRdeuBumPLffgs3YceiY7Pm4D/gZZVt5p0m4Cx+A==} + engines: {node: '>=18.0.0'} + + '@rivetkit/virtual-websocket@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-FZBDcOvdg/ywND8KT/lHidfvWsKu1/7RFm1Blc0ULOpp3+788H2NX4zyHvOuF71L8QWfjt9fz1EUm+H87zbhxA==} + + '@rivetkit/virtual-websocket@2.3.7': + resolution: {integrity: sha512-jwxDvYbr3YB6vFuxwRdB/AqUUMwdtC1rIAc4tNN6VQR3weHbE+PrDoHKbe85zrtlBtcnG89/p8tlovrj8qyQWA==} + + '@rivetkit/workflow-engine@0.0.0-feat-workflows-public-host-apis.0ff6164': + resolution: {integrity: sha512-yjOCVmv67cq66vLeIndsx+jlKo1ct+Tlye1E65O2Xzbc7OcAKVAw/SLOKeBsB4dBMVwTe5Fau4ueuS4xVT3axA==} + engines: {node: '>=18.0.0'} + + '@rivetkit/workflow-engine@2.3.7': + resolution: {integrity: sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g==} + engines: {node: '>=18.0.0'} + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@secure-exec/core@0.2.1': + resolution: {integrity: sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg==} + + '@secure-exec/nodejs@0.2.1': + resolution: {integrity: sha512-UJMJqVFxexlHJV0Q9nWURvrz6GElj8673DDOOFln6FHR6JS+9SaSU3eISrN158DuNC3SFi4rgjb/scKnK4YOYQ==} + + '@secure-exec/v8-darwin-arm64@0.2.1': + resolution: {integrity: sha512-gEWhMHzUpLwzuBNAD0lVkZXE8wFlWMLp4IOZ+56FYwOW/C+m07cYxuW4TjHyPqZ+vPm3IkoaMqqH5yT9VhjX/Q==} + cpu: [arm64] + os: [darwin] + + '@secure-exec/v8-darwin-x64@0.2.1': + resolution: {integrity: sha512-H2Z5K+Cq+fn/kxjGvhJzepnNFWG6qNdyhZybVWGr5bAAZoSz/Qkad4WnXcurWU+880tKDtnf19LHBXrg7zewNQ==} + cpu: [x64] + os: [darwin] + + '@secure-exec/v8-linux-arm64-gnu@0.2.1': + resolution: {integrity: sha512-14subGhVV/gW35mYYm7Gv1Keeex7PxIgQfoKji/JH7wYyDuarP6kgaES0nJw+JXVkxEVud52c+kbcIjIggqCEw==} + cpu: [arm64] + os: [linux] + + '@secure-exec/v8-linux-x64-gnu@0.2.1': + resolution: {integrity: sha512-Az4s+vUf+78vWtsC7rTn/jQc6WKJafAdt2YpEjB4Gnu+sX+FFTIst1hRV4gJonbRyJdy6SW+OQ6DZatmwczorQ==} + cpu: [x64] + os: [linux] + + '@secure-exec/v8@0.2.1': + resolution: {integrity: sha512-ye/seCqzvyMGnvyP+AO7RkVMR/lE3x9m0D2PfmiAXA457R78ZmOFmZ6v+JlJG2vv3LM30KsSXTUhwpG+Teh0hw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/retry@0.12.2': + resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + asn1.js@4.10.1: + resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browser-resolve@2.0.0: + resolution: {integrity: sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==} + + browserify-aes@1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + + browserify-cipher@1.0.1: + resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==} + + browserify-des@1.0.2: + resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==} + + browserify-rsa@4.1.1: + resolution: {integrity: sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==} + engines: {node: '>= 0.10'} + + browserify-sign@4.2.6: + resolution: {integrity: sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==} + engines: {node: '>= 0.10'} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + + buffer-xor@1.0.3: + resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.5: + resolution: {integrity: sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cipher-base@1.0.7: + resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} + engines: {node: '>= 0.10'} + + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + + commander@10.0.0: + resolution: {integrity: sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==} + engines: {node: '>=14'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + console-browserify@1.2.0: + resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==} + + constants-browserify@1.0.0: + resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + create-ecdh@4.0.4: + resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} + + create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + + create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + + crypto-browserify@3.12.1: + resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==} + engines: {node: '>= 0.10'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diffie-hellman@5.0.3: + resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} + + domain-browser@4.22.0: + resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==} + engines: {node: '>=10'} + + drizzle-orm@0.44.7: + resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + evp_bytestokey@1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdb-tuple@1.0.0: + resolution: {integrity: sha512-8jSvKPCYCgTpi9Pt87qlfTk6griyMx4Gk3Xv31Dp72Qp8b6XgIyFsMm8KzPmFJ9iJ8K4pGvRxvOS8D0XGnrkjw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-base@3.0.5: + resolution: {integrity: sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==} + engines: {node: '>= 0.10'} + + hash-base@3.1.2: + resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} + engines: {node: '>= 0.8'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + hono@4.13.3: + resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + engines: {node: '>=16.9.0'} + + https-browserify@1.0.0: + resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isomorphic-timers-promises@1.0.1: + resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==} + engines: {node: '>=10'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + long-timeout@0.1.1: + resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + + miller-rabin@4.0.1: + resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + node-stdlib-browser@1.3.1: + resolution: {integrity: sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==} + engines: {node: '>=10'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi3-ts@4.6.1: + resolution: {integrity: sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==} + + os-browserify@0.3.0: + resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-retry@6.2.1: + resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} + engines: {node: '>=16.17'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-asn1@5.1.9: + resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==} + engines: {node: '>= 0.10'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pbkdf2@3.1.6: + resolution: {integrity: sha512-BT6eelPB1EyGHo8pC0o9Bl6k6SYVhKO1jEbd3lcTrtr7XHdjP8BW1YpfCV3G9Kwkxgattk+S5q2/RvuttCsS1g==} + engines: {node: '>= 0.10'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@5.0.0: + resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} + engines: {node: '>=10'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + public-encrypt@4.0.3: + resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + querystring-es3@0.2.1: + resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} + engines: {node: '>=0.4.x'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + randomfill@1.0.4: + resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + ripemd160@2.0.3: + resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} + engines: {node: '>= 0.8'} + + rivetkit@0.0.0-feat-workflows-public-host-apis.0ff6164: + resolution: {integrity: sha512-oFSxfTtFdvOiW4ufuGr3liEzy3gXtx08VIqL6LpcbsLdKmg4ivfr48GUGTRpgt+PcmqtmJ/2L90ucHxRK/r83Q==} + engines: {node: '>=22.0.0'} + peerDependencies: + drizzle-kit: ^0.31.2 + eventsource: ^4.0.0 + ws: ^8.0.0 + peerDependenciesMeta: + drizzle-kit: + optional: true + eventsource: + optional: true + ws: + optional: true + + rivetkit@2.3.7: + resolution: {integrity: sha512-V2IWlXq9SRLJWrcYVa8WUftELCpbdsBVWZIqz2WQjhhzRK9Aan65RfMoZVx62A9k8HuIO6MH1nAPLhOuk1xePQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + drizzle-kit: ^0.31.2 + eventsource: ^4.0.0 + ws: ^8.0.0 + peerDependenciesMeta: + drizzle-kit: + optional: true + eventsource: + optional: true + ws: + optional: true + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-exec@0.2.1: + resolution: {integrity: sha512-oaQDzTPDSCOckYC8G0PimIqzEVxY6sYEvcx0fMGsRR/Wl4wkFVHaZgQ3kc2DHWysV6WHWt5g1AXc/6seafO2XQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + stream-http@3.2.0: + resolution: {integrity: sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@3.2.0: + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + + timers-browserify@2.0.12: + resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} + engines: {node: '>=0.6.0'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + tty-browserify@0.0.1: + resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + url@0.11.4: + resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} + engines: {node: '>= 0.4'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@12.0.1: + resolution: {integrity: sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw==} + hasBin: true + + vbare@0.0.4: + resolution: {integrity: sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg==} + engines: {node: '>=18.0.0'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vm-browserify@1.1.2: + resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} + + web-streams-polyfill@4.3.0: + resolution: {integrity: sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw==} + engines: {node: '>= 8'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@asteasolutions/zod-to-openapi@9.1.0(zod@4.4.3)': + dependencies: + openapi3-ts: 4.6.1 + zod: 4.4.3 + + '@bare-ts/lib@0.3.0': {} + + '@bare-ts/tools@0.13.0(@bare-ts/lib@0.3.0)': + dependencies: + '@bare-ts/lib': 0.3.0 + commander: 10.0.0 + + '@biomejs/biome@2.3.11': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.11 + '@biomejs/cli-darwin-x64': 2.3.11 + '@biomejs/cli-linux-arm64': 2.3.11 + '@biomejs/cli-linux-arm64-musl': 2.3.11 + '@biomejs/cli-linux-x64': 2.3.11 + '@biomejs/cli-linux-x64-musl': 2.3.11 + '@biomejs/cli-win32-arm64': 2.3.11 + '@biomejs/cli-win32-x64': 2.3.11 + + '@biomejs/cli-darwin-arm64@2.3.11': + optional: true + + '@biomejs/cli-darwin-x64@2.3.11': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.11': + optional: true + + '@biomejs/cli-linux-arm64@2.3.11': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.11': + optional: true + + '@biomejs/cli-linux-x64@2.3.11': + optional: true + + '@biomejs/cli-win32-arm64@2.3.11': + optional: true + + '@biomejs/cli-win32-x64@2.3.11': + optional: true + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/zod-openapi@1.6.0(hono@4.13.3)(zod@4.4.3)': + dependencies: + '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) + '@hono/zod-validator': 0.9.0(hono@4.13.3)(zod@4.4.3) + hono: 4.13.3 + openapi3-ts: 4.6.1 + zod: 4.4.3 + + '@hono/zod-validator@0.9.0(hono@4.13.3)(zod@4.4.3)': + dependencies: + hono: 4.13.3 + zod: 4.4.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/cli@2.18.4': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@pinojs/redact@0.4.0': {} + + '@rivet-dev/agent-os-core@0.1.1': + dependencies: + '@rivet-dev/agent-os-posix': 0.1.0 + '@rivet-dev/agent-os-python': 0.1.0 + '@secure-exec/core': 0.2.1 + '@secure-exec/nodejs': 0.2.1 + '@secure-exec/v8': 0.2.1 + croner: 10.0.1 + long-timeout: 0.1.1 + secure-exec: 0.2.1 + transitivePeerDependencies: + - pyodide + + '@rivet-dev/agent-os-posix@0.1.0': + dependencies: + '@secure-exec/core': 0.2.1 + + '@rivet-dev/agent-os-python@0.1.0': + dependencies: + '@secure-exec/core': 0.2.1 + + '@rivetkit/bare-ts@0.6.2': {} + + '@rivetkit/engine-cli-darwin-arm64@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/engine-cli-darwin-arm64@2.3.7': + optional: true + + '@rivetkit/engine-cli-darwin-x64@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/engine-cli-darwin-x64@2.3.7': + optional: true + + '@rivetkit/engine-cli-linux-arm64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/engine-cli-linux-arm64-musl@2.3.7': + optional: true + + '@rivetkit/engine-cli-linux-x64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/engine-cli-linux-x64-musl@2.3.7': + optional: true + + '@rivetkit/engine-cli-win32-x64@2.3.7': + optional: true + + '@rivetkit/engine-cli@0.0.0-feat-workflows-public-host-apis.0ff6164': + optionalDependencies: + '@rivetkit/engine-cli-darwin-arm64': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/engine-cli-darwin-x64': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/engine-cli-linux-arm64-musl': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/engine-cli-linux-x64-musl': 0.0.0-feat-workflows-public-host-apis.0ff6164 + + '@rivetkit/engine-cli@2.3.7': + optionalDependencies: + '@rivetkit/engine-cli-darwin-arm64': 2.3.7 + '@rivetkit/engine-cli-darwin-x64': 2.3.7 + '@rivetkit/engine-cli-linux-arm64-musl': 2.3.7 + '@rivetkit/engine-cli-linux-x64-musl': 2.3.7 + '@rivetkit/engine-cli-win32-x64': 2.3.7 + + '@rivetkit/engine-envoy-protocol@0.0.0-feat-workflows-public-host-apis.0ff6164': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + + '@rivetkit/engine-envoy-protocol@2.3.7': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + + '@rivetkit/on-change@6.0.1': {} + + '@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/rivetkit-napi-darwin-arm64@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi-darwin-x64@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/rivetkit-napi-darwin-x64@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-feat-workflows-public-host-apis.0ff6164': + optional: true + + '@rivetkit/rivetkit-napi-linux-x64-musl@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi-win32-x64-msvc@2.3.7': + optional: true + + '@rivetkit/rivetkit-napi@0.0.0-feat-workflows-public-host-apis.0ff6164': + dependencies: + '@napi-rs/cli': 2.18.4 + '@rivetkit/engine-envoy-protocol': 0.0.0-feat-workflows-public-host-apis.0ff6164 + optionalDependencies: + '@rivetkit/rivetkit-napi-darwin-arm64': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/rivetkit-napi-darwin-x64': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/rivetkit-napi-linux-arm64-gnu': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/rivetkit-napi-linux-arm64-musl': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/rivetkit-napi-linux-x64-gnu': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/rivetkit-napi-linux-x64-musl': 0.0.0-feat-workflows-public-host-apis.0ff6164 + + '@rivetkit/rivetkit-napi@2.3.7': + dependencies: + '@napi-rs/cli': 2.18.4 + '@rivetkit/engine-envoy-protocol': 2.3.7 + optionalDependencies: + '@rivetkit/rivetkit-napi-darwin-arm64': 2.3.7 + '@rivetkit/rivetkit-napi-darwin-x64': 2.3.7 + '@rivetkit/rivetkit-napi-linux-arm64-gnu': 2.3.7 + '@rivetkit/rivetkit-napi-linux-arm64-musl': 2.3.7 + '@rivetkit/rivetkit-napi-linux-x64-gnu': 2.3.7 + '@rivetkit/rivetkit-napi-linux-x64-musl': 2.3.7 + '@rivetkit/rivetkit-napi-win32-x64-msvc': 2.3.7 + + '@rivetkit/rivetkit-wasm@0.0.0-feat-workflows-public-host-apis.0ff6164': {} + + '@rivetkit/rivetkit-wasm@2.3.7': {} + + '@rivetkit/traces@0.0.0-feat-workflows-public-host-apis.0ff6164': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + cbor-x: 1.6.5 + fdb-tuple: 1.0.0 + vbare: 0.0.4 + + '@rivetkit/traces@2.3.7': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + cbor-x: 1.6.5 + fdb-tuple: 1.0.0 + vbare: 0.0.4 + + '@rivetkit/virtual-websocket@0.0.0-feat-workflows-public-host-apis.0ff6164': {} + + '@rivetkit/virtual-websocket@2.3.7': {} + + '@rivetkit/workflow-engine@0.0.0-feat-workflows-public-host-apis.0ff6164': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + cbor-x: 1.6.5 + fdb-tuple: 1.0.0 + pino: 9.14.0 + vbare: 0.0.4 + + '@rivetkit/workflow-engine@2.3.7': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + cbor-x: 1.6.5 + fdb-tuple: 1.0.0 + pino: 9.14.0 + vbare: 0.0.4 + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@secure-exec/core@0.2.1': + dependencies: + better-sqlite3: 12.11.1 + + '@secure-exec/nodejs@0.2.1': + dependencies: + '@secure-exec/core': 0.2.1 + '@secure-exec/v8': 0.2.1 + cbor-x: 1.6.5 + cjs-module-lexer: 2.2.1 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + node-stdlib-browser: 1.3.1 + web-streams-polyfill: 4.3.0 + + '@secure-exec/v8-darwin-arm64@0.2.1': + optional: true + + '@secure-exec/v8-darwin-x64@0.2.1': + optional: true + + '@secure-exec/v8-linux-arm64-gnu@0.2.1': + optional: true + + '@secure-exec/v8-linux-x64-gnu@0.2.1': + optional: true + + '@secure-exec/v8@0.2.1': + dependencies: + cbor-x: 1.6.5 + optionalDependencies: + '@secure-exec/v8-darwin-arm64': 0.2.1 + '@secure-exec/v8-darwin-x64': 0.2.1 + '@secure-exec/v8-linux-arm64-gnu': 0.2.1 + '@secure-exec/v8-linux-x64-gnu': 0.2.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/retry@0.12.2': {} + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.18.0: {} + + any-promise@1.3.0: {} + + asn1.js@4.10.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + base64-js@1.5.1: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.5: {} + + bn.js@5.2.5: {} + + brorand@1.1.0: {} + + browser-resolve@2.0.0: + dependencies: + resolve: 1.22.12 + + browserify-aes@1.2.0: + dependencies: + buffer-xor: 1.0.3 + cipher-base: 1.0.7 + create-hash: 1.2.0 + evp_bytestokey: 1.0.3 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-cipher@1.0.1: + dependencies: + browserify-aes: 1.2.0 + browserify-des: 1.0.2 + evp_bytestokey: 1.0.3 + + browserify-des@1.0.2: + dependencies: + cipher-base: 1.0.7 + des.js: 1.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-rsa@4.1.1: + dependencies: + bn.js: 5.2.5 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + browserify-sign@4.2.6: + dependencies: + bn.js: 5.2.5 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + create-hmac: 1.1.7 + elliptic: 6.6.1 + inherits: 2.0.4 + parse-asn1: 5.1.9 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + + buffer-xor@1.0.3: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-status-codes@3.0.0: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.5: + optionalDependencies: + cbor-extract: 2.2.2 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@1.1.4: {} + + cipher-base@1.0.7: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + cjs-module-lexer@2.2.1: {} + + commander@10.0.0: {} + + commander@12.1.0: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + console-browserify@1.2.0: {} + + constants-browserify@1.0.0: {} + + core-util-is@1.0.3: {} + + create-ecdh@4.0.4: + dependencies: + bn.js: 4.12.5 + elliptic: 6.6.1 + + create-hash@1.2.0: + dependencies: + cipher-base: 1.0.7 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.3 + sha.js: 2.4.12 + + create-hmac@1.1.7: + dependencies: + cipher-base: 1.0.7 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + + create-require@1.1.1: {} + + croner@10.0.1: {} + + crypto-browserify@3.12.1: + dependencies: + browserify-cipher: 1.0.1 + browserify-sign: 4.2.6 + create-ecdh: 4.0.4 + create-hash: 1.2.0 + create-hmac: 1.1.7 + diffie-hellman: 5.0.3 + hash-base: 3.0.5 + inherits: 2.0.4 + pbkdf2: 3.1.6 + public-encrypt: 4.0.3 + randombytes: 2.1.0 + randomfill: 1.0.4 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + detect-libc@2.1.2: {} + + diffie-hellman@5.0.3: + dependencies: + bn.js: 4.12.5 + miller-rabin: 4.0.1 + randombytes: 2.1.0 + + domain-browser@4.22.0: {} + + drizzle-orm@0.44.7(better-sqlite3@12.11.1): + optionalDependencies: + better-sqlite3: 12.11.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.5 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + events@3.3.0: {} + + evp_bytestokey@1.0.3: + dependencies: + md5.js: 1.3.5 + safe-buffer: 5.2.1 + + expand-template@2.0.3: {} + + expect-type@1.4.0: {} + + fdb-tuple@1.0.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-uri-to-path@1.0.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.4 + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + github-from-package@0.0.0: {} + + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-base@3.0.5: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + + hash-base@3.1.2: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + + hono@4.13.3: {} + + https-browserify@1.0.0: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + is-network-error@1.3.2: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isomorphic-timers-promises@1.0.1: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + long-timeout@0.1.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + md5.js@1.3.5: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + miller-rabin@4.0.1: + dependencies: + bn.js: 4.12.5 + brorand: 1.1.0 + + mimic-response@3.1.0: {} + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-stdlib-browser@1.3.1: + dependencies: + assert: 2.1.0 + browser-resolve: 2.0.0 + browserify-zlib: 0.2.0 + buffer: 5.7.1 + console-browserify: 1.2.0 + constants-browserify: 1.0.0 + create-require: 1.1.1 + crypto-browserify: 3.12.1 + domain-browser: 4.22.0 + events: 3.3.0 + https-browserify: 1.0.0 + isomorphic-timers-promises: 1.0.1 + os-browserify: 0.3.0 + path-browserify: 1.0.1 + pkg-dir: 5.0.0 + process: 0.11.10 + punycode: 1.4.1 + querystring-es3: 0.2.1 + readable-stream: 3.6.2 + stream-browserify: 3.0.0 + stream-http: 3.2.0 + string_decoder: 1.3.0 + timers-browserify: 2.0.12 + tty-browserify: 0.0.1 + url: 0.11.4 + util: 0.12.5 + vm-browserify: 1.1.2 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openapi3-ts@4.6.1: + dependencies: + yaml: 2.9.0 + + os-browserify@0.3.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-retry@6.2.1: + dependencies: + '@types/retry': 0.12.2 + is-network-error: 1.3.2 + retry: 0.13.1 + + pako@1.0.11: {} + + parse-asn1@5.1.9: + dependencies: + asn1.js: 4.10.1 + browserify-aes: 1.2.0 + evp_bytestokey: 1.0.3 + pbkdf2: 3.1.6 + safe-buffer: 5.2.1 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pbkdf2@3.1.6: + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + to-buffer: 1.2.2 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.2.0 + + pirates@4.0.7: {} + + pkg-dir@5.0.0: + dependencies: + find-up: 5.0.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + possible-typed-array-names@1.1.0: {} + + postcss-load-config@6.0.1(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.26 + tsx: 4.23.12 + yaml: 2.9.0 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + process-nextick-args@2.0.1: {} + + process-warning@5.1.0: {} + + process@0.11.10: {} + + public-encrypt@4.0.3: + dependencies: + bn.js: 4.12.5 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + parse-asn1: 5.1.9 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@1.4.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + querystring-es3@0.2.1: {} + + quick-format-unescaped@4.0.4: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + randomfill@1.0.4: + dependencies: + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + real-require@0.2.0: {} + + resolve-from@5.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retry@0.13.1: {} + + ripemd160@2.0.3: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + + rivetkit@0.0.0-feat-workflows-public-host-apis.0ff6164(better-sqlite3@12.11.1): + dependencies: + '@hono/zod-openapi': 1.6.0(hono@4.13.3)(zod@4.4.3) + '@rivet-dev/agent-os-core': 0.1.1 + '@rivetkit/bare-ts': 0.6.2 + '@rivetkit/engine-cli': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/engine-envoy-protocol': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/on-change': 6.0.1 + '@rivetkit/rivetkit-napi': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/rivetkit-wasm': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/traces': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/virtual-websocket': 0.0.0-feat-workflows-public-host-apis.0ff6164 + '@rivetkit/workflow-engine': 0.0.0-feat-workflows-public-host-apis.0ff6164 + cbor-x: 1.6.5 + drizzle-orm: 0.44.7(better-sqlite3@12.11.1) + hono: 4.13.3 + invariant: 2.2.4 + p-retry: 6.2.1 + pino: 9.14.0 + uuid: 12.0.1 + vbare: 0.0.4 + zod: 4.4.3 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - pyodide + - sql.js + - sqlite3 + + rivetkit@2.3.7(better-sqlite3@12.11.1): + dependencies: + '@hono/zod-openapi': 1.6.0(hono@4.13.3)(zod@4.4.3) + '@rivet-dev/agent-os-core': 0.1.1 + '@rivetkit/bare-ts': 0.6.2 + '@rivetkit/engine-cli': 2.3.7 + '@rivetkit/engine-envoy-protocol': 2.3.7 + '@rivetkit/on-change': 6.0.1 + '@rivetkit/rivetkit-napi': 2.3.7 + '@rivetkit/rivetkit-wasm': 2.3.7 + '@rivetkit/traces': 2.3.7 + '@rivetkit/virtual-websocket': 2.3.7 + '@rivetkit/workflow-engine': 2.3.7 + cbor-x: 1.6.5 + drizzle-orm: 0.44.7(better-sqlite3@12.11.1) + hono: 4.13.3 + invariant: 2.2.4 + p-retry: 6.2.1 + pino: 9.14.0 + uuid: 12.0.1 + vbare: 0.0.4 + zod: 4.4.3 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - pyodide + - sql.js + - sqlite3 + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + secure-exec@0.2.1: + dependencies: + '@secure-exec/core': 0.2.1 + '@secure-exec/nodejs': 0.2.1 + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setimmediate@1.0.5: {} + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + stream-http@3.2.0: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + xtend: 4.0.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@3.2.0: + dependencies: + real-require: 0.2.0 + + timers-browserify@2.0.12: + dependencies: + setimmediate: 1.0.5 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.26)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.26 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tty-browserify@0.0.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + url@0.11.4: + dependencies: + punycode: 1.4.1 + qs: 6.15.3 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.22 + + uuid@12.0.1: {} + + vbare@0.0.4: {} + + vite-node@3.2.4(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + tsx: 4.23.12 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vm-browserify@1.1.2: {} + + web-streams-polyfill@4.3.0: {} + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + xtend@4.0.2: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7dd1213 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - packages/* + +onlyBuiltDependencies: + - '@biomejs/biome' + - cbor-extract + - esbuild diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts new file mode 100644 index 0000000..3a2309c --- /dev/null +++ b/scripts/check-boundaries.ts @@ -0,0 +1,46 @@ +import { readdir, readFile } from "node:fs/promises"; +import { extname, join, relative } from "node:path"; + +const root = new URL("../packages/workflows", import.meta.url).pathname; +const forbidden = [ + "rivetkit/src", + "@/", + "ACTOR_CONTEXT_INTERNAL_SYMBOL", + "RUN_FUNCTION_CONFIG_SYMBOL", + "AnyStaticActorInstance", + "_rivet_wf_kv", + "makeWorkflowKey", + "workflowStoragePrefix", + "@rivetkit/workflow-engine", +]; + +async function files(path: string): Promise { + const output: string[] = []; + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) { + output.push(...(await files(child))); + } else if ([".ts", ".tsx", ".js", ".mjs"].includes(extname(child))) { + output.push(child); + } + } + return output; +} + +const violations: string[] = []; +for (const file of await files(join(root, "src"))) { + const source = await readFile(file, "utf8"); + for (const token of forbidden) { + if (source.includes(token)) { + violations.push( + `${relative(root, file)}: forbidden token ${JSON.stringify(token)}`, + ); + } + } +} + +if (violations.length > 0) { + throw new Error( + `Private RivetKit boundary violations:\n${violations.join("\n")}`, + ); +} diff --git a/scripts/resolve-release.mjs b/scripts/resolve-release.mjs new file mode 100644 index 0000000..ab781b3 --- /dev/null +++ b/scripts/resolve-release.mjs @@ -0,0 +1,42 @@ +import { appendFile } from "node:fs/promises"; + +const args = Object.fromEntries( + process.argv.slice(2).map((argument) => { + const [key, ...value] = argument.replace(/^--/, "").split("="); + return [key, value.join("=")]; + }), +); + +const version = args.version; +if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`invalid release version: ${version ?? ""}`); +} + +const sanitize = (value) => + value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, "") + .slice(0, 64); + +let tag = args.tag ?? "auto"; +if (tag === "auto") { + const prerelease = version.split("-")[1]?.split(".")[0]; + tag = prerelease === "rc" || prerelease === "next" ? prerelease : "latest"; +} else if (tag === "preview") { + tag = `preview-${sanitize(args.branch ?? "branch")}`; +} +if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(tag)) { + throw new Error(`invalid npm dist-tag: ${tag}`); +} + +const output = [ + `version=${version}`, + `npm_tag=${tag}`, + `real_release=${!tag.startsWith("preview-")}`, +]; +if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `${output.join("\n")}\n`); +} else { + console.log(output.join("\n")); +} diff --git a/scripts/verify-pack.ts b/scripts/verify-pack.ts new file mode 100644 index 0000000..cdca863 --- /dev/null +++ b/scripts/verify-pack.ts @@ -0,0 +1,167 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { "skip-install": { type: "boolean" } }, +}); +const root = new URL("..", import.meta.url).pathname; +const packageDir = join(root, "packages/workflows"); +const temp = await mkdtemp(join(tmpdir(), "rivet-workflows-pack-")); + +try { + const tarballName = execFileSync( + "npm", + ["pack", "--silent", "--pack-destination", temp], + { + cwd: packageDir, + encoding: "utf8", + }, + ).trim(); + const tarball = join(temp, tarballName); + const manifest = JSON.parse( + await readFile(join(packageDir, "package.json"), "utf8"), + ); + const rivetkitSpecifier = manifest.devDependencies?.rivetkit; + if (typeof rivetkitSpecifier !== "string") { + throw new Error("package must declare a RivetKit development dependency"); + } + const packedFiles = JSON.parse( + execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: packageDir, + encoding: "utf8", + }), + )[0].files.map((entry: { path: string }) => entry.path); + + for (const required of [ + "dist/tsup/index.js", + "dist/tsup/index.cjs", + "dist/tsup/index.d.ts", + "dist/tsup/index.d.cts", + "dist/tsup/index.js.map", + "dist/tsup/index.cjs.map", + "dist/tsup/testing.js", + "dist/tsup/testing.cjs", + "dist/tsup/testing.d.ts", + "dist/tsup/testing.d.cts", + "dist/tsup/testing.js.map", + "dist/tsup/testing.cjs.map", + "schemas/v1.bare", + ]) { + if (!packedFiles.includes(required)) + throw new Error(`packed tarball is missing ${required}`); + } + const serialized = JSON.stringify(manifest); + if (serialized.includes("workspace:") || serialized.includes("catalog:")) { + throw new Error( + "packed manifest contains an unresolved workspace/catalog specifier", + ); + } + if (manifest.peerDependencies?.rivetkit !== ">=2.4.0 <3") { + throw new Error("packed manifest has the wrong RivetKit peer range"); + } + if (manifest.dependencies?.rivetkit) { + throw new Error("RivetKit must remain a peer dependency"); + } + + const declarations = ["dist/tsup/index.d.ts", "dist/tsup/index.d.cts"]; + for (const declaration of declarations) { + const source = await readFile(join(packageDir, declaration), "utf8"); + for (const token of [ + "rivetkit/src", + "ACTOR_CONTEXT_INTERNAL_SYMBOL", + "RUN_FUNCTION_CONFIG_SYMBOL", + "AnyStaticActorInstance", + "@rivetkit/workflow-engine", + ]) { + if (source.includes(token)) { + throw new Error(`${declaration} exposes private token ${token}`); + } + } + } + + if (!values["skip-install"]) { + const fixture = join(temp, "fixture"); + await mkdir(fixture); + execFileSync("npm", ["init", "-y"], { cwd: fixture, stdio: "ignore" }); + const installArgs = ["install", "--ignore-scripts"]; + if (rivetkitSpecifier.startsWith("0.0.0-")) { + installArgs.push("--legacy-peer-deps"); + } + installArgs.push(tarball, `rivetkit@${rivetkitSpecifier}`); + execFileSync("npm", installArgs, { + cwd: fixture, + stdio: "inherit", + }); + execFileSync( + "node", + [ + "--input-type=module", + "-e", + "await import('@rivet-dev/workflows'); await import('@rivet-dev/workflows/testing')", + ], + { + cwd: fixture, + stdio: "inherit", + }, + ); + execFileSync( + "node", + [ + "-e", + "require('@rivet-dev/workflows'); require('@rivet-dev/workflows/testing')", + ], + { + cwd: fixture, + stdio: "inherit", + }, + ); + await writeFile( + join(fixture, "smoke.ts"), + [ + 'import { actor } from "rivetkit";', + 'import { workflow } from "@rivet-dev/workflows";', + 'import { InMemoryDriver } from "@rivet-dev/workflows/testing";', + "const definition = actor({", + " run: workflow(async (ctx) => {", + ' await ctx.step("typed", async (step) => {', + ' step.log.info("compiled");', + " return 1;", + " });", + " }),", + "});", + "void definition; void new InMemoryDriver();", + ].join("\n"), + ); + await writeFile( + join(fixture, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + skipLibCheck: true, + strict: true, + target: "ES2022", + }, + include: ["smoke.ts"], + }), + ); + execFileSync( + "pnpm", + [ + "--filter", + "@rivet-dev/workflows", + "exec", + "tsc", + "--project", + join(fixture, "tsconfig.json"), + ], + { cwd: root, stdio: "inherit" }, + ); + } +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..d2613f2 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "stripInternal": true, + "moduleResolution": "bundler", + "downlevelIteration": true, + "lib": ["ESNext"], + "types": ["node"] + } +} +