diff --git a/.changeset/move-ui-library-in-repo.md b/.changeset/move-ui-library-in-repo.md
deleted file mode 100644
index b8dd6b002..000000000
--- a/.changeset/move-ui-library-in-repo.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-'@workflowbuilder/sdk': minor
----
-
-Consume the UI component library from the in-repo `@workflowbuilder/ui` (Base UI) instead of the published `@synergycodes/overflow-ui`.
-
-The SDK previously bundled `@synergycodes/overflow-ui@1.0.0-beta.27` (built on MUI / Mantine / Emotion / Floating UI). It now bundles the in-repo `@workflowbuilder/ui@2.0.0`, rebuilt on [Base UI](https://base-ui.com/). `@base-ui/react` is now a regular dependency of the SDK (installed automatically, not bundled) rather than an inlined implementation detail. Bundled component visuals and interaction details change accordingly; the SDK's exported symbols are unchanged, but public types deriving from the UI library (`InputControlProps`, `TextAreaControlProps`) now build on `@workflowbuilder/ui` type shapes (picked keys unchanged), and the internal DOM structure and class names of all bundled UI changed (MUI Base + Mantine → Base UI) — styles or tests written against those internal class names may need updating. Modal open/close now runs its enter and exit fade transitions (previously the dialog appeared and disappeared instantly).
diff --git a/.changeset/sdk-date-picker-trigger-height.md b/.changeset/sdk-date-picker-trigger-height.md
deleted file mode 100644
index 8d95131a5..000000000
--- a/.changeset/sdk-date-picker-trigger-height.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/sdk': patch
----
-
-Date and datetime variable inputs regained their intended `2.5rem` trigger height and left-aligned text; the style override now matches the new DatePicker markup.
diff --git a/.changeset/sdk-is-start-node-flag.md b/.changeset/sdk-is-start-node-flag.md
deleted file mode 100644
index 5ce8e71f4..000000000
--- a/.changeset/sdk-is-start-node-flag.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/sdk': minor
----
-
-Node data gains an `isStartNode?: boolean` flag marking the workflow's entry point. Declare it on the palette item (`NodeDefinition`) and the editor copies it into the node's `data` when the node is dropped, so execution integrations can read `data.isStartNode` instead of matching the node's xyflow `type` against `'start-node'`. `templateType` keeps selecting the visual template only.
diff --git a/.changeset/sdk-single-top-layer.md b/.changeset/sdk-single-top-layer.md
deleted file mode 100644
index 21f883139..000000000
--- a/.changeset/sdk-single-top-layer.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/sdk': minor
----
-
-The SDK stylesheet now declares a single top-level cascade layer: XYFlow's stylesheet and the SDK resets moved from the `ext-lib` / `reset` layers into `ui.base`, and the file opens with the same `@layer ui.base, ui.component;` statement as every `@workflowbuilder/ui` stylesheet. Component styling no longer depends on stylesheet load order. If you targeted the removed `reset` / `ext-lib` layer names, plain unlayered CSS wins over all library layers.
diff --git a/.changeset/sdk-ssr-modal-portal.md b/.changeset/sdk-ssr-modal-portal.md
deleted file mode 100644
index 1dd301c26..000000000
--- a/.changeset/sdk-ssr-modal-portal.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/sdk': patch
----
-
-`ModalProvider` no longer touches `document` during server-side rendering; the modal portal mounts after hydration. Fixes `ReferenceError: document is not defined` when the editor renders in SSR frameworks such as Next.js.
diff --git a/.changeset/ui-base-ui-1-7.md b/.changeset/ui-base-ui-1-7.md
deleted file mode 100644
index 9f26fb365..000000000
--- a/.changeset/ui-base-ui-1-7.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/ui': minor
----
-
-`@base-ui/react` dependency moved from `1.4.1` to `1.7.0` (still exact-pinned). Overlay transitions (Modal, Menu, Select, Tooltip, DatePicker) were re-validated on the 1.7 line.
diff --git a/.changeset/ui-export-prop-types.md b/.changeset/ui-export-prop-types.md
deleted file mode 100644
index 383d256fe..000000000
--- a/.changeset/ui-export-prop-types.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/ui': minor
----
-
-Component prop types are now exported: `AvatarProps`, `CheckboxProps`, `RadioProps`, `StatusProps`, `TooltipProps`, `MenuProps`, `ModalProps`, `EdgeLabelProps`, `NodeIconProps`, `NodeDescriptionProps`, `NodeAsPortWrapperProps`, `SegmentPickerProps` (with its controlled/uncontrolled variants), the NavButton variant prop types, and `DatePickerProps` now covers the component's full runtime surface (`value`, `defaultValue`, `placeholder`, `valueFormat`, `type`, `error`). Supporting types used in those signatures (`Shape`, `IconNode`) are exported as well.
diff --git a/.changeset/ui-export-use-edge-style-params.md b/.changeset/ui-export-use-edge-style-params.md
deleted file mode 100644
index 31e85cb50..000000000
--- a/.changeset/ui-export-use-edge-style-params.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/ui': minor
----
-
-`UseEdgeStyleParams`, the parameter type of the `useEdgeStyle` hook, is now exported.
diff --git a/.changeset/ui-layer-root-defaults.md b/.changeset/ui-layer-root-defaults.md
deleted file mode 100644
index e51b1f16b..000000000
--- a/.changeset/ui-layer-root-defaults.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/ui': minor
----
-
-Variable defaults (`--ax-public-*` component defaults and the `--ax-*` design tokens in `tokens.css`) now ship inside the `ui.base` cascade layer. A plain `:root { --ax-…: … }` override in your app now wins regardless of stylesheet load order; previously a lazily loaded component stylesheet could silently restore the default.
diff --git a/.changeset/ui-react-18-peer.md b/.changeset/ui-react-18-peer.md
deleted file mode 100644
index 908ca2778..000000000
--- a/.changeset/ui-react-18-peer.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflowbuilder/ui': minor
----
-
-React 18 is accepted again: the `react` / `react-dom` peer ranges widened from `^19.0.0` to `^18.0.0 || ^19.0.0`, matching Base UI's own support range.
diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml
index 9adba0961..6e0c02272 100644
--- a/.github/workflows/deploy-ai-studio.yml
+++ b/.github/workflows/deploy-ai-studio.yml
@@ -90,35 +90,43 @@ jobs:
# The VM runs the repo's compose files, shipped here on every deploy (base64,
# so the script stays free of quoting). Compose is run from the project
# directory, not with -f: that is what applies docker-compose.override.yml
- # by default and honours COMPOSE_FILE from the VM's .env.
+ # by default.
#
- # The retired-key check runs before anything is written, so a refused deploy
- # leaves the VM exactly as it was. It lives here rather than in the compose
- # file because Compose 2.21 and older evaluate a nested `${A:+${B:?}}` guard
- # eagerly and fail on every command, key set or not.
- #
- # The image tags are written into that .env rather than exported: an export
- # dies with this shell, and the next `docker compose up -d worker` on the VM
- # would fall back to the local ai-studio-* names. Only the two image lines
- # are replaced; the rest of .env is the VM's own and stays untouched.
+ # .env is generated in full from the repo secrets/vars on every deploy,
+ # so nothing on the VM is edited by hand. It holds the image tags too: an
+ # export dies with this shell, and the next `docker compose up -d worker`
+ # on the VM would fall back to the local ai-studio-* names.
- name: Refresh docker compose on Azure VM
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.APP }}:${{ needs.build-and-push.outputs.image_tag }}
+ # repo-level secrets for credentials, vars for the rest — no `environment:`,
+ # which would change the OIDC subject the Azure federated credential trusts
+ AI_API_KEY: ${{ secrets.AI_API_KEY }}
+ TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
+ APP_DB_PASSWORD: ${{ secrets.APP_DB_PASSWORD }}
+ TEMPORAL_DB_PASSWORD: ${{ secrets.TEMPORAL_DB_PASSWORD }}
+ AI_BASE_URL: ${{ vars.AI_BASE_URL }}
+ AI_MODEL: ${{ vars.AI_MODEL }}
+ RATE_LIMIT_EXECUTE_PER_MINUTE: ${{ vars.RATE_LIMIT_EXECUTE_PER_MINUTE || '10' }}
+ RATE_LIMIT_EXECUTE_PER_DAY: ${{ vars.RATE_LIMIT_EXECUTE_PER_DAY || '50' }}
run: |
+ # the databases keep the password they were created with — an empty one
+ # would fall back to the compose default and lock the apps out
+ : "${APP_DB_PASSWORD:?set secret APP_DB_PASSWORD}" "${TEMPORAL_DB_PASSWORD:?set secret TEMPORAL_DB_PASSWORD}"
+ # .env is generated in full on every deploy; single quotes keep values literal
+ ENV_B64=$(for k in AI_API_KEY AI_BASE_URL AI_MODEL TAVILY_API_KEY \
+ RATE_LIMIT_EXECUTE_PER_MINUTE RATE_LIMIT_EXECUTE_PER_DAY \
+ APP_DB_PASSWORD TEMPORAL_DB_PASSWORD; do
+ printf "%s='%s'\n" "$k" "${!k}"
+ done | cat - <(printf "RUNTIME_IMAGE='%s'\nWEB_IMAGE='%s'\n" "$IMAGE-runtime" "$IMAGE-web") | base64 -w0)
COMPOSE_B64=$(base64 -w0 deploy/ai-studio/docker-compose.yml)
OVERRIDE_B64=$(base64 -w0 deploy/ai-studio/docker-compose.override.yml)
SCRIPT=$(cat < docker-compose.yml
echo "$OVERRIDE_B64" | base64 -d > docker-compose.override.yml
- touch .env
- { grep -vE '^(RUNTIME_IMAGE|WEB_IMAGE)=' .env || true; printf 'RUNTIME_IMAGE=%s\nWEB_IMAGE=%s\n' "$IMAGE-runtime" "$IMAGE-web"; } > .env.tmp
- chmod --reference=.env .env.tmp && chown --reference=.env .env.tmp && mv .env.tmp .env
+ (umask 077; echo "$ENV_B64" | base64 -d > .env)
az acr login --name synergycodes
docker compose pull
docker compose up -d --no-build --force-recreate --remove-orphans
diff --git a/.github/workflows/pr-check-docs.yml b/.github/workflows/pr-check-docs.yml
index 84d61443d..3bb1fa73a 100644
--- a/.github/workflows/pr-check-docs.yml
+++ b/.github/workflows/pr-check-docs.yml
@@ -8,11 +8,13 @@ name: PR Check (docs)
# Typecheck is deliberately absent: apps/docs tolerates known starlight
# virtual-module type errors.
+# PRs into main and release get checks. Add an integration branch here for
+# the time it is the base of stacked PRs.
on:
pull_request:
branches:
- main
- - release # merging into release is what deploys the docs site
+ - release # the docs site is deployed by hand from release, so its PRs get the same checks
paths:
- 'apps/docs/**'
- 'packages/ui/**'
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 9ff75f7d5..122f0513a 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -8,14 +8,19 @@ name: PR Check
# determinism tests guard Temporal replay safety and so must not be able to
# regress silently. Plus the deploy compose files, which ship to the demo VM on
# every deploy, and global format consistency. apps/docs has its own path-filtered workflow
-# (pr-check-docs.yml); demo and ai-studio are not checked here — they're
-# internal and have their own broken-state tolerances.
+# (pr-check-docs.yml); demo and ai-studio are not built or type-checked here — they're
+# internal and have their own broken-state tolerances — but their CSS goes
+# through the style lint below like every other workspace's.
+# PRs into main and release get checks. Add an integration branch here for
+# the time it is the base of stacked PRs.
on:
pull_request:
branches:
- main
- release # the release PR is the last stop before a tag, so it gets the same checks
+ # Integration branch for the human-in-the-loop work: feature PRs land there first.
+ - feat/human-in-the-loop
permissions:
contents: read
@@ -262,6 +267,9 @@ jobs:
# the check:built-css guard.
run: pnpm build:ui
+ - name: Style lint (token usage + fallbacks)
+ run: pnpm lint:styles
+
execution:
name: Execution pipeline lint + typecheck + test
runs-on: ubuntu-latest
diff --git a/.github/workflows/release-temporal.yml b/.github/workflows/release-temporal.yml
index b6ae32da5..ade4c7223 100644
--- a/.github/workflows/release-temporal.yml
+++ b/.github/workflows/release-temporal.yml
@@ -5,12 +5,9 @@ name: Release Temporal
# after merging the version-bump PR (which ran `pnpm release:version `). See
# packages/RELEASE.md (the release flow is shared between all published packages).
#
-# One-time npm setup: @workflowbuilder/temporal needs its own GitHub Actions trusted
-# publisher registered on npmjs.com pointing at THIS workflow file
-# (.github/workflows/release-temporal.yml). npm only offers that on a package that
-# already exists, so the first version is published by hand and this workflow then
-# only creates the GitHub Release for it: packages/RELEASE.md § "First release
-# of a new package".
+# @workflowbuilder/temporal 0.1.0 is on npm; from 0.2.0 this workflow publishes via OIDC.
+# Its npm Trusted Publisher must point at .github/workflows/release-temporal.yml.
+# See packages/RELEASE.md for the shared release procedure.
on:
push:
diff --git a/.gitignore b/.gitignore
index f0626771d..754c030cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,7 +72,9 @@ CLAUDE.local.md
# generation (see astro.config.mjs).
apps/docs/src/content/docs/api/
-# UI Library props + CSS-variable data, generated from @workflowbuilder/ui by
-# apps/docs/scripts/generate-ui-api.mjs (TypeDoc + CSS extraction) on every
-# docs build / dev. Source of truth is the library, so keep it out of git.
+# UI API Reference, emitted the same way from the types in apps/docs/src/generated/ui-types.ts.
+apps/docs/src/content/docs/ui-api/
+
+# Generated from @workflowbuilder/ui by apps/docs/scripts/generate-ui-api.mjs
+# on every docs build / dev. Source of truth is the library, so keep it out of git.
apps/docs/src/generated/
diff --git a/.prettierignore b/.prettierignore
index ebef0901d..ab6d57a23 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,6 +5,8 @@
apps/icons/src/utils/icons.gen.ts
# Astro auto-generated content collections + types (gitignored, regenerated on build/dev)
**/.astro/
+# Designer changelog checked in verbatim — reformatting corrupted token paths in emphasis markers
+packages/tokens/migration/
# Recorded Temporal Event Histories. Machine-written, and deliberately kept byte-identical
# to what `temporal workflow show --output json` emits so the two stay interchangeable.
packages/temporal/test/replay/histories/
diff --git a/.stylelintrc.mjs b/.stylelintrc.mjs
new file mode 100644
index 000000000..52908d696
--- /dev/null
+++ b/.stylelintrc.mjs
@@ -0,0 +1,13 @@
+// The csstools rule import()s an importFrom path as given, which Windows rejects
+// (C:\ reads as a URL scheme); an object source never reaches that code path.
+import customProperties from './tools/stylelint/custom-properties.mjs';
+
+/** @type {import('stylelint').Config} */
+export default {
+ plugins: ['stylelint-value-no-unknown-custom-properties', './tools/stylelint/no-system-token-fallbacks.mjs'],
+ ignoreFiles: ['**/node_modules/**', '**/dist/**', 'apps/docs/**'],
+ rules: {
+ 'csstools/value-no-unknown-custom-properties': [true, { importFrom: [customProperties] }],
+ 'wb/no-system-token-fallbacks': true,
+ },
+};
diff --git a/CLAUDE.md b/CLAUDE.md
index ae2f7ded3..585eebf68 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,7 @@ Each workspace has its own context. Read the relevant file before extending a wo
| Workspace | Authoritative docs |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `packages/sdk` | `packages/sdk/README.md` |
-| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) |
+| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`, `packages/ui/built-css-pitfalls.md`) |
| `packages/tokens` | `packages/tokens/README.md` |
| `packages/ai-config` | `packages/ai-config/README.md` |
| `packages/execution-core` | `packages/execution-core/README.md` |
@@ -141,7 +141,7 @@ This repo is public, but ticket IDs (`WB-123`) point to a private ClickUp — fo
- Write the comment self-sufficiently: state the limitation and the direction of the fix in plain words.
- End it with a stable kebab-case slug naming the work: `(follow-up: temporal-payload-codec)`.
- Add a matching `Code marker: ` line to the ClickUp task's description, so picking the task up later starts with `grep -r "follow-up: "` — grep survives file moves.
-- Ticket IDs belong in commit messages and PR descriptions, where `git blame` leads to full context.
+- Ticket IDs stay out of branch names, commit messages, PR titles, and PR descriptions too: they are public, and the IDs lead nowhere for external readers. Describe the change in plain words instead.
## Getting Started
@@ -174,13 +174,13 @@ If you're new to this repo and want to build your own consumer app or POC, follo
### Releasing the `@workflowbuilder/*` packages
-Three workspaces publish to npm: `@workflowbuilder/sdk` (on npm), `@workflowbuilder/ui` (the component library, built on Base UI) and `@workflowbuilder/temporal` (the Temporal Plugin). The last two are publishable but not on npm yet. Their first version is published by hand, because npm cannot register a trusted publisher for a package that does not exist; see [`packages/RELEASE.md`](packages/RELEASE.md) § "First release of a new package". Everything else under `apps/` and `packages/` is `private: true`, and Changesets skips it through `privatePackages` in `.changeset/config.json`. There is no `ignore` list on purpose: Changesets refuses the CLI `--ignore` flag while one exists, and `pnpm release:version` depends on that flag. Each package publishes via its own scoped release tag (`@workflowbuilder/sdk@X.Y.Z`, `@workflowbuilder/ui@X.Y.Z`, `@workflowbuilder/temporal@X.Y.Z`) and its own workflow (`release-sdk.yml`, `release-ui.yml`, `release-temporal.yml`), and each is released on its own: `pnpm release:version ` consumes only that package's changesets, `pnpm release:tag ` pushes only that package's tag. Never run bare `pnpm changeset version` or `pnpm changeset tag`. See `packages/RELEASE.md`.
+Three workspaces publish to npm: `@workflowbuilder/sdk` (on npm), `@workflowbuilder/ui` (the component library, built on Base UI) and `@workflowbuilder/temporal` (the Temporal Plugin, on npm). Only `@workflowbuilder/ui` is publishable but not on npm yet. Its first version is published by hand, because npm cannot register a trusted publisher for a package that does not exist; see [`packages/RELEASE.md`](packages/RELEASE.md) § "First release of a new package". Everything else under `apps/` and `packages/` is `private: true`, and Changesets skips it through `privatePackages` in `.changeset/config.json`. There is no `ignore` list on purpose: Changesets refuses the CLI `--ignore` flag while one exists, and `pnpm release:version` depends on that flag. Each package publishes via its own scoped release tag (`@workflowbuilder/sdk@X.Y.Z`, `@workflowbuilder/ui@X.Y.Z`, `@workflowbuilder/temporal@X.Y.Z`) and its own workflow (`release-sdk.yml`, `release-ui.yml`, `release-temporal.yml`), and each is released on its own: `pnpm release:version ` consumes only that package's changesets, `pnpm release:tag ` pushes only that package's tag. Never run bare `pnpm changeset version` or `pnpm changeset tag`. See `packages/RELEASE.md`.
**`@workflowbuilder/temporal` declares every `@temporalio/*` package its `dist` imports as a peer and lists none of those in its own `devDependencies`.** pnpm installs a missing peer as an ordinary dependency of the package, and that survives the `--prod` install in `deploy/ai-studio/Dockerfile`. A peer that is also a devDependency counts as satisfied, `--prod` then removes it, and the production image fails at import time while every local install works and pnpm prints no warning. The packages the tests alone use (`@temporalio/common`, `@temporalio/testing`, `@temporalio/worker`) belong in its devDependencies. `@temporalio/worker` is also an optional peer, because a consumer that runs a Worker supplies it, but nothing in `dist` imports it, so `--prod` dropping it costs nothing.
**Changesets for bundled execution packages.** `@workflow-builder/execution-core` and `@workflow-builder/types` are private and source-only, and `@workflowbuilder/temporal` bundles both into its `dist` (they reach it through `packages/temporal/src/core-contract.ts`, the one file allowed to import them by relative path). A change in either that alters execution behaviour or the published types therefore needs a changeset for `@workflowbuilder/temporal` - that release is how it reaches consumers. A pure refactor needs none. `pr-check.yml` warns when those paths change without one.
-**Changesets for `@workflowbuilder/temporal` follow the SDK rules.** One changeset (`.changeset/temporal-plugin-package.md`) is queued to seed the first release notes; the release PR that consumes it rewrites the generated section to describe the package as it ships. `pr-check.yml` warns when `packages/execution-core` or `packages/types` change without a changeset for it.
+**Changesets for `@workflowbuilder/temporal` follow the SDK rules.** `pr-check.yml` warns when `packages/execution-core` or `packages/types` change without a changeset for it.
**Commit format is enforced.** Every commit goes through `commitlint` via the `commit-msg` husky hook — Conventional Commits format only (`(): `, types from `feat / fix / perf / refactor / docs / test / chore / build / ci / style / revert`). Bad messages are rejected before they land in git history.
diff --git a/DECISION-LOGS.md b/DECISION-LOGS.md
index c2df150c7..0a7acbd25 100644
--- a/DECISION-LOGS.md
+++ b/DECISION-LOGS.md
@@ -6,14 +6,24 @@
- _08.04.2025_: [Lazy-loaded Icons](./apps/icons/lazy-loaded-icons-08-04-2025.decision-log.md)
- _15.04.2025_: [Internationalization implementation with i18next](./packages/sdk/src/features/i18n/i18next.decision-log.md)
- _26.05.2025_: [JSON Form Validation Strategy](./packages/sdk/src/features/json-form/form-validation.decision-log.md)
+- _05.03.2026_: [Independent docs deployment strategy](./apps/docs/docs-deployment.decision-log.md)
+- _13.03.2026_: [Remark plugin for automatic base path link rewriting](./apps/docs/remark-base-path-links.decision-log.md)
- _16.04.2026_: [CSP-safe Ajv replacement with @cfworker/json-schema](./packages/sdk/src/utils/validation/workflow-builder-validator-16-04-2026.decision-log.md)
- _22.04.2026_: [SDK restructuring — inversion, relocation, plugin API, config naming](./packages/sdk/sdk-restructuring.decision-log.md)
- _27.04.2026_: [Default to 127.0.0.1 binding for the reference backend](./apps/backend/local-dev-binding.decision-log.md)
- _27.04.2026_: [Workflow cancellation handling in Temporal engine](./packages/temporal/src/workflow/cancellation-handling.decision-log.md)
- _28.04.2026_: [Topological scheduling for the graph runner](./packages/execution-core/topological-scheduling.decision-log.md)
- _29.04.2026_: [Decision executor fails fast on no matching branch](./packages/execution-core/decision-no-match.decision-log.md)
+- _30.04.2026 (revised 04.05.2026 after team review)_: [Audience-based docs IA + schema authoring reference](./apps/docs/docs-restructure.decision-log.md)
+- _30.04.2026_: [TypeDoc-driven API Reference for `@workflowbuilder/sdk`](./apps/docs/typedoc-api-reference.decision-log.md)
- _05.05.2026_: [Extract AI Studio from `apps/demo` into its own `apps/ai-studio` app](./apps/ai-studio/ai-studio-extraction.decision-log.md)
- _05.05.2026_: [Workspace layout — relocate libraries to `packages/`](./packages/sdk/workspace-layout.decision-log.md)
- _06.05.2026_: [Make execution-core generic over the consumer's node union](./packages/execution-core/generic-execution-core.decision-log.md)
- _15.05.2026_: [AuthPort seam for backend authn/authz](./apps/backend/auth-port.decision-log.md)
+- _03.06.2026_: [TenantContextPort — multi-tenant identity seam for the reference backend](./apps/backend/tenant-context-port.decision-log.md)
+- _07.08.2026_: [Keep the postcss box-sizing plugin over lint-based or selector-based alternatives](./packages/ui/postcss-box-sizing.decision-log.md)
- _24.08.2026_: [`incomplete` as a third terminal state, distinct from `failed` and from a stall](./packages/execution-core/terminal-states.decision-log.md)
+- _31.08.2026_: [Ship common font faces inline and the rest as assets](./packages/ui/font-assets.decision-log.md)
+- _07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome)_: [Decision request as versioned data on a node](./apps/backend/decision-request.decision-log.md)
+- _07.09.2026 (revised 14.09.2026, 15.09.2026 and 16.09.2026)_: [Derive the ConnectableItem width from the real container insets](./packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item-width.decision-log.md)
+- _08.09.2026 (pause), 21.09.2026 (outcome)_: [Durable pause, the Temporal side of the human-in-the-loop seam](./packages/temporal/src/workflow/durable-pause.decision-log.md)
diff --git a/README.md b/README.md
index 5303c445f..ce10126b0 100644
--- a/README.md
+++ b/README.md
@@ -26,13 +26,9 @@ Used in production by teams including [Vercom](https://www.workflowbuilder.io/ca
-> 🎉 **Workflow Builder 2.0 is here.**
+> **Since 2.0, this repository is the home of Workflow Builder.** Previously we worked in a private monorepo and only partially mirrored changes here. Now every commit lands here directly.
>
-> A best-in-class SDK for embedding workflow editors, now paired with a dedicated reference backend and a fully modular plugin surface. Building products on top of a workflow editor has never been easier.
->
-> Starting with 2.0, this repository is the home of Workflow Builder. Previously we worked in a private monorepo and only partially mirrored changes here. From now on, every commit lands here directly.
->
-> See the [CHANGELOG](./CHANGELOG.md) for everything that's changed since the last release.
+> See the [SDK changelog](./packages/sdk/CHANGELOG.md) for released changes and the [3.0 upgrade guide](./apps/docs/src/content/docs/get-started/upgrade-to-3.md) for moving from 2.x to 3.0.
## Get started
diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md
index 383877afe..7430391d7 100644
--- a/apps/ai-studio/README.md
+++ b/apps/ai-studio/README.md
@@ -1,6 +1,6 @@
# AI Studio
-Reference frontend for the Workflow Builder AI Studio product. Consumes `@workflowbuilder/sdk` like an external user would, composing app-shell UI directly via JSX and using the plugin API only for per-node markers + translations.
+Reference frontend for the Workflow Builder AI Studio product. Consumes `@workflowbuilder/sdk` like an external user would, composing app-shell UI directly via JSX and using the plugin API only for per-node markers + translations, and to hide the properties panel's Delete button.
> ⚠️ Local development only. Depends on the reference backend, which has no auth/authz. See [apps/backend/README.md](../backend/README.md).
@@ -11,16 +11,76 @@ Reference frontend for the Workflow Builder AI Studio product. Consumes `@workfl
A complete, runnable AI workflow product built on top of the Workflow Builder SDK. It demonstrates:
- Connecting to the reference Hono backend over HTTP + Server-Sent Events
-- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`)
-- Live execution UI: Play/Stop controls, log panel, per-node status markers, edge highlighting, node-detail overlay
+- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`, `ai-studio/human-decision`, `ai-studio/visualize`)
+- A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port.
+- The author picks what the decider's form shows. The panel lists the fields the proposal source (the named `proposalSourceNodeId`, else the single predecessor) declares under `properties.outputSchema` that the form can show (text, number, yes/no, and nullable text or yes/no; integers, nullable numbers, objects, arrays and keys with a dot or bracket are left out), each at one of four levels: Hidden, Read-only, Editable, or Editable and required. The control lives in `src/components/human-decision/decision-fields/`; from the moment the backend starts a run until Reset it is hidden, so the panel shows only the run (the decision form, then the record).
+- The picks are `decisionRequest.schema` itself, in the contract's format, so the canvas, the Run payload and the decider's form read one schema: a field it leaves out is Hidden, and Editable and required drops `null` from a nullable type, so a null the model left holds Approve back. A required field the decider empties holds it back too, because the backend refuses it. A required text field emptied or left with only whitespace shows no error of its own, and nothing on screen says why Approve is disabled. A field the source no longer declares stays in the schema, listed as "not in the source", until the author hides it. A stored field of a type the form cannot show gets no row, and the next pick drops it from the schema, `readOnly` and `required` included. Hidden keeps a field off the form and refuses edits to it; the value stays in the source's output, which the log panel shows and later nodes read.
+- A rejection ends the run as a result, not a dead end: the run closes `completed`, the log panel names the outcome and who settled it, and the reject handle needs no edge. The node's output carries `resolvedBy` beside the other decision fields. The run's pill stays `completed` on purpose, a rejection being a result and not a failure; whether the panel marks it visually is for the design pass.
+- The person decides in the node's properties sidebar: the editor's own form over `decisionRequest.schema`, filled from the proposal source's output, with read-only fields disabled and only the changed editable fields sent as `edits`. Approve and "Reject…" sit in the panel's footer. The panel shows no Delete button for any selection, deliberately for now; deleting stays on the Delete and Backspace keys. From the moment the backend starts a run until Reset the canvas is read-only, so the form reads the graph the run executes; `use-run-locks-canvas.ts` lists the exceptions. Should undo change the picks under an open form, the form keeps the fields it opened with.
+- An AI Agent node that answers as structured fields: the Response format dropdown sets `properties.outputSchema` to a preset JSON Schema, the worker asks the model for that shape (see [apps/execution-worker/README.md](../execution-worker/README.md#ai-agent-structured-output)), and in Refund Review the draft's fields fill the decision form, and the reply the person approves, edits included, is what the customer gets. Plain text stays the default and returns `{ response }`. The draft's `internalReasoning` stays off the form because the decision request's schema leaves it out, and out of the confirmation only because the second agent's prompt says so; nothing downstream enforces that. A node switched to a structured format has no `response`, so a downstream `{{ nodes..response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent.
+- Live execution UI: Run/Stop controls, log panel, per-node status markers, a footer on a waiting decision node with Decide, a snackbar while the run waits for a decision, edge highlighting, node-detail overlay
+- Decide selects the waiting node through the SDK's `useSetSelection`, replacing the selection, and moves the focus to its decision form. Neither the footer nor the snackbar shows while the run is `cancelling`, since the backend refuses a decision then, and the snackbar offers no Decide for a node the canvas lacks. A closed snackbar stays closed for that wait until the page reloads.
+- A run survives a reload. Run writes the run's id into the URL, so a reload opens that run: its executed graph, read-only, with the stream reopened and the snapshot rebuilding markers and log. A closed tab comes back only through the browser's history, which restores the URL. [Stopping and resetting a run](#stopping-and-resetting-a-run) covers Stop, Reset and the limits.
+- A URL opens a stored workflow or a run: `?workflowId=` to edit a workflow, `?executionId=` to watch or replay a run. See [Opening a workflow or a run from the URL](#opening-a-workflow-or-a-run-from-the-url).
This is a sibling to `apps/demo`, not a layer over it. They share the SDK; nothing else.
## Compared to apps/demo
-| | `apps/demo` | `apps/ai-studio` |
-| ------------ | --------------------------- | -------------------------------------------------------- |
-| Purpose | Minimal embed showcase | Full AI workflow product |
-| Backend | None (pure SPA) | Required (Hono + Temporal) |
-| Plugin model | Plugins decorate the editor | Direct JSX composition; one slim plugin for node markers |
-| Dev port | 4200 | 4201 |
+| | `apps/demo` | `apps/ai-studio` |
+| ------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| Purpose | Minimal embed showcase | Full AI workflow product |
+| Backend | None (pure SPA) | Required (Hono + Temporal) |
+| Plugin model | Plugins decorate the editor | Direct JSX composition; slim plugins for node markers, the panel's Delete button and the run view's hidden Save button |
+| Dev port | 4200 | 4201 |
+
+## Stopping and resetting a run
+
+Stop sends `DELETE /api/executions/:id`. The controls offer it for every status in which the server may still hold the run: `pending`, `running`, `waiting`, `cancelling` and `disconnected`. They offer Reset once the run has ended, and beside Stop as soon as Stop is clicked, because a cancel the server accepts can still never finish. Until the run ends, that Reset is labelled "Reset without cancelling: the run may still be running on the server". The controls stay on screen while a run exists, even after its trigger node is deleted, and show Run only when the canvas has a start node. From the click on Run until the backend answers, Stop shows disabled and Reset stays hidden, so a second start cannot follow.
+
+| Answer to Stop | What the client does |
+| -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| `200` or `409` | Reopens the stream unless the run already ended over the old one. The snapshot shows where the run stands. |
+| `404` with `code: execution_not_found` | Forgets the run, as Reset does; a run view reloads. |
+| Anything else, or no answer | Keeps the run. Reset stays available. |
+
+The client keeps the Stop request in memory only. After a reload, one more Stop brings Reset back, and a run the server reports as `cancelling` brings it back without one.
+
+Reset clears the client only. It closes the stream, forgets the run and removes `executionId` from the URL, and never cancels the run on the server.
+
+`disconnected` means the client lost the stream, not that the run ended, and a reload tries the stream again. A refused stream shows `disconnected` at once, because the browser never retries one. That covers any non-200 answer and a wrong MIME type, a proxy's `502` included. After a network error the browser retries on its own, and the client gives up with `disconnected` after five failed retries.
+
+## Opening a workflow or a run from the URL
+
+The URL is the only record of what the editor shows. Four rules:
+
+1. AI Studio reads the URL once, while the page loads, behind a loading screen. Editing the URL and pressing Enter loads the page again; a URL changed any other way is ignored until the next load.
+2. `executionId` wins: the canvas shows the graph the run executed (`GET /api/executions/:id/snapshot`), read-only, live or replayed, named `Run `. `workflowId` without a run opens the workflow's draft (its published version when it has no draft, an empty canvas when it has neither) under the workflow's name, and the editor saves into that draft. Neither id opens the local draft in `localStorage`, as before.
+3. The app writes the URL in two places. Run adds `executionId` with `history.replaceState` and keeps `workflowId`. Forgetting the run, on Reset or on a Stop the server answers with `execution_not_found`, removes it and, in a run view, reloads the page, which lands on the workflow when the URL names one and on the local draft otherwise.
+4. A URL that cannot be opened shows one screen with the reason and a way out: to the workflow when the URL names one and only the run failed, to the local draft otherwise. The URL stays, so a reload tries again. The same screen catches a diagram that throws while drawing, and from then on the page saves nothing into the workflow. With no id in the URL it also offers to discard the local draft, since that draft is what failed.
+
+So after every action the canvas shows what a reload of the current URL would show.
+
+| URL | Canvas | Save | Run |
+| ----------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------ |
+| no parameter | The local draft in `localStorage` | The editor's Save button and autosave, into `localStorage` | Creates a workflow for every run |
+| `?workflowId=` | The workflow's draft, under the workflow's name | The editor's Save button, autosave and the save on close, all `PATCH /api/workflows/:id/draft` | Saves the draft, then runs the workflow |
+| `?executionId=` | The graph the run executed, read-only | None: the Save button is hidden | Not offered |
+| `?workflowId=…&executionId=…` | As `?executionId=` | None | Not offered; Reset returns to the workflow |
+
+- Ids are trimmed and lowercased, the form the backend stores and sends back. The stream follows the run's stored id, so any spelling Postgres reads as that uuid goes live. Nothing else is checked in the browser: a malformed id goes to the backend, and its answer ends on the error screen.
+- Saving a workflow goes through the SDK's `props` integration: `onDataSave` sends the draft with `keepalive`, so the save on close outlives the page. An autosave, the save on close included, sends nothing when the draft is what the tab last wrote, Run's save included, or when nothing was edited since the link opened. Selecting a node is not an edit, nor is the canvas measuring one, so closing a tab that was only looked at writes nothing. The SDK autosaves only when a change comes more than 10 s after its last save, so the latest edits often wait for Save or for the save on close. Browsers refuse a keepalive body of 64 KiB or more, so a larger draft is sent without it and its save on close may not arrive. A failed autosave shows an error snackbar, since the SDK shows nothing for it; a failed Save shows the SDK's own.
+- A run view gets the `props` integration with a save callback that is never called, and `plugins/run-view/` hides the editor's Save button, which is also where autosave and the save on close live.
+- Notices show in the editor's snackbars (`showSnackbar`); one raised before the editor mounts waits for it. Warnings and errors stay until closed and show once per text, so a failing autosave does not stack copies; a success goes by itself.
+- A diagram opened from the URL never touches the local draft.
+
+Known limits:
+
+- Whoever has a run's URL can do what its owner can: read it, including the inputs, prompts and answers; stop it; decide for it. The welcome disclaimer tells visitors so and asks them not to enter personal or confidential data.
+- A reload after the run finished still shows it, read-only, until Reset, because the URL still names it.
+- The run view is read-only through the run lock only. The app bar's read-only switch lifts it, and edits made then are saved nowhere.
+- Autosave and the save on close keep a workflow's edits, so a reload no longer discards them; undo is the only way back.
+- Before the first save, only edits the editor tracks count. The decision node's Add branch is not tracked, and the SDK reports a node moved with the keyboard only as a node change, which does not count; a tab whose only edit is either drops it on close unless Save runs first.
+- The graph is drawn as stored. A draft saved through the API in a shape the editor cannot draw ends on the error screen; one that draws wrongly shows wrongly.
+- In local mode the SDK saves the local draft itself, so an edit that breaks the canvas, an import for example, can still reach `localStorage` after the error screen, and "Open local draft" draws it again. "Discard local draft" removes it and starts from the template; the edits since the last working state are lost.
+- Nodes of a type this app does not know show without a properties panel.
diff --git a/apps/ai-studio/src/adapters/execution-stream-adapter.test.ts b/apps/ai-studio/src/adapters/execution-stream-adapter.test.ts
new file mode 100644
index 000000000..161e87c01
--- /dev/null
+++ b/apps/ai-studio/src/adapters/execution-stream-adapter.test.ts
@@ -0,0 +1,137 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { resetExecution, setExecutionStarted, useExecutionStore } from '../stores/use-execution-store';
+import { snapshotFrame } from '../test/execution-history';
+import { installFakeEventSource, latestStream } from '../test/fake-event-source';
+import { connectExecutionStream } from './execution-stream-adapter';
+
+const STREAM_URL = '/api/executions/exec-1/stream';
+
+const runStatus = () => useExecutionStore.getState().status;
+
+function connectWaitingRun() {
+ setExecutionStarted('exec-1', STREAM_URL);
+ useExecutionStore.setState({ status: 'waiting' });
+ connectExecutionStream('exec-1', STREAM_URL);
+ return latestStream();
+}
+
+const failStorageWrites = () =>
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('The quota has been exceeded.', 'QuotaExceededError');
+ });
+
+beforeEach(() => {
+ installFakeEventSource();
+ resetExecution();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('connectExecutionStream: when the stream cannot be read', () => {
+ it('a refused stream is lost at once, but keeps the run id for Stop to resolve', () => {
+ const stream = connectWaitingRun();
+
+ stream.refuse();
+
+ expect(runStatus()).toBe('disconnected');
+ expect(stream.closed).toBe(true);
+ expect(useExecutionStore.getState().executionId).toBe('exec-1');
+ expect(useExecutionStore.getState().streamUrl).toBe(STREAM_URL);
+ });
+
+ it('the browser is given five attempts before the run is called lost', () => {
+ const RETRY_BUDGET = 5;
+ const stream = connectWaitingRun();
+
+ for (let attempt = 0; attempt < RETRY_BUDGET; attempt += 1) {
+ stream.blip();
+ }
+
+ expect(runStatus()).toBe('waiting');
+ expect(stream.closed).toBe(false);
+
+ stream.blip();
+
+ expect(runStatus()).toBe('disconnected');
+ expect(stream.closed).toBe(true);
+ });
+
+ it('a message between blips restores the patience', () => {
+ const stream = connectWaitingRun();
+
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ stream.blip();
+ }
+ // node_started would re-derive the run to running.
+ stream.emit({
+ executionId: 'exec-1',
+ sequence: 1,
+ timestamp: '2026-09-15T12:00:00.000Z',
+ type: 'node_waiting',
+ nodeId: 'human-1',
+ });
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ stream.blip();
+ }
+
+ expect(runStatus()).toBe('waiting');
+ });
+});
+
+// The server ends the response right after a terminal frame. A source left open reconnects every few seconds,
+// is answered with the same frame, and the answer resets the retry count, so nothing ever stops it.
+describe('connectExecutionStream: when the run is over', () => {
+ it('a terminal snapshot ends the stream: the server has nothing more to send', () => {
+ const stream = connectWaitingRun();
+
+ stream.emit(snapshotFrame('completed', []));
+
+ expect(runStatus()).toBe('completed');
+ expect(stream.closed).toBe(true);
+ });
+
+ it('a terminal event ends the stream too, for a run that was still live', () => {
+ const stream = connectWaitingRun();
+
+ stream.emit({
+ executionId: 'exec-1',
+ sequence: 1,
+ timestamp: '2026-09-15T12:00:00.000Z',
+ type: 'execution_completed',
+ });
+
+ expect(stream.closed).toBe(true);
+ });
+});
+
+// jsdom swallows an exception thrown by a listener on a window-less EventTarget, so emit does not rethrow it.
+describe('connectExecutionStream: when the store cannot save the last frame', () => {
+ it('a terminal snapshot still ends the stream', () => {
+ const stream = connectWaitingRun();
+ const setItem = failStorageWrites();
+
+ stream.emit(snapshotFrame('completed', []));
+
+ expect(setItem).toHaveBeenCalled();
+ expect(stream.closed).toBe(true);
+ });
+
+ it('a terminal event still ends the stream', () => {
+ const stream = connectWaitingRun();
+ const setItem = failStorageWrites();
+
+ stream.emit({
+ executionId: 'exec-1',
+ sequence: 1,
+ timestamp: '2026-09-15T12:00:00.000Z',
+ type: 'execution_completed',
+ });
+
+ expect(setItem).toHaveBeenCalled();
+ expect(stream.closed).toBe(true);
+ });
+});
diff --git a/apps/ai-studio/src/adapters/execution-stream-adapter.ts b/apps/ai-studio/src/adapters/execution-stream-adapter.ts
index 6cd307646..8154c9e58 100644
--- a/apps/ai-studio/src/adapters/execution-stream-adapter.ts
+++ b/apps/ai-studio/src/adapters/execution-stream-adapter.ts
@@ -26,26 +26,26 @@ export function connectExecutionStream(executionId: string, streamUrl: string):
const parsed = JSON.parse(message.data as string) as ExecutionSnapshot | ExecutionEvent;
+ // Closed first: a throwing store write must not skip it and leave the browser reconnecting.
if ('events' in parsed && 'lastSequence' in parsed) {
const snapshot = parsed as ExecutionSnapshot;
- applySnapshot(snapshot);
-
if (TERMINAL_STATUSES.has(snapshot.status)) {
eventSource.close();
- return;
}
+ applySnapshot(snapshot);
} else {
const event = parsed as ExecutionEvent;
- applyEvent(event);
-
if (TERMINAL_TYPES.has(event.type)) {
eventSource.close();
}
+ applyEvent(event);
}
});
eventSource.addEventListener('error', () => {
- if (++retries > MAX_RETRIES) {
+ // Any non-200 answer or wrong MIME type, a proxy's 502 included, closes the source and the browser
+ // never retries; only a network error stays CONNECTING and retries on its own.
+ if (eventSource.readyState === EventSource.CLOSED || ++retries > MAX_RETRIES) {
eventSource.close();
applyConnectionLost();
}
diff --git a/apps/ai-studio/src/adapters/save-workflow-draft.test.ts b/apps/ai-studio/src/adapters/save-workflow-draft.test.ts
new file mode 100644
index 000000000..b1712d0d5
--- /dev/null
+++ b/apps/ai-studio/src/adapters/save-workflow-draft.test.ts
@@ -0,0 +1,183 @@
+import { type IntegrationDataFormat, useChangesTrackerStore } from '@workflowbuilder/sdk';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { BACKEND_URL } from '../config';
+import { useNoticesStore } from '../stores/use-notices-store';
+import { jsonResponse } from '../test/json-response';
+import { patchDraft, saveDraftOf } from './save-workflow-draft';
+
+const WORKFLOW = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+const node = { id: 'n-1', type: 'node', position: { x: 0, y: 0 }, data: { type: 'x', properties: {} } };
+const data = {
+ name: 'Refund desk',
+ globalVariables: {},
+ layoutDirection: 'LR',
+ nodes: [node],
+ edges: [],
+} as unknown as IntegrationDataFormat;
+const moved = { ...data, nodes: [{ ...node, position: { x: 40, y: 0 } }] } as unknown as IntegrationDataFormat;
+
+let fetchMock: ReturnType;
+
+const notices = () => useNoticesStore.getState().notices.map((notice) => notice.text);
+const request = () => fetchMock.mock.calls[0] as [string, RequestInit];
+const edited = () =>
+ useChangesTrackerStore.setState({ lastChangeName: 'nodeDragStop', lastChangeTimestamp: Date.now() + 1 });
+const autosave = { isAutoSave: true };
+
+beforeEach(() => {
+ fetchMock = vi.fn(async () => jsonResponse(200, { id: WORKFLOW, name: 'Refund desk' }));
+ vi.stubGlobal('fetch', fetchMock);
+ useNoticesStore.setState({ notices: [] });
+ useChangesTrackerStore.setState({ lastChangeName: '', lastChangeTimestamp: 0 });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('saveDraftOf', () => {
+ it('PATCHes the nodes and edges into the draft, with keepalive so the save on close outlives the page', async () => {
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+
+ const status = await save(data, autosave);
+
+ expect(status).toBe('success');
+ const [url, init] = request();
+ expect(url).toBe(`${BACKEND_URL}/api/workflows/${WORKFLOW}/draft`);
+ expect(init.method).toBe('PATCH');
+ expect(init.keepalive).toBe(true);
+ expect(JSON.parse(init.body as string)).toEqual({ draftJson: { nodes: [node], edges: [] } });
+ });
+
+ it('sends a draft of 64 KiB or more without keepalive, which browsers refuse', async () => {
+ const big = { ...data, nodes: [{ ...node, data: { type: 'x', properties: { text: 'y'.repeat(70_000) } } }] };
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+
+ await save(big as unknown as IntegrationDataFormat, autosave);
+
+ expect(request()[1].keepalive).toBe(false);
+ });
+
+ it('a failed autosave throws and raises an error notice, which the SDK does not', async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(500, { message: 'boom' }));
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+
+ await expect(save(data, autosave)).rejects.toThrow('the server answered 500');
+
+ expect(notices()).toEqual(['The workflow draft could not be saved automatically: the server answered 500.']);
+ });
+
+ it("a failed manual save throws and raises no notice: the SDK's own error snackbar shows", async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(500, { message: 'boom' }));
+
+ await expect(saveDraftOf(WORKFLOW)(data, { isAutoSave: false })).rejects.toThrow('the server answered 500');
+
+ expect(notices()).toEqual([]);
+ });
+
+ it('no answer from the server is an error too', async () => {
+ fetchMock.mockImplementation(async () => {
+ throw new TypeError('Failed to fetch');
+ });
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+
+ await expect(save(data, autosave)).rejects.toThrow('the server did not answer');
+
+ expect(notices()[0]).toContain('the server did not answer');
+ });
+});
+
+describe('saveDraftOf: an autosave with nothing new', () => {
+ it('sends nothing when nothing was edited since the link opened, so closing a tab only looked at writes nothing', async () => {
+ expect(await saveDraftOf(WORKFLOW)(data, autosave)).toBe('success');
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ // React Flow's measuring after mount and a selecting click both reach the SDK's tracker under this name.
+ it("sends nothing after only the SDK's node changes, so a tab only looked at writes nothing on close", async () => {
+ const save = saveDraftOf(WORKFLOW);
+ useChangesTrackerStore.setState({ lastChangeName: 'nodeDragChange', lastChangeTimestamp: Date.now() + 1 });
+
+ expect(await save(data, autosave)).toBe('success');
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('still sends an edit that a node change followed', async () => {
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+ useChangesTrackerStore.setState({ lastChangeName: 'nodeDragChange', lastChangeTimestamp: Date.now() + 2 });
+
+ await save(data, autosave);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("compares with Run's write too, so going back to an earlier save is sent", async () => {
+ const save = saveDraftOf(WORKFLOW);
+ await save(data, { isAutoSave: false });
+ await patchDraft(WORKFLOW, moved.nodes, moved.edges);
+
+ await save(moved, autosave);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ await save(data, autosave);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it('sends nothing when the draft is what it last saved', async () => {
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+ await save(data, autosave);
+
+ expect(await save(data, autosave)).toBe('success');
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends an edit made after the last save, tracked or not', async () => {
+ const save = saveDraftOf(WORKFLOW);
+ await save(data, { isAutoSave: false });
+
+ await save(moved, autosave);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('never holds back a manual Save', async () => {
+ await saveDraftOf(WORKFLOW)(data, { isAutoSave: false });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends again after a save that failed', async () => {
+ fetchMock.mockImplementationOnce(async () => jsonResponse(500, { message: 'boom' }));
+ const save = saveDraftOf(WORKFLOW);
+ edited();
+ await expect(save(data, autosave)).rejects.toThrow();
+
+ await save(data, autosave);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('saveDraftOf after a crash', () => {
+ it('sends nothing once saves are halted, neither an autosave nor Save', async () => {
+ vi.resetModules();
+ const adapter = await import('./save-workflow-draft');
+ const save = adapter.saveDraftOf(WORKFLOW);
+ adapter.haltSaves();
+
+ await expect(save(data, autosave)).rejects.toThrow();
+ await expect(save(data, { isAutoSave: false })).rejects.toThrow();
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/ai-studio/src/adapters/save-workflow-draft.ts b/apps/ai-studio/src/adapters/save-workflow-draft.ts
new file mode 100644
index 000000000..893850fd8
--- /dev/null
+++ b/apps/ai-studio/src/adapters/save-workflow-draft.ts
@@ -0,0 +1,87 @@
+import { type OnSaveExternal, type OnSaveParams, useChangesTrackerStore } from '@workflowbuilder/sdk';
+
+import { BACKEND_URL } from '../config';
+import { addNotice } from '../stores/use-notices-store';
+
+// Browsers refuse a keepalive request whose body reaches 64 KiB. A larger draft goes without it: it still
+// saves while the page is up, and on close the last autosave stands.
+const KEEPALIVE_BODY_LIMIT = 64 * 1024;
+
+// The SDK ticks this for every React Flow node change, the measuring after mount and a selecting click included.
+const NOT_AN_EDIT = 'nodeDragChange';
+
+const writtenDrafts = new Map();
+
+let isHalted = false;
+
+/**
+ * After a crash the store holds what would not draw, and the SDK's autosave timer outlives the editor.
+ * Once the SDK clears that timer on unmount, this goes (follow-up: sdk-autosave-timer-unmount).
+ */
+export function haltSaves(): void {
+ isHalted = true;
+}
+
+/** Writes a workflow's draft. The editor's saves and Run both come here, so an autosave knows what the draft holds. */
+export async function patchDraft(workflowId: string, nodes: unknown[], edges: unknown[]): Promise {
+ const body = JSON.stringify({ draftJson: { nodes, edges } });
+ const response = await fetch(`${BACKEND_URL}/api/workflows/${workflowId}/draft`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ keepalive: new TextEncoder().encode(body).byteLength < KEEPALIVE_BODY_LIMIT,
+ });
+ if (response.ok) {
+ writtenDrafts.set(workflowId, body);
+ }
+ return response;
+}
+
+/** The editor's save callback for the link's workflow: Save, autosave and the save on close all land here. */
+export function saveDraftOf(workflowId: string): OnSaveExternal {
+ const openedAt = Date.now();
+ // Only this editor's writes count: the draft may have changed elsewhere since.
+ writtenDrafts.delete(workflowId);
+ let isEdited = false;
+ useChangesTrackerStore.subscribe(({ lastChangeName, lastChangeTimestamp }) => {
+ if (lastChangeTimestamp > openedAt && lastChangeName !== NOT_AN_EDIT) isEdited = true;
+ });
+
+ // The SDK also autosaves on close with nothing edited, which would overwrite newer edits made elsewhere.
+ // Once the SDK skips an unchanged save itself, the guard goes (follow-up: sdk-autosave-skips-unchanged).
+ const isUnchanged = (body: string) => {
+ const written = writtenDrafts.get(workflowId);
+ return written === undefined ? !isEdited : body === written;
+ };
+
+ return async ({ nodes, edges }, params) => {
+ if (isHalted) {
+ throw new Error('The editor stopped after a crash, so nothing is saved.');
+ }
+ if (params?.isAutoSave && isUnchanged(JSON.stringify({ draftJson: { nodes, edges } }))) {
+ return 'success';
+ }
+
+ let response: Response;
+ try {
+ response = await patchDraft(workflowId, nodes, edges);
+ } catch {
+ return failed(params, 'the server did not answer');
+ }
+ if (!response.ok) {
+ return failed(params, `the server answered ${response.status}`);
+ }
+
+ return 'success';
+ };
+}
+
+// The SDK's props wrapper takes any resolved value, 'error' included, for a finished save, so a failure throws.
+// It shows nothing for a failed autosave; a manual Save gets its own error snackbar.
+function failed(params: OnSaveParams | undefined, reason: string): never {
+ if (params?.isAutoSave) {
+ addNotice(`The workflow draft could not be saved automatically: ${reason}.`, 'error');
+ }
+
+ throw new Error(`The workflow draft could not be saved: ${reason}.`);
+}
diff --git a/apps/ai-studio/src/adapters/submit-decision.test.ts b/apps/ai-studio/src/adapters/submit-decision.test.ts
new file mode 100644
index 000000000..755356f80
--- /dev/null
+++ b/apps/ai-studio/src/adapters/submit-decision.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// The backend's schema for the decision itself, which the route extends with nodeId and attempt; read through
+// the backend's own node_modules, as decision-request-contract.test.ts does.
+import { submittedDecisionSchema } from '../../../backend/src/domain/decision/validate-submitted-decision';
+import { BACKEND_URL } from '../config';
+import { type DecisionInput, submitDecision } from './submit-decision';
+
+const wait = { executionId: 'exec-1', nodeId: 'human-1', attempt: 1 };
+const addressed = { nodeId: 'human-1', attempt: 1 };
+const approve = { action: 'approve' };
+
+function answer(status: number, payload: unknown, headers: Record = {}) {
+ return new Response(JSON.stringify(payload), {
+ status,
+ headers: { 'Content-Type': 'application/json', ...headers },
+ });
+}
+
+function stubFetch(response: Response | Error) {
+ const fetchMock = vi.fn();
+ fetchMock.mockImplementation(() =>
+ response instanceof Error ? Promise.reject(response) : Promise.resolve(response),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+async function sentBody(input: DecisionInput) {
+ const fetchMock = stubFetch(answer(200, { effect: 'resume' }));
+ await submitDecision(wait, input);
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ return JSON.parse(init.body as string) as Record;
+}
+
+describe('the body submitDecision sends', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('carries exactly the node, the wait and the action when there is nothing else to say', async () => {
+ expect(await sentBody({ ...approve, edits: {}, reason: ' ' })).toEqual({ ...addressed, ...approve });
+ });
+
+ it('adds edits only when non-empty and reason only when non-blank', async () => {
+ expect(await sentBody({ ...approve, edits: { refundAmount: 120 } })).toEqual({
+ ...addressed,
+ ...approve,
+ edits: { refundAmount: 120 },
+ });
+ expect(await sentBody({ action: 'reject', reason: 'Too high' })).toEqual({
+ ...addressed,
+ action: 'reject',
+ reason: 'Too high',
+ });
+ });
+
+ it.each([
+ ['approve without edits', approve],
+ ['approve with edits', { ...approve, edits: { refundAmount: 120, replyDraft: null } }],
+ ['reject with a reason', { action: 'reject', reason: 'Too high' }],
+ ])('%s parses with the backend submittedDecisionSchema', async (_name, input) => {
+ const { nodeId, attempt, ...decision } = await sentBody(input);
+ const parsed = submittedDecisionSchema.strict().safeParse(decision);
+
+ expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);
+ expect({ nodeId, attempt }).toEqual(addressed);
+ });
+});
+
+describe('submitDecision', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('posts to the execution decision route and reads the effect', async () => {
+ const fetchMock = stubFetch(answer(200, { effect: 'resume-with-edits' }));
+
+ const result = await submitDecision(wait, { ...approve, edits: { refundAmount: 120 } });
+
+ expect(result).toEqual({ ok: true });
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe(`${BACKEND_URL}/api/executions/exec-1/decision`);
+ expect(init.method).toBe('POST');
+ });
+
+ it('returns the refusal code and message of a 409, with the current attempt when named', async () => {
+ stubFetch(
+ answer(409, {
+ code: 'decision_attempt_mismatch',
+ message: 'The decision names a wait that is not the current one',
+ attempt: 2,
+ }),
+ );
+
+ expect(await submitDecision(wait, approve)).toEqual({
+ ok: false,
+ status: 409,
+ code: 'decision_attempt_mismatch',
+ message: 'The decision names a wait that is not the current one',
+ currentAttempt: 2,
+ });
+ });
+
+ it('reads Retry-After from a 503', async () => {
+ stubFetch(answer(503, { code: 'decision_delivery_timeout', message: 'Send it again' }, { 'Retry-After': '5' }));
+
+ expect(await submitDecision(wait, approve)).toMatchObject({
+ ok: false,
+ status: 503,
+ code: 'decision_delivery_timeout',
+ retryAfterSeconds: 5,
+ });
+ });
+
+ it('surfaces the first detail of a 400', async () => {
+ stubFetch(
+ answer(400, {
+ code: 'invalid_decision',
+ message: 'Decision failed validation',
+ details: [{ code: 'reason_required', message: "action 'reject' requires a reason" }],
+ }),
+ );
+
+ expect(await submitDecision(wait, approve)).toMatchObject({
+ ok: false,
+ status: 400,
+ code: 'invalid_decision',
+ detail: "action 'reject' requires a reason",
+ });
+ });
+
+ it('says what happened when a proxy answers without the refusal envelope or a status text', async () => {
+ stubFetch(new Response('gateway', { status: 502 }));
+
+ expect(await submitDecision(wait, approve)).toEqual({
+ ok: false,
+ status: 502,
+ code: 'http_502',
+ message: 'The backend answered HTTP 502 without saying why.',
+ });
+ });
+
+ it('does not take a success page from somewhere else for an accepted decision', async () => {
+ stubFetch(new Response('app', { status: 200 }));
+
+ expect(await submitDecision(wait, approve)).toMatchObject({ ok: false, status: 200 });
+ });
+
+ it('reports a failed request as a network error', async () => {
+ stubFetch(new TypeError('Failed to fetch'));
+
+ expect(await submitDecision(wait, approve)).toEqual({
+ ok: false,
+ status: 0,
+ code: 'network_error',
+ message: 'Failed to fetch',
+ });
+ });
+});
diff --git a/apps/ai-studio/src/adapters/submit-decision.ts b/apps/ai-studio/src/adapters/submit-decision.ts
new file mode 100644
index 000000000..8c8290f23
--- /dev/null
+++ b/apps/ai-studio/src/adapters/submit-decision.ts
@@ -0,0 +1,91 @@
+import { BACKEND_URL } from '../config';
+import type { DecisionWait } from '../stores/use-execution-store';
+import { hasText } from '../utils/has-text';
+import { isPlainObject } from '../utils/is-plain-object';
+
+/** The decision itself, apart from the wait it answers. */
+export type DecisionInput = { action: string; edits?: Record; reason?: string };
+
+export type SubmitDecisionResult =
+ | { ok: true }
+ | {
+ ok: false;
+ status: number;
+ code: string;
+ message: string;
+ /** From a 503 `Retry-After` header. */
+ retryAfterSeconds?: number;
+ /** From a `decision_attempt_mismatch` envelope: the wait the server currently holds. */
+ currentAttempt?: number;
+ /** The first entry of a 400 envelope's `details`. */
+ detail?: string;
+ };
+
+// A blank reason would be recorded as given, and an empty `edits` is left out to keep the body minimal.
+function decisionBody({ nodeId, attempt }: DecisionWait, { action, edits, reason }: DecisionInput) {
+ return {
+ nodeId,
+ attempt,
+ action,
+ ...(edits !== undefined && Object.keys(edits).length > 0 ? { edits } : {}),
+ ...(hasText(reason) ? { reason } : {}),
+ };
+}
+
+async function readJson(response: Response): Promise> {
+ try {
+ const parsed: unknown = await response.json();
+ return isPlainObject(parsed) ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function stringOf(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+function firstDetailMessage(details: unknown): string | undefined {
+ if (!Array.isArray(details) || details.length === 0) {
+ return undefined;
+ }
+ const first = details[0];
+ return isPlainObject(first) ? stringOf(first['message']) : undefined;
+}
+
+export async function submitDecision(wait: DecisionWait, input: DecisionInput): Promise {
+ let response: Response;
+ try {
+ response = await fetch(`${BACKEND_URL}/api/executions/${wait.executionId}/decision`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(decisionBody(wait, input)),
+ });
+ } catch (error) {
+ return {
+ ok: false,
+ status: 0,
+ code: 'network_error',
+ message: error instanceof Error ? error.message : 'The decision could not be sent',
+ };
+ }
+
+ const payload = await readJson(response);
+ // The route names the effect of every decision it accepts; a success without one was answered on its behalf.
+ if (response.ok && stringOf(payload['effect']) !== undefined) {
+ return { ok: true };
+ }
+
+ const retryAfter = Number(response.headers.get('Retry-After'));
+ const currentAttempt = payload['attempt'];
+ const detail = firstDetailMessage(payload['details']);
+ return {
+ ok: false,
+ status: response.status,
+ code: stringOf(payload['code']) ?? `http_${response.status}`,
+ message: stringOf(payload['message']) ?? `The backend answered HTTP ${response.status} without saying why.`,
+ ...(Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}),
+ ...(typeof currentAttempt === 'number' ? { currentAttempt } : {}),
+ ...(detail === undefined ? {} : { detail }),
+ };
+}
diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx
index df5548fbf..50ffc1a00 100644
--- a/apps/ai-studio/src/app/app.tsx
+++ b/apps/ai-studio/src/app/app.tsx
@@ -5,40 +5,60 @@ import '@workflowbuilder/sdk/style.css';
import logoDark from '../assets/workflow-builder-logo-white.svg';
import logoLight from '../assets/workflow-builder-logo.svg';
+import { responseControlRenderer } from '../components/ai-agent/response-control';
import { AiStudioControls } from '../components/controls/ai-studio-controls';
import { DisclaimerModal } from '../components/disclaimer/disclaimer-modal';
import { ExecutionHighlighting } from '../components/execution/highlighting';
import { ExecutionLogPanel } from '../components/execution/log-panel';
+import { decisionFieldsRenderer } from '../components/human-decision/decision-fields/decision-fields-control';
+import { decisionFormRenderer } from '../components/human-decision/decision-form/decision-form-control';
+import { HumanDecisionNodeTemplate } from '../components/human-decision/node-template/human-decision-template';
+import { DecisionWaitingSnackbar } from '../components/human-decision/waiting-snackbar/decision-waiting-snackbar';
+import { OpenNotices } from '../components/open-from-url/open-notices';
import { aiStudioTemplates } from '../data/ai-studio-templates';
import { aiStudioNodeTypes } from '../data/node-types';
import { supportTriageFlow } from '../data/support-triage-flow';
-import { plugin as aiStudioFeaturesPlugin } from '../plugin';
-import { plugin as undoRedoPlugin } from '../plugins/undo-redo/plugin-exports';
+import { humanDecisionNodeType } from '../nodes/human-decision';
+import type { OpenedSource } from './open-from-url';
+import { rootPropsFor } from './root-props';
const flagship = supportTriageFlow.value;
+// Module-level: `nodeTemplates` must keep the same reference across renders.
+const nodeTemplates = { [humanDecisionNodeType]: HumanDecisionNodeTemplate };
+const jsonForm = { renderers: [decisionFormRenderer, responseControlRenderer, decisionFieldsRenderer] };
+
// A start node is where the run begins, so it can never be a connection target.
const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode;
-export function App() {
+export function App({ opened }: { opened: OpenedSource }) {
+ const { name, initialNodes, initialEdges, integration, plugins } = rootPropsFor(opened);
+ const workflowId = opened.kind === 'workflow' ? opened.workflowId : undefined;
+ const isRunView = opened.kind === 'execution';
+
return (
-
+
+
+
);
}
diff --git a/apps/ai-studio/src/app/open-error.ts b/apps/ai-studio/src/app/open-error.ts
new file mode 100644
index 000000000..40bba5806
--- /dev/null
+++ b/apps/ai-studio/src/app/open-error.ts
@@ -0,0 +1,10 @@
+/** Why the address did not open. `AppBoundary` shows the message and picks the way out by `what`. */
+export class OpenError extends Error {
+ readonly what: 'run' | 'workflow';
+
+ constructor(what: 'run' | 'workflow', reason: string) {
+ super(`The ${what} in the link could not be opened: ${reason}.`);
+ this.name = 'OpenError';
+ this.what = what;
+ }
+}
diff --git a/apps/ai-studio/src/app/open-from-url.test.ts b/apps/ai-studio/src/app/open-from-url.test.ts
new file mode 100644
index 000000000..519ef0826
--- /dev/null
+++ b/apps/ai-studio/src/app/open-from-url.test.ts
@@ -0,0 +1,153 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { WorkflowRecord } from '@workflow-builder/types/workflow-execution/api';
+
+import { BACKEND_URL } from '../config';
+import { refundReviewFlow } from '../data/refund-review-flow';
+import { resetExecution, setLogCollapsed, useExecutionStore } from '../stores/use-execution-store';
+import { jsonResponse, unparsableResponse } from '../test/json-response';
+import { OpenError } from './open-error';
+import { openFromUrl } from './open-from-url';
+
+const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const WORKFLOW = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+const graph = { nodes: refundReviewFlow.value.diagram.nodes, edges: refundReviewFlow.value.diagram.edges };
+
+const workflow: WorkflowRecord = {
+ id: WORKFLOW,
+ name: 'Refund desk',
+ draftJson: graph,
+ publishedJson: null,
+ publishedAt: null,
+ createdAt: '2026-09-28T10:00:00.000Z',
+ updatedAt: '2026-09-28T10:00:00.000Z',
+};
+const snapshot = { workflowId: WORKFLOW, sourceVersion: 'draft', snapshot: graph };
+
+let fetchMock: ReturnType;
+
+const requestedPaths = () => fetchMock.mock.calls.map(([url]) => String(url).replace(BACKEND_URL, ''));
+
+beforeEach(() => {
+ resetExecution();
+ fetchMock = vi.fn(async (url: string) =>
+ String(url).endsWith('/snapshot') ? jsonResponse(200, snapshot) : jsonResponse(200, workflow),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('openFromUrl: a run', () => {
+ it('reads the graph the run executed and puts the run in the store, where the one stream opener finds it', async () => {
+ const opened = await openFromUrl(`?executionId=${RUN}`);
+
+ expect(requestedPaths()).toEqual([`/api/executions/${RUN}/snapshot`]);
+ expect(opened).toEqual({ kind: 'execution', executionId: RUN, diagram: graph });
+ expect(useExecutionStore.getState()).toMatchObject({
+ executionId: RUN,
+ streamUrl: `/api/executions/${RUN}/stream`,
+ status: 'pending',
+ });
+ });
+
+ it('keeps the log as the tab left it, collapsed included', async () => {
+ setLogCollapsed(true);
+
+ await openFromUrl(`?executionId=${RUN}`);
+
+ expect(useExecutionStore.getState().isLogCollapsed).toBe(true);
+ });
+
+ it('wins over a workflow in the same address, which is not read', async () => {
+ const opened = await openFromUrl(`?workflowId=${WORKFLOW}&executionId=${RUN}`);
+
+ expect(requestedPaths()).toEqual([`/api/executions/${RUN}/snapshot`]);
+ expect(opened.kind).toBe('execution');
+ });
+
+ it('is read under its lowercase id, the form the backend stores', async () => {
+ const opened = await openFromUrl(`?executionId=${RUN.toUpperCase()}`);
+
+ expect(requestedPaths()).toEqual([`/api/executions/${RUN}/snapshot`]);
+ expect(opened).toMatchObject({ executionId: RUN });
+ expect(useExecutionStore.getState().executionId).toBe(RUN);
+ });
+
+ it('an id in any other shape is asked of the server as written', async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(500, { code: 'internal_error', message: 'Internal' }));
+
+ await expect(openFromUrl('?executionId=nope')).rejects.toBeInstanceOf(OpenError);
+
+ expect(requestedPaths()).toEqual(['/api/executions/nope/snapshot']);
+ });
+
+ it.each([
+ [
+ 'a 404',
+ () => jsonResponse(404, { code: 'execution_not_found', message: 'Not found' }),
+ 'the server answered 404',
+ ],
+ ['a 502', () => jsonResponse(502, { message: 'Bad gateway' }), 'the server answered 502'],
+ [
+ 'no answer',
+ () => {
+ throw new TypeError('Failed to fetch');
+ },
+ 'the server did not answer',
+ ],
+ ["a proxy's page for a 200", () => unparsableResponse(200), "the server's answer could not be read"],
+ ])('%s rejects with the reason and leaves the store idle', async (_, answer, reason) => {
+ fetchMock.mockImplementation(async () => answer());
+
+ const opening = openFromUrl(`?executionId=${RUN}`);
+
+ await expect(opening).rejects.toBeInstanceOf(OpenError);
+ await expect(opening).rejects.toMatchObject({
+ what: 'run',
+ message: `The run in the link could not be opened: ${reason}.`,
+ });
+ expect(useExecutionStore.getState()).toMatchObject({ executionId: undefined, status: 'idle' });
+ });
+});
+
+describe('openFromUrl: a workflow', () => {
+ it('opens its draft under its name and leaves the store idle', async () => {
+ const opened = await openFromUrl(`?workflowId=${WORKFLOW}`);
+
+ expect(requestedPaths()).toEqual([`/api/workflows/${WORKFLOW}`]);
+ expect(opened).toEqual({ kind: 'workflow', workflowId: WORKFLOW, name: 'Refund desk', diagram: graph });
+ expect(useExecutionStore.getState().status).toBe('idle');
+ });
+
+ it('opens the published version when there is no draft, and an empty canvas when there is neither', async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(200, { ...workflow, draftJson: null, publishedJson: graph }));
+ expect(await openFromUrl(`?workflowId=${WORKFLOW}`)).toMatchObject({ diagram: graph });
+
+ fetchMock.mockImplementation(async () => jsonResponse(200, { ...workflow, draftJson: null, publishedJson: null }));
+ expect(await openFromUrl(`?workflowId=${WORKFLOW}`)).toMatchObject({ diagram: { nodes: [], edges: [] } });
+ });
+
+ it('that will not open rejects naming the workflow, so the error screen offers the local draft', async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(404, { code: 'workflow_not_found', message: 'Not found' }));
+
+ await expect(openFromUrl(`?workflowId=${WORKFLOW}`)).rejects.toMatchObject({
+ what: 'workflow',
+ message: 'The workflow in the link could not be opened: the server answered 404.',
+ });
+ });
+});
+
+describe('openFromUrl: a bare address', () => {
+ it.each(['', '?executionId=', '?workflowId=%20', '?other=1'])(
+ '%j opens the local draft without asking the server',
+ async (search) => {
+ expect(await openFromUrl(search)).toEqual({ kind: 'local' });
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(useExecutionStore.getState().status).toBe('idle');
+ },
+ );
+});
diff --git a/apps/ai-studio/src/app/open-from-url.ts b/apps/ai-studio/src/app/open-from-url.ts
new file mode 100644
index 000000000..e93b374d5
--- /dev/null
+++ b/apps/ai-studio/src/app/open-from-url.ts
@@ -0,0 +1,59 @@
+import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk';
+
+import type { GetExecutionSnapshotResponse, WorkflowRecord } from '@workflow-builder/types/workflow-execution/api';
+
+import { BACKEND_URL } from '../config';
+import { setExecutionStarted } from '../stores/use-execution-store';
+import { OpenError } from './open-error';
+
+export type Diagram = { nodes: WorkflowBuilderNode[]; edges: WorkflowBuilderEdge[] };
+
+export type OpenedSource =
+ | { kind: 'local' }
+ | { kind: 'workflow'; workflowId: string; name: string; diagram: Diagram }
+ | { kind: 'execution'; executionId: string; diagram: Diagram };
+
+const EMPTY_DIAGRAM: Diagram = { nodes: [], edges: [] };
+
+async function read(what: 'run' | 'workflow', path: string): Promise {
+ let response: Response;
+ try {
+ response = await fetch(`${BACKEND_URL}${path}`);
+ } catch {
+ throw new OpenError(what, 'the server did not answer');
+ }
+ if (!response.ok) throw new OpenError(what, `the server answered ${response.status}`);
+ try {
+ return (await response.json()) as T;
+ } catch {
+ throw new OpenError(what, "the server's answer could not be read");
+ }
+}
+
+// Lowercased, the form the backend stores and sends back.
+function idIn(address: URLSearchParams, name: 'executionId' | 'workflowId'): string | undefined {
+ const value = address.get(name)?.trim().toLowerCase();
+ return value || undefined;
+}
+
+/** Runs once, before the editor mounts. A run goes into the store here, and `useBackendExecution` opens its stream. */
+export async function openFromUrl(search: string): Promise {
+ const address = new URLSearchParams(search);
+ const executionId = idIn(address, 'executionId');
+ const workflowId = idIn(address, 'workflowId');
+
+ if (executionId !== undefined) {
+ const path = `/api/executions/${encodeURIComponent(executionId)}/snapshot`;
+ const run = await read('run', path);
+ setExecutionStarted(executionId, `/api/executions/${executionId}/stream`, { keepLogChoice: true });
+ return { kind: 'execution', executionId, diagram: run.snapshot as Diagram };
+ }
+
+ if (workflowId !== undefined) {
+ const workflow = await read('workflow', `/api/workflows/${encodeURIComponent(workflowId)}`);
+ const diagram = (workflow.draftJson ?? workflow.publishedJson ?? EMPTY_DIAGRAM) as Diagram;
+ return { kind: 'workflow', workflowId, name: workflow.name, diagram };
+ }
+
+ return { kind: 'local' };
+}
diff --git a/apps/ai-studio/src/app/opened-app.test.tsx b/apps/ai-studio/src/app/opened-app.test.tsx
new file mode 100644
index 000000000..347fc2e86
--- /dev/null
+++ b/apps/ai-studio/src/app/opened-app.test.tsx
@@ -0,0 +1,68 @@
+import { Suspense, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AppBoundary } from '../components/open-from-url/app-boundary';
+import { LoadingScreen } from '../components/open-from-url/loading-screen';
+import { deferred } from '../test/deferred';
+import { OpenError } from './open-error';
+import type { OpenedSource } from './open-from-url';
+import { OpenedApp } from './opened-app';
+
+vi.mock('./app', () => ({
+ App: ({ opened }: { opened: OpenedSource }) =>
,
+}));
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+// A render that suspends inside a synchronous act() never retries, so the mount is awaited.
+async function mount(opening: Promise) {
+ const container = document.createElement('div');
+ const root = createRoot(container);
+ await act(async () =>
+ root.render(
+
+ }>
+
+
+ ,
+ ),
+ );
+ return { container, unmount: () => act(() => root.unmount()) };
+}
+
+describe('OpenedApp', () => {
+ it('shows the loading screen until the link is resolved, then mounts the app on what it opened', async () => {
+ const opening = deferred();
+ const { container, unmount } = await mount(opening.promise);
+
+ expect(container.textContent).toBe('Loading...');
+ expect(container.querySelector('[data-opened]')).toBeNull();
+
+ await act(async () => {
+ opening.resolve({ kind: 'workflow', workflowId: 'w', name: 'n', diagram: { nodes: [], edges: [] } });
+ await opening.promise;
+ });
+
+ expect(container.querySelector('[data-opened]')?.getAttribute('data-opened')).toBe('workflow');
+ unmount();
+ });
+
+ it('a link that will not open ends on the error screen, not a blank page', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const opening = deferred();
+ const { container, unmount } = await mount(opening.promise);
+
+ await act(async () => {
+ opening.reject(new OpenError('run', 'the server answered 404'));
+ await opening.promise.catch(() => {});
+ });
+
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('the server answered 404');
+ unmount();
+ });
+});
diff --git a/apps/ai-studio/src/app/opened-app.tsx b/apps/ai-studio/src/app/opened-app.tsx
new file mode 100644
index 000000000..d07f63353
--- /dev/null
+++ b/apps/ai-studio/src/app/opened-app.tsx
@@ -0,0 +1,8 @@
+import { use } from 'react';
+
+import { App } from './app';
+import type { OpenedSource } from './open-from-url';
+
+export function OpenedApp({ opening }: { opening: Promise }) {
+ return ;
+}
diff --git a/apps/ai-studio/src/app/root-props.test.ts b/apps/ai-studio/src/app/root-props.test.ts
new file mode 100644
index 000000000..6483ee0bd
--- /dev/null
+++ b/apps/ai-studio/src/app/root-props.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+
+import { refundReviewFlow } from '../data/refund-review-flow';
+import { supportTriageFlow } from '../data/support-triage-flow';
+import { plugin as runViewPlugin } from '../plugins/run-view/plugin';
+import type { OpenedSource } from './open-from-url';
+import { neverSaves, rootPropsFor } from './root-props';
+
+const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const WORKFLOW = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+const diagram = { nodes: refundReviewFlow.value.diagram.nodes, edges: refundReviewFlow.value.diagram.edges };
+
+const workflowSource: OpenedSource = { kind: 'workflow', workflowId: WORKFLOW, name: 'Refund desk', diagram };
+const executionSource: OpenedSource = { kind: 'execution', executionId: RUN, diagram };
+
+describe('rootPropsFor', () => {
+ it('keeps the local draft on the default localStorage strategy and the flagship seed', () => {
+ const props = rootPropsFor({ kind: 'local' });
+
+ expect(props.integration).toBeUndefined();
+ expect(props.initialNodes).toBe(supportTriageFlow.value.diagram.nodes);
+ expect(props.plugins).not.toContain(runViewPlugin);
+ });
+
+ it("opens a workflow under its name, off localStorage, with the editor's own Save button", () => {
+ const props = rootPropsFor(workflowSource);
+
+ expect(props.integration).toMatchObject({ strategy: 'props' });
+ expect(props.name).toBe('Refund desk');
+ expect(props.initialNodes).toBe(diagram.nodes);
+ expect(props.initialEdges).toBe(diagram.edges);
+ expect(props.plugins).not.toContain(runViewPlugin);
+ });
+
+ it('opens a run under a short run name, off localStorage, with Save hidden', () => {
+ const props = rootPropsFor(executionSource);
+
+ expect(props.integration).toMatchObject({ strategy: 'props' });
+ expect(props.name).toBe('Run 7c9e6679');
+ expect(props.plugins).toContain(runViewPlugin);
+ });
+
+ it('hands every render the same props for one opened source', () => {
+ expect(rootPropsFor(workflowSource)).toBe(rootPropsFor(workflowSource));
+ expect(rootPropsFor(workflowSource).integration).not.toBe(rootPropsFor(executionSource).integration);
+ });
+
+ it('refuses the editor save a run view never triggers', async () => {
+ await expect(neverSaves(rootPropsFor(executionSource) as never)).rejects.toThrow();
+ });
+});
diff --git a/apps/ai-studio/src/app/root-props.ts b/apps/ai-studio/src/app/root-props.ts
new file mode 100644
index 000000000..792c4c50a
--- /dev/null
+++ b/apps/ai-studio/src/app/root-props.ts
@@ -0,0 +1,75 @@
+import type {
+ OnSaveExternal,
+ WorkflowBuilderEdge,
+ WorkflowBuilderIntegration,
+ WorkflowBuilderNode,
+ WorkflowBuilderPlugin,
+} from '@workflowbuilder/sdk';
+
+import { saveDraftOf } from '../adapters/save-workflow-draft';
+import { supportTriageFlow } from '../data/support-triage-flow';
+import { plugin as aiStudioFeaturesPlugin } from '../plugin';
+import { plugin as runViewPlugin } from '../plugins/run-view/plugin';
+import { plugin as undoRedoPlugin } from '../plugins/undo-redo/plugin-exports';
+import type { OpenedSource } from './open-from-url';
+
+const flagship = supportTriageFlow.value;
+
+export const neverSaves: OnSaveExternal = () =>
+ Promise.reject(new Error('A run view has no Save button, so the editor never saves it'));
+
+// Module-level, and one props object per opened source below: the run lock re-applies itself whenever the
+// editor's save callback changes identity.
+const RUN_VIEW_INTEGRATION: WorkflowBuilderIntegration = { strategy: 'props', onDataSave: neverSaves };
+const LOCAL_PLUGINS: WorkflowBuilderPlugin[] = [aiStudioFeaturesPlugin, undoRedoPlugin];
+const RUN_VIEW_PLUGINS: WorkflowBuilderPlugin[] = [...LOCAL_PLUGINS, runViewPlugin];
+
+type RootProps = {
+ name: string;
+ initialNodes: WorkflowBuilderNode[];
+ initialEdges: WorkflowBuilderEdge[];
+ integration?: WorkflowBuilderIntegration;
+ plugins: WorkflowBuilderPlugin[];
+};
+
+const propsBySource = new WeakMap();
+
+export function rootPropsFor(opened: OpenedSource): RootProps {
+ let props = propsBySource.get(opened);
+ if (props === undefined) {
+ props = buildRootProps(opened);
+ propsBySource.set(opened, props);
+ }
+ return props;
+}
+
+function buildRootProps(opened: OpenedSource): RootProps {
+ switch (opened.kind) {
+ case 'local': {
+ return {
+ name: flagship.name,
+ initialNodes: flagship.diagram.nodes,
+ initialEdges: flagship.diagram.edges,
+ plugins: LOCAL_PLUGINS,
+ };
+ }
+ case 'workflow': {
+ return {
+ name: opened.name,
+ initialNodes: opened.diagram.nodes,
+ initialEdges: opened.diagram.edges,
+ integration: { strategy: 'props', onDataSave: saveDraftOf(opened.workflowId) },
+ plugins: LOCAL_PLUGINS,
+ };
+ }
+ case 'execution': {
+ return {
+ name: `Run ${opened.executionId.slice(0, 8)}`,
+ initialNodes: opened.diagram.nodes,
+ initialEdges: opened.diagram.edges,
+ integration: RUN_VIEW_INTEGRATION,
+ plugins: RUN_VIEW_PLUGINS,
+ };
+ }
+ }
+}
diff --git a/apps/ai-studio/src/app/url-mode-local-draft.test.tsx b/apps/ai-studio/src/app/url-mode-local-draft.test.tsx
new file mode 100644
index 000000000..1d2769c6b
--- /dev/null
+++ b/apps/ai-studio/src/app/url-mode-local-draft.test.tsx
@@ -0,0 +1,190 @@
+import { getStoreNodes, useChangesTrackerStore, useStore } from '@workflowbuilder/sdk';
+import { act, useContext } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { IntegrationContext } from '../../../../packages/sdk/src/features/integration/components/integration-variants/context/integration-context-wrapper';
+import { RuntimeIntegrationWrapper } from '../../../../packages/sdk/src/features/integration/components/runtime-integration-wrapper';
+import { SaveButton } from '../../../../packages/sdk/src/features/integration/components/save-button/save-button';
+import {
+ showSnackbarSaveErrorIfNeeded,
+ showSnackbarSaveSuccessIfNeeded,
+} from '../../../../packages/sdk/src/features/integration/utils/show-snackbar';
+import { OptionalAppBarTools } from '../../../../packages/sdk/src/features/plugins-core/components/app/optional-app-bar-toolbar';
+import { resolveIntegration } from '../../../../packages/sdk/src/workflow-builder-root/resolve-integration';
+import { BACKEND_URL } from '../config';
+import { refundReviewFlow } from '../data/refund-review-flow';
+import { plugin as runViewPlugin } from '../plugins/run-view/plugin';
+import { jsonResponse } from '../test/json-response';
+import type { OpenedSource } from './open-from-url';
+import { rootPropsFor } from './root-props';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, Icon: ({ name }: { name: string }) => };
+});
+
+// Loading and saving call enqueueSnackbar, which needs a provider this test does not mount.
+vi.mock('../../../../packages/sdk/src/utils/show-translated-snackbar', () => ({ showTranslatedSnackbar: vi.fn() }));
+vi.mock('../../../../packages/sdk/src/features/integration/utils/show-snackbar', () => ({
+ showSnackbarSaveSuccessIfNeeded: vi.fn(),
+ showSnackbarSaveErrorIfNeeded: vi.fn(),
+}));
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const LOCAL_DRAFT_KEY = 'workflowBuilderDiagram';
+const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const WORKFLOW = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+const diagram = { nodes: refundReviewFlow.value.diagram.nodes, edges: refundReviewFlow.value.diagram.edges };
+const otherLocalDraft = JSON.stringify({
+ name: 'Local draft',
+ nodes: [{ id: 'local-1', type: 'node', position: { x: 0, y: 0 }, data: { type: 'x', properties: {} } }],
+ edges: [],
+});
+
+let save: ((isAutoSave: boolean) => Promise) | undefined;
+
+function SaveHandle() {
+ const { onSave } = useContext(IntegrationContext);
+ save = (isAutoSave) => onSave({ isAutoSave });
+ return null;
+}
+
+let container: HTMLDivElement;
+let root: ReturnType;
+let fetchMock: ReturnType;
+
+function mount(opened: OpenedSource) {
+ const props = rootPropsFor(opened);
+ const { strategy, endpoints, onDataSave } = resolveIntegration(props.integration);
+ act(() =>
+ root.render(
+
+
+
+
+
+ ,
+ ),
+ );
+}
+
+async function leaveThePage() {
+ await act(async () => {
+ globalThis.dispatchEvent(new Event('beforeunload'));
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+}
+
+// A real edit between saves, so each save carries something new.
+const moveFirstNode = () =>
+ act(() =>
+ useStore.setState((state) => ({
+ nodes: state.nodes.map((node, index) =>
+ index === 0 ? { ...node, position: { x: node.position.x + 10, y: node.position.y } } : node,
+ ),
+ })),
+ );
+
+const draftPatches = () =>
+ fetchMock.mock.calls.filter(
+ ([url, init]) => String(url).endsWith('/draft') && (init as RequestInit | undefined)?.method === 'PATCH',
+ );
+
+beforeEach(() => {
+ vi.mocked(showSnackbarSaveSuccessIfNeeded).mockClear();
+ vi.mocked(showSnackbarSaveErrorIfNeeded).mockClear();
+ useChangesTrackerStore.setState({ lastChangeName: '', lastChangeTimestamp: 0 });
+ vi.useFakeTimers();
+ localStorage.clear();
+ localStorage.setItem(LOCAL_DRAFT_KEY, otherLocalDraft);
+ fetchMock = vi.fn(async () => jsonResponse(200, { id: WORKFLOW, name: 'Refund desk' }));
+ vi.stubGlobal('fetch', fetchMock);
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+// Decorators register for the whole file, so the run view, which registers one, goes last.
+describe('the local draft in URL mode', () => {
+ it('is written in local mode when the page closes, so the checks below can see a write', async () => {
+ mount({ kind: 'local' });
+
+ await leaveThePage();
+
+ expect(localStorage.getItem(LOCAL_DRAFT_KEY)).not.toBe(otherLocalDraft);
+ });
+
+ it("a workflow from the link keeps the editor's Save button, and Save, autosave and the save on close go to its draft", async () => {
+ mount({ kind: 'workflow', workflowId: WORKFLOW, name: 'Refund desk', diagram });
+
+ expect(getStoreNodes().map((node) => node.id)).toEqual(diagram.nodes.map((node) => node.id));
+ expect(container.querySelectorAll('button')).toHaveLength(1);
+
+ await act(async () => {
+ await save?.(false);
+ });
+ moveFirstNode();
+ await act(async () => {
+ await save?.(true);
+ });
+ moveFirstNode();
+ await leaveThePage();
+
+ expect(draftPatches()).toHaveLength(3);
+ expect(draftPatches()[0]![0]).toBe(`${BACKEND_URL}/api/workflows/${WORKFLOW}/draft`);
+ expect(localStorage.getItem(LOCAL_DRAFT_KEY)).toBe(otherLocalDraft);
+ });
+
+ it('a workflow link opened and closed without an edit writes nothing', async () => {
+ mount({ kind: 'workflow', workflowId: WORKFLOW, name: 'Refund desk', diagram });
+
+ await leaveThePage();
+
+ expect(draftPatches()).toHaveLength(0);
+ });
+
+ it("a Save the server refuses shows the SDK's error, not its success", async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(500, { message: 'boom' }));
+ mount({ kind: 'workflow', workflowId: WORKFLOW, name: 'Refund desk', diagram });
+
+ let status: unknown;
+ await act(async () => {
+ status = await save?.(false);
+ });
+
+ expect(status).toBe('error');
+ expect(showSnackbarSaveSuccessIfNeeded).not.toHaveBeenCalled();
+ expect(showSnackbarSaveErrorIfNeeded).toHaveBeenCalledWith({ isAutoSave: false });
+ });
+
+ it('a run from the link has no Save button, saves nowhere and leaves the local draft alone', async () => {
+ runViewPlugin();
+
+ mount({ kind: 'execution', executionId: RUN, diagram });
+
+ expect(container.querySelectorAll('button')).toHaveLength(0);
+ await leaveThePage();
+ expect(draftPatches()).toHaveLength(0);
+ expect(localStorage.getItem(LOCAL_DRAFT_KEY)).toBe(otherLocalDraft);
+ });
+});
diff --git a/apps/ai-studio/src/app/workflow-link-close-save.test.tsx b/apps/ai-studio/src/app/workflow-link-close-save.test.tsx
new file mode 100644
index 000000000..0798464a4
--- /dev/null
+++ b/apps/ai-studio/src/app/workflow-link-close-save.test.tsx
@@ -0,0 +1,151 @@
+import { trackFutureChange, useChangesTrackerStore, useStore } from '@workflowbuilder/sdk';
+import { Suspense } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useIntegrationStore } from '../../../../packages/sdk/src/features/integration/stores/use-integration-store';
+import { refundReviewFlow } from '../data/refund-review-flow';
+import { resetExecution } from '../stores/use-execution-store';
+import { jsonResponse } from '../test/json-response';
+import { App } from './app';
+import type { OpenedSource } from './open-from-url';
+
+vi.setConfig({ testTimeout: 30_000 });
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+
+const WORKFLOW = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+
+// jsdom lays nothing out; browsers report every observed node's size right after it mounts.
+class ReportingResizeObserver {
+ constructor(private readonly callback: (entries: Array<{ target: Element }>, observer: unknown) => void) {}
+ observe(target: Element) {
+ setTimeout(() => this.callback([{ target }], this), 16);
+ }
+ unobserve() {}
+ disconnect() {}
+}
+
+const sizeDescriptors = {
+ offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'),
+ offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'),
+};
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+let fetchMock: ReturnType;
+let container: HTMLDivElement;
+let unmount: (() => void) | undefined;
+
+const draftPatches = () =>
+ fetchMock.mock.calls.filter(
+ ([url, init]) => String(url).endsWith('/draft') && (init as RequestInit | undefined)?.method === 'PATCH',
+ );
+
+async function openWorkflowLink() {
+ const opened: OpenedSource = {
+ kind: 'workflow',
+ workflowId: WORKFLOW,
+ name: 'Refund desk',
+ diagram: structuredClone({
+ nodes: refundReviewFlow.value.diagram.nodes,
+ edges: refundReviewFlow.value.diagram.edges,
+ }),
+ };
+ const root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ unmount = () => root.unmount();
+ for (let tries = 0; tries < 100 && !container.querySelector('[aria-label="Save"]'); tries++) await wait(50);
+ await wait(300);
+}
+
+async function leaveThePage() {
+ globalThis.dispatchEvent(new Event('beforeunload'));
+ await wait(200);
+}
+
+const clickFirstNode = async () => {
+ container.querySelector('.react-flow__node')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await wait(100);
+};
+
+// Real timers: fake ones freeze Date.now, and the save rule compares change times with the open.
+beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
+ resetExecution();
+ localStorage.clear();
+ localStorage.setItem('ai-studio:disclaimer-acknowledged-v2', 'true');
+ useChangesTrackerStore.setState({ lastChangeName: '', lastChangeParams: {}, lastChangeTimestamp: 0 });
+ useIntegrationStore.setState({ savingStatus: 'disabled' });
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 240 });
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 80 });
+ vi.stubGlobal('ResizeObserver', ReportingResizeObserver);
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener() {},
+ removeEventListener() {},
+ addListener() {},
+ removeListener() {},
+ onchange: null,
+ dispatchEvent: () => false,
+ }));
+ vi.stubGlobal(
+ 'DOMMatrixReadOnly',
+ class {
+ m22 = 1;
+ },
+ );
+ fetchMock = vi.fn(async () => jsonResponse(200, { id: WORKFLOW, name: 'Refund desk' }));
+ vi.stubGlobal('fetch', fetchMock);
+ container = document.createElement('div');
+ document.body.append(container);
+});
+
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ container.remove();
+ for (const [name, descriptor] of Object.entries(sizeDescriptors)) {
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, name, descriptor);
+ }
+ vi.unstubAllGlobals();
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+});
+
+describe('a workflow link on the real canvas, closed', () => {
+ it('writes nothing after the canvas measured its nodes and a click selected one', async () => {
+ await openWorkflowLink();
+ await clickFirstNode();
+
+ await leaveThePage();
+
+ expect(useChangesTrackerStore.getState().lastChangeName).toBe('nodeDragChange');
+ expect(draftPatches()).toHaveLength(0);
+ });
+
+ it('writes a property edit, even when a click followed it', async () => {
+ await openWorkflowLink();
+ trackFutureChange('dataUpdate');
+ useStore.setState((state) => ({
+ nodes: state.nodes.map((node, index) =>
+ index === 0
+ ? { ...node, data: { ...node.data, properties: { ...node.data.properties, label: 'Edited' } } }
+ : node,
+ ),
+ }));
+ await clickFirstNode();
+
+ await leaveThePage();
+
+ expect(draftPatches()).toHaveLength(1);
+ expect(String((draftPatches()[0]![1] as RequestInit).body)).toContain('"label":"Edited"');
+ });
+});
diff --git a/apps/ai-studio/src/components/ai-agent/response-control.test.tsx b/apps/ai-studio/src/components/ai-agent/response-control.test.tsx
new file mode 100644
index 000000000..bd1e8129a
--- /dev/null
+++ b/apps/ai-studio/src/components/ai-agent/response-control.test.tsx
@@ -0,0 +1,323 @@
+import { useStore } from '@workflowbuilder/sdk';
+import type { ControlProps, PaletteItem } from '@workflowbuilder/sdk';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The editor's real panel, so the control runs with the tester, validator and store AI Studio gives it.
+import { registerCustomRenderers } from '../../../../../packages/sdk/src/features/json-form/extension-registry';
+import { NodeProperties } from '../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties';
+import { aiAgentPaletteItem } from '../../nodes/ai-agent';
+import { uischema } from '../../nodes/ai-agent/uischema';
+import { refundReviewOutputSchema } from '../../utils/ai-agent/response-options';
+import { ResponseControl, responseControlRenderer } from './response-control';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+registerCustomRenderers([responseControlRenderer]);
+
+// Base UI opens and selects on the pointer sequence, not on `click` alone.
+const click = (element: Element) =>
+ act(() => {
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
+ element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
+ }
+ (element as HTMLElement).click();
+ });
+
+describe('ResponseControl', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+ const handleChange = vi.fn();
+
+ const render = ({ data, enabled = true }: { data?: unknown; enabled?: boolean } = {}) =>
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+
+ beforeEach(() => {
+ handleChange.mockClear();
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ const trigger = () => container.querySelector('button');
+ const choose = (label: string) => {
+ click(trigger()!);
+ const option = [...document.querySelectorAll('[role="option"]')].find((candidate) =>
+ candidate.textContent?.includes(label),
+ );
+ click(option!);
+ };
+
+ // The element on the node and the tester that claims it are written apart; a rename of one is silent.
+ it('claims exactly one element of the node uischema', () => {
+ const elements = (uischema as unknown as { elements: unknown[] }).elements;
+ const ranks = elements.map((element) => responseControlRenderer.tester(element as never, {} as never, {} as never));
+
+ expect(ranks.filter((rank) => rank > 0)).toHaveLength(1);
+ });
+
+ it('shows plain text for a node without an output schema', () => {
+ render();
+
+ expect(container.textContent).toContain('Response format');
+ expect(container.textContent).toContain('Plain text');
+ });
+
+ it('shows the refund review option for the seeded schema', () => {
+ render({ data: refundReviewOutputSchema });
+
+ expect(container.textContent).toContain('Structured: refund review');
+ });
+
+ it('writes the refund review schema when that option is chosen', () => {
+ render();
+
+ choose('Structured: refund review');
+
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ expect(handleChange).toHaveBeenCalledWith('outputSchema', refundReviewOutputSchema);
+ });
+
+ it('clears the schema when plain text is chosen', () => {
+ render({ data: refundReviewOutputSchema });
+
+ choose('Plain text');
+
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ expect(handleChange).toHaveBeenCalledWith('outputSchema', undefined);
+ });
+
+ it('writes nothing when the selected option is chosen again', () => {
+ render({ data: refundReviewOutputSchema });
+
+ choose('Structured: refund review');
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it('writes nothing when plain text is chosen again on a node without a schema', () => {
+ render();
+
+ choose('Plain text');
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it('writes nothing when plain text is chosen again on a node with a null schema', () => {
+ render({ data: null });
+
+ choose('Plain text');
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it('writes nothing when the preset is chosen again on a copy of it', () => {
+ render({ data: structuredClone(refundReviewOutputSchema) });
+
+ choose('Structured: refund review');
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it('lists the custom schema entry on a node on a preset too, so the list never changes length', () => {
+ render({ data: refundReviewOutputSchema });
+
+ click(trigger()!);
+
+ const labels = [...document.querySelectorAll('[role="option"]')].map((option) => option.textContent);
+ expect(labels).toEqual(['Plain text', 'Structured: refund review', 'Structured: custom schema']);
+ });
+
+ describe('with a schema no preset matches', () => {
+ const otherSchema = { type: 'object', properties: { score: { type: 'number' } } };
+
+ it('shows it as a custom schema, not as plain text', () => {
+ render({ data: otherSchema });
+
+ expect(trigger()?.textContent).toContain('Structured: custom schema');
+ });
+
+ it('cannot choose the custom schema entry', () => {
+ render({ data: otherSchema });
+
+ choose('Structured: custom schema');
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it('clears the schema when plain text is chosen', () => {
+ render({ data: otherSchema });
+
+ choose('Plain text');
+
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ expect(handleChange).toHaveBeenCalledWith('outputSchema', undefined);
+ });
+ });
+
+ it('is disabled when enabled is false', () => {
+ render({ enabled: false });
+
+ const button = trigger();
+ expect(button?.disabled === true || button?.getAttribute('aria-disabled') === 'true').toBe(true);
+ });
+});
+
+const storedProperties = () => useStore.getState().nodes[0]?.data.properties;
+const propertiesOf = (id: string) => useStore.getState().nodes.find((node) => node.id === id)?.data.properties;
+
+const aiAgentNode = (id: string, properties: Record) => ({
+ id,
+ position: { x: 0, y: 0 },
+ data: {
+ type: aiAgentPaletteItem.type,
+ icon: aiAgentPaletteItem.icon,
+ properties: { ...aiAgentPaletteItem.defaultPropertiesData, ...properties },
+ },
+});
+
+// JsonForms reports a change after a short debounce; the node data follows that report.
+const settle = () =>
+ act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+
+describe('the Response format in the properties panel', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ const renderPanel = (properties: Record, isReadOnlyMode = false) => {
+ const node = aiAgentNode('draft-1', properties);
+ act(() => useStore.setState({ data: [aiAgentPaletteItem as PaletteItem], nodes: [node], isReadOnlyMode }));
+ act(() => root.render( ));
+ };
+
+ const trigger = () => container.querySelector('[role="combobox"]');
+ // Clicking another node on the canvas closes the list, so the node switch meets it still mounted.
+ const openList = () => click(trigger()!);
+
+ const choose = async (label: string) => {
+ click(trigger()!);
+ const option = [...document.querySelectorAll('[role="option"]')].find((candidate) =>
+ candidate.textContent?.includes(label),
+ );
+ click(option!);
+ await settle();
+ };
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(async () => {
+ await settle();
+ act(() => root.unmount());
+ container.remove();
+ useStore.setState({ data: [], nodes: [], isReadOnlyMode: false });
+ });
+
+ it('renders the node uischema element with this control', () => {
+ renderPanel({});
+
+ expect(trigger()?.textContent).toContain('Plain text');
+ });
+
+ it('writes the preset to the node, and the node schema accepts it', async () => {
+ renderPanel({});
+
+ await choose('Structured: refund review');
+
+ expect(storedProperties()?.['outputSchema']).toEqual(refundReviewOutputSchema);
+ expect(storedProperties()?.errors).toEqual([]);
+ });
+
+ it('removes the key from the node when plain text is chosen', async () => {
+ renderPanel({ outputSchema: refundReviewOutputSchema });
+
+ await choose('Plain text');
+
+ expect(storedProperties()).not.toHaveProperty('outputSchema');
+ });
+
+ it('leaves the node untouched when the preset is chosen again on a copy of it', async () => {
+ renderPanel({ outputSchema: structuredClone(refundReviewOutputSchema) });
+ const nodes = useStore.getState().nodes;
+
+ await choose('Structured: refund review');
+
+ expect(useStore.getState().nodes).toBe(nodes);
+ });
+
+ it('is disabled in read-only mode', () => {
+ renderPanel({}, true);
+
+ const button = trigger();
+ expect(button?.disabled === true || button?.getAttribute('aria-disabled') === 'true').toBe(true);
+ });
+
+ describe('beside a node whose schema no preset matches', () => {
+ const custom = aiAgentNode('custom-1', { outputSchema: { type: 'object', properties: { score: {} } } });
+ const preset = aiAgentNode('preset-1', { outputSchema: refundReviewOutputSchema });
+ const text = aiAgentNode('text-1', {});
+
+ // The properties bar renders NodeProperties without a key, so selecting another node reuses the control.
+ const select = (node: typeof custom) => act(() => root.render( ));
+
+ beforeEach(() => {
+ act(() => useStore.setState({ data: [aiAgentPaletteItem as PaletteItem], nodes: [custom, preset, text] }));
+ });
+
+ it('keeps the preset chosen on the custom node', async () => {
+ select(custom);
+
+ await choose('Structured: refund review');
+
+ expect(propertiesOf('custom-1')?.['outputSchema']).toEqual(refundReviewOutputSchema);
+ });
+
+ it('leaves the next node alone when the panel switches away from the custom node with its list open', async () => {
+ select(custom);
+ openList();
+
+ select(preset);
+ await settle();
+
+ expect(propertiesOf('preset-1')?.['outputSchema']).toBe(refundReviewOutputSchema);
+ });
+
+ it('writes no schema onto a plain-text node shown after a preset node and the custom one with its list open', async () => {
+ select(preset);
+ select(custom);
+ openList();
+
+ select(text);
+ await settle();
+
+ expect(propertiesOf('text-1')).not.toHaveProperty('outputSchema');
+ });
+ });
+});
diff --git a/apps/ai-studio/src/components/ai-agent/response-control.tsx b/apps/ai-studio/src/components/ai-agent/response-control.tsx
new file mode 100644
index 000000000..83f408a86
--- /dev/null
+++ b/apps/ai-studio/src/components/ai-agent/response-control.tsx
@@ -0,0 +1,36 @@
+import { FormControlWithLabel, rankWith, uiTypeIs, withJsonFormsControlProps } from '@workflowbuilder/sdk';
+import type { ControlProps, JsonFormsRendererExtension } from '@workflowbuilder/sdk';
+import { Select } from '@workflowbuilder/ui';
+import type { SelectBaseProps } from '@workflowbuilder/ui';
+
+import {
+ customResponseOption,
+ outputSchemaFor,
+ responseOptionOf,
+ responseOptions,
+} from '../../utils/ai-agent/response-options';
+
+// Fixed length: a mounted Base UI Select resets its value when its items change, and the reset arrives as a change.
+const items = [...responseOptions, customResponseOption];
+
+// Edits the node's `outputSchema` as a choice between presets; the schema itself is never typed here.
+export function ResponseControl({ data, handleChange, path, enabled, label }: ControlProps) {
+ const current = responseOptionOf(data);
+
+ // Base UI reports a click on the selected item as a change.
+ const onChange: SelectBaseProps['onChange'] = (_event, value) => {
+ if (value === current) return;
+ handleChange(path, outputSchemaFor(value));
+ };
+
+ return (
+
+
+
+ );
+}
+
+export const responseControlRenderer: JsonFormsRendererExtension = {
+ tester: rankWith(5, uiTypeIs('ResponseSelect')),
+ renderer: withJsonFormsControlProps(ResponseControl),
+};
diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.module.css b/apps/ai-studio/src/components/controls/ai-studio-controls.module.css
index 80833ed6a..c9796e7b8 100644
--- a/apps/ai-studio/src/components/controls/ai-studio-controls.module.css
+++ b/apps/ai-studio/src/components/controls/ai-studio-controls.module.css
@@ -17,9 +17,9 @@
}
.panel {
- background: var(--wb-app-bar-background);
- border-radius: var(--wb-app-bar-border-radius);
- border: 0.0625rem solid var(--wb-app-bar-border-color);
+ background: var(--wb-sdk-app-bar-background);
+ border-radius: var(--wb-sdk-app-bar-border-radius);
+ border: 0.0625rem solid var(--wb-sdk-app-bar-border-color);
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
@@ -29,3 +29,8 @@
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
+
+/* Run and Stop replace each other, so a shared width keeps the swap from resizing the button. */
+.run-stop-button {
+ min-width: var(--wb-ds-size-1200);
+}
diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.test.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.test.tsx
new file mode 100644
index 000000000..ccbf6fa25
--- /dev/null
+++ b/apps/ai-studio/src/components/controls/ai-studio-controls.test.tsx
@@ -0,0 +1,389 @@
+import { useStore } from '@workflowbuilder/sdk';
+import { type ComponentProps, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ExecutionStatus } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import styles from './ai-studio-controls.module.css';
+
+import { BACKEND_URL } from '../../config';
+import { supportTriageFlow } from '../../data/support-triage-flow';
+import {
+ applyConnectionLost,
+ applySnapshot,
+ applyStopRequested,
+ resetExecution,
+ setExecutionStarted,
+ useExecutionStore,
+} from '../../stores/use-execution-store';
+import { useNoticesStore } from '../../stores/use-notices-store';
+import { cancelledEvent, snapshotFrame } from '../../test/execution-history';
+import { installFakeEventSource, latestStream, openStreams } from '../../test/fake-event-source';
+import { jsonResponse } from '../../test/json-response';
+import { AiStudioControls } from './ai-studio-controls';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, Icon: ({ name }: { name: string }) => };
+});
+
+const address = vi.hoisted(() => ({ leaveRunView: vi.fn() }));
+vi.mock('../../utils/open-from-url/address-execution-id', async (importOriginal) => ({
+ ...(await importOriginal()),
+ leaveRunView: address.leaveRunView,
+}));
+
+const startNode = vi.hoisted(() => ({ exists: true }));
+vi.mock('../../hooks/use-has-start-node', () => ({ useHasStartNode: () => startNode.exists }));
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+function setRunStatus(status: ExecutionStatus) {
+ act(() => applySnapshot(snapshotFrame(status, [])));
+}
+
+describe('AiStudioControls', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ installFakeEventSource();
+ fetchMock = vi.fn(async () => jsonResponse(200, { id: 'exec-1', status: 'cancelling' }));
+ vi.stubGlobal('fetch', fetchMock);
+ resetExecution();
+ startNode.exists = true;
+ useNoticesStore.setState({ notices: [] });
+ address.leaveRunView.mockClear();
+ useStore.getState().setToggleReadOnlyMode(false);
+ useStore.setState({ nodes: [], edges: [] });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ render();
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ const render = (props: Partial> = {}) =>
+ act(() => root.render( ));
+
+ const icons = () => [...container.querySelectorAll('[data-icon]')].map((icon) => icon.dataset['icon']);
+
+ const clickIcon = (name: string) =>
+ act(async () => {
+ container.querySelector(`[data-icon="${name}"]`)?.closest('button')?.click();
+ });
+
+ const clickStop = () => clickIcon('Stop');
+ const clickReset = () => clickIcon('ArrowCounterClockwise');
+
+ const buttonOf = (name: string) => container.querySelector(`[data-icon="${name}"]`)?.closest('button');
+
+ const labelOf = (name: string) => buttonOf(name)?.getAttribute('aria-label');
+
+ const isVisible = () => container.firstElementChild?.classList.contains(styles['container--visible']!);
+
+ const deleteStartNode = () => {
+ startNode.exists = false;
+ render();
+ };
+
+ // The words on Run and Stop are their names; an aria-label would replace what a person reads.
+ it('names Run and Stop by the words on them', () => {
+ expect(buttonOf('Play')?.textContent).toBe('Run');
+ expect(labelOf('Play')).toBeNull();
+
+ setRunStatus('running');
+ expect(buttonOf('Stop')?.textContent).toBe('Stop');
+ expect(labelOf('Stop')).toBeNull();
+ });
+
+ it('offers Stop while the run waits for a decision, the same as while it starts or runs', () => {
+ setRunStatus('pending');
+ expect(icons()).toEqual(['Stop']);
+
+ setRunStatus('running');
+ expect(icons()).toEqual(['Stop']);
+
+ setRunStatus('waiting');
+ expect(icons()).toEqual(['Stop']);
+ });
+
+ it('offers Stop as soon as Run is pressed, so a second start cannot follow before the backend answers', async () => {
+ const request = vi.fn(() => new Promise(() => {}));
+ vi.stubGlobal('fetch', request);
+ setRunStatus('completed');
+ expect(icons()).toEqual(['Play', 'ArrowCounterClockwise']);
+
+ await clickIcon('Play');
+
+ expect(icons()).toEqual(['Stop']);
+ expect(buttonOf('Stop')?.disabled).toBe(true);
+ await clickStop();
+ expect(request).toHaveBeenCalledTimes(1);
+ });
+
+ it('offers Run again after a start the backend refused', async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(503, { message: 'Unavailable' }));
+ const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await clickIcon('Play');
+
+ expect(logged).toHaveBeenCalled();
+ expect(icons()).toEqual(['Play']);
+ });
+
+ it('tells the user why a start failed', async () => {
+ fetchMock.mockImplementation(async () => jsonResponse(503, { message: 'Unavailable' }));
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await clickIcon('Play');
+
+ expect(useNoticesStore.getState().notices.map((notice) => notice.text)).toEqual([
+ 'The run did not start: Unavailable.',
+ ]);
+ });
+
+ it("Run on the link's workflow saves into that workflow first", async () => {
+ const workflow = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+ render({ workflowId: workflow });
+
+ await clickIcon('Play');
+
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(`${BACKEND_URL}/api/workflows/${workflow}/draft`);
+ });
+
+ it("Run saves the editor's clean shape, which autosave compares against, and still reads the prompt", async () => {
+ const start = supportTriageFlow.value.diagram.nodes.find((node) => node.data.isStartNode)!;
+ useStore.setState({
+ nodes: [{ ...start, selected: true, dragging: true, measured: { width: 200, height: 80 } }],
+ edges: [],
+ });
+ render({ workflowId: '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11' });
+
+ await clickIcon('Play');
+
+ const [draft, execute] = fetchMock.mock.calls;
+ expect(JSON.parse(String(draft?.[1]?.body)).draftJson.nodes).toEqual([{ ...start, selected: false }]);
+ expect(JSON.parse(String(execute?.[1]?.body)).triggerPayload).toEqual({
+ input: (start.data.properties as { inputPrompt: string }).inputPrompt,
+ });
+ });
+
+ // The canvas holds the run's graph, which is saved nowhere; running it again is a feature of its own.
+ it.each([
+ ['under a workflow link', { isRunView: true, workflowId: '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11' }],
+ ['on its own', { isRunView: true }],
+ ])('offers no Run in a run view %s, only Reset', (_, props) => {
+ render(props);
+ setRunStatus('completed');
+
+ expect(icons()).toEqual(['ArrowCounterClockwise']);
+ });
+
+ it('adds Reset once a cancel is in flight: a cancel the server never resolves would trap the user', () => {
+ act(() => applySnapshot(snapshotFrame('cancelling')));
+
+ expect(icons()).toEqual(['Stop', 'ArrowCounterClockwise']);
+ });
+
+ it('offers Stop, and no Reset, after the stream was lost: the run may still be alive on the server', () => {
+ setRunStatus('waiting');
+ act(() => applyConnectionLost());
+
+ expect(icons()).toEqual(['Stop']);
+ });
+
+ it('Stop after a lost stream still asks the server to cancel', async () => {
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ setRunStatus('waiting');
+ act(() => applyConnectionLost());
+
+ await clickStop();
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${BACKEND_URL}/api/executions/exec-1`,
+ expect.objectContaining({ method: 'DELETE' }),
+ );
+ });
+
+ it('offers Play and Reset once the run has ended', () => {
+ setRunStatus('waiting');
+ setRunStatus('completed');
+
+ expect(icons()).toEqual(['Play', 'ArrowCounterClockwise']);
+ });
+
+ it('adds Reset alongside Stop once a Stop was asked for after a lost stream: the user is never trapped', () => {
+ setRunStatus('waiting');
+ act(() => applyConnectionLost());
+ act(() => applyStopRequested());
+
+ expect(icons()).toEqual(['Stop', 'ArrowCounterClockwise']);
+ });
+
+ it('that Reset clears the canvas and asks the server nothing: it abandons the run, it does not cancel it', async () => {
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ setRunStatus('waiting');
+ act(() => applyConnectionLost());
+ act(() => applyStopRequested());
+
+ await clickReset();
+
+ expect(useExecutionStore.getState()).toMatchObject({ status: 'idle', executionId: undefined });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('Reset stays on the page in the local draft', async () => {
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ setRunStatus('completed');
+
+ await clickReset();
+
+ expect(address.leaveRunView).not.toHaveBeenCalled();
+ });
+
+ it('Reset in the run view forgets the run and goes back to where edits are saved', async () => {
+ render({ isRunView: true });
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ setRunStatus('completed');
+
+ await clickReset();
+
+ expect(useExecutionStore.getState()).toMatchObject({ status: 'idle', executionId: undefined });
+ expect(address.leaveRunView).toHaveBeenCalledTimes(1);
+ });
+
+ it('a Stop the server answers with execution_not_found leaves the run view, as Reset does', async () => {
+ render({ isRunView: true });
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ setRunStatus('waiting');
+ fetchMock.mockImplementation(async () =>
+ jsonResponse(404, { code: 'execution_not_found', message: 'Execution not found' }),
+ );
+
+ await clickStop();
+
+ expect(useExecutionStore.getState()).toMatchObject({ status: 'idle', executionId: undefined });
+ expect(address.leaveRunView).toHaveBeenCalledTimes(1);
+ });
+
+ it('the same answer in the local draft stays on the page and offers Run', async () => {
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ setRunStatus('waiting');
+ fetchMock.mockImplementation(async () =>
+ jsonResponse(404, { code: 'execution_not_found', message: 'Execution not found' }),
+ );
+
+ await clickStop();
+
+ expect(address.leaveRunView).not.toHaveBeenCalled();
+ expect(icons()).toEqual(['Play']);
+ });
+
+ it('keeps the Reset escape when a snapshot arrives again: an answering server has not ended the run', () => {
+ setRunStatus('waiting');
+ act(() => applyConnectionLost());
+ act(() => applyStopRequested());
+
+ act(() => applySnapshot(snapshotFrame('waiting')));
+
+ expect(icons()).toEqual(['Stop', 'ArrowCounterClockwise']);
+ });
+
+ it('names Reset as abandoning the run once a Stop was asked for on a live run', () => {
+ setRunStatus('waiting');
+ act(() => applyStopRequested());
+
+ expect(labelOf('ArrowCounterClockwise')).toContain('may still be running');
+ });
+
+ it('names Reset plainly once the run has ended, even after a Stop was asked for', () => {
+ setRunStatus('waiting');
+ act(() => applyStopRequested());
+ setRunStatus('completed');
+
+ expect(labelOf('ArrowCounterClockwise')).toBe('Reset');
+ });
+
+ it('keeps the canvas read-only while it shows a run, ended or not, and gives it back on reset', () => {
+ setRunStatus('waiting');
+ expect(useStore.getState().isReadOnlyMode).toBe(true);
+
+ setRunStatus('completed');
+ expect(useStore.getState().isReadOnlyMode).toBe(true);
+
+ act(() => resetExecution());
+ expect(useStore.getState().isReadOnlyMode).toBe(false);
+ });
+
+ describe('without a start node', () => {
+ it('hides the controls while there is no run', () => {
+ expect(isVisible()).toBe(true);
+ deleteStartNode();
+
+ expect(isVisible()).toBe(false);
+ });
+
+ it('keeps Stop reachable for a live run, and offers no Play', () => {
+ setRunStatus('waiting');
+ deleteStartNode();
+
+ expect(isVisible()).toBe(true);
+ expect(icons()).toEqual(['Stop']);
+ });
+
+ it('offers Reset, and no Play, once the run has ended', () => {
+ setRunStatus('completed');
+ deleteStartNode();
+
+ expect(isVisible()).toBe(true);
+ expect(icons()).toEqual(['ArrowCounterClockwise']);
+ });
+ });
+
+ describe('after Stop', () => {
+ beforeEach(() => {
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ act(() => applySnapshot(snapshotFrame('waiting')));
+ });
+
+ it('an accepted cancel offers Stop and Reset while cancelling, then Play and Reset once cancelled', async () => {
+ await clickStop();
+ act(() => latestStream().emit(snapshotFrame('cancelling')));
+
+ expect(useExecutionStore.getState()).toMatchObject({ status: 'cancelling', isStopRequested: true });
+ expect(icons()).toEqual(['Stop', 'ArrowCounterClockwise']);
+
+ act(() => latestStream().emit(cancelledEvent));
+
+ expect(useExecutionStore.getState().status).toBe('cancelled');
+ expect(openStreams()).toHaveLength(0);
+ expect(icons()).toEqual(['Play', 'ArrowCounterClockwise']);
+ });
+
+ it.each([
+ [200, { id: 'exec-1', status: 'cancelling' }],
+ [409, { code: 'execution_not_cancellable', message: 'Execution already finished' }],
+ ])('a %i whose fresh stream is refused keeps the run and offers Stop and Reset', async (status, body) => {
+ fetchMock.mockImplementation(async () => jsonResponse(status, body));
+
+ await clickStop();
+ act(() => latestStream().refuse());
+
+ expect(useExecutionStore.getState()).toMatchObject({ status: 'disconnected', executionId: 'exec-1' });
+ expect(icons()).toEqual(['Stop', 'ArrowCounterClockwise']);
+ });
+ });
+});
diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx
index 45133ba32..6bafa576d 100644
--- a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx
+++ b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx
@@ -1,34 +1,62 @@
-import { Icon, getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk';
-import { NavButton } from '@workflowbuilder/ui';
+import { Icon, getStoreDataForIntegration } from '@workflowbuilder/sdk';
+import { Button, NavButton } from '@workflowbuilder/ui';
import clsx from 'clsx';
-import { useCallback } from 'react';
+import { useCallback, useState } from 'react';
import styles from './ai-studio-controls.module.css';
import { useBackendExecution } from '../../hooks/use-backend-execution';
import { useHasStartNode } from '../../hooks/use-has-start-node';
+import { useRunLocksCanvas } from '../../hooks/use-run-locks-canvas';
+import { isRunAlive, useExecutionStore } from '../../stores/use-execution-store';
+import { addNotice } from '../../stores/use-notices-store';
+import { leaveRunView } from '../../utils/open-from-url/address-execution-id';
-export function AiStudioControls() {
- const { executeFromCanvas, cancel, reset, status } = useBackendExecution();
- const shouldShowControls = useHasStartNode();
+type Props = {
+ /** The link's workflow: Run saves the canvas into its draft before it runs. */
+ workflowId?: string;
+ /** The canvas shows a run's graph, saved nowhere: no Run, and Reset reloads the page without the run. */
+ isRunView: boolean;
+};
+
+export function AiStudioControls({ workflowId, isRunView }: Props) {
+ // A run view shows only its run: once the run is forgotten, the page reloads onto the workflow or the local draft.
+ const { executeFromCanvas, cancel, reset, status } = useBackendExecution(isRunView ? leaveRunView : undefined);
+ const hasStartNode = useHasStartNode();
+ // A run outlives its trigger node, so Stop and Reset stay reachable after it is deleted.
+ const shouldShowControls = hasStartNode || status !== 'idle';
+ const isStopRequested = useExecutionStore((state) => state.isStopRequested);
+ // A start waits for the backend; a second one meanwhile would leave two runs streaming into one view.
+ const [isStarting, setIsStarting] = useState(false);
+ useRunLocksCanvas();
+ const canRun = hasStartNode && !isRunView;
const handleExecute = useCallback(async () => {
- const nodes = getStoreNodes();
- const edges = getStoreEdges();
+ // The shape the editor's autosave sends, so its compare recognises Run's write.
+ const { nodes, edges } = getStoreDataForIntegration();
const startNode = nodes.find((n) => n.data.isStartNode);
const inputPrompt = (startNode?.data.properties as { inputPrompt?: string })?.inputPrompt ?? '';
const triggerPayload = inputPrompt ? { input: inputPrompt } : {};
+ setIsStarting(true);
try {
- await executeFromCanvas(nodes, edges, triggerPayload);
+ await executeFromCanvas(nodes, edges, triggerPayload, workflowId);
} catch (error) {
console.error('Execution failed:', error);
+ addNotice(`The run did not start: ${error instanceof Error ? error.message : String(error)}.`, 'error');
+ } finally {
+ setIsStarting(false);
}
- }, [executeFromCanvas]);
+ }, [executeFromCanvas, workflowId]);
- const isRunning = status === 'pending' || status === 'running';
- const isDone = status === 'completed' || status === 'incomplete' || status === 'failed' || status === 'cancelled';
+ const isRunning = isRunAlive(status);
+ const isDone = status !== 'idle' && !isRunning;
+ // Any Stop may never resolve, so asking is enough to offer Reset; `cancelling` covers one asked before a reload.
+ const hasAskedToStop = isStopRequested || status === 'cancelling';
+ const isDoneOrStuck = isDone || hasAskedToStop;
+ const resetTooltip =
+ !isDone && hasAskedToStop ? 'Reset without cancelling: the run may still be running on the server' : 'Reset';
return (
- {isRunning ? (
-
-
-
- ) : (
-
-
-
- )}
- {isDone && (
-
-
-
+ {isRunning || isStarting ? (
+ // There is no run to cancel until the backend names it.
+ }
+ >
+ Stop
+
+ ) : canRun ? (
+ }
+ >
+ Run
+
+ ) : null}
+ {isDoneOrStuck && !isStarting && (
+ }
+ />
)}
diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css
index 24fe2fe2b..fda55d594 100644
--- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css
+++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css
@@ -14,13 +14,15 @@
position: relative;
width: 100%;
max-width: 460px;
+ max-height: 100%;
+ overflow-y: auto;
padding: 2rem;
- font-family: var(--wb-font-family);
- background: var(--ax-ui-bg-primary-default, #ffffff);
- border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
- border-radius: var(--wb-app-bar-border-radius, 0.75rem);
+ font-family: var(--wb-public-font-family, 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
+ background: var(--wb-ds-ui-bg-base);
+ border: 0.0625rem solid var(--wb-ds-ui-stroke-default);
+ border-radius: var(--wb-sdk-app-bar-border-radius);
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.25);
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
}
.close {
@@ -35,13 +37,13 @@
border: none;
border-radius: 0.5rem;
background: transparent;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
cursor: pointer;
}
.close:hover {
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
- color: var(--ax-txt-primary-default, #151516);
+ background: var(--wb-ds-ui-bg-elevated);
+ color: var(--wb-ds-ui-text-default);
}
.heading {
@@ -59,22 +61,22 @@
height: 2.25rem;
flex-shrink: 0;
border-radius: 0.6rem;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
- color: var(--ax-colors-acc1-500, #1096e7);
+ background: var(--wb-ds-ui-bg-elevated);
+ color: var(--wb-ds-colors-acc1-500);
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
}
.body {
margin: 0 0 1.5rem;
font-size: 0.92rem;
line-height: 1.6;
- color: var(--ax-txt-secondary-default, #4d5059);
+ color: var(--wb-ds-ui-text-subtle-default);
}
.body p {
@@ -86,7 +88,7 @@
}
.body strong {
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
font-weight: 600;
}
@@ -98,7 +100,7 @@
padding: 0.7rem 1rem;
border: none;
border-radius: 0.625rem;
- background: var(--ax-colors-acc1-500, #1096e7);
+ background: var(--wb-ds-colors-acc1-500);
color: #ffffff;
font-size: 0.95rem;
font-weight: 600;
@@ -107,7 +109,7 @@
}
.cta:hover {
- background: var(--ax-colors-acc1-600, #0477c5);
+ background: var(--wb-ds-colors-acc1-600);
}
.reopen {
@@ -120,14 +122,14 @@
justify-content: center;
width: 2.5rem;
height: 2.5rem;
- border: 0.0625rem solid var(--wb-app-bar-border-color);
+ border: 0.0625rem solid var(--wb-sdk-app-bar-border-color);
border-radius: 50%;
- background: var(--wb-app-bar-background);
- color: var(--ax-txt-secondary-default, #4d5059);
+ background: var(--wb-sdk-app-bar-background);
+ color: var(--wb-ds-ui-text-subtle-default);
cursor: pointer;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.reopen:hover {
- color: var(--ax-colors-acc1-500, #1096e7);
+ color: var(--wb-ds-colors-acc1-500);
}
diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
index 4c7855f57..9e36674d2 100644
--- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
+++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
@@ -6,7 +6,8 @@ import styles from './disclaimer-modal.module.css';
import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor';
import { useExecutionStore } from '../../stores/use-execution-store';
-const STORAGE_KEY = 'ai-studio:disclaimer-acknowledged';
+// Versioned: a new key shows changed text once to visitors who dismissed the old one.
+const STORAGE_KEY = 'ai-studio:disclaimer-acknowledged-v2';
function hasAcknowledged(): boolean {
try {
@@ -75,6 +76,11 @@ export function DisclaimerModal() {
is to show what you can build with Workflow Builder. To keep the demo open to everyone, runs are
rate-limited.
+
+ AI Studio is a shared, public workspace . Anyone with a run's link can see everything
+ in it, from your input and the prompts to the model's answers and any decision made, and can act on it.
+ Do not enter personal or confidential data.
+
diff --git a/apps/ai-studio/src/components/editor-form/editor-form.module.css b/apps/ai-studio/src/components/editor-form/editor-form.module.css
new file mode 100644
index 000000000..88de7a3ef
--- /dev/null
+++ b/apps/ai-studio/src/components/editor-form/editor-form.module.css
@@ -0,0 +1,7 @@
+/* The SDK's VerticalLayout names a class its module lacks, so a form nested below the panel's own gets no gap
+ (follow-up: sdk-vertical-layout-class). */
+.fields > div {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
diff --git a/apps/ai-studio/src/components/editor-form/editor-form.tsx b/apps/ai-studio/src/components/editor-form/editor-form.tsx
new file mode 100644
index 000000000..e9195626d
--- /dev/null
+++ b/apps/ai-studio/src/components/editor-form/editor-form.tsx
@@ -0,0 +1,95 @@
+import { JsonForms, useJsonForms } from '@workflowbuilder/sdk';
+import type { JsonSchema } from '@workflowbuilder/sdk';
+import { type ComponentProps, type Ref, useEffect, useImperativeHandle, useRef, useState } from 'react';
+
+import styles from './editor-form.module.css';
+
+import { editorLayout } from '../../utils/editor-form/editor-layout';
+import { type SchemaError, invalidFieldsOf } from '../../utils/editor-form/form-schema';
+import { isPlainObject } from '../../utils/is-plain-object';
+import { FormBoundary } from './form-boundary';
+
+type Middleware = NonNullable['middleware']>;
+
+type ValidationMode = NonNullable['validationMode']>;
+
+type EditorFormSnapshot = { data: Record; invalidFields: ReadonlySet };
+
+function snapshotOf(data: unknown, errors: readonly SchemaError[] | undefined): EditorFormSnapshot {
+ return { data: isPlainObject(data) ? data : {}, invalidFields: invalidFieldsOf(errors) };
+}
+
+export type EditorFormHandle = { snapshot: () => EditorFormSnapshot };
+
+type Props = {
+ schema: JsonSchema;
+ initialData: Record;
+ readOnly?: boolean;
+ validate?: boolean;
+ /**
+ * Receives the data and the top-level fields the schema finds fault with, as JsonForms reports them: first for the
+ * starting data, then after each change.
+ */
+ onChange?: (snapshot: EditorFormSnapshot) => void;
+ /** Called when the validator or a control throws: the form shows a notice in place of its fields. */
+ onFail?: () => void;
+ /** Receives the data the form holds as it unmounts, including a change the debounced report has not sent yet. */
+ onUnmount?: (data: Record) => void;
+ ref?: Ref;
+};
+
+/**
+ * A schema and its data rendered with the editor's own controls and validator, for data that is not a node's
+ * properties. Mounted inside the properties form, whose renderers it borrows. Everything but `readOnly` and the
+ * callbacks is read once, when the form mounts.
+ */
+export function EditorForm({
+ schema,
+ initialData,
+ readOnly = false,
+ validate = true,
+ onChange,
+ onFail,
+ onUnmount,
+ ref,
+}: Props) {
+ const { renderers, cells, core } = useJsonForms();
+ // JsonForms resets to `data` when `data`, `schema`, `uischema` or `validationMode` changes, so all hold from mount.
+ const [fixed] = useState(() => {
+ const validationMode: ValidationMode = validate ? 'ValidateAndShow' : 'NoValidation';
+ return { schema, data: initialData, layout: editorLayout(schema), validationMode };
+ });
+ // JsonForms reports changes debounced; the middleware sees each one as it happens.
+ const latest = useRef({ data: initialData, invalidFields: new Set() });
+
+ const track: Middleware = (state, action, reduce) => {
+ const next = reduce(state, action);
+ latest.current = snapshotOf(next.data, next.errors);
+ return next;
+ };
+
+ useImperativeHandle(ref, () => ({ snapshot: () => latest.current }), []);
+
+ const onUnmountRef = useRef(onUnmount);
+ onUnmountRef.current = onUnmount;
+ useEffect(() => () => onUnmountRef.current?.(latest.current.data), []);
+
+ return (
+
+ These fields cannot be shown here.} onError={onFail}>
+ onChange?.(snapshotOf(data, errors))}
+ />
+
+
+ );
+}
diff --git a/apps/ai-studio/src/components/editor-form/form-boundary.tsx b/apps/ai-studio/src/components/editor-form/form-boundary.tsx
new file mode 100644
index 000000000..c27272c5d
--- /dev/null
+++ b/apps/ai-studio/src/components/editor-form/form-boundary.tsx
@@ -0,0 +1,20 @@
+import { Component, type ReactNode } from 'react';
+
+type Props = { fallback: ReactNode; onError?: () => void; children: ReactNode };
+
+/** Renders `fallback` in place of a form that throws while it is built, instead of unmounting the page with it. */
+export class FormBoundary extends Component {
+ override state = { failed: false };
+
+ static getDerivedStateFromError() {
+ return { failed: true };
+ }
+
+ override componentDidCatch() {
+ this.props.onError?.();
+ }
+
+ override render() {
+ return this.state.failed ? this.props.fallback : this.props.children;
+ }
+}
diff --git a/apps/ai-studio/src/components/execution/highlighting.css b/apps/ai-studio/src/components/execution/highlighting.css
index f485e2779..d44c4547e 100644
--- a/apps/ai-studio/src/components/execution/highlighting.css
+++ b/apps/ai-studio/src/components/execution/highlighting.css
@@ -1,34 +1,35 @@
html[data-theme='light'] {
- --ai-studio-edge-color--active: var(--ax-colors-green-400);
- --ai-studio-status-color--incomplete: var(--ax-txt-warning-default);
+ --ai-studio-edge-color--active: var(--wb-ds-colors-green-400);
+ --ai-studio-status-color--completed: var(--wb-ds-colors-green-400);
}
html[data-theme='dark'] {
- --ai-studio-edge-color--active: var(--ax-colors-green-200);
- /* No dark warning-accent token exists (--ax-txt-warning-default is orange-100 body-text cream). */
- --ai-studio-status-color--incomplete: var(--ax-colors-orange-300);
+ --ai-studio-edge-color--active: var(--wb-ds-colors-green-200);
+ --ai-studio-status-color--completed: var(--wb-ds-colors-green-200);
}
:root {
- --ai-studio-status-color--completed: var(--ax-txt-success-default);
- --ai-studio-status-color--failed: var(--ax-txt-error-default);
+ --ai-studio-status-color--waiting: var(--wb-ds-ui-text-info-default);
+ --ai-studio-status-color--incomplete: var(--wb-ds-ui-text-warning-default);
+ --ai-studio-status-color--failed: var(--wb-ds-ui-text-critical-default);
--ai-studio-status-bg--completed: color-mix(in srgb, var(--ai-studio-status-color--completed), transparent 85%);
+ --ai-studio-status-bg--waiting: color-mix(in srgb, var(--ai-studio-status-color--waiting), transparent 85%);
--ai-studio-status-bg--incomplete: color-mix(in srgb, var(--ai-studio-status-color--incomplete), transparent 85%);
--ai-studio-status-bg--failed: color-mix(in srgb, var(--ai-studio-status-color--failed), transparent 85%);
--ai-studio-node-shadow-color--active: var(--ai-studio-edge-color--active);
--ai-studio-node-shadow-color--completed: var(--ai-studio-edge-color--active);
- --ai-studio-node-shadow-color--failed: var(--ax-colors-red-400);
+ --ai-studio-node-shadow-color--failed: var(--wb-ds-colors-red-400);
- --ai-studio-node-shadow--active: var(--ax-token-shadow-focus-node-active-x) var(--ax-token-shadow-focus-node-active-y)
- var(--ax-token-shadow-focus-node-active-blur) var(--ax-token-shadow-focus-node-active-spread)
- var(--ai-studio-node-shadow-color--active);
+ --ai-studio-node-shadow--active: var(--wb-ds-shadow-canvas-focus-ring-node-x)
+ var(--wb-ds-shadow-canvas-focus-ring-node-y) var(--wb-ds-shadow-canvas-focus-ring-node-blur)
+ var(--wb-ds-shadow-canvas-focus-ring-node-spread) var(--ai-studio-node-shadow-color--active);
- --ai-studio-node-shadow--completed: var(--ax-token-shadow-focus-node-active-x)
- var(--ax-token-shadow-focus-node-active-y) var(--ax-token-shadow-focus-node-active-blur)
- var(--ax-token-shadow-focus-node-active-spread) var(--ai-studio-node-shadow-color--completed);
+ --ai-studio-node-shadow--completed: var(--wb-ds-shadow-canvas-focus-ring-node-x)
+ var(--wb-ds-shadow-canvas-focus-ring-node-y) var(--wb-ds-shadow-canvas-focus-ring-node-blur)
+ var(--wb-ds-shadow-canvas-focus-ring-node-spread) var(--ai-studio-node-shadow-color--completed);
- --ai-studio-node-shadow--failed: var(--ax-token-shadow-focus-node-active-x) var(--ax-token-shadow-focus-node-active-y)
- var(--ax-token-shadow-focus-node-active-blur) var(--ax-token-shadow-focus-node-active-spread)
- var(--ai-studio-node-shadow-color--failed);
+ --ai-studio-node-shadow--failed: var(--wb-ds-shadow-canvas-focus-ring-node-x)
+ var(--wb-ds-shadow-canvas-focus-ring-node-y) var(--wb-ds-shadow-canvas-focus-ring-node-blur)
+ var(--wb-ds-shadow-canvas-focus-ring-node-spread) var(--ai-studio-node-shadow-color--failed);
}
diff --git a/apps/ai-studio/src/components/execution/highlighting.test.tsx b/apps/ai-studio/src/components/execution/highlighting.test.tsx
new file mode 100644
index 000000000..cffd3ffc8
--- /dev/null
+++ b/apps/ai-studio/src/components/execution/highlighting.test.tsx
@@ -0,0 +1,56 @@
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { applyEvent, resetExecution, setExecutionStarted } from '../../stores/use-execution-store';
+import { ExecutionHighlighting } from './highlighting';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, getStoreEdges: () => [] };
+});
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const waiting = (nodeId: string): ExecutionEvent => ({
+ executionId: 'exec-1',
+ sequence: 1,
+ timestamp: '2026-09-15T12:00:00.000Z',
+ type: 'node_waiting',
+ nodeId,
+});
+
+describe('ExecutionHighlighting', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ resetExecution();
+ setExecutionStarted('exec-1', '/stream');
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ document.querySelector('#us-css-ai-studio-highlighting')?.remove();
+ });
+
+ it('keeps the active shadow on a node that waits for a decision', () => {
+ act(() => root.render( ));
+ act(() => applyEvent(waiting('human-1')));
+
+ const css = document.querySelector('#us-css-ai-studio-highlighting')?.innerHTML ?? '';
+ const activeRule = css.split('}').find((rule) => rule.includes('[data-id="human-1"]'));
+
+ expect(activeRule).toContain('--ai-studio-node-shadow--active');
+ });
+});
diff --git a/apps/ai-studio/src/components/execution/highlighting.tsx b/apps/ai-studio/src/components/execution/highlighting.tsx
index b28db5ec8..03790dbb3 100644
--- a/apps/ai-studio/src/components/execution/highlighting.tsx
+++ b/apps/ai-studio/src/components/execution/highlighting.tsx
@@ -38,7 +38,8 @@ export function ExecutionHighlighting() {
}
switch (state.status) {
- case 'running': {
+ case 'running':
+ case 'waiting': {
byStatus.running.push(nodeId);
break;
}
diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css
index 22b063107..7e0819777 100644
--- a/apps/ai-studio/src/components/execution/log-panel.module.css
+++ b/apps/ai-studio/src/components/execution/log-panel.module.css
@@ -1,18 +1,20 @@
.panel {
position: fixed;
bottom: 1rem;
+ /* stylelint-disable-next-line csstools/value-no-unknown-custom-properties -- set from log-panel.tsx inline style */
right: var(--log-panel-right, 1rem);
width: 30rem;
max-width: calc(100vw - 3rem);
max-height: 26.875rem;
- background: var(--wb-app-bar-background);
- border: 0.0625rem solid var(--wb-app-bar-border-color);
- border-radius: var(--wb-app-bar-border-radius);
- color: var(--ax-txt-primary-default, #151516);
+ background: var(--wb-sdk-app-bar-background);
+ border: 0.0625rem solid var(--wb-sdk-app-bar-border-color);
+ border-radius: var(--wb-sdk-app-bar-border-radius);
+ color: var(--wb-ds-ui-text-default);
display: flex;
flex-direction: column;
z-index: 10;
font-size: 0.75rem;
+ font-family: var(--wb-public-font-family, 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
@@ -28,7 +30,7 @@
padding: 0.5rem 0.75rem;
cursor: pointer;
user-select: none;
- border-bottom: 0.0625rem solid var(--wb-app-bar-border-color);
+ border-bottom: 0.0625rem solid var(--wb-sdk-app-bar-border-color);
flex-shrink: 0;
}
@@ -55,7 +57,7 @@
.event {
padding: 0.375rem 0.75rem;
- border-bottom: 0.0625rem solid color-mix(in srgb, var(--wb-app-bar-border-color), transparent 50%);
+ border-bottom: 0.0625rem solid color-mix(in srgb, var(--wb-sdk-app-bar-border-color), transparent 50%);
&:last-child {
border-bottom: none;
@@ -91,7 +93,11 @@
.badge--node_started,
.badge--node_skipped {
- color: var(--ax-txt-secondary-default, #888);
+ color: var(--wb-ds-ui-text-subtle-default);
+}
+
+.badge--node_waiting {
+ color: var(--ai-studio-status-color--waiting);
}
.badge--execution_incomplete {
@@ -104,7 +110,7 @@
}
.badge--execution_cancelled {
- color: var(--ax-txt-secondary-default, #888);
+ color: var(--wb-ds-ui-text-subtle-default);
}
.node-id {
@@ -132,7 +138,7 @@
.detail {
margin-top: 0.25rem;
padding: 0.375rem 0.5rem;
- background: color-mix(in srgb, var(--wb-app-bar-border-color), transparent 70%);
+ background: color-mix(in srgb, var(--wb-sdk-app-bar-border-color), transparent 70%);
border-radius: 0.25rem;
white-space: pre-wrap;
word-break: break-word;
@@ -163,7 +169,13 @@
background: var(--ai-studio-status-bg--completed);
}
-.status--incomplete {
+.status--waiting {
+ color: var(--ai-studio-status-color--waiting);
+ background: var(--ai-studio-status-bg--waiting);
+}
+
+.status--incomplete,
+.status--disconnected {
color: var(--ai-studio-status-color--incomplete);
background: var(--ai-studio-status-bg--incomplete);
}
@@ -173,11 +185,12 @@
background: var(--ai-studio-status-bg--failed);
}
-.status--cancelled {
+.status--cancelled,
+.status--cancelling {
opacity: 0.5;
}
.event--highlighted {
- background: color-mix(in srgb, var(--ax-colors-acc1-500, #1096e7), transparent 88%);
- box-shadow: inset 0.1875rem 0 0 var(--ax-colors-acc1-500, #1096e7);
+ background: color-mix(in srgb, var(--wb-ds-colors-acc1-500), transparent 88%);
+ box-shadow: inset 0.1875rem 0 0 var(--wb-ds-colors-acc1-500);
}
diff --git a/apps/ai-studio/src/components/execution/log-panel.test.tsx b/apps/ai-studio/src/components/execution/log-panel.test.tsx
new file mode 100644
index 000000000..51fefd93f
--- /dev/null
+++ b/apps/ai-studio/src/components/execution/log-panel.test.tsx
@@ -0,0 +1,67 @@
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { executionEvent as event } from '../../stores/execution-event.fixture';
+import { applyEvent, resetExecution, setExecutionStarted } from '../../stores/use-execution-store';
+import { ExecutionLogPanel } from './log-panel';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useSingleSelectedElement: () => null };
+});
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('ExecutionLogPanel', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ resetExecution();
+ setExecutionStarted('exec-1', '/stream');
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ function renderAfter(...events: ExecutionEvent[]) {
+ act(() => {
+ for (const entry of events) applyEvent(entry);
+ root.render( );
+ });
+ }
+
+ it('names the outcome, who settled it and where, on the execution completed row', () => {
+ renderAfter(
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({
+ type: 'execution_completed',
+ payload: { outcome: { value: 'rejected', resolvedBy: 'human', nodeId: 'human-1' } },
+ }),
+ );
+
+ expect(container.textContent).toContain('rejected · resolved by human · human-1');
+ });
+
+ it('gives a completed row without an outcome no detail line', () => {
+ renderAfter(
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'execution_completed' }),
+ );
+
+ expect(container.textContent).toContain('execution completed');
+ expect(container.textContent).not.toContain('resolved by');
+ });
+});
diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx
index 37682d779..a6771f197 100644
--- a/apps/ai-studio/src/components/execution/log-panel.tsx
+++ b/apps/ai-studio/src/components/execution/log-panel.tsx
@@ -50,6 +50,12 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo
break;
}
+ case 'execution_completed': {
+ const outcome = event.payload?.outcome;
+ if (outcome) detail = `${outcome.value} · resolved by ${outcome.resolvedBy} · ${outcome.nodeId}`;
+
+ break;
+ }
case 'execution_incomplete': {
detail = event.payload.deadEnds
.map(({ nodeId, port }) => `${nodeId} routed to "${port}" — nothing connected to that handle`)
diff --git a/apps/ai-studio/src/components/execution/node-markers.module.css b/apps/ai-studio/src/components/execution/node-markers.module.css
index 2d2711449..ccc94344b 100644
--- a/apps/ai-studio/src/components/execution/node-markers.module.css
+++ b/apps/ai-studio/src/components/execution/node-markers.module.css
@@ -1,17 +1,17 @@
.container {
position: absolute;
- top: calc(100% + 0.5rem);
+ top: calc(100% + var(--wb-ds-space-100));
left: 0;
font-weight: 500;
- padding: var(--ax-public-node-padding);
- border-radius: var(--ax-public-node-border-radius);
- border: var(--ax-public-node-border-size) solid var(--ax-public-node-border-color);
- background: var(--ax-public-node-background-color);
- color: var(--ax-public-node-title-subtitle);
+ padding: var(--wb-public-node-padding);
+ border-radius: var(--wb-public-node-border-radius);
+ border: var(--wb-public-node-border-size) solid var(--wb-public-node-border-color);
+ background: var(--wb-public-node-background-color);
+ color: var(--wb-public-node-title-subtitle);
display: flex;
align-items: center;
justify-content: center;
- gap: 0.5rem;
+ gap: var(--wb-ds-space-100);
&,
* {
@@ -34,7 +34,7 @@
}
.icon--completed {
- color: var(--ax-public-node-title-color);
+ color: var(--wb-public-node-title-color);
}
.icon--failed {
@@ -42,7 +42,7 @@
}
.icon--skipped {
- color: var(--ax-txt-secondary-default, #888);
+ color: var(--wb-ds-ui-text-subtle-default);
opacity: 0.7;
}
diff --git a/apps/ai-studio/src/components/execution/node-markers.test.tsx b/apps/ai-studio/src/components/execution/node-markers.test.tsx
new file mode 100644
index 000000000..c5b7d8a49
--- /dev/null
+++ b/apps/ai-studio/src/components/execution/node-markers.test.tsx
@@ -0,0 +1,75 @@
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import styles from './node-markers.module.css';
+
+import {
+ applyEvent,
+ resetExecution,
+ setExecutionStarted,
+ setLogCollapsed,
+ useExecutionStore,
+} from '../../stores/use-execution-store';
+import { nodeEvent } from '../../test/execution-history';
+import { ExecutionNodeMarkers } from './node-markers';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, Icon: ({ name }: { name: string }) => };
+});
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('ExecutionNodeMarkers', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ resetExecution();
+ setExecutionStarted('exec-1', '/stream');
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ function render(nodeId: string) {
+ act(() => root.render( ));
+ }
+
+ function click() {
+ act(() => {
+ container.firstElementChild?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+ }
+
+ it('leaves a waiting node without a marker: the decision template shows the wait itself', () => {
+ act(() => applyEvent(nodeEvent('node_waiting', 'human-1')));
+
+ render('human-1');
+
+ expect(container.childElementCount).toBe(0);
+ });
+
+ it('shows the completed flag once the decision lands, and that one opens the log', () => {
+ act(() => applyEvent(nodeEvent('node_waiting', 'human-1')));
+ render('human-1');
+ act(() => applyEvent(nodeEvent('node_completed', 'human-1')));
+ setLogCollapsed(true);
+
+ expect(container.querySelector('[data-icon="FlagBannerFold"]')).not.toBeNull();
+ expect(container.firstElementChild?.classList.contains(styles['container--clickable']!)).toBe(true);
+
+ click();
+ expect(useExecutionStore.getState().isLogCollapsed).toBe(false);
+ });
+});
diff --git a/apps/ai-studio/src/components/execution/node-markers.tsx b/apps/ai-studio/src/components/execution/node-markers.tsx
index d760aa9b6..19ed1024c 100644
--- a/apps/ai-studio/src/components/execution/node-markers.tsx
+++ b/apps/ai-studio/src/components/execution/node-markers.tsx
@@ -15,7 +15,8 @@ export function ExecutionNodeMarkers({ props }: Props) {
const nodeId = props?.nodeId ?? '';
const nodeState = useExecutionStore((s) => s.nodeStates[nodeId]);
- if (!nodeState || nodeState.status === 'idle') return null;
+ // Only a decision node waits, and its template says so in its own footer.
+ if (!nodeState || nodeState.status === 'idle' || nodeState.status === 'waiting') return null;
const isClickable =
nodeState.status === 'completed' || nodeState.status === 'failed' || nodeState.status === 'skipped';
diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css
new file mode 100644
index 000000000..73a5894cf
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css
@@ -0,0 +1,17 @@
+.fields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ /* The accordion's grid track grows to the widest label; this keeps it at the panel's width. */
+ contain: inline-size;
+}
+
+.hint {
+ composes: wb-text-body-s from global;
+
+ margin: 0;
+ padding: 0.75rem;
+ border-radius: var(--wb-ds-radius-75);
+ background: var(--wb-ds-ui-bg-inset-subtle);
+ color: var(--wb-ds-ui-text-muted-default);
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx
new file mode 100644
index 000000000..72b0ec109
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx
@@ -0,0 +1,487 @@
+import { useChangesTrackerStore, useStore } from '@workflowbuilder/sdk';
+import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk';
+import type { Select } from '@workflowbuilder/ui';
+import { type ComponentProps, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// SDK internals by path: the public API mounts these only inside a whole .
+import { registerCustomRenderers } from '../../../../../../packages/sdk/src/features/json-form/extension-registry';
+import { NodeProperties } from '../../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties';
+// The real receivers, not copies, as in ../../../nodes/human-decision/decision-request-contract.test.ts
+// (follow-up: decision-request-contract-test-home).
+import { decisionRequestSchema } from '../../../../../backend/src/domain/decision/decision-request-schema';
+import { findDecisionRequest } from '../../../../../backend/src/domain/decision/find-decision-request';
+import { validateSubmittedDecision } from '../../../../../backend/src/domain/decision/validate-submitted-decision';
+import { workflowSnapshotSchema } from '../../../../../backend/src/domain/mapper/snapshot-schema';
+import { refundReviewFlow, refundReviewRequest } from '../../../data/refund-review-flow';
+import { useRunLocksCanvas } from '../../../hooks/use-run-locks-canvas';
+import { humanDecisionNodeType, humanDecisionPaletteItem } from '../../../nodes/human-decision';
+import { defaultDecisionRequest } from '../../../nodes/human-decision/default-properties-data';
+import { executionEvent as event } from '../../../stores/execution-event.fixture';
+import {
+ applyConnectionLost,
+ applyEvent,
+ resetExecution,
+ setExecutionStarted,
+ useExecutionStore,
+} from '../../../stores/use-execution-store';
+import { FIELD_MODES } from '../../../utils/human-decision/decision-fields';
+import { decisionFormRenderer } from '../decision-form/decision-form-control';
+import { decisionFieldsRenderer } from './decision-fields-control';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, Icon: ({ name }: { name: string }) => };
+});
+
+vi.mock('@workflowbuilder/ui', async (importOriginal) => {
+ const actual = await importOriginal();
+ // jsdom cannot drive Base UI's portal; a native select with the same props stands in.
+ function NativeSelect({ items, value, onChange, disabled }: ComponentProps) {
+ return (
+ onChange?.(changeEvent, changeEvent.target.value)}
+ >
+ {items.map((item) =>
+ item.type === 'separator' ? null : (
+
+ {item.label}
+
+ ),
+ )}
+
+ );
+ }
+ return { ...actual, Select: NativeSelect };
+});
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]);
+
+const HUMAN = 'human-1';
+
+const refundOutput = {
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ orderDate: { type: 'string', title: 'Order date' },
+ replyDraft: { type: 'string', title: 'Reply draft' },
+ internalReasoning: { type: 'string', title: 'Internal reasoning' },
+ },
+};
+
+function agent(id: string, outputSchema: unknown): WorkflowBuilderNode {
+ return {
+ id,
+ type: 'node',
+ position: { x: 0, y: 0 },
+ data: {
+ segments: [],
+ properties: { label: id, description: '', systemPrompt: '', webSearch: false, outputSchema },
+ type: 'ai-studio/ai-agent',
+ icon: 'AiAgent',
+ },
+ };
+}
+
+function human(decisionRequest: unknown): WorkflowBuilderNode {
+ return {
+ id: HUMAN,
+ type: humanDecisionNodeType,
+ position: { x: 350, y: 0 },
+ data: {
+ segments: [],
+ properties: { label: 'Review Refund', description: '', decisionRequest },
+ type: humanDecisionNodeType,
+ icon: 'UserCheck',
+ },
+ };
+}
+
+function edge(source: string): WorkflowBuilderEdge {
+ return {
+ id: `edge-${source}`,
+ source,
+ sourceHandle: 'source',
+ target: HUMAN,
+ targetHandle: 'target',
+ type: 'labelEdge',
+ data: {},
+ };
+}
+
+function Host() {
+ const node = useStore((state) => state.nodes.find((candidate) => candidate.id === HUMAN));
+ return node ? : null;
+}
+
+function RunLock() {
+ useRunLocksCanvas();
+ return null;
+}
+
+const storedProperties = () => useStore.getState().nodes.find((node) => node.id === HUMAN)?.data.properties;
+const runStatus = () => useExecutionStore.getState().status;
+const storedSchema = () => (storedProperties()?.['decisionRequest'] as { schema: unknown }).schema;
+
+// JsonForms debounces onChange by 10 ms.
+const settle = () =>
+ act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ });
+
+describe('the decision fields control in the real properties panel', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+ let dataUpdates = 0;
+ let unsubscribe: () => void;
+
+ beforeEach(() => {
+ useStore.setState(useStore.getInitialState(), true);
+ resetExecution();
+ dataUpdates = 0;
+ unsubscribe = useChangesTrackerStore.subscribe((state) => {
+ if (state.lastChangeName === 'dataUpdate') dataUpdates += 1;
+ });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ unsubscribe();
+ act(() => root.unmount());
+ container.remove();
+ useStore.setState(useStore.getInitialState(), true);
+ resetExecution();
+ });
+
+ const rows = () => [...container.querySelectorAll('[data-output-field]')];
+ const rowKeys = () => rows().map((row) => row.dataset['outputField']);
+ const selects = () => rows().map((row) => row.querySelector('select')!);
+ const sectionHeader = () =>
+ [...container.querySelectorAll('[aria-expanded]')].find(
+ (element) => element.textContent === 'Fields the decider sees',
+ );
+ // A row of the decider's form is found by its label, the way a person finds it.
+ const formField = (label: string) =>
+ [...container.querySelectorAll('[data-decision-form] span')]
+ .find((span) => span.childElementCount === 0 && span.textContent === label)
+ ?.parentElement?.parentElement?.querySelector('input, textarea') ??
+ undefined;
+
+ async function renderPanel(nodes: WorkflowBuilderNode[], edges: WorkflowBuilderEdge[]) {
+ useStore.setState({
+ nodes,
+ edges,
+ selectedNodesIds: [HUMAN],
+ selectedEdgesIds: [],
+ data: [humanDecisionPaletteItem as never],
+ });
+ act(() => root.render( ));
+ await settle();
+ }
+
+ const renderRefund = (decisionRequest: unknown = defaultDecisionRequest) =>
+ renderPanel([agent('draft-1', refundOutput), human(decisionRequest)], [edge('draft-1')]);
+
+ async function choose(key: string, mode: string) {
+ const select = container.querySelector(`[data-output-field="${key}"] select`);
+ if (!select) throw new Error(`no dropdown for ${key}`);
+ act(() => {
+ select.value = mode;
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await settle();
+ }
+
+ it("lists the source's fields in its order, every one Hidden on a node fresh from the palette", async () => {
+ await renderRefund();
+
+ expect(rowKeys()).toEqual(['refundAmount', 'orderDate', 'replyDraft', 'internalReasoning']);
+ expect(selects().map((select) => select.value)).toEqual(Array.from({ length: 4 }, () => 'hidden'));
+ });
+
+ it('a pick is one undo step and stores the contract shape, leaving the rest of the node alone', async () => {
+ await renderRefund();
+
+ await choose('orderDate', 'readOnly');
+
+ expect(dataUpdates).toBe(1);
+ expect(storedSchema()).toEqual({
+ type: 'object',
+ properties: { orderDate: { type: 'string', title: 'Order date', readOnly: true } },
+ });
+ expect((storedProperties()?.['decisionRequest'] as typeof defaultDecisionRequest).actions).toBe(
+ defaultDecisionRequest.actions,
+ );
+ expect(storedProperties()?.['label']).toBe('Review Refund');
+ });
+
+ // Base UI reports a click on the selected item as a change; the stored entry lacks the source's title on purpose.
+ it('picking the mode a row already shows writes nothing', async () => {
+ await renderRefund({
+ ...defaultDecisionRequest,
+ schema: { type: 'object', properties: { orderDate: { type: 'string', readOnly: true } } },
+ });
+
+ await choose('orderDate', 'readOnly');
+
+ expect(dataUpdates).toBe(0);
+ });
+
+ it('locks the dropdowns while the canvas is in the app bar read-only mode', async () => {
+ await renderRefund();
+
+ act(() => useStore.getState().setToggleReadOnlyMode(true));
+ expect(selects().every((select) => select.disabled)).toBe(true);
+
+ act(() => useStore.getState().setToggleReadOnlyMode(false));
+ expect(selects().every((select) => !select.disabled)).toBe(true);
+ });
+
+ it('steps aside from Run until Reset, section header included, even with the canvas lock lifted', async () => {
+ await renderRefund();
+ act(() =>
+ root.render(
+ <>
+
+
+ >,
+ ),
+ );
+ expect(sectionHeader()).toBeDefined();
+
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ expect(runStatus()).toBe('pending');
+ expect(sectionHeader()).toBeUndefined();
+
+ act(() => useStore.getState().setToggleReadOnlyMode(false));
+ act(() => applyEvent(event({ type: 'node_waiting', nodeId: HUMAN })));
+ expect(sectionHeader()).toBeUndefined();
+
+ act(() => applyConnectionLost());
+ expect(runStatus()).toBe('disconnected');
+ expect(sectionHeader()).toBeUndefined();
+
+ act(() => {
+ applyEvent(
+ event({
+ type: 'node_completed',
+ nodeId: HUMAN,
+ payload: { output: { action: 'approve', effect: 'resume', resolvedBy: 'human' } },
+ }),
+ );
+ applyEvent(event({ type: 'execution_completed' }));
+ });
+ expect(sectionHeader()).toBeUndefined();
+
+ act(() => resetExecution());
+ expect(rows()).toHaveLength(4);
+ expect(selects().every((select) => !select.disabled)).toBe(true);
+ });
+
+ it('with two predecessors, lists the fields of the declared proposal source', async () => {
+ const summaryOutput = { type: 'object', properties: { summary: { type: 'string', title: 'Summary' } } };
+
+ await renderPanel(
+ [
+ agent('draft-1', refundOutput),
+ agent('draft-2', summaryOutput),
+ human({ ...defaultDecisionRequest, proposalSourceNodeId: 'draft-2' }),
+ ],
+ [edge('draft-1'), edge('draft-2')],
+ );
+
+ expect(rowKeys()).toEqual(['summary']);
+ });
+
+ const stored = {
+ ...defaultDecisionRequest,
+ schema: { type: 'object', properties: { replyDraft: { type: 'string', title: 'Reply draft' } } },
+ };
+ const rowLabels = () => rows().map((row) => row.querySelector('span')?.textContent);
+
+ it('with nothing connected, says to connect a block and keeps listing the stored fields', async () => {
+ await renderPanel([agent('draft-1', refundOutput), human(stored)], []);
+
+ expect(container.textContent).toContain('Connect a block before this one');
+ expect(container.textContent).not.toContain('declares no output fields');
+ expect(rowLabels()).toEqual(['Reply draft (not in the source)']);
+ });
+
+ it('with two predecessors and no declared source, says several blocks lead in and lists the stored fields', async () => {
+ await renderPanel(
+ [agent('draft-1', refundOutput), agent('draft-2', refundOutput), human(stored)],
+ [edge('draft-1'), edge('draft-2')],
+ );
+
+ expect(container.textContent).toContain('Several blocks lead into this one');
+ expect(container.textContent).not.toContain('Connect a block before this one');
+ expect(container.textContent).not.toContain('declares no output fields');
+ expect(rowLabels()).toEqual(['Reply draft (not in the source)']);
+ });
+
+ it('a declared source that is not a predecessor is not read: with another block connected, no hint', async () => {
+ const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' };
+ await renderPanel([agent('draft-1', refundOutput), human(ghost)], [edge('draft-1')]);
+
+ expect(container.textContent).not.toContain('declares no output fields');
+ expect(container.textContent).not.toContain('Connect a block before this one');
+ expect(rowLabels()).toEqual([
+ 'Refund amount (not in the source)',
+ 'Order date (not in the source)',
+ 'Reply draft (not in the source)',
+ ]);
+ });
+
+ it('a declared source that is not a predecessor is not read: with nothing connected, the unconnected hint', async () => {
+ const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' };
+ await renderPanel([agent('draft-1', refundOutput), human(ghost)], []);
+
+ expect(container.textContent).toContain('Connect a block before this one');
+ expect(container.textContent).not.toContain('declares no output fields');
+ });
+
+ describe('on the "Refund Review" template', () => {
+ const template = refundReviewFlow.value.diagram;
+ const renderTemplate = (nodes: WorkflowBuilderNode[] = template.nodes) => renderPanel(nodes, template.edges);
+
+ // What the backend answers at decision time: the snapshot the run carries, then the submitted edits.
+ function answerTo(edits: Record): string {
+ const { nodes, edges } = useStore.getState();
+ const parsed = workflowSnapshotSchema.safeParse(structuredClone({ nodes, edges }));
+ if (!parsed.success) throw new Error(`snapshot refused: ${JSON.stringify(parsed.error.issues)}`);
+ const found = findDecisionRequest(parsed.data, HUMAN);
+ if (found.error !== undefined) throw new Error(found.error);
+ return validateSubmittedDecision(found.request, { action: 'approve', edits }).error?.code ?? 'accepted';
+ }
+
+ const edit: Record = {
+ refundAmount: 40,
+ orderDate: '2026-09-01',
+ replyDraft: 'Hi',
+ internalReasoning: 'Why',
+ };
+ const ANSWER = {
+ hidden: 'unknown_field',
+ readOnly: 'field_not_editable',
+ editable: 'accepted',
+ required: 'accepted',
+ };
+ const templateOutput = {
+ refundAmount: 49,
+ orderDate: '2026-09-02',
+ replyDraft: 'Hi Marcus, we refunded the duplicate charge.',
+ internalReasoning: 'Duplicate charge, refunded in full.',
+ };
+
+ it("lists the draft's four fields under their titles, in the draft's order, with the template's picks", async () => {
+ await renderTemplate();
+
+ expect(rowLabels()).toEqual(['Refund amount', 'Order date', 'Reply draft', 'Internal reasoning']);
+ expect(selects().map((select) => select.value)).toEqual(['required', 'readOnly', 'editable', 'hidden']);
+ expect(container.textContent).not.toContain('declares no output fields');
+ });
+
+ it('as shipped, the backend refuses an edit to the read-only and the hidden field and takes the rest', async () => {
+ await renderTemplate();
+
+ expect(answerTo({ refundAmount: 40 })).toBe('accepted');
+ expect(answerTo({ orderDate: '2026-09-01' })).toBe('field_not_editable');
+ expect(answerTo({ replyDraft: 'Hi' })).toBe('accepted');
+ expect(answerTo({ internalReasoning: 'Why' })).toBe('unknown_field');
+ });
+
+ it.each(Object.keys(edit).flatMap((key) => FIELD_MODES.map((mode) => [key, mode] as const)))(
+ '%s picked %s stores a request the backend takes, and an edit to it gets the answer the pick promises',
+ async (key, mode) => {
+ await renderTemplate();
+
+ await choose(key, mode);
+
+ const request = storedProperties()?.['decisionRequest'];
+ const parsed = decisionRequestSchema.safeParse(request);
+ expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
+ expect(answerTo({ [key]: edit[key] })).toBe(ANSWER[mode]);
+ },
+ );
+
+ it('a pick leaves the template itself as it was, for the next time it is opened', async () => {
+ await renderTemplate();
+
+ await choose('orderDate', 'editable');
+ await choose('internalReasoning', 'readOnly');
+
+ expect(storedProperties()?.['decisionRequest']).not.toBe(refundReviewRequest);
+ expect(refundReviewRequest.schema).toEqual({
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ orderDate: { type: 'string', title: 'Order date', readOnly: true },
+ replyDraft: { type: 'string', title: 'Reply draft' },
+ },
+ required: ['refundAmount'],
+ });
+ });
+
+ it("a pick made before Run is the decider's form: a Hidden field is absent, a Read-only one disabled", async () => {
+ await renderTemplate();
+ await choose('replyDraft', 'hidden');
+ await choose('internalReasoning', 'readOnly');
+
+ act(() => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ applyEvent(event({ type: 'node_completed', nodeId: 'draft-1', payload: { output: templateOutput } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: HUMAN }));
+ });
+ await settle();
+
+ expect(container.querySelector('[data-decision-form]')).not.toBeNull();
+ expect(formField('Reply draft')).toBeUndefined();
+ expect(formField('Internal reasoning')?.value).toBe('Duplicate charge, refunded in full.');
+ expect(formField('Internal reasoning')?.disabled).toBe(true);
+ expect(formField('Order date')?.disabled).toBe(true);
+ expect(formField('Refund amount')?.value).toBe('49');
+ expect(formField('Refund amount')?.disabled).toBe(false);
+ });
+
+ const withDraftProperties = (change: (properties: Record) => Record) =>
+ template.nodes.map((node) =>
+ node.id === 'draft-1' ? { ...node, data: { ...node.data, properties: change(node.data.properties) } } : node,
+ );
+
+ it('with the draft on Plain text, says it declares no fields and keeps listing the stored ones', async () => {
+ await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: undefined })));
+
+ expect(container.textContent).toContain('declares no output fields');
+ expect(container.textContent).not.toContain('Connect a block before this one');
+ expect(rowLabels()).toEqual([
+ 'Refund amount (not in the source)',
+ 'Order date (not in the source)',
+ 'Reply draft (not in the source)',
+ ]);
+ });
+
+ it('with a draft that still declares one of the stored fields, marks the others and gives no hint', async () => {
+ const amountOnly = { type: 'object', properties: { refundAmount: { type: 'number', title: 'Refund amount' } } };
+
+ await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: amountOnly })));
+
+ expect(container.textContent).not.toContain('declares no output fields');
+ expect(rowLabels()).toEqual([
+ 'Refund amount',
+ 'Order date (not in the source)',
+ 'Reply draft (not in the source)',
+ ]);
+ });
+ });
+});
diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx
new file mode 100644
index 000000000..b9c619bf2
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx
@@ -0,0 +1,82 @@
+import {
+ rankWith,
+ uiTypeIs,
+ useSingleSelectedElement,
+ useStore,
+ withJsonFormsControlProps,
+} from '@workflowbuilder/sdk';
+import type { ControlProps, JsonFormsRendererExtension } from '@workflowbuilder/sdk';
+import { Accordion } from '@workflowbuilder/ui';
+
+import styles from './decision-fields-control.module.css';
+
+import { proposalSourceIdOf } from '../../../hooks/use-node-decision';
+import { useExecutionStore } from '../../../stores/use-execution-store';
+import {
+ type FieldMode,
+ type SourceHint,
+ fieldModeOf,
+ fieldRows,
+ sourceHintOf,
+ withFieldMode,
+} from '../../../utils/human-decision/decision-fields';
+import { readDecisionRequest } from '../../../utils/human-decision/decision-request';
+import { FieldModeRow } from './field-mode-row';
+
+const HINTS = {
+ unconnected: 'Connect a block before this one — its output fields will appear here (e.g. the AI step).',
+ ambiguous: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.',
+ noFields:
+ 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.',
+} satisfies Record;
+
+function DecisionFieldsControl({ data, handleChange, path, enabled, label }: ControlProps) {
+ const nodeId = useSingleSelectedElement()?.node?.id;
+ const request = readDecisionRequest(data);
+ const edges = useStore((state) => state.edges);
+ const predecessors = edges
+ .filter((edge) => edge.target === nodeId && edge.source !== nodeId)
+ .map((edge) => edge.source);
+ const resolved = nodeId === undefined ? undefined : proposalSourceIdOf(request?.proposalSourceNodeId, edges, nodeId);
+ // The backend refuses a declared source that is not a predecessor, so the list does not read one either.
+ const sourceId = resolved !== undefined && predecessors.includes(resolved) ? resolved : undefined;
+ const outputSchema = useStore((state) =>
+ sourceId === undefined
+ ? undefined
+ : state.nodes.find((node) => node.id === sourceId)?.data.properties['outputSchema'],
+ );
+ // From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock.
+ const isRunShown = useExecutionStore((state) => state.executionId !== undefined);
+
+ if (request === undefined || isRunShown) {
+ return null;
+ }
+
+ const { schema } = request;
+ const rows = fieldRows(outputSchema, schema);
+ const hint = sourceHintOf(sourceId, predecessors.length, rows);
+ const pick = (key: string, mode: FieldMode) =>
+ handleChange(path, { ...data, schema: withFieldMode(schema, rows, key, mode) });
+
+ return (
+
+
+ {hint &&
{HINTS[hint]}
}
+ {rows.map((row) => (
+
pick(row.key, mode)}
+ />
+ ))}
+
+
+ );
+}
+
+export const decisionFieldsRenderer: JsonFormsRendererExtension = {
+ tester: rankWith(5, uiTypeIs('DecisionFields')),
+ renderer: withJsonFormsControlProps(DecisionFieldsControl),
+};
diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/field-mode-row.module.css b/apps/ai-studio/src/components/human-decision/decision-fields/field-mode-row.module.css
new file mode 100644
index 000000000..d1a918398
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-fields/field-mode-row.module.css
@@ -0,0 +1,26 @@
+.row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-height: 2.25rem;
+}
+
+.label {
+ composes: wb-text-body-s from global;
+
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--wb-ds-ui-text-default);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.row--muted .label {
+ color: var(--wb-ds-ui-text-muted-default);
+}
+
+.select {
+ flex: none;
+ width: 10.5rem;
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/field-mode-row.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/field-mode-row.tsx
new file mode 100644
index 000000000..e3c19f1ba
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-fields/field-mode-row.tsx
@@ -0,0 +1,46 @@
+import { Select } from '@workflowbuilder/ui';
+import type { SelectItem } from '@workflowbuilder/ui';
+import clsx from 'clsx';
+
+import styles from './field-mode-row.module.css';
+
+import { FIELD_MODES, type FieldMode, type FieldRow, isFieldMode } from '../../../utils/human-decision/decision-fields';
+
+const MODE_LABELS = {
+ hidden: 'Hidden',
+ readOnly: 'Read-only',
+ editable: 'Editable',
+ required: 'Editable, required',
+} satisfies Record;
+
+// Fixed items: a mounted Base UI Select resets its value when its items change.
+const MODE_ITEMS: SelectItem[] = FIELD_MODES.map((mode) => ({ value: mode, label: MODE_LABELS[mode] }));
+
+type Props = { row: FieldRow; mode: FieldMode; disabled: boolean; onPick: (mode: FieldMode) => void };
+
+export function FieldModeRow({ row, mode, disabled, onPick }: Props) {
+ const stale = row.declaration === undefined;
+ const label = stale ? `${row.title} (not in the source)` : row.title;
+ return (
+
+
+ {label}
+
+
+ {
+ if (isFieldMode(value) && value !== mode) onPick(value);
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx
new file mode 100644
index 000000000..2d763905e
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx
@@ -0,0 +1,1014 @@
+import { type ComponentProps, Fragment, type ReactNode, StrictMode, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The editor's real form, so the decision form runs with the controls and validator the panel gives it.
+import { registerCustomRenderers } from '../../../../../../packages/sdk/src/features/json-form/extension-registry';
+import { JSONForm } from '../../../../../../packages/sdk/src/features/json-form/json-form';
+import { workflowBuilderValidator } from '../../../../../../packages/sdk/src/utils/validation/workflow-builder-validator';
+import { type SubmitDecisionResult, submitDecision } from '../../../adapters/submit-decision';
+import { schema as nodeSchema } from '../../../nodes/human-decision/schema';
+import { uischema as nodeUischema } from '../../../nodes/human-decision/uischema';
+import { executionEvent as event } from '../../../stores/execution-event.fixture';
+import {
+ applyEvent,
+ applySnapshot,
+ requestDecisionFocus,
+ resetExecution,
+ saveDecisionSend,
+ setExecutionStarted,
+ useExecutionStore,
+} from '../../../stores/use-execution-store';
+import { reviewRequest } from '../../../utils/human-decision/review-request.fixture';
+import { decisionFormRenderer } from './decision-form-control';
+
+// The sidebar renders for the single selected node; the test moves the selection by hand, and the node's properties are
+// the data it last rendered.
+const selection: { nodeId: string | undefined; properties: unknown } = { nodeId: 'human-1', properties: undefined };
+const edges = [
+ { id: 'e1', source: 'draft-1', target: 'human-1' },
+ { id: 'e2', source: 'human-1', target: 'send-1' },
+ { id: 'e3', source: 'draft-2', target: 'human-2' },
+];
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useSingleSelectedElement: () =>
+ selection.nodeId === undefined
+ ? null
+ : { node: { id: selection.nodeId, data: { properties: selection.properties } }, edge: null },
+ useStore: (selector: (state: { edges: typeof edges }) => unknown) => selector({ edges }),
+ // Rendered in place: without the properties panel the real one has no footer to render into.
+ PropertiesPanelFooter: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ };
+});
+
+vi.mock('../../../adapters/submit-decision', () => ({ submitDecision: vi.fn() }));
+const submit = vi.mocked(submitDecision);
+
+registerCustomRenderers([decisionFormRenderer]);
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const draftOutput = {
+ refundAmount: 80,
+ orderDate: '2026-09-01',
+ replyDraft: 'Dear customer',
+ itemCount: 3,
+ internalReasoning: 'hidden',
+};
+
+// With the app bar's read-only switch lifted, undo can take a pick back while the node waits.
+function refundAmountReadOnly() {
+ const refundAmount = { type: 'number', title: 'Refund amount', readOnly: true };
+ return {
+ ...reviewRequest,
+ schema: { ...reviewRequest.schema, properties: { ...reviewRequest.schema.properties, refundAmount } },
+ };
+}
+
+// A text field the panel sets to Editable and required: a plain `string`, since the pick drops `null` from the type.
+function replyDraftRequired() {
+ return { ...reviewRequest, schema: { ...reviewRequest.schema, required: ['refundAmount', 'replyDraft'] } };
+}
+
+const humanOneWait = { executionId: 'exec-1', nodeId: 'human-1', attempt: 1 };
+
+function parkHumanOne(output: unknown = draftOutput) {
+ act(() => {
+ applyEvent(event({ type: 'node_completed', nodeId: 'draft-1', payload: { output } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ });
+}
+
+// A reconnected stream replays the run as a snapshot, so every output arrives as a new object.
+function reconnect() {
+ const { executionId, events } = useExecutionStore.getState();
+ const replayed = structuredClone(events);
+ act(() =>
+ applySnapshot({ executionId: executionId!, status: 'waiting', lastSequence: replayed.length, events: replayed }),
+ );
+}
+
+function decideHumanOne(output: unknown) {
+ act(() => applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output } })));
+}
+
+// The editor's text controls keep the typing locally and hand the value over on blur.
+function commit(element: HTMLInputElement | HTMLTextAreaElement, text: string) {
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(prototype, 'value')?.set?.call(element, text);
+ act(() => {
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ act(() => {
+ element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ });
+}
+
+// JsonForms reports a change after a short debounce; the buttons and the node data follow that report.
+async function settle() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+}
+
+function sentEdits() {
+ return submit.mock.calls[0]?.[1]?.edits;
+}
+
+// The reject dialog renders in a portal on the page, outside the panel.
+function dialog() {
+ return document.querySelector('[role="dialog"]');
+}
+
+function reasonField() {
+ return dialog()?.querySelector('textarea') ?? undefined;
+}
+
+async function click(element: Element) {
+ await act(async () => {
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+}
+
+// StrictMode mounts every effect twice, so each form writes a draft as it mounts; a production build does not.
+// Each mode hides regressions the other one catches.
+describe.each([
+ ['in development', StrictMode],
+ ['without StrictMode, as a production build mounts', Fragment],
+])('the decision form in the properties panel, %s', (_mode, Mode) => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+ let nodeChanges: unknown[];
+ let renderedData: unknown[];
+
+ const render = (decisionRequest: unknown = reviewRequest, readonly = false) => {
+ // JsonForms reports a change after its debounce even from an unmounted form, so each test keeps its own list.
+ const changes = nodeChanges;
+ const data = { label: 'Review Refund', description: '', decisionRequest };
+ renderedData.push(data);
+ selection.properties = data;
+ act(() =>
+ root.render(
+
+ ['uischema']}
+ data={data}
+ readonly={readonly}
+ onChange={({ data }) => changes.push(data)}
+ />
+ ,
+ ),
+ );
+ };
+
+ beforeEach(() => {
+ resetExecution();
+ selection.nodeId = 'human-1';
+ nodeChanges = [];
+ renderedData = [];
+ submit.mockReset();
+ submit.mockResolvedValue({ ok: true });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ act(() => setExecutionStarted('exec-1', 'http://backend/stream'));
+ render();
+ });
+
+ afterEach(async () => {
+ await settle();
+ act(() => root.unmount());
+ container.remove();
+ // The form never writes the node: whatever node data the panel reports is data the test rendered.
+ for (const data of nodeChanges) {
+ expect(renderedData).toContainEqual(data);
+ }
+ });
+
+ const form = () => container.querySelector('[data-decision-form]');
+ const record = () => container.querySelector('[data-decision-record]');
+ // A row is found by its label, the way a person finds it.
+ const labelled = (label: string) =>
+ [...container.querySelectorAll('span')].find((span) => span.childElementCount === 0 && span.textContent === label)
+ ?.parentElement?.parentElement ?? undefined;
+ const fieldOf = (label: string) =>
+ labelled(label)?.querySelector('input, textarea') ?? undefined;
+ const valueOf = (label: string) => labelled(label)?.querySelector('p')?.textContent ?? undefined;
+ const hasError = (label: string) => labelled(label)!.querySelector('[aria-invalid="true"]') !== null;
+ const button = (label: string, within: ParentNode = container) =>
+ [...within.querySelectorAll('button')].find((candidate) => candidate.textContent?.trim() === label)!;
+ const dialogButton = (label: string) => button(label, dialog()!);
+
+ describe('what it shows', () => {
+ it('renders nothing until the node waits, then the fields the request declares', () => {
+ expect(form()).toBeNull();
+
+ parkHumanOne();
+
+ for (const label of ['Refund amount', 'orderDate', 'Reply draft', 'Expedite']) {
+ expect(labelled(label), label).toBeDefined();
+ }
+ expect(labelled('Item count')).toBeUndefined();
+ expect(labelled('tags')).toBeUndefined();
+ expect(container.textContent).not.toContain('internalReasoning');
+ });
+
+ it('fills the fields from the proposal and disables the read-only one', () => {
+ parkHumanOne();
+
+ expect(fieldOf('Refund amount')?.value).toBe('80');
+ expect(fieldOf('orderDate')?.value).toBe('2026-09-01');
+ expect(fieldOf('orderDate')?.disabled).toBe(true);
+ expect(fieldOf('Reply draft')?.value).toBe('Dear customer');
+ expect(fieldOf('Reply draft')?.disabled).toBe(false);
+ });
+
+ it('puts the actions in the properties panel footer and leaves the fields out of it', () => {
+ parkHumanOne();
+
+ const footer = container.querySelector('[data-properties-panel-footer]');
+ expect(footer?.contains(button('Approve')!)).toBe(true);
+ expect(footer?.contains(fieldOf('Refund amount')!)).toBe(false);
+ });
+
+ it('renders nothing for a node whose properties carry no well-formed request', () => {
+ parkHumanOne();
+ render({ actions: 'approve' });
+
+ expect(form()).toBeNull();
+ });
+
+ it('lets the person decide while the canvas is read-only, as it is for the whole run', async () => {
+ render(reviewRequest, true);
+ parkHumanOne();
+
+ expect(fieldOf('Title')?.disabled).toBe(true);
+ expect(fieldOf('Refund amount')?.disabled).toBe(false);
+
+ commit(fieldOf('Refund amount')!, '120');
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ refundAmount: 120 });
+ });
+
+ it('keeps the page and the reject when the validator cannot check the request', async () => {
+ // Valid in JavaScript, but not under the `u` flag the SDK validator compiles patterns with.
+ const schema = {
+ type: 'object',
+ properties: { note: { type: 'string', title: 'Note', pattern: String.raw`^ORD\-\d+$` } },
+ };
+ render({ ...reviewRequest, schema });
+ parkHumanOne({ note: 'ORD-1' });
+
+ expect(fieldOf('Title')).toBeDefined();
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('cannot be shown here');
+ expect(button('Approve').disabled).toBe(true);
+
+ await click(button('Reject…'));
+ await click(dialogButton('Confirm Rejection'));
+
+ expect(submit).toHaveBeenCalledWith(humanOneWait, { action: 'reject', reason: '' });
+ });
+
+ it('takes the proposal from a declared source over the incoming edge', () => {
+ render({ ...reviewRequest, proposalSourceNodeId: 'draft-2' });
+ act(() =>
+ applyEvent(event({ type: 'node_completed', nodeId: 'draft-2', payload: { output: { refundAmount: 15 } } })),
+ );
+ parkHumanOne();
+
+ expect(fieldOf('Refund amount')?.value).toBe('15');
+ });
+
+ it('checks the fields with the validator the editor hands it, which runs without eval', () => {
+ const compile = vi.spyOn(workflowBuilderValidator, 'compile');
+
+ parkHumanOne();
+
+ expect(compile).toHaveBeenCalledWith(reviewRequest.schema);
+ compile.mockRestore();
+ });
+
+ it('leaves out a field JsonForms cannot address, and still edits one whose key it escapes', async () => {
+ const properties = {
+ 'a/b': { type: 'string', title: 'Slash' },
+ 'a.b': { type: 'string', title: 'Dot' },
+ 'x~~y': { type: 'string', title: 'Tildes' },
+ constructor: { type: 'string', title: 'Constructor' },
+ // Neither has a value, so JsonForms reads the empty key as the whole form and lodash the bracket as a path.
+ '': { type: 'string', title: 'Empty' },
+ 'a[0]': { type: 'string', title: 'Bracket' },
+ };
+ render({ ...reviewRequest, schema: { type: 'object', properties } });
+ parkHumanOne({ 'a/b': 'one', 'a.b': 'two', 'x~~y': 'three', constructor: 'four' });
+
+ expect(labelled('Dot')).toBeUndefined();
+ expect(labelled('Tildes')).toBeUndefined();
+ expect(labelled('Constructor')).toBeUndefined();
+ expect(labelled('Empty')).toBeUndefined();
+ expect(labelled('Bracket')).toBeUndefined();
+ expect(fieldOf('Slash')?.value).toBe('one');
+
+ commit(fieldOf('Slash')!, 'changed');
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ 'a/b': 'changed' });
+ });
+
+ it('shows and edits a required field that structured output types with null', async () => {
+ const schema = {
+ type: 'object',
+ properties: { note: { type: ['string', 'null'], title: 'Note' } },
+ required: ['note'],
+ };
+ render({ ...reviewRequest, schema });
+ parkHumanOne({ note: 'Call back' });
+
+ expect(fieldOf('Note')?.value).toBe('Call back');
+
+ commit(fieldOf('Note')!, 'Refunded');
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ note: 'Refunded' });
+ });
+
+ it('lets through a required field the model left null and the person left alone', async () => {
+ const schema = {
+ type: 'object',
+ properties: { note: { type: ['string', 'null'], title: 'Note' } },
+ required: ['note'],
+ };
+ render({ ...reviewRequest, schema });
+ parkHumanOne({ note: null });
+ await settle();
+
+ expect(button('Approve').disabled).toBe(false);
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({});
+ });
+
+ it('lets through a required field the model left null, even after the person types and clears it', async () => {
+ const schema = {
+ type: 'object',
+ properties: { note: { type: ['string', 'null'], title: 'Note' } },
+ required: ['note'],
+ };
+ render({ ...reviewRequest, schema });
+ parkHumanOne({ note: null });
+
+ commit(fieldOf('Note')!, 'x');
+ await settle();
+ commit(fieldOf('Note')!, '');
+ await settle();
+
+ expect(button('Approve').disabled).toBe(false);
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({});
+ });
+
+ it('still lets the person decide when the request declares no fields', async () => {
+ render({ ...reviewRequest, schema: { type: 'object', properties: {} } });
+ parkHumanOne();
+
+ expect(button('Reject…')).toBeDefined();
+
+ await click(button('Approve'));
+
+ expect(submit.mock.calls[0]?.[1]?.action).toBe('approve');
+ expect(sentEdits()).toEqual({});
+ });
+
+ it('ends with the actions, the resume rightmost', () => {
+ parkHumanOne();
+
+ const buttons = [...form()!.querySelectorAll('button')].map((element) => element.textContent?.trim());
+
+ expect(buttons).toEqual(['Reject…', 'Approve']);
+ });
+ });
+
+ describe('the focus Decide asks for', () => {
+ it('takes it when the form mounts after the request', () => {
+ selection.nodeId = 'draft-1';
+ render();
+ parkHumanOne();
+ expect(form()).toBeNull();
+
+ act(() => requestDecisionFocus('human-1'));
+ selection.nodeId = 'human-1';
+ render();
+
+ expect(document.activeElement).toBe(form());
+ expect(useExecutionStore.getState().decisionFocusRequest).toBeUndefined();
+ });
+
+ it('takes it while the form already shows', () => {
+ parkHumanOne();
+
+ act(() => requestDecisionFocus('human-1'));
+
+ expect(document.activeElement).toBe(form());
+ });
+
+ it("leaves it to another node's form", () => {
+ parkHumanOne();
+
+ act(() => requestDecisionFocus('human-2'));
+
+ expect(document.activeElement).not.toBe(form());
+ expect(useExecutionStore.getState().decisionFocusRequest).toBe('human-2');
+ });
+ });
+
+ describe('what the person types', () => {
+ it('keeps what the person types to itself: the node data never changes', async () => {
+ parkHumanOne();
+
+ commit(fieldOf('Refund amount')!, '120');
+ await settle();
+
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ expect(nodeChanges).not.toHaveLength(0);
+ for (const data of nodeChanges) {
+ expect((data as { decisionRequest: unknown }).decisionRequest).toEqual(reviewRequest);
+ }
+ });
+
+ // In the panel a re-render comes from the selection hook when the node's other properties change.
+ it('keeps what the person typed when the panel renders the control again', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+
+ render({ ...reviewRequest });
+
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ await click(button('Approve'));
+ expect(sentEdits()).toEqual({ refundAmount: 120 });
+ });
+
+ it('keeps what the person typed when the request arrives as a new but equal object', () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+
+ render(structuredClone(reviewRequest));
+
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ });
+
+ it('keeps the fields it opened with when the picks change under it, and sends what it shows', async () => {
+ parkHumanOne();
+ render(refundAmountReadOnly());
+
+ expect(fieldOf('Refund amount')?.disabled).toBe(false);
+ commit(fieldOf('Refund amount')!, '120');
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ refundAmount: 120 });
+ });
+
+ it('sends the edit to a field the picks hide under it, because it sends what it shows', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+ render({ ...reviewRequest, schema: { type: 'object', properties: {} } });
+
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ await click(button('Approve'));
+ expect(sentEdits()).toEqual({ refundAmount: 120 });
+ });
+
+ it('opens again under the changed picks: a field turned read-only shows the proposal, the rest keep the draft', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+ commit(fieldOf('Reply draft')!, 'Hello');
+
+ selection.nodeId = 'draft-1';
+ render();
+ selection.nodeId = 'human-1';
+ render(refundAmountReadOnly());
+
+ expect(fieldOf('Refund amount')?.disabled).toBe(true);
+ expect(fieldOf('Refund amount')?.value).toBe('80');
+ expect(fieldOf('Reply draft')?.value).toBe('Hello');
+ await click(button('Approve'));
+ expect(sentEdits()).toEqual({ replyDraft: 'Hello' });
+ });
+
+ it('opens again under the changed picks: a field the draft was taken without starts from the proposal', async () => {
+ const properties = Object.fromEntries(
+ Object.entries(reviewRequest.schema.properties).filter(([key]) => key !== 'replyDraft'),
+ );
+ render({ ...reviewRequest, schema: { ...reviewRequest.schema, properties } });
+ parkHumanOne();
+ expect(labelled('Reply draft')).toBeUndefined();
+ commit(fieldOf('Refund amount')!, '120');
+
+ selection.nodeId = 'draft-1';
+ render();
+ selection.nodeId = 'human-1';
+ render(reviewRequest);
+
+ expect(fieldOf('Reply draft')?.value).toBe('Dear customer');
+ await click(button('Approve'));
+ expect(sentEdits()).toEqual({ refundAmount: 120 });
+ });
+
+ it('opens again with a field the person cleared before leaving still empty, and sends it emptied', async () => {
+ parkHumanOne();
+ commit(fieldOf('Reply draft')!, '');
+
+ selection.nodeId = 'draft-1';
+ render();
+ selection.nodeId = 'human-1';
+ render();
+
+ expect(fieldOf('Reply draft')?.value).toBe('');
+ await click(button('Approve'));
+ expect(sentEdits()).toEqual({ replyDraft: '' });
+ });
+
+ it('keeps what was typed while the panel shows another node, and measures the edits against the proposal', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+ await click(button('Reject…'));
+ commit(reasonField()!, 'Checked with the customer');
+ await click(dialogButton('Cancel'));
+
+ selection.nodeId = 'draft-1';
+ render();
+ expect(form()).toBeNull();
+
+ selection.nodeId = 'human-1';
+ render();
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ await click(button('Reject…'));
+ expect(reasonField()?.value).toBe('Checked with the customer');
+ await click(dialogButton('Cancel'));
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ refundAmount: 120 });
+ });
+
+ it('sends no edit for a field it does not show when the stream reconnects, also after a visit elsewhere', async () => {
+ parkHumanOne({ ...draftOutput, tags: ['vip'] });
+ reconnect();
+
+ selection.nodeId = 'draft-1';
+ render();
+ reconnect();
+ selection.nodeId = 'human-1';
+ render();
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({});
+ });
+
+ it('keeps a separate draft for each waiting node', () => {
+ parkHumanOne();
+ act(() => {
+ applyEvent(event({ type: 'node_completed', nodeId: 'draft-2', payload: { output: { refundAmount: 15 } } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-2' }));
+ });
+ commit(fieldOf('Refund amount')!, '120');
+
+ selection.nodeId = 'human-2';
+ render();
+ expect(fieldOf('Refund amount')?.value).toBe('15');
+ commit(fieldOf('Refund amount')!, '20');
+
+ selection.nodeId = 'human-1';
+ render();
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+
+ selection.nodeId = 'human-2';
+ render();
+ expect(fieldOf('Refund amount')?.value).toBe('20');
+ });
+
+ it('starts over on the second wait of the same node and answers that wait, not the first', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+
+ act(() => {
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: { action: 'approve' } } }));
+ applyEvent(
+ event({
+ type: 'node_completed',
+ nodeId: 'draft-1',
+ payload: { output: { ...draftOutput, refundAmount: 95 } },
+ }),
+ );
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ });
+
+ expect(fieldOf('Refund amount')?.value).toBe('95');
+
+ await click(button('Approve'));
+
+ expect(submit.mock.calls[0]?.[0]?.attempt).toBe(2);
+ expect(sentEdits()).toEqual({});
+ });
+
+ it('starts a new run from the proposal, and the closing form of the old one leaves no draft in it', () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+
+ act(() => setExecutionStarted('exec-2', 'http://backend/stream'));
+ expect(form()).toBeNull();
+ expect(useExecutionStore.getState().decisionDrafts).toEqual({});
+
+ parkHumanOne();
+ expect(fieldOf('Refund amount')?.value).toBe('80');
+ });
+ });
+
+ describe('what blocks a decision', () => {
+ it('refuses to send while the form fails its schema, even before the button greys out', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '');
+
+ await click(button('Approve'));
+
+ expect(submit).not.toHaveBeenCalled();
+ });
+
+ it('refuses to send a required text field emptied to blank text, even before the button greys out', async () => {
+ render(replyDraftRequired());
+ parkHumanOne();
+ commit(fieldOf('Reply draft')!, ' ');
+
+ await click(button('Approve'));
+
+ expect(submit).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['an empty string', ''],
+ ['whitespace', ' '],
+ ])(
+ 'holds back Approve while a required text field is emptied to %s, and lets it through once filled again',
+ async (_name, blank) => {
+ render(replyDraftRequired());
+ parkHumanOne();
+
+ commit(fieldOf('Reply draft')!, blank);
+ await settle();
+
+ expect(button('Approve').disabled).toBe(true);
+
+ commit(fieldOf('Reply draft')!, 'Refunded');
+ await settle();
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ replyDraft: 'Refunded' });
+ },
+ );
+
+ it('still holds back a required text field the model left null after the person types and clears it', async () => {
+ render(replyDraftRequired());
+ parkHumanOne({ ...draftOutput, replyDraft: null });
+
+ commit(fieldOf('Reply draft')!, 'x');
+ await settle();
+ commit(fieldOf('Reply draft')!, '');
+ await settle();
+
+ expect(button('Approve').disabled).toBe(true);
+ });
+
+ it('opens the reject for a required reason, and confirms it only once the reason is given', async () => {
+ parkHumanOne();
+ render({
+ ...reviewRequest,
+ actions: [reviewRequest.actions[0], { ...reviewRequest.actions[1], reasonRequired: true }],
+ });
+
+ expect(button('Reject…').disabled).toBe(false);
+ await click(button('Reject…'));
+ expect(dialogButton('Confirm Rejection').disabled).toBe(true);
+
+ commit(reasonField()!, ' ');
+ expect(dialogButton('Confirm Rejection').disabled).toBe(true);
+
+ commit(reasonField()!, 'Outside the policy');
+
+ expect(dialogButton('Confirm Rejection').disabled).toBe(false);
+ await click(dialogButton('Confirm Rejection'));
+ expect(submit).toHaveBeenCalledTimes(1);
+ });
+
+ it('marks and blocks while the form fails its schema, a required field emptied', async () => {
+ parkHumanOne();
+
+ commit(fieldOf('Refund amount')!, '');
+ await settle();
+
+ expect(hasError('Refund amount')).toBe(true);
+ expect(button('Approve').disabled).toBe(true);
+ expect(button('Reject…').disabled).toBe(false);
+ });
+
+ it('keeps blocking a required field emptied before the picks hide it under the form', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '');
+ render({ ...reviewRequest, schema: { type: 'object', properties: {} } });
+ await settle();
+
+ expect(button('Approve').disabled).toBe(true);
+ });
+
+ it('blocks the approve from the start when the proposal leaves a required field out', async () => {
+ parkHumanOne({ orderDate: '2026-09-01' });
+ await settle();
+
+ expect(button('Approve').disabled).toBe(true);
+
+ commit(fieldOf('Refund amount')!, '49');
+ await settle();
+
+ expect(button('Approve').disabled).toBe(false);
+ });
+
+ it('does not block on a read-only field the proposal filled wrongly, which the person cannot correct', async () => {
+ parkHumanOne({ ...draftOutput, orderDate: null });
+ await settle();
+
+ expect(button('Approve').disabled).toBe(false);
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({});
+ });
+
+ it.each([
+ ['a field the form does not show', 'tags'],
+ ['a read-only field', 'orderDate'],
+ ])('does not block when the proposal leaves out %s that the schema requires', async (_name, required) => {
+ const schema = { ...reviewRequest.schema, required: ['refundAmount', required] };
+ render({ ...reviewRequest, schema });
+ parkHumanOne(Object.fromEntries(Object.entries(draftOutput).filter(([key]) => key !== required)));
+ await settle();
+
+ expect(button('Approve').disabled).toBe(false);
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({});
+ });
+ });
+
+ describe('sending a decision', () => {
+ it('sends the decision for the wait the node is on, with no edits when nothing changed', async () => {
+ parkHumanOne();
+
+ await click(button('Approve'));
+
+ expect(submit).toHaveBeenCalledWith(humanOneWait, { action: 'approve', edits: {} });
+ });
+
+ it('sends what was typed even when Approve is clicked before the form reports the change', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '130');
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ refundAmount: 130 });
+ });
+
+ it('sends only the editable fields the person changed', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120.5');
+ commit(fieldOf('Reply draft')!, 'Dear customer, refunded.');
+
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ refundAmount: 120.5, replyDraft: 'Dear customer, refunded.' });
+ });
+
+ it('sends a switched boolean field', async () => {
+ parkHumanOne();
+
+ // The switch forwards a click to a hidden checkbox; jsdom does not run that forwarding, so the test clicks it.
+ await click(labelled('Expedite')!.querySelector('input[type="checkbox"]')!);
+ await click(button('Approve'));
+
+ expect(sentEdits()).toEqual({ expedite: true });
+ });
+
+ it('asks for the reason in a dialog instead of the panel, and sends nothing on Cancel', async () => {
+ parkHumanOne();
+ expect(fieldOf('Rejection reason')).toBeUndefined();
+ expect(dialog()).toBeNull();
+
+ await click(button('Reject…'));
+ expect(labelled('Rejection reason')).toBeUndefined();
+ expect(dialog()?.textContent).toContain('Rejection reason');
+ commit(reasonField()!, 'Not sure yet');
+ await click(dialogButton('Cancel'));
+
+ expect(dialog()).toBeNull();
+ expect(submit).not.toHaveBeenCalled();
+ });
+
+ it('sends a reject with its reason and without edits, and closes the dialog', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+ await click(button('Reject…'));
+ commit(reasonField()!, 'Outside the policy');
+
+ await click(dialogButton('Confirm Rejection'));
+
+ expect(submit).toHaveBeenCalledWith(humanOneWait, { action: 'reject', reason: 'Outside the policy' });
+ expect(dialog()).toBeNull();
+ });
+
+ it('sends one reject when Confirm Rejection is pressed twice as the dialog closes', async () => {
+ parkHumanOne();
+ await click(button('Reject…'));
+ const confirm = dialogButton('Confirm Rejection');
+
+ await act(async () => {
+ confirm.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ confirm.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+
+ expect(submit).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows Confirm Rejection disabled while a decision for the wait is on its way', async () => {
+ parkHumanOne();
+ await click(button('Reject…'));
+ expect(dialogButton('Confirm Rejection').disabled).toBe(false);
+
+ act(() => saveDecisionSend(humanOneWait, { status: 'sending' }));
+
+ expect(dialogButton('Confirm Rejection').disabled).toBe(true);
+ });
+
+ it('locks the fields while the decision is on its way, so nothing typed then is lost', async () => {
+ parkHumanOne();
+ submit.mockReturnValue(new Promise(() => {}));
+
+ await click(button('Approve'));
+
+ expect(fieldOf('Refund amount')?.disabled).toBe(true);
+ expect(fieldOf('Reply draft')?.disabled).toBe(true);
+ expect(button('Reject…').disabled).toBe(true);
+ });
+
+ it('keeps a decision on its way when the person leaves and comes back, so a second one cannot be sent', async () => {
+ parkHumanOne();
+ submit.mockReturnValue(new Promise(() => {}));
+ await click(button('Approve'));
+
+ selection.nodeId = 'draft-1';
+ render();
+ selection.nodeId = 'human-1';
+ render();
+
+ expect(button('Approve').disabled).toBe(true);
+ expect(button('Reject…').disabled).toBe(true);
+ expect(submit).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows a refusal that arrived while the person was on another node', async () => {
+ parkHumanOne();
+ let answer!: (result: SubmitDecisionResult) => void;
+ submit.mockReturnValue(new Promise((resolve) => (answer = resolve)));
+ await click(button('Approve'));
+
+ selection.nodeId = 'draft-1';
+ render();
+ await act(async () =>
+ answer({ ok: false, status: 409, code: 'decision_already_made', message: 'Someone decided' }),
+ );
+ selection.nodeId = 'human-1';
+ render();
+
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe('Someone decided.');
+ expect(button('Approve').disabled).toBe(false);
+ });
+
+ it('shows what the backend refused and keeps the values', async () => {
+ parkHumanOne();
+ commit(fieldOf('Refund amount')!, '120');
+ submit.mockResolvedValue({
+ ok: false,
+ status: 409,
+ code: 'decision_attempt_mismatch',
+ message: 'The decision names a wait that is not the current one',
+ currentAttempt: 2,
+ });
+
+ await click(button('Approve'));
+
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('not the current one');
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('now 2');
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ expect(fieldOf('Refund amount')?.disabled).toBe(false);
+ expect(button('Approve').disabled).toBe(false);
+ });
+
+ it('keeps the buttons down once a decision was accepted, and says so should the run be slow to show it', async () => {
+ parkHumanOne();
+
+ await click(button('Approve'));
+
+ expect(button('Approve').disabled).toBe(true);
+ expect(container.querySelector('[role="alert"]')).toBeNull();
+ // Its style keeps the line hidden for its first second, so a healthy stack never shows it.
+ expect(container.querySelector('[role="status"]')?.textContent).toBe(
+ 'Sent. Waiting for the run to record the decision.',
+ );
+ });
+ });
+
+ describe('after the decision', () => {
+ it('shows the approved values read-only once the run moves on', () => {
+ parkHumanOne();
+ decideHumanOne({
+ action: 'approve',
+ effect: 'resume-with-edits',
+ edits: { refundAmount: 120 },
+ resolvedBy: 'human',
+ });
+
+ expect(form()).toBeNull();
+ expect(record()).not.toBeNull();
+ expect(fieldOf('Refund amount')?.value).toBe('120');
+ expect(fieldOf('Refund amount')?.disabled).toBe(true);
+ expect(fieldOf('Reply draft')?.value).toBe('Dear customer');
+ expect(fieldOf('Reply draft')?.disabled).toBe(true);
+ expect(labelled('Decision')).toBeUndefined();
+ expect(container.querySelectorAll('button')).toHaveLength(0);
+ });
+
+ it('shows a rejection with its reason and the proposal it turned down', () => {
+ parkHumanOne();
+ decideHumanOne({
+ action: 'reject',
+ effect: 'reject',
+ edits: {},
+ reason: 'Outside the policy',
+ resolvedBy: 'human',
+ });
+
+ expect(fieldOf('Refund amount')?.value).toBe('80');
+ expect(valueOf('Reason')).toBe('Outside the policy');
+ });
+
+ it('does not check a settled decision again: a required field the proposal left out is not an error there', async () => {
+ parkHumanOne({ orderDate: '2026-09-01' });
+ decideHumanOne({ action: 'reject', effect: 'reject', edits: {}, reason: 'No amount', resolvedBy: 'human' });
+ await settle();
+
+ expect(fieldOf('Refund amount')?.value).toBe('');
+ expect(hasError('Refund amount')).toBe(false);
+ });
+
+ it('keeps showing the decision after the run has ended', () => {
+ parkHumanOne();
+ decideHumanOne({ action: 'approve', effect: 'resume', edits: {}, resolvedBy: 'human' });
+ act(() => applyEvent(event({ type: 'execution_completed', payload: undefined })));
+
+ expect(record()).not.toBeNull();
+ expect(fieldOf('Refund amount')?.value).toBe('80');
+ });
+
+ it('shows the settled values under the current picks, also when they change after the decision', () => {
+ parkHumanOne();
+ decideHumanOne({ action: 'approve', effect: 'resume', edits: {}, resolvedBy: 'human' });
+
+ const properties = Object.fromEntries(
+ Object.entries(reviewRequest.schema.properties).filter(([key]) => key !== 'replyDraft'),
+ );
+ render({ ...reviewRequest, schema: { ...reviewRequest.schema, properties } });
+
+ expect(labelled('Reply draft')).toBeUndefined();
+ expect(fieldOf('Refund amount')?.value).toBe('80');
+ });
+
+ it('shows nothing when the run is cancelled while parked, because nothing was decided', () => {
+ parkHumanOne();
+ act(() => applyEvent(event({ type: 'execution_cancelled', payload: {} })));
+
+ expect(form()).toBeNull();
+ expect(record()).toBeNull();
+ });
+ });
+});
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.tsx
new file mode 100644
index 000000000..f7339e92f
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.tsx
@@ -0,0 +1,57 @@
+import { rankWith, uiTypeIs, useSingleSelectedElement, withJsonFormsControlProps } from '@workflowbuilder/sdk';
+import type { JsonFormsRendererExtension } from '@workflowbuilder/sdk';
+
+import { useNodeDecision } from '../../../hooks/use-node-decision';
+import { saveDecisionDraft, waitKey } from '../../../stores/use-execution-store';
+import { readDecisionOutcome } from '../../../utils/human-decision/decision-outcome';
+import { readDecisionRequest } from '../../../utils/human-decision/decision-request';
+import { proposedValues, withEdits } from '../../../utils/human-decision/decision-values';
+import { DecisionForm } from './decision-form';
+import { DecisionRecord } from './decision-record';
+
+// The request is read off the selected node, not off `data`: JsonForms updates `data` one render after the selection
+// moves, so a form keyed on the new wait would mount with the previous node's schema (see decision-form-panel.test.tsx).
+function DecisionFormControl() {
+ const node = useSingleSelectedElement()?.node;
+ const nodeId = node?.id;
+ const request = readDecisionRequest(node?.data.properties['decisionRequest']);
+ const decision = useNodeDecision(nodeId, request?.proposalSourceNodeId);
+
+ if (request === undefined || decision.phase === 'none') {
+ return null;
+ }
+
+ const { schema, actions } = request;
+ const { wait } = decision;
+ const proposal = proposedValues(decision.sourceOutput, schema);
+
+ if (decision.phase === 'decided') {
+ const outcome = readDecisionOutcome(decision.output);
+ return outcome === undefined ? null : (
+
+ );
+ }
+
+ return (
+ saveDecisionDraft(wait, change)}
+ wait={wait}
+ />
+ );
+}
+
+export const decisionFormRenderer: JsonFormsRendererExtension = {
+ tester: rankWith(5, uiTypeIs('DecisionForm')),
+ renderer: withJsonFormsControlProps(DecisionFormControl),
+};
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx
new file mode 100644
index 000000000..c08c7dc1f
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx
@@ -0,0 +1,203 @@
+import { useSingleSelectedElement, useStore } from '@workflowbuilder/sdk';
+import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// SDK internals by path: the public API mounts these only inside a whole .
+import { registerCustomRenderers } from '../../../../../../packages/sdk/src/features/json-form/extension-registry';
+import { PropertiesBar } from '../../../../../../packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar';
+import { submitDecision } from '../../../adapters/submit-decision';
+import { humanDecisionNodeType, humanDecisionPaletteItem } from '../../../nodes/human-decision';
+import { plugin } from '../../../plugin';
+import { executionEvent as event } from '../../../stores/execution-event.fixture';
+import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store';
+import { reviewRequest } from '../../../utils/human-decision/review-request.fixture';
+import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control';
+import { decisionFormRenderer } from './decision-form-control';
+
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, Icon: ({ name }: { name: string }) => };
+});
+
+vi.mock('../../../adapters/submit-decision', () => ({ submitDecision: vi.fn() }));
+const submit = vi.mocked(submitDecision);
+
+registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]);
+plugin();
+
+function agent(id: string): WorkflowBuilderNode {
+ return {
+ id,
+ type: 'node',
+ position: { x: 0, y: 0 },
+ data: {
+ segments: [],
+ properties: { label: id, description: '', systemPrompt: '', webSearch: false },
+ type: 'ai-studio/ai-agent',
+ icon: 'AiAgent',
+ },
+ };
+}
+
+// A decision node whose decider sees one editable text field.
+function human(id: string, field: string, title: string): WorkflowBuilderNode {
+ const decisionRequest = {
+ ...reviewRequest,
+ schema: { type: 'object', properties: { [field]: { type: 'string', title } } },
+ };
+ return {
+ id,
+ type: humanDecisionNodeType,
+ position: { x: 350, y: 0 },
+ data: {
+ segments: [],
+ properties: { label: title, description: '', decisionRequest },
+ type: humanDecisionNodeType,
+ icon: 'UserCheck',
+ },
+ };
+}
+
+function edge(source: string, target: string): WorkflowBuilderEdge {
+ return {
+ id: `${source}-${target}`,
+ source,
+ sourceHandle: 'source',
+ target,
+ targetHandle: 'target',
+ type: 'labelEdge',
+ data: {},
+ };
+}
+
+// The SDK's own panel, with AI Studio's decorator on it: one panel, its content kept from one selected node to the next.
+function Host() {
+ return (
+ {}}
+ onDeleteClick={() => {}}
+ />
+ );
+}
+
+// What a click on the canvas calls.
+function select(nodeId: string) {
+ act(() => {
+ const { nodes, onSelectionChange } = useStore.getState();
+ onSelectionChange({ nodes: nodes.filter((node) => node.id === nodeId), edges: [] });
+ });
+}
+
+// JsonForms debounces onChange by 10 ms.
+const settle = () =>
+ act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ });
+
+async function click(element: Element) {
+ await act(async () => {
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+}
+
+// The editor's text controls keep the typing locally and hand the value over on blur.
+function commit(element: HTMLInputElement | HTMLTextAreaElement, text: string) {
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(prototype, 'value')?.set?.call(element, text);
+ act(() => {
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ act(() => {
+ element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ });
+}
+
+describe('the decision form in the real properties panel, as the selection moves between two waiting decisions', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ useStore.setState(useStore.getInitialState(), true);
+ resetExecution();
+ submit.mockReset();
+ submit.mockResolvedValue({ ok: true });
+ useStore.setState({
+ nodes: [agent('draft-1'), human('human-1', 'alpha', 'Alpha'), agent('draft-2'), human('human-2', 'beta', 'Beta')],
+ edges: [edge('draft-1', 'human-1'), edge('draft-2', 'human-2')],
+ data: [humanDecisionPaletteItem as never],
+ });
+ act(() => {
+ setExecutionStarted('exec-1', 'http://backend/stream');
+ applyEvent(event({ type: 'node_completed', nodeId: 'draft-1', payload: { output: { alpha: 'first' } } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ applyEvent(event({ type: 'node_completed', nodeId: 'draft-2', payload: { output: { beta: 'second' } } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-2' }));
+ });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ act(() => root.render( ));
+ });
+
+ afterEach(async () => {
+ await settle();
+ act(() => root.unmount());
+ container.remove();
+ useStore.setState(useStore.getInitialState(), true);
+ resetExecution();
+ });
+
+ // A row of the decider's form is found by its label, the way a person finds it.
+ const formField = (label: string) =>
+ [...container.querySelectorAll('[data-decision-form] span')]
+ .find((span) => span.childElementCount === 0 && span.textContent === label)
+ ?.parentElement?.parentElement?.querySelector('input, textarea') ??
+ undefined;
+ const button = (label: string) =>
+ [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.trim() === label);
+ const approve = () => button('Approve')!;
+
+ it("puts Approve in the panel's footer, outside the form's scrolling fields, and shows no Delete", async () => {
+ select('human-1');
+ await settle();
+
+ expect(formField('Alpha')).toBeDefined();
+ expect(approve().closest('[data-decision-form]')).toBeNull();
+ expect(button('Delete node')).toBeUndefined();
+ });
+
+ it('shows the field of the node the selection lands on, not the one it left', async () => {
+ select('human-1');
+ await settle();
+ select('human-2');
+ await settle();
+
+ expect(formField('Alpha')).toBeUndefined();
+ expect(formField('Beta')?.value).toBe('second');
+ });
+
+ it("sends the edit under the node's own field", async () => {
+ select('human-1');
+ await settle();
+ select('human-2');
+ await settle();
+
+ const beta = formField('Beta');
+ expect(beta).toBeDefined();
+ commit(beta!, 'edited');
+ await settle();
+ await click(approve());
+
+ expect(submit).toHaveBeenCalledWith(
+ { executionId: 'exec-1', nodeId: 'human-2', attempt: 1 },
+ { action: 'approve', edits: { beta: 'edited' } },
+ );
+ });
+});
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form.module.css b/apps/ai-studio/src/components/human-decision/decision-form/decision-form.module.css
new file mode 100644
index 000000000..053ca25f9
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form.module.css
@@ -0,0 +1,5 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form.tsx
new file mode 100644
index 000000000..eeb099c98
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form.tsx
@@ -0,0 +1,92 @@
+import type { JsonSchema } from '@workflowbuilder/sdk';
+import { useEffect, useRef, useState } from 'react';
+
+import styles from './decision-form.module.css';
+
+import { useDecisionSubmit } from '../../../hooks/use-decision-submit';
+import {
+ type DecisionDraft,
+ type DecisionWait,
+ clearDecisionFocusRequest,
+ useExecutionStore,
+} from '../../../stores/use-execution-store';
+import { schemaFields } from '../../../utils/editor-form/form-schema';
+import type { OfferedActions } from '../../../utils/human-decision/decision-actions';
+import { blocksApproval, editsOf, startingValues } from '../../../utils/human-decision/decision-values';
+import { EditorForm, type EditorFormHandle } from '../../editor-form/editor-form';
+import { DecisionVerdict } from './decision-verdict';
+
+type Props = {
+ schema: JsonSchema;
+ actions: OfferedActions;
+ /** Where the fields start when there is no draft, and what the edits are measured against. */
+ proposal: Record;
+ draft: DecisionDraft | undefined;
+ saveDraft: (change: DecisionDraft) => void;
+ wait: DecisionWait;
+};
+
+// The control remounts it (React `key`) for each wait, so it starts from that wait's draft, or from the proposal.
+export function DecisionForm({ actions, draft, saveDraft, wait, ...opened }: Props) {
+ // Undo can change the picks under an open decision once the lock is lifted; the form keeps the fields it opened with.
+ const [{ schema, proposal }] = useState(opened);
+ const fields = useRef(null);
+ const formElement = useRef(null);
+ const isFocusRequested = useExecutionStore((state) => state.decisionFocusRequest === wait.nodeId);
+ const [isApproveBlocked, setIsApproveBlocked] = useState(false);
+ const { isBusy, isAccepted, message, submit } = useDecisionSubmit(wait);
+ const reason = draft?.reason ?? '';
+
+ // Decide asks for it, whether this form is about to mount or already shows.
+ useEffect(() => {
+ if (isFocusRequested) {
+ formElement.current?.focus();
+ clearDecisionFocusRequest();
+ }
+ }, [isFocusRequested]);
+
+ const approve = () => {
+ const snapshot = fields.current?.snapshot();
+ if (!snapshot) {
+ return;
+ }
+ const edits = editsOf(proposal, snapshot.data, schema);
+ if (!blocksApproval(snapshot.invalidFields, schema, edits)) {
+ void submit({ action: actions.resume.name, edits });
+ }
+ };
+
+ return (
+
+
+ setIsApproveBlocked(blocksApproval(invalidFields, schema, editsOf(proposal, data, schema)))
+ }
+ onFail={() => setIsApproveBlocked(true)}
+ onUnmount={(values) => saveDraft({ values, fields: schemaFields(schema).map(([key]) => key) })}
+ />
+ saveDraft({ reason: next })}
+ onApprove={approve}
+ onReject={(reject) => void submit({ action: reject.name, reason })}
+ />
+
+ );
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-record.module.css b/apps/ai-studio/src/components/human-decision/decision-form/decision-record.module.css
new file mode 100644
index 000000000..ef24da01e
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-record.module.css
@@ -0,0 +1,9 @@
+.record {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.value {
+ margin: 0;
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-record.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-record.tsx
new file mode 100644
index 000000000..773ac7737
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-record.tsx
@@ -0,0 +1,27 @@
+import { FormControlWithLabel } from '@workflowbuilder/sdk';
+import type { JsonSchema } from '@workflowbuilder/sdk';
+
+import styles from './decision-record.module.css';
+
+import { EditorForm } from '../../editor-form/editor-form';
+
+type Props = {
+ schema: JsonSchema;
+ values: Record;
+ reason: string | undefined;
+};
+
+/** A decision already made: the values it settled, read-only, and the reason when one was given. */
+export function DecisionRecord({ schema, values, reason }: Props) {
+ return (
+
+ {/* A settled decision is not checked again: an emptied or missing field is part of what was decided. */}
+
+ {reason !== undefined && (
+
+ {reason}
+
+ )}
+
+ );
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-verdict.module.css b/apps/ai-studio/src/components/human-decision/decision-form/decision-verdict.module.css
new file mode 100644
index 000000000..2d4f0e2b7
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-verdict.module.css
@@ -0,0 +1,27 @@
+.verdict {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wb-ds-space-100);
+}
+
+.buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--wb-ds-space-100);
+}
+
+.message {
+ margin: 0;
+}
+
+/* On a healthy stack the record replaces the form first, so the line shows only when the run is slow to record it. */
+.pending {
+ margin: 0;
+ animation: appear 0s 1s both;
+}
+
+@keyframes appear {
+ from {
+ visibility: hidden;
+ }
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-verdict.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-verdict.tsx
new file mode 100644
index 000000000..c7347db31
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-verdict.tsx
@@ -0,0 +1,80 @@
+import { PropertiesPanelFooter } from '@workflowbuilder/sdk';
+import { Button } from '@workflowbuilder/ui';
+import { useState } from 'react';
+
+import styles from './decision-verdict.module.css';
+
+import type { OfferedActions, RejectOffer } from '../../../utils/human-decision/decision-actions';
+import { RejectDialog } from './reject-dialog';
+
+type Props = {
+ actions: OfferedActions;
+ reason: string;
+ isApproveBlocked: boolean;
+ isBusy: boolean;
+ isAccepted: boolean;
+ message: string | undefined;
+ onReasonChange: (reason: string) => void;
+ onApprove: () => void;
+ onReject: (reject: RejectOffer) => void;
+};
+
+/** The verdict half of the form, in the panel's footer: the actions the decider may take, a rejection asking for its reason first. */
+export function DecisionVerdict({
+ actions: { resume, reject },
+ reason,
+ isApproveBlocked,
+ isBusy,
+ isAccepted,
+ message,
+ onReasonChange,
+ onApprove,
+ onReject,
+}: Props) {
+ const [isRejecting, setIsRejecting] = useState(false);
+
+ return (
+ <>
+
+
+ {message && (
+
+ {message}
+
+ )}
+ {isAccepted && (
+
+ Sent. Waiting for the run to record the decision.
+
+ )}
+
+ {reject && (
+ setIsRejecting(true)}>
+ {`${reject.label}…`}
+
+ )}
+ {/* A disabled button does not say why, and its state trails the form's debounced report, so on a touch screen
+ the first tap after a correction is lost (follow-up: decision-form-blocked-button-a11y). */}
+
+ {resume.label}
+
+
+
+
+ {reject && (
+ setIsRejecting(false)}
+ onConfirm={() => {
+ setIsRejecting(false);
+ onReject(reject);
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/reject-dialog.module.css b/apps/ai-studio/src/components/human-decision/decision-form/reject-dialog.module.css
new file mode 100644
index 000000000..989b7621b
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/reject-dialog.module.css
@@ -0,0 +1,10 @@
+/* The modal centres its content; the reason takes the dialog's width. */
+.field {
+ align-self: stretch;
+}
+
+.buttons {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/apps/ai-studio/src/components/human-decision/decision-form/reject-dialog.tsx b/apps/ai-studio/src/components/human-decision/decision-form/reject-dialog.tsx
new file mode 100644
index 000000000..b9cdb71e8
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/decision-form/reject-dialog.tsx
@@ -0,0 +1,51 @@
+import { FormControlWithLabel } from '@workflowbuilder/sdk';
+import { Button, Modal, TextArea } from '@workflowbuilder/ui';
+
+import styles from './reject-dialog.module.css';
+
+import { hasText } from '../../../utils/has-text';
+import type { RejectOffer } from '../../../utils/human-decision/decision-actions';
+
+type Props = {
+ reject: RejectOffer;
+ open: boolean;
+ isBusy: boolean;
+ reason: string;
+ onReasonChange: (reason: string) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+};
+
+/** Asks for the reason before a rejection is sent. What was typed stays in the draft when the person cancels. */
+export function RejectDialog({ reject, open, isBusy, reason, onReasonChange, onCancel, onConfirm }: Props) {
+ const reasonMissing = reject.reasonRequired === true && !hasText(reason);
+
+ return (
+
+
+ Cancel
+
+
+ Confirm Rejection
+
+
+ }
+ >
+
+
+
+ );
+}
diff --git a/apps/ai-studio/src/components/human-decision/node-template/decision-waiting-footer.tsx b/apps/ai-studio/src/components/human-decision/node-template/decision-waiting-footer.tsx
new file mode 100644
index 000000000..71c6bf1a0
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/node-template/decision-waiting-footer.tsx
@@ -0,0 +1,48 @@
+import { Icon, useSetSelection } from '@workflowbuilder/sdk';
+import { Button } from '@workflowbuilder/ui';
+
+import styles from './human-decision-template.module.css';
+
+import { isDecidable, requestDecisionFocus, useExecutionStore } from '../../../stores/use-execution-store';
+import { hasText } from '../../../utils/has-text';
+
+type Props = {
+ nodeId: string;
+ nodeLabel: string | undefined;
+};
+
+/** Says on the node that the run waits for this decision, and leads the person to it. */
+export function DecisionWaitingFooter({ nodeId, nodeLabel }: Props) {
+ const isAwaitingDecision = useExecutionStore(
+ (state) => state.nodeStates[nodeId]?.status === 'waiting' && isDecidable(state.status),
+ );
+ const setSelection = useSetSelection();
+
+ if (!isAwaitingDecision) {
+ return null;
+ }
+
+ const decide = () => {
+ if (setSelection({ nodeIds: [nodeId] })) {
+ requestDecisionFocus(nodeId);
+ }
+ };
+
+ return (
+
+
+
+ Waiting for decision
+
+
+ Decide
+
+
+ );
+}
diff --git a/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.module.css b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.module.css
new file mode 100644
index 000000000..1f4c8cb21
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.module.css
@@ -0,0 +1,47 @@
+.actions {
+ display: flex;
+ gap: var(--wb-ds-space-100);
+}
+
+.actions--vertical {
+ flex-direction: column;
+}
+
+.action {
+ composes: wb-text-body-s-emphasized from global;
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wb-ds-space-150);
+ padding: var(--wb-ds-space-125) var(--wb-ds-space-150);
+ color: var(--wb-ds-ui-text-default);
+ background-color: var(--wb-ds-canvas-node-bg-content-default);
+ border-radius: var(--wb-ds-radius-75);
+}
+
+.action-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.waiting {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wb-ds-space-100);
+ margin-top: var(--wb-ds-space-100);
+ padding-top: var(--wb-ds-space-100);
+ border-top: var(--wb-public-node-border-size) solid var(--wb-public-node-border-color);
+}
+
+.waiting-label {
+ composes: wb-text-body-s from global;
+
+ display: flex;
+ align-items: center;
+ gap: var(--wb-ds-space-50);
+ color: var(--ai-studio-status-color--waiting);
+}
diff --git a/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.test.tsx b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.test.tsx
new file mode 100644
index 000000000..1d9431c52
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.test.tsx
@@ -0,0 +1,299 @@
+import { type Node, ReactFlowProvider, type ReactFlowState, useStoreApi } from '@xyflow/react';
+import { type ReactNode, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { defaultDecisionRequest } from '../../../nodes/human-decision/default-properties-data';
+import { applyEvent, applySnapshot, resetExecution, useExecutionStore } from '../../../stores/use-execution-store';
+import { nodeEvent, snapshotFrame } from '../../../test/execution-history';
+import { HumanDecisionNodeTemplate } from './human-decision-template';
+
+// The slot is observed through a marker element; the real one renders its children unchanged.
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ Icon: () => null,
+ OptionalNodeContent: ({ nodeId, children }: { nodeId: string; children?: ReactNode }) => (
+ {children}
+ ),
+ };
+});
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const data = {
+ type: 'ai-studio/human-decision',
+ icon: 'UserCheck' as const,
+ properties: { label: 'Human decision', description: '', decisionRequest: defaultDecisionRequest },
+};
+
+const parkOnHuman1 = () => act(() => applySnapshot(snapshotFrame('waiting')));
+
+function handles(container: HTMLElement, type: 'source' | 'target') {
+ return [...container.querySelectorAll(`.react-flow__handle.${type}`)];
+}
+
+describe('HumanDecisionNodeTemplate', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ resetExecution();
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ function render(element: ReactNode) {
+ act(() => root.render({element} ));
+ }
+
+ it('mounts one source handle per action, with the action port as the handle id', () => {
+ render(
+ ,
+ );
+
+ expect(handles(container, 'source').map((handle) => handle.dataset['handleid'])).toEqual([
+ 'source:inner:approved',
+ 'source:inner:rejected',
+ ]);
+ expect(container.textContent).toContain('Approve');
+ expect(container.textContent).toContain('Reject');
+ });
+
+ it('derives the handles from the request: labels, ports and count come from its actions', () => {
+ const request = {
+ version: 1,
+ actions: [
+ { name: 'ship', label: 'Ship it', effect: 'resume', port: 'source:inner:shipped' },
+ { name: 'escalate', label: 'Escalate', effect: 'resume', port: 'source:inner:escalated' },
+ { name: 'send-back', label: 'Send back', effect: 'reject', port: 'source:inner:sent-back' },
+ { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 },
+ ],
+ schema: { type: 'object', properties: {} },
+ };
+
+ render(
+ ,
+ );
+
+ expect(handles(container, 'source').map((handle) => handle.dataset['handleid'])).toEqual([
+ 'source:inner:shipped',
+ 'source:inner:escalated',
+ 'source:inner:sent-back',
+ ]);
+ for (const label of ['Ship it', 'Escalate', 'Send back']) {
+ expect(container.textContent).toContain(label);
+ }
+ expect(container.textContent).not.toContain('Ask again');
+ expect(container.textContent).not.toContain('Approve');
+ });
+
+ it.each([
+ ['no request', undefined],
+ ['actions that are not a list', { version: 1, actions: 'nope' }],
+ ['a request that is not an object', 'nope'],
+ ])('renders the header and only the target handle with %s', (_case, decisionRequest) => {
+ render(
+ ,
+ );
+
+ expect(container.textContent).toContain('Human decision');
+ expect(handles(container, 'source')).toHaveLength(0);
+ expect(handles(container, 'target').map((handle) => handle.dataset['handleid'])).toEqual(['target']);
+ });
+
+ it('skips actions without a port and shows the port when an action has no label', () => {
+ const request = {
+ version: 1,
+ actions: [{ name: 'go', port: 'source:inner:go' }, { name: 'stay', label: 'No port here' }, null, 'garbage'],
+ };
+
+ render(
+ ,
+ );
+
+ expect(handles(container, 'source').map((handle) => handle.dataset['handleid'])).toEqual(['source:inner:go']);
+ expect(container.textContent).toContain('source:inner:go');
+ expect(container.textContent).not.toContain('No port here');
+ });
+
+ it('mounts one target handle', () => {
+ render(
+ ,
+ );
+
+ expect(handles(container, 'target').map((handle) => handle.dataset['handleid'])).toEqual(['target']);
+ });
+
+ it('renders the actions inside the OptionalNodeContent slot, where the execution markers mount', () => {
+ render(
+ ,
+ );
+
+ const slot = container.querySelector('[data-optional-node-content="human-1"]');
+ expect(slot).not.toBeNull();
+ expect(slot?.querySelectorAll('.react-flow__handle.source')).toHaveLength(2);
+ });
+
+ it('follows the layout direction: handles hang below and above in a top-down diagram', () => {
+ render(
+ ,
+ );
+
+ expect(handles(container, 'source').map((handle) => handle.dataset['handlepos'])).toEqual(['bottom', 'bottom']);
+ expect(handles(container, 'target').map((handle) => handle.dataset['handlepos'])).toEqual(['top']);
+ });
+
+ it('shows only the header in the palette preview: no handles, no slot', () => {
+ render(
+ ,
+ );
+
+ expect(container.querySelectorAll('.react-flow__handle')).toHaveLength(0);
+ expect(container.querySelector('[data-optional-node-content]')).toBeNull();
+ expect(container.textContent).toContain('Human decision');
+ });
+
+ it('greys out in the palette while it cannot be added, the same as the built-in nodes', () => {
+ render(
+ ,
+ );
+
+ // The panel shell, the icon and the label each carry the disabled state.
+ expect(container.querySelectorAll('[class*="disabled"]')).toHaveLength(3);
+ });
+
+ const decideButton = () =>
+ [...container.querySelectorAll('button')].find((button) => button.textContent === 'Decide');
+
+ it('shows the wait and Decide only while the run waits on this node', () => {
+ render(
+ ,
+ );
+ expect(container.textContent).not.toContain('Waiting for decision');
+ expect(decideButton()).toBeUndefined();
+
+ parkOnHuman1();
+ expect(container.textContent).toContain('Waiting for decision');
+ expect(decideButton()).toBeDefined();
+
+ act(() => applyEvent(nodeEvent('node_completed', 'human-1')));
+ expect(container.textContent).not.toContain('Waiting for decision');
+ expect(decideButton()).toBeUndefined();
+ });
+
+ it('keeps the wait off while the run is cancelling, since the backend refuses a decision then', () => {
+ render(
+ ,
+ );
+
+ act(() => applySnapshot(snapshotFrame('cancelling')));
+
+ expect(container.textContent).not.toContain('Waiting for decision');
+ });
+
+ it('names Decide after its node, and a press on it does not drag the node', () => {
+ render(
+ ,
+ );
+ parkOnHuman1();
+
+ expect(decideButton()?.getAttribute('aria-label')).toBe('Decide: Human decision');
+ expect(decideButton()?.classList.contains('nodrag')).toBe(true);
+ });
+
+ it('asks for no focus when the canvas has no such node to select', () => {
+ render(
+ ,
+ );
+ parkOnHuman1();
+
+ act(() => decideButton()?.click());
+
+ expect(useExecutionStore.getState().decisionFocusRequest).toBeUndefined();
+ });
+
+ it('keeps the wait off a decision node the run is not parked on', () => {
+ render(
+ ,
+ );
+ parkOnHuman1();
+
+ expect(decideButton()).toBeUndefined();
+ });
+
+ it('selects the node through React Flow on Decide, replacing the selection, and asks its form for the focus', () => {
+ const nodes: Node[] = [
+ { id: 'human-1', position: { x: 0, y: 0 }, data: {} },
+ { id: 'other', position: { x: 300, y: 0 }, data: {}, selected: true },
+ ];
+ let store: { getState: () => ReactFlowState } | undefined;
+ function StoreProbe() {
+ store = useStoreApi();
+ return null;
+ }
+ act(() =>
+ root.render(
+
+
+
+ ,
+ ),
+ );
+ parkOnHuman1();
+
+ act(() => decideButton()?.click());
+
+ expect(
+ store
+ ?.getState()
+ .nodes.filter((node) => node.selected)
+ .map((node) => node.id),
+ ).toEqual(['human-1']);
+ expect(useExecutionStore.getState().decisionFocusRequest).toBe('human-1');
+ });
+});
diff --git a/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx
new file mode 100644
index 000000000..a95701547
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx
@@ -0,0 +1,86 @@
+import { Icon, OptionalNodeContent, defineNodeTemplate, getHandleId } from '@workflowbuilder/sdk';
+import type { NodeDataProperties, WorkflowNodeTemplateProps } from '@workflowbuilder/sdk';
+import { NodeDescription, NodeIcon, NodePanel, Status } from '@workflowbuilder/ui';
+import { Handle, Position } from '@xyflow/react';
+import clsx from 'clsx';
+import { memo, useMemo } from 'react';
+
+import styles from './human-decision-template.module.css';
+
+import type { HumanDecisionSchema } from '../../../nodes/human-decision/schema';
+import { DecisionWaitingFooter } from './decision-waiting-footer';
+
+type HumanDecisionProperties = NodeDataProperties;
+
+type RoutedAction = { label: string; port: string };
+
+// One handle per action with a port, so the port a decision routes on is written once, in the request.
+function routedActions(decisionRequest: unknown): RoutedAction[] {
+ const actions = (decisionRequest as { actions?: unknown } | undefined)?.actions;
+ if (!Array.isArray(actions)) {
+ return [];
+ }
+ return actions.flatMap((action: unknown) => {
+ const { label, port } = (action ?? {}) as { label?: unknown; port?: unknown };
+ if (typeof port !== 'string' || port.length === 0) {
+ return [];
+ }
+ return [{ label: typeof label === 'string' ? label : port, port }];
+ });
+}
+
+export const HumanDecisionNodeTemplate = defineNodeTemplate(
+ memo(
+ ({
+ id,
+ icon,
+ label,
+ description,
+ data,
+ selected = false,
+ disabled = false,
+ layoutDirection = 'RIGHT',
+ showHandles = true,
+ isValid,
+ }: WorkflowNodeTemplateProps) => {
+ const iconElement = useMemo(() => , [icon]);
+ const decisionRequest = data?.properties.decisionRequest;
+ const actions = useMemo(() => routedActions(decisionRequest), [decisionRequest]);
+
+ const isHorizontal = layoutDirection === 'RIGHT';
+ const isCanvasNode = showHandles;
+
+ return (
+
+
+
+
+
+
+
+
+ {actions.length > 0 && (
+
+ {actions.map(({ label: actionLabel, port }) => (
+
+ {actionLabel}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+ },
+ ),
+);
diff --git a/apps/ai-studio/src/components/human-decision/waiting-snackbar/decision-waiting-snackbar.test.tsx b/apps/ai-studio/src/components/human-decision/waiting-snackbar/decision-waiting-snackbar.test.tsx
new file mode 100644
index 000000000..752950195
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/waiting-snackbar/decision-waiting-snackbar.test.tsx
@@ -0,0 +1,223 @@
+import { type ShowSnackbarOptions, useStore } from '@workflowbuilder/sdk';
+import { type Node, ReactFlowProvider, type ReactFlowState, useStoreApi } from '@xyflow/react';
+import { StrictMode, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ applyEvent,
+ applySnapshot,
+ resetExecution,
+ setExecutionStarted,
+ useExecutionStore,
+} from '../../../stores/use-execution-store';
+import { nodeEvent, snapshotFrame } from '../../../test/execution-history';
+import { DecisionWaitingSnackbar } from './decision-waiting-snackbar';
+
+// The SDK's own spec covers how a snackbar looks and closes; this one follows what the app asks of it.
+const snackbars = vi.hoisted(() => ({ shown: [] as ShowSnackbarOptions[], closed: new Set(), count: 0 }));
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ showSnackbar: (options: ShowSnackbarOptions) => {
+ const key = options.key ?? `snackbar-${++snackbars.count}`;
+ snackbars.shown.push({ ...options, key });
+ return key;
+ },
+ closeSnackbar: (key: string) => snackbars.closed.add(key),
+ };
+});
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const open = () => snackbars.shown.filter((options) => !snackbars.closed.has(options.key ?? ''));
+const onlyOpen = () => {
+ expect(open()).toHaveLength(1);
+ return open()[0]!;
+};
+
+const decisionNode = (id: string, label: string) => ({
+ id,
+ position: { x: 0, y: 0 },
+ data: { type: 'ai-studio/human-decision', icon: 'UserCheck' as const, properties: { label } },
+});
+
+const apply = (type: 'node_waiting' | 'node_completed', nodeId: string) =>
+ act(() => applyEvent(nodeEvent(type, nodeId)));
+
+const selectInSdk = (ids: string[]) => act(() => useStore.setState({ selectedNodesIds: ids }));
+
+describe('DecisionWaitingSnackbar', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+ let reactFlowStore: { getState: () => ReactFlowState } | undefined;
+
+ function StoreProbe() {
+ reactFlowStore = useStoreApi();
+ return null;
+ }
+
+ beforeEach(() => {
+ snackbars.shown.length = 0;
+ snackbars.closed.clear();
+ resetExecution();
+ setExecutionStarted('exec-1', '/stream');
+ useStore.setState({ nodes: [decisionNode('human-1', 'Review Refund'), decisionNode('human-2', 'Review Tone')] });
+ const flowNodes: Node[] = [
+ { id: 'human-1', position: { x: 0, y: 0 }, data: {} },
+ { id: 'other', position: { x: 300, y: 0 }, data: {}, selected: true },
+ ];
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ act(() =>
+ root.render(
+
+
+
+
+
+ ,
+ ),
+ );
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ useStore.setState({ nodes: [], selectedNodesIds: [] });
+ });
+
+ it('says nothing while no node waits', () => {
+ expect(open()).toHaveLength(0);
+ });
+
+ it('names the waiting node, stays until it is closed, and on Decide selects the node and asks its form for the focus', () => {
+ apply('node_waiting', 'human-1');
+
+ const snackbar = onlyOpen();
+ expect(snackbar).toMatchObject({
+ variant: 'info',
+ title: 'Waiting for decision',
+ subtitle: 'Review Refund',
+ buttonLabel: 'Decide',
+ autoHideDuration: null,
+ });
+
+ act(() => snackbar.onButtonClick?.());
+
+ const selected = reactFlowStore
+ ?.getState()
+ .nodes.filter((node) => node.selected)
+ .map((node) => node.id);
+ expect(selected).toEqual(['human-1']);
+ expect(useExecutionStore.getState().decisionFocusRequest).toBe('human-1');
+ });
+
+ it('offers no Decide for a waiting node the canvas lacks, and still stays', () => {
+ act(() => useStore.setState({ nodes: [decisionNode('human-2', 'Review Tone')] }));
+
+ apply('node_waiting', 'human-1');
+
+ const snackbar = onlyOpen();
+ expect(snackbar.title).toBe('Waiting for decision');
+ expect(snackbar.buttonLabel).toBeUndefined();
+ expect(snackbar.onButtonClick).toBeUndefined();
+ });
+
+ it('says nothing while the run is cancelling, since the backend refuses a decision then', () => {
+ act(() => applySnapshot(snapshotFrame('cancelling')));
+
+ expect(open()).toHaveLength(0);
+ });
+
+ it('closes for a node whose id holds a space', () => {
+ act(() => useStore.setState({ nodes: [decisionNode('human 1', 'Review Refund')] }));
+ apply('node_waiting', 'human 1');
+
+ act(() => onlyOpen().onClose?.());
+
+ expect(open()).toHaveLength(0);
+ });
+
+ it('does not show again when a waiting node is renamed while several wait', () => {
+ apply('node_waiting', 'human-1');
+ apply('node_waiting', 'human-2');
+ const shownBefore = snackbars.shown.length;
+
+ act(() =>
+ useStore.setState({ nodes: [decisionNode('human-1', 'Renamed'), decisionNode('human-2', 'Review Tone')] }),
+ );
+
+ expect(snackbars.shown).toHaveLength(shownBefore);
+ });
+
+ // In the app the SDK copies a React Flow selection into its store; here the store is set directly.
+ it('steps aside while a waiting node is selected, however it was selected, and comes back after', () => {
+ apply('node_waiting', 'human-1');
+
+ selectInSdk(['human-1']);
+ expect(open()).toHaveLength(0);
+
+ selectInSdk(['other']);
+ expect(onlyOpen().title).toBe('Waiting for decision');
+ });
+
+ it('stays while the waiting node is only part of a larger selection', () => {
+ apply('node_waiting', 'human-1');
+
+ selectInSdk(['human-1', 'other']);
+
+ expect(onlyOpen().title).toBe('Waiting for decision');
+ });
+
+ it('stays closed for the same wait and comes back when another node parks', () => {
+ apply('node_waiting', 'human-1');
+ act(() => onlyOpen().onClose?.());
+ expect(open()).toHaveLength(0);
+
+ apply('node_waiting', 'human-2');
+ expect(onlyOpen().title).toBe('2 decisions are waiting');
+ });
+
+ it('stays closed when one of the closed waits ends', () => {
+ apply('node_waiting', 'human-1');
+ apply('node_waiting', 'human-2');
+ act(() => onlyOpen().onClose?.());
+
+ apply('node_completed', 'human-2');
+
+ expect(open()).toHaveLength(0);
+ });
+
+ it('comes back when the same node parks again', () => {
+ apply('node_waiting', 'human-1');
+ act(() => onlyOpen().onClose?.());
+ apply('node_completed', 'human-1');
+
+ apply('node_waiting', 'human-1');
+ expect(onlyOpen().title).toBe('Waiting for decision');
+ });
+
+ it('counts several waits and offers no Decide', () => {
+ apply('node_waiting', 'human-1');
+ apply('node_waiting', 'human-2');
+
+ const snackbar = onlyOpen();
+ expect(snackbar.title).toBe('2 decisions are waiting');
+ expect(snackbar.buttonLabel).toBeUndefined();
+ expect(snackbar.onButtonClick).toBeUndefined();
+ });
+
+ it('leaves once the wait ends', () => {
+ apply('node_waiting', 'human-1');
+ apply('node_completed', 'human-1');
+
+ expect(open()).toHaveLength(0);
+ });
+});
diff --git a/apps/ai-studio/src/components/human-decision/waiting-snackbar/decision-waiting-snackbar.tsx b/apps/ai-studio/src/components/human-decision/waiting-snackbar/decision-waiting-snackbar.tsx
new file mode 100644
index 000000000..d55cc39e7
--- /dev/null
+++ b/apps/ai-studio/src/components/human-decision/waiting-snackbar/decision-waiting-snackbar.tsx
@@ -0,0 +1,73 @@
+import { closeSnackbar, showSnackbar, useSetSelection, useStore } from '@workflowbuilder/sdk';
+import { useEffect, useState } from 'react';
+
+import { attemptOf } from '../../../hooks/use-node-decision';
+import { isDecidable, requestDecisionFocus, useExecutionStore, waitKey } from '../../../stores/use-execution-store';
+
+/** Tells the person the run waits for them until the wait ends or they close it; it steps aside while a waiting node alone is selected. */
+export function DecisionWaitingSnackbar() {
+ const executionId = useExecutionStore((state) => state.executionId);
+ const isRunDecidable = useExecutionStore((state) => isDecidable(state.status));
+ const nodeStates = useExecutionStore((state) => state.nodeStates);
+ const events = useExecutionStore((state) => state.events);
+ const waitingIds = Object.keys(nodeStates)
+ .filter((nodeId) => nodeStates[nodeId]?.status === 'waiting')
+ .sort();
+ const onlyWaitingId = waitingIds.length === 1 ? waitingIds[0] : undefined;
+ // Read for a single wait only: the count the snackbar shows for several does not change with a node's label.
+ const label = useStore((state) => state.nodes.find((node) => node.id === onlyWaitingId)?.data.properties.label);
+ const isOnlyWaitingNodeOnCanvas = useStore((state) => state.nodes.some((node) => node.id === onlyWaitingId));
+ // The properties panel shows the decision form for a single selection only.
+ const selectedNodeId = useStore((state) =>
+ state.selectedNodesIds.length === 1 && state.selectedEdgesIds.length === 0 ? state.selectedNodesIds[0] : undefined,
+ );
+ const setSelection = useSetSelection();
+ // Closing hides the waits it showed: another node parking, or one parking again, shows it anew, and so does a reload.
+ const [dismissedWaits, setDismissedWaits] = useState>(() => new Set());
+
+ const waitKeys =
+ executionId === undefined
+ ? []
+ : waitingIds.map((nodeId) => waitKey({ executionId, nodeId, attempt: attemptOf(events, nodeId) }));
+ // One string for the effect's dependencies; JSON, since a node id may hold any character.
+ const waitKeysJson = JSON.stringify(waitKeys);
+ const isWaitingNodeSelected = selectedNodeId !== undefined && waitingIds.includes(selectedNodeId);
+ const isShown = isRunDecidable && waitKeys.some((key) => !dismissedWaits.has(key)) && !isWaitingNodeSelected;
+ const waitingCount = waitingIds.length;
+
+ useEffect(() => {
+ if (!isShown) {
+ return;
+ }
+ const shownWaits: string[] = JSON.parse(waitKeysJson);
+ const sharedOptions = {
+ variant: 'info',
+ autoHideDuration: null,
+ onClose: () => setDismissedWaits((dismissed) => new Set([...dismissed, ...shownWaits])),
+ } as const;
+ const decide = (nodeId: string) => {
+ if (setSelection({ nodeIds: [nodeId] })) {
+ requestDecisionFocus(nodeId);
+ }
+ };
+ const key = showSnackbar(
+ // One button cannot know which of the nodes the person wants.
+ onlyWaitingId === undefined
+ ? {
+ ...sharedOptions,
+ title: `${waitingCount} decisions are waiting`,
+ subtitle: 'Select a waiting node to decide.',
+ }
+ : {
+ ...sharedOptions,
+ title: 'Waiting for decision',
+ subtitle: label,
+ // A node the canvas lacks, such as after an import during the run, cannot be selected.
+ ...(isOnlyWaitingNodeOnCanvas && { buttonLabel: 'Decide', onButtonClick: () => decide(onlyWaitingId) }),
+ },
+ );
+ return () => closeSnackbar(key);
+ }, [isShown, waitKeysJson, onlyWaitingId, waitingCount, label, isOnlyWaitingNodeOnCanvas, setSelection]);
+
+ return null;
+}
diff --git a/apps/ai-studio/src/components/open-from-url/app-boundary.test.tsx b/apps/ai-studio/src/components/open-from-url/app-boundary.test.tsx
new file mode 100644
index 000000000..60c7446a5
--- /dev/null
+++ b/apps/ai-studio/src/components/open-from-url/app-boundary.test.tsx
@@ -0,0 +1,128 @@
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { OpenError } from '../../app/open-error';
+import { AppBoundary } from './app-boundary';
+
+const saves = vi.hoisted(() => ({ halt: vi.fn() }));
+vi.mock('../../adapters/save-workflow-draft', () => ({ haltSaves: saves.halt }));
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const WORKFLOW = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+
+let container: HTMLDivElement;
+let root: ReturnType;
+
+function atAddress(href: string) {
+ const assign = vi.fn();
+ vi.stubGlobal('location', { href, pathname: new URL(href).pathname, assign });
+ return assign;
+}
+
+function renderThrowing(error: unknown) {
+ function Throwing(): never {
+ throw error;
+ }
+ act(() =>
+ root.render(
+
+
+ ,
+ ),
+ );
+}
+
+const text = () => container.textContent ?? '';
+const button = () => container.querySelector('button')!;
+const buttons = () => [...container.querySelectorAll('button')].map((each) => each.textContent);
+const clickExit = () => act(() => button().click());
+
+beforeEach(() => {
+ saves.halt.mockClear();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ container = document.createElement('div');
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ localStorage.clear();
+});
+
+describe('AppBoundary', () => {
+ it('a crash while drawing offers the local draft, even under a workflow link the draft may have caused', () => {
+ const assign = atAddress(`http://localhost/?workflowId=${WORKFLOW}`);
+ renderThrowing(new TypeError("Cannot read properties of undefined (reading 'x')"));
+
+ expect(text()).toContain('The diagram could not be drawn.');
+ expect(button().textContent).toBe('Open local draft');
+ clickExit();
+ expect(assign).toHaveBeenCalledWith('/');
+ });
+
+ // In local mode the SDK's own autosave can save the graph that would not draw, so the local draft loops.
+ it('a crash in the local draft also offers to discard it, which empties the SDK key and starts over', () => {
+ const assign = atAddress('http://localhost/');
+ localStorage.setItem('workflowBuilderDiagram', '{"nodes":[]}');
+ renderThrowing(new TypeError("Cannot read properties of undefined (reading 'x')"));
+
+ expect(text()).toContain('Discarding it starts from the template.');
+ expect(buttons()).toEqual(['Open local draft', 'Discard local draft']);
+ act(() => container.querySelectorAll('button')[1]!.click());
+ expect(localStorage.getItem('workflowBuilderDiagram')).toBeNull();
+ expect(assign).toHaveBeenCalledWith('/');
+ });
+
+ it('offers no discard under a link: the local draft is not what failed', () => {
+ atAddress(`http://localhost/?executionId=${RUN}`);
+ renderThrowing(new TypeError("Cannot read properties of undefined (reading 'x')"));
+
+ expect(buttons()).toEqual(['Open local draft']);
+ });
+
+ // The editor's pending autosave fires after it unmounted, with the graph that would not draw.
+ it('stops the editor saving once it caught a crash', () => {
+ atAddress(`http://localhost/?workflowId=${WORKFLOW}`);
+ renderThrowing(new TypeError("Cannot read properties of undefined (reading 'x')"));
+
+ expect(saves.halt).toHaveBeenCalled();
+ });
+
+ it('a run that would not open under a workflow link offers that workflow, without the run in the address', () => {
+ const assign = atAddress(`http://localhost/?workflowId=${WORKFLOW}&executionId=${RUN}`);
+ renderThrowing(new OpenError('run', 'the server answered 404'));
+
+ expect(text()).toContain('The run in the link could not be opened: the server answered 404.');
+ expect(text()).toContain('Reload the page to try the link again.');
+ expect(button().textContent).toBe('Open the workflow');
+ clickExit();
+ expect(assign).toHaveBeenCalledWith(`http://localhost/?workflowId=${WORKFLOW}`);
+ });
+
+ it('a run that would not open on its own offers the local draft', () => {
+ const assign = atAddress(`http://localhost/?executionId=${RUN}`);
+ renderThrowing(new OpenError('run', 'the server did not answer'));
+
+ expect(button().textContent).toBe('Open local draft');
+ clickExit();
+ expect(assign).toHaveBeenCalledWith('/');
+ });
+
+ it('a workflow that would not open offers the local draft, never itself', () => {
+ const assign = atAddress(`http://localhost/?workflowId=${WORKFLOW}`);
+ renderThrowing(new OpenError('workflow', 'the server answered 404'));
+
+ expect(button().textContent).toBe('Open local draft');
+ clickExit();
+ expect(assign).toHaveBeenCalledWith('/');
+ });
+});
diff --git a/apps/ai-studio/src/components/open-from-url/app-boundary.tsx b/apps/ai-studio/src/components/open-from-url/app-boundary.tsx
new file mode 100644
index 000000000..997ba0333
--- /dev/null
+++ b/apps/ai-studio/src/components/open-from-url/app-boundary.tsx
@@ -0,0 +1,92 @@
+import clsx from 'clsx';
+import { Component, type ReactNode } from 'react';
+
+import styles from './full-page.module.css';
+
+import { haltSaves } from '../../adapters/save-workflow-draft';
+import { OpenError } from '../../app/open-error';
+
+type Exit = { label: string; href: string };
+
+// Only a run that failed under a workflow link goes back to the workflow. Anything else goes to the local
+// draft, so a workflow draft that throws while drawing cannot send the person back into it.
+function exitFor(error: unknown): Exit {
+ const address = new URL(globalThis.location.href);
+ if (error instanceof OpenError && error.what === 'run' && address.searchParams.has('workflowId')) {
+ address.searchParams.delete('executionId');
+ return { label: 'Open the workflow', href: address.toString() };
+ }
+ return { label: 'Open local draft', href: address.pathname };
+}
+
+// The SDK's localStorage strategy key, named on WorkflowBuilder.Root's integration prop.
+const LOCAL_DRAFT_KEY = 'workflowBuilderDiagram';
+
+// With no id in the address the local draft is what failed to draw, and the SDK may have saved it after the
+// crash, so opening it again fails the same way.
+function isLocalDraftCrash(): boolean {
+ const { searchParams } = new URL(globalThis.location.href);
+ return !searchParams.has('workflowId') && !searchParams.has('executionId');
+}
+
+function discardLocalDraft(): void {
+ try {
+ localStorage.removeItem(LOCAL_DRAFT_KEY);
+ } catch {
+ // storage unavailable
+ }
+ globalThis.location.assign(globalThis.location.pathname);
+}
+
+type State = { failed: boolean; error: unknown };
+
+export class AppBoundary extends Component<{ children: ReactNode }, State> {
+ override state: State = { failed: false, error: undefined };
+
+ static getDerivedStateFromError(error: unknown): State {
+ return { failed: true, error };
+ }
+
+ override componentDidCatch(): void {
+ haltSaves();
+ }
+
+ override render() {
+ if (!this.state.failed) {
+ return this.props.children;
+ }
+
+ const { error } = this.state;
+ const exit = exitFor(error);
+ const mayDiscard = isLocalDraftCrash();
+
+ return (
+
+
+
{error instanceof OpenError ? error.message : 'The diagram could not be drawn.'}
+ {error instanceof OpenError &&
Reload the page to try the link again.
}
+ {mayDiscard && (
+
+ If the local draft is what failed, opening it again fails the same way. Discarding it starts from the
+ template.
+
+ )}
+
+ globalThis.location.assign(exit.href)}>
+ {exit.label}
+
+ {mayDiscard && (
+
+ Discard local draft
+
+ )}
+
+
+
+ );
+ }
+}
diff --git a/apps/ai-studio/src/components/open-from-url/full-page.module.css b/apps/ai-studio/src/components/open-from-url/full-page.module.css
new file mode 100644
index 000000000..2fd388f19
--- /dev/null
+++ b/apps/ai-studio/src/components/open-from-url/full-page.module.css
@@ -0,0 +1,52 @@
+.screen {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ font-family: var(--wb-public-font-family, 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
+ background: var(--wb-ds-ui-bg-base);
+ color: var(--wb-ds-ui-text-default);
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 28rem;
+ padding: 2rem;
+ border: 0.0625rem solid var(--wb-ds-ui-stroke-default);
+ border-radius: var(--wb-sdk-app-bar-border-radius);
+ color: var(--wb-ds-ui-text-default);
+}
+
+.button {
+ padding: 0.7rem 1rem;
+ border: none;
+ border-radius: 0.625rem;
+ background: var(--wb-ds-colors-acc1-500);
+ color: #ffffff;
+ font-size: 0.95rem;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+}
+
+.button:hover {
+ background: var(--wb-ds-colors-acc1-600);
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.button--secondary {
+ border: 0.0625rem solid var(--wb-ds-ui-stroke-default);
+ background: transparent;
+ color: var(--wb-ds-ui-text-default);
+}
+
+.button--secondary:hover {
+ background: var(--wb-ds-ui-bg-fill-hover);
+}
diff --git a/apps/ai-studio/src/components/open-from-url/loading-screen.tsx b/apps/ai-studio/src/components/open-from-url/loading-screen.tsx
new file mode 100644
index 000000000..5caafc391
--- /dev/null
+++ b/apps/ai-studio/src/components/open-from-url/loading-screen.tsx
@@ -0,0 +1,11 @@
+import clsx from 'clsx';
+
+import styles from './full-page.module.css';
+
+export function LoadingScreen() {
+ return (
+
+ Loading...
+
+ );
+}
diff --git a/apps/ai-studio/src/components/open-from-url/open-notices.test.tsx b/apps/ai-studio/src/components/open-from-url/open-notices.test.tsx
new file mode 100644
index 000000000..090e5e0eb
--- /dev/null
+++ b/apps/ai-studio/src/components/open-from-url/open-notices.test.tsx
@@ -0,0 +1,101 @@
+import { StrictMode, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { addNotice, useNoticesStore } from '../../stores/use-notices-store';
+import { OpenNotices } from './open-notices';
+
+const snackbar = vi.hoisted(() => ({ show: vi.fn() }));
+vi.mock('@workflowbuilder/sdk', async (importOriginal) => ({
+ ...(await importOriginal()),
+ showSnackbar: snackbar.show,
+}));
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+let container: HTMLDivElement;
+let root: ReturnType;
+
+const mount = () =>
+ act(() =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+const shownTitles = () => snackbar.show.mock.calls.map(([options]) => (options as { title: string }).title);
+
+beforeEach(() => {
+ snackbar.show.mockClear();
+ useNoticesStore.setState({ notices: [] });
+ container = document.createElement('div');
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+});
+
+describe('OpenNotices', () => {
+ it('shows the notices raised before the editor mounted in its snackbars, once each and in order', () => {
+ addNotice('The run could not be opened.');
+ addNotice('Some nodes use types this app does not know.');
+
+ mount();
+
+ expect(shownTitles()).toEqual(['The run could not be opened.', 'Some nodes use types this app does not know.']);
+ expect(useNoticesStore.getState().notices).toEqual([]);
+ });
+
+ it('shows a notice raised later at once', () => {
+ mount();
+
+ act(() => addNotice('The workflow draft could not be saved.', 'error'));
+
+ expect(shownTitles()).toEqual(['The workflow draft could not be saved.']);
+ });
+
+ it('keeps a warning or an error until it is closed, and lets a success go by itself', () => {
+ mount();
+
+ act(() => {
+ addNotice('The run did not start.', 'error');
+ addNotice('The workflow draft is saved.', 'success');
+ });
+
+ expect(snackbar.show.mock.calls[0]![0]).toMatchObject({ variant: 'error', autoHideDuration: null });
+ expect(snackbar.show.mock.calls[1]![0]).toMatchObject({ variant: 'success' });
+ expect(snackbar.show.mock.calls[1]![0]).not.toHaveProperty('autoHideDuration');
+ });
+
+ it('gives an error the key of its text, so the editor shows a repeat once until it is closed', () => {
+ mount();
+
+ act(() => {
+ addNotice('The workflow draft could not be saved automatically: the server did not answer.', 'error');
+ addNotice('The workflow draft could not be saved automatically: the server did not answer.', 'error');
+ });
+
+ const [first, second] = snackbar.show.mock.calls.map(([options]) => options as { key?: string });
+ expect(first!.key).toBe('The workflow draft could not be saved automatically: the server did not answer.');
+ expect(second!.key).toBe(first!.key);
+ });
+
+ // The editor's snackbars show nothing while it is not mounted, so a notice waits for the next one.
+ it('holds a notice raised while no editor is on screen', () => {
+ mount();
+ act(() => root.unmount());
+ root = createRoot(container);
+
+ addNotice('The run could not be opened.');
+
+ expect(snackbar.show).not.toHaveBeenCalled();
+ expect(useNoticesStore.getState().notices).toHaveLength(1);
+ });
+});
diff --git a/apps/ai-studio/src/components/open-from-url/open-notices.tsx b/apps/ai-studio/src/components/open-from-url/open-notices.tsx
new file mode 100644
index 000000000..c607e0e60
--- /dev/null
+++ b/apps/ai-studio/src/components/open-from-url/open-notices.tsx
@@ -0,0 +1,23 @@
+import { type ShowSnackbarOptions, showSnackbar } from '@workflowbuilder/sdk';
+import { useEffect } from 'react';
+
+import { type Notice, takeNotices, useNoticesStore } from '../../stores/use-notices-store';
+
+function snackbarOf({ text, variant }: Notice): ShowSnackbarOptions {
+ // A success goes after the SDK's default time; anything to act on stays until closed, keyed by its text because
+ // a failing autosave repeats on every edit.
+ return variant === 'success' ? { variant, title: text } : { key: text, variant, title: text, autoHideDuration: null };
+}
+
+/** Hands the notices to the editor's snackbars, which show nothing until the editor has mounted. */
+export function OpenNotices() {
+ const pendingCount = useNoticesStore((state) => state.notices.length);
+
+ useEffect(() => {
+ for (const notice of takeNotices()) {
+ showSnackbar(snackbarOf(notice));
+ }
+ }, [pendingCount]);
+
+ return null;
+}
diff --git a/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx b/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx
index e3837defe..2d60950a7 100644
--- a/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx
+++ b/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx
@@ -9,12 +9,6 @@ vi.mock('../../utils/export-visualization', () => ({
copyResult: vi.fn(),
}));
-declare global {
- // eslint-disable-next-line no-var
- var IS_REACT_ACT_ENVIRONMENT: boolean;
-}
-globalThis.IS_REACT_ACT_ENVIRONMENT = true;
-
async function click(button: HTMLButtonElement) {
await act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
diff --git a/apps/ai-studio/src/components/visualize/renderers.module.css b/apps/ai-studio/src/components/visualize/renderers.module.css
index 6d64314e0..cd133c035 100644
--- a/apps/ai-studio/src/components/visualize/renderers.module.css
+++ b/apps/ai-studio/src/components/visualize/renderers.module.css
@@ -1,7 +1,7 @@
.markdown {
font-size: 0.85rem;
line-height: 1.6;
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
overflow-wrap: anywhere;
}
@@ -40,7 +40,7 @@
}
.markdown a {
- color: var(--ax-colors-acc1-500, #1096e7);
+ color: var(--wb-ds-ui-text-action-default);
text-decoration: underline;
}
@@ -51,7 +51,7 @@
.markdown code {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.82em;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
+ background: var(--wb-ds-ui-bg-elevated);
padding: 0.1em 0.35em;
border-radius: 0.25rem;
}
@@ -59,7 +59,7 @@
.markdown pre {
margin: 0.6em 0;
padding: 0.7em 0.85em;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
+ background: var(--wb-ds-ui-bg-elevated);
border-radius: 0.5rem;
overflow-x: auto;
}
@@ -72,8 +72,8 @@
.markdown blockquote {
margin: 0.6em 0;
padding-left: 0.85em;
- border-left: 0.1875rem solid var(--ax-ui-stroke-primary-default, #edeff3);
- color: var(--ax-txt-secondary-default, #4d5059);
+ border-left: 0.1875rem solid var(--wb-ds-ui-stroke-default);
+ color: var(--wb-ds-ui-text-subtle-default);
}
.rich p {
@@ -89,13 +89,13 @@
.rich code {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.85em;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
+ background: var(--wb-ds-ui-bg-elevated);
padding: 0.05em 0.3em;
border-radius: 0.2rem;
}
.rich a {
- color: var(--ax-colors-acc1-500, #1096e7);
+ color: var(--wb-ds-ui-text-action-default);
text-decoration: underline;
}
@@ -108,7 +108,7 @@
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.8rem;
line-height: 1.55;
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@@ -117,7 +117,7 @@
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.78rem;
line-height: 1.5;
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
}
.json-row {
@@ -132,18 +132,18 @@
.json-children {
padding-left: 0.9rem;
- border-left: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
+ border-left: 0.0625rem solid var(--wb-ds-ui-stroke-default);
margin-left: 0.25rem;
}
.json-toggle {
width: 0.7rem;
flex-shrink: 0;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
}
.json-key {
- color: var(--ax-colors-acc1-600, #0477c5);
+ color: var(--wb-ds-colors-acc1-600);
}
.json-string {
@@ -160,7 +160,7 @@
}
.json-bracket {
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
}
.table {
@@ -171,14 +171,14 @@
.table th,
.table td {
- border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
+ border: 0.0625rem solid var(--wb-ds-ui-stroke-default);
padding: 0.3em 0.5em;
text-align: left;
vertical-align: top;
}
.table th {
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
+ background: var(--wb-ds-ui-bg-elevated);
font-weight: 600;
position: sticky;
top: 0;
@@ -192,15 +192,15 @@
.stat-card {
padding: 0.6rem 0.7rem;
- border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
+ border: 0.0625rem solid var(--wb-ds-ui-stroke-default);
border-radius: 0.5rem;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
+ background: var(--wb-ds-ui-bg-elevated);
}
.stat-value {
font-size: 1.05rem;
font-weight: 600;
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
overflow-wrap: anywhere;
}
@@ -209,13 +209,13 @@
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
}
.fallback-note {
margin-bottom: 0.4rem;
font-size: 0.72rem;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
}
.chart {
diff --git a/apps/ai-studio/src/components/visualize/visualize-card.module.css b/apps/ai-studio/src/components/visualize/visualize-card.module.css
index 90a45315a..2465c79d6 100644
--- a/apps/ai-studio/src/components/visualize/visualize-card.module.css
+++ b/apps/ai-studio/src/components/visualize/visualize-card.module.css
@@ -2,10 +2,10 @@
width: 100%;
margin-top: 0.4rem;
padding-top: 0.5rem;
- border-top: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
- font-family: var(--wb-font-family);
+ border-top: 0.0625rem solid var(--wb-ds-ui-stroke-default);
+ font-family: var(--wb-public-font-family, 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
text-align: left;
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
&,
* {
@@ -30,8 +30,8 @@
font-size: 0.64rem;
padding: 0.15rem 0.4rem;
border-radius: 0.3rem;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
- color: var(--ax-txt-tertiary-default, #6f7480);
+ background: var(--wb-ds-ui-bg-elevated);
+ color: var(--wb-ds-ui-text-muted-default);
}
.actions {
@@ -50,7 +50,7 @@
border: none;
border-radius: 0.3rem;
background: transparent;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
cursor: pointer;
}
@@ -60,8 +60,8 @@
}
.action:hover {
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
- color: var(--ax-txt-primary-default, #151516);
+ background: var(--wb-ds-ui-bg-elevated);
+ color: var(--wb-ds-ui-text-default);
}
.action:disabled {
@@ -93,7 +93,7 @@
.empty-icon {
width: 1.6rem;
height: 1.6rem;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
opacity: 0.55;
}
@@ -101,7 +101,7 @@
margin: 0;
font-size: 0.74rem;
line-height: 1.45;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
}
.dots {
@@ -114,7 +114,7 @@
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
- background: var(--ax-colors-acc1-500, #1096e7);
+ background: var(--wb-ds-colors-acc1-500);
animation: md-pulse 1s ease-in-out infinite;
}
diff --git a/apps/ai-studio/src/components/visualize/visualize-modal.module.css b/apps/ai-studio/src/components/visualize/visualize-modal.module.css
index 7850c1acc..e22b238e2 100644
--- a/apps/ai-studio/src/components/visualize/visualize-modal.module.css
+++ b/apps/ai-studio/src/components/visualize/visualize-modal.module.css
@@ -8,7 +8,7 @@
padding: 2rem;
background: rgba(8, 10, 14, 0.55);
backdrop-filter: blur(2px);
- font-family: var(--wb-font-family);
+ font-family: var(--wb-public-font-family, 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
}
.modal {
@@ -16,11 +16,11 @@
flex-direction: column;
width: min(56rem, 92vw);
max-height: 86vh;
- background: var(--ax-ui-bg-primary-default, #ffffff);
- border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
+ background: var(--wb-ds-ui-bg-base);
+ border: 0.0625rem solid var(--wb-ds-ui-stroke-default);
border-radius: 0.9rem;
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.4);
- color: var(--ax-txt-primary-default, #151516);
+ color: var(--wb-ds-ui-text-default);
overflow: hidden;
&,
@@ -34,7 +34,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
- border-bottom: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3);
+ border-bottom: 0.0625rem solid var(--wb-ds-ui-stroke-default);
}
.title {
@@ -47,8 +47,8 @@
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 0.3rem;
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
- color: var(--ax-txt-tertiary-default, #6f7480);
+ background: var(--wb-ds-ui-bg-elevated);
+ color: var(--wb-ds-ui-text-muted-default);
}
.actions {
@@ -66,7 +66,7 @@
border: none;
border-radius: 0.4rem;
background: transparent;
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
cursor: pointer;
}
@@ -76,8 +76,8 @@
}
.action:hover {
- background: var(--ax-ui-bg-secondary-default, #f5f5f7);
- color: var(--ax-txt-primary-default, #151516);
+ background: var(--wb-ds-ui-bg-elevated);
+ color: var(--wb-ds-ui-text-default);
}
.body {
@@ -87,6 +87,6 @@
}
.loading {
- color: var(--ax-txt-tertiary-default, #6f7480);
+ color: var(--wb-ds-ui-text-muted-default);
font-size: 0.85rem;
}
diff --git a/apps/ai-studio/src/data/ai-debate-flow.ts b/apps/ai-studio/src/data/ai-debate-flow.ts
index a98de28cd..d36623e88 100644
--- a/apps/ai-studio/src/data/ai-debate-flow.ts
+++ b/apps/ai-studio/src/data/ai-debate-flow.ts
@@ -21,8 +21,6 @@ const diagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 63 },
- dragging: false,
},
{
id: 'optimist-1',
@@ -41,8 +39,6 @@ why now. Be persuasive but honest, no hype.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'skeptic-1',
@@ -61,8 +57,6 @@ hidden costs, what could go wrong. Surface the objections others gloss over.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'verdict-1',
@@ -92,8 +86,6 @@ Be decisive.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'visualize-1',
@@ -110,8 +102,6 @@ Be decisive.`,
icon: 'Eye',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
],
edges: [
diff --git a/apps/ai-studio/src/data/ai-studio-templates.ts b/apps/ai-studio/src/data/ai-studio-templates.ts
index a603b4fe7..e71c0effc 100644
--- a/apps/ai-studio/src/data/ai-studio-templates.ts
+++ b/apps/ai-studio/src/data/ai-studio-templates.ts
@@ -3,6 +3,7 @@ import type { TemplateModel } from '@workflowbuilder/sdk';
import { aiDebateFlow } from './ai-debate-flow';
import { contentRepurposerFlow } from './content-repurposer-flow';
import { meetingNotesFlow } from './meeting-notes-flow';
+import { refundReviewFlow } from './refund-review-flow';
import { researchFlow } from './research-flow';
import { supportTriageFlow } from './support-triage-flow';
@@ -12,4 +13,5 @@ export const aiStudioTemplates: TemplateModel[] = [
contentRepurposerFlow,
meetingNotesFlow,
researchFlow,
+ refundReviewFlow,
];
diff --git a/apps/ai-studio/src/data/content-repurposer-flow.ts b/apps/ai-studio/src/data/content-repurposer-flow.ts
index 8eefa272c..53e91f552 100644
--- a/apps/ai-studio/src/data/content-repurposer-flow.ts
+++ b/apps/ai-studio/src/data/content-repurposer-flow.ts
@@ -26,8 +26,6 @@ of the most tedious parts of their week.`,
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 63 },
- dragging: false,
},
{
id: 'twitter-1',
@@ -48,8 +46,6 @@ Number each post (1/, 2/, ...).`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'linkedin-1',
@@ -71,8 +67,6 @@ Keep it under 200 words. Professional but human.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'instagram-1',
@@ -93,8 +87,6 @@ Keep it under 200 words. Professional but human.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'pack-1',
@@ -125,8 +117,6 @@ Keep each draft's wording as-is - do not rewrite it. Just organize and label.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'visualize-1',
@@ -143,8 +133,6 @@ Keep each draft's wording as-is - do not rewrite it. Just organize and label.`,
icon: 'Eye',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
],
edges: [
diff --git a/apps/ai-studio/src/data/meeting-notes-flow.ts b/apps/ai-studio/src/data/meeting-notes-flow.ts
index dcf46a382..7937c929a 100644
--- a/apps/ai-studio/src/data/meeting-notes-flow.ts
+++ b/apps/ai-studio/src/data/meeting-notes-flow.ts
@@ -25,8 +25,6 @@ const diagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 63 },
- dragging: false,
},
{
id: 'summary-1',
@@ -44,8 +42,6 @@ was decided. Neutral, factual tone.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'actions-1',
@@ -64,8 +60,6 @@ mark it "unassigned". Do not invent items that were not discussed.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'recap-1',
@@ -94,8 +88,6 @@ line "_Meeting Bot_".`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'visualize-1',
@@ -112,8 +104,6 @@ line "_Meeting Bot_".`,
icon: 'Eye',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
],
edges: [
diff --git a/apps/ai-studio/src/data/node-types.ts b/apps/ai-studio/src/data/node-types.ts
index 48ad1faef..5143bb571 100644
--- a/apps/ai-studio/src/data/node-types.ts
+++ b/apps/ai-studio/src/data/node-types.ts
@@ -2,6 +2,7 @@ import type { PaletteItemOrGroup } from '@workflowbuilder/sdk';
import { aiAgentPaletteItem } from '../nodes/ai-agent';
import { decisionPaletteItem } from '../nodes/decision';
+import { humanDecisionPaletteItem } from '../nodes/human-decision';
import { triggerPaletteItem } from '../nodes/trigger';
import { visualizePaletteItem } from '../nodes/visualize';
@@ -9,6 +10,12 @@ export const aiStudioNodeTypes: PaletteItemOrGroup[] = [
{
label: 'AI Studio',
isOpen: true,
- groupItems: [triggerPaletteItem, aiAgentPaletteItem, decisionPaletteItem, visualizePaletteItem],
+ groupItems: [
+ triggerPaletteItem,
+ aiAgentPaletteItem,
+ decisionPaletteItem,
+ humanDecisionPaletteItem,
+ visualizePaletteItem,
+ ],
},
];
diff --git a/apps/ai-studio/src/data/refund-review-flow.test.ts b/apps/ai-studio/src/data/refund-review-flow.test.ts
new file mode 100644
index 000000000..ce722a9a4
--- /dev/null
+++ b/apps/ai-studio/src/data/refund-review-flow.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest';
+
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { humanDecisionNodeType } from '../nodes/human-decision';
+import { defaultDecisionRequest } from '../nodes/human-decision/default-properties-data';
+import { refundReviewOutputSchema } from '../utils/ai-agent/response-options';
+import { editableFields } from '../utils/editor-form/editor-layout';
+import { aiStudioTemplates } from './ai-studio-templates';
+import { refundReviewFlow, refundReviewRequest } from './refund-review-flow';
+
+const { nodes, edges } = refundReviewFlow.value.diagram;
+const human = nodes.find((node) => node.id === 'human-1')!;
+const draft = nodes.find((node) => node.id === 'draft-1')!;
+
+const draftProperties: Record = refundReviewOutputSchema.properties;
+const draftFields = Object.keys(draftProperties);
+const formProperties: Record = refundReviewRequest.schema.properties;
+const edgesInto = (nodeId: string) => edges.filter((edge) => edge.target === nodeId);
+const edgesOutOf = (nodeId: string) => edges.filter((edge) => edge.source === nodeId);
+const ports = (request: DecisionRequest) =>
+ request.actions.flatMap((action) => ('port' in action ? [action.port] : []));
+
+describe('refundReviewFlow', () => {
+ it('is registered once among the AI Studio templates, under an id no other template uses', () => {
+ const ids = aiStudioTemplates.map((template) => template.id);
+
+ expect(aiStudioTemplates.filter((template) => template === refundReviewFlow)).toHaveLength(1);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('renders the human-decision node with its own template: the React Flow type is the palette type', () => {
+ expect(human.type).toBe(humanDecisionNodeType);
+ expect(human.data.type).toBe(humanDecisionNodeType);
+ expect(human.data.properties['decisionRequest']).toBe(refundReviewRequest);
+ });
+
+ it('keeps the preset actions and ports, adding only the refund form', () => {
+ expect(refundReviewRequest.actions).toEqual(defaultDecisionRequest.actions);
+ expect(refundReviewRequest.version).toBe(1);
+ expect(refundReviewRequest.schema.properties.orderDate.readOnly).toBe(true);
+ expect(refundReviewRequest.schema.required).toEqual(['refundAmount']);
+ expect(refundReviewRequest).not.toHaveProperty('proposalSourceNodeId');
+ });
+
+ it('gives the deciding node exactly one predecessor, as publishing requires', () => {
+ expect(edgesInto('human-1').map((edge) => edge.source)).toEqual(['draft-1']);
+ });
+
+ it('draws one edge per action port and none from a port the request does not offer', () => {
+ const handles = edgesOutOf('human-1').map((edge) => edge.sourceHandle);
+
+ expect([...handles].sort()).toEqual([...ports(refundReviewRequest)].sort());
+ });
+
+ it('connects every edge to nodes that exist', () => {
+ const ids = new Set(nodes.map((node) => node.id));
+
+ for (const edge of edges) {
+ expect(ids.has(edge.source), edge.id).toBe(true);
+ expect(ids.has(edge.target), edge.id).toBe(true);
+ }
+ });
+});
+
+describe('the draft the person reviews', () => {
+ it('seeds the shared refund review schema, the one the Response format dropdown offers', () => {
+ expect(draft.data.properties['outputSchema']).toBe(refundReviewOutputSchema);
+ });
+
+ it('lists the four draft fields, each with a title', () => {
+ expect(draftFields).toEqual(['refundAmount', 'orderDate', 'replyDraft', 'internalReasoning']);
+ for (const field of draftFields) {
+ expect(typeof draftProperties[field]?.title, field).toBe('string');
+ }
+ });
+
+ it("meets the provider's strict mode at the top level: every field required, no extra keys", () => {
+ expect([...refundReviewOutputSchema.required].sort()).toEqual([...draftFields].sort());
+ expect(refundReviewOutputSchema.additionalProperties).toBe(false);
+ });
+
+ // The confirmation sends the reply the person approves, so it has to stay a field they can correct.
+ it('lets the person correct the amount and the reply, not the order date', () => {
+ expect([...editableFields(refundReviewRequest.schema)]).toEqual(['refundAmount', 'replyDraft']);
+ });
+
+ it('puts every draft field except internalReasoning on the decision form, under the same titles and types', () => {
+ expect(Object.keys(formProperties)).toEqual(['refundAmount', 'orderDate', 'replyDraft']);
+ for (const [field, declared] of Object.entries(formProperties)) {
+ expect(declared.title, field).toBe(draftProperties[field]?.title);
+ expect(declared.type, field).toBe(draftProperties[field]?.type);
+ }
+ });
+});
diff --git a/apps/ai-studio/src/data/refund-review-flow.ts b/apps/ai-studio/src/data/refund-review-flow.ts
new file mode 100644
index 000000000..d98f5779b
--- /dev/null
+++ b/apps/ai-studio/src/data/refund-review-flow.ts
@@ -0,0 +1,207 @@
+import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk';
+
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { humanDecisionNodeType } from '../nodes/human-decision';
+import { defaultDecisionRequest } from '../nodes/human-decision/default-properties-data';
+import { refundReviewOutputSchema } from '../utils/ai-agent/response-options';
+
+const REFUND_CONTEXT = `You work in customer support for Lumen, a SaaS analytics product.
+
+Refund policy: a duplicate charge is refunded in full; an unused month on the Pro plan ($49 / month)
+is refunded pro rata; refunds go back to the original card within 5 to 10 business days.
+Style: empathetic, concise, no promises the team cannot keep.`;
+
+// The palette preset with the refund form on top: the amount and the reply may be corrected, the order
+// date may not, and the reasoning stays with the team, so it is not a field of the form at all.
+// Only the confirmation prompt keeps it out of the reply to the customer.
+export const refundReviewRequest = {
+ ...defaultDecisionRequest,
+ schema: {
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ orderDate: { type: 'string', title: 'Order date', readOnly: true },
+ replyDraft: { type: 'string', title: 'Reply draft' },
+ },
+ required: ['refundAmount'],
+ },
+} satisfies DecisionRequest;
+
+const diagram: DiagramModel = {
+ name: 'Refund Review',
+ diagram: {
+ nodes: [
+ {
+ id: 'trigger-1',
+ type: 'start-node',
+ position: { x: 0, y: 300 },
+ data: {
+ segments: [],
+ isStartNode: true,
+ properties: {
+ label: 'Refund Request',
+ description: 'A customer asks for a refund.',
+ inputPrompt: `Subject: Refund for a duplicate charge
+
+Hi, on 2026-09-02 I was charged $49 twice for my Pro plan (order #48213). Could you refund the extra charge? I would also like to know whether this will happen again next month.
+
+Thanks,
+Marcus
+Head of Ops, Brightwave`,
+ },
+ type: 'ai-studio/trigger',
+ icon: 'Lightning',
+ },
+ },
+ {
+ id: 'draft-1',
+ type: 'node',
+ position: { x: 350, y: 300 },
+ data: {
+ segments: [],
+ properties: {
+ label: 'Draft the Refund Reply',
+ description: 'Proposes a refund and drafts the reply.',
+ systemPrompt: `${REFUND_CONTEXT}
+
+Read the customer's message. Decide the refund amount under the policy, take the order date from the
+message, and draft the reply: the amount refunded, where and when it arrives, and one sentence on
+preventing a repeat. Keep your reasoning about the policy for the team, not for the customer.`,
+ webSearch: false,
+ outputSchema: refundReviewOutputSchema,
+ },
+ type: 'ai-studio/ai-agent',
+ icon: 'AiAgent',
+ },
+ },
+ {
+ id: 'human-1',
+ type: humanDecisionNodeType,
+ position: { x: 700, y: 300 },
+ data: {
+ segments: [],
+ properties: {
+ label: 'Review Refund',
+ description: 'A person approves or rejects it.',
+ decisionRequest: refundReviewRequest,
+ },
+ type: humanDecisionNodeType,
+ icon: 'UserCheck',
+ },
+ },
+ {
+ id: 'send-1',
+ type: 'node',
+ position: { x: 1100, y: 200 },
+ data: {
+ segments: [],
+ properties: {
+ label: 'Send the Confirmation',
+ description: 'Writes the confirmation to the customer.',
+ systemPrompt: `${REFUND_CONTEXT}
+
+A person approved the refund. The context holds the draft (refundAmount, orderDate, replyDraft,
+internalReasoning) and the decision record, whose edits hold every field the person corrected. An
+edited value wins over the draft. internalReasoning is for the team: leave it out.
+
+Send replyDraft to the customer as the confirmation, keeping its wording. Change it only where the
+amount it names differs from the approved refundAmount, and keep it signed "Lumen Support".
+Answer with the message alone.`,
+ webSearch: false,
+ },
+ type: 'ai-studio/ai-agent',
+ icon: 'AiAgent',
+ },
+ },
+ {
+ id: 'done-1',
+ type: 'node',
+ position: { x: 1450, y: 200 },
+ data: {
+ segments: [],
+ properties: {
+ label: 'Confirmation',
+ description: 'What the customer receives.',
+ mode: 'markdown',
+ },
+ type: 'ai-studio/visualize',
+ icon: 'Eye',
+ },
+ },
+ {
+ id: 'rejected-1',
+ type: 'node',
+ position: { x: 1100, y: 600 },
+ data: {
+ segments: [],
+ properties: {
+ label: 'Rejected',
+ description: 'The decision record.',
+ mode: 'json',
+ },
+ type: 'ai-studio/visualize',
+ icon: 'Eye',
+ },
+ },
+ ],
+ edges: [
+ {
+ source: 'trigger-1',
+ sourceHandle: 'source',
+ target: 'draft-1',
+ targetHandle: 'target',
+ type: 'labelEdge',
+ id: 'edge-trigger-draft',
+ data: {},
+ },
+ {
+ source: 'draft-1',
+ sourceHandle: 'source',
+ target: 'human-1',
+ targetHandle: 'target',
+ type: 'labelEdge',
+ id: 'edge-draft-human',
+ data: {},
+ },
+ {
+ source: 'human-1',
+ sourceHandle: 'source:inner:approved',
+ zIndex: 1001,
+ target: 'send-1',
+ targetHandle: 'target',
+ type: 'labelEdge',
+ id: 'edge-human-send',
+ data: {},
+ },
+ {
+ source: 'human-1',
+ sourceHandle: 'source:inner:rejected',
+ zIndex: 1001,
+ target: 'rejected-1',
+ targetHandle: 'target',
+ type: 'labelEdge',
+ id: 'edge-human-rejected',
+ data: {},
+ },
+ {
+ source: 'send-1',
+ sourceHandle: 'source',
+ target: 'done-1',
+ targetHandle: 'target',
+ type: 'labelEdge',
+ id: 'edge-send-done',
+ data: {},
+ },
+ ],
+ viewport: { x: 100, y: 100, zoom: 0.6 },
+ },
+ layoutDirection: 'RIGHT',
+};
+
+export const refundReviewFlow: TemplateModel = {
+ id: 306,
+ name: 'Refund Review',
+ value: diagram,
+ icon: 'Receipt',
+};
diff --git a/apps/ai-studio/src/data/research-flow.ts b/apps/ai-studio/src/data/research-flow.ts
index 065408ee6..31639c192 100644
--- a/apps/ai-studio/src/data/research-flow.ts
+++ b/apps/ai-studio/src/data/research-flow.ts
@@ -20,8 +20,6 @@ const diagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 63 },
- dragging: false,
},
{
id: 'research-1',
@@ -52,8 +50,6 @@ Only state things you found via search. If a claim isn't supported by a result,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'visualize-1',
@@ -70,8 +66,6 @@ Only state things you found via search. If a claim isn't supported by a result,
icon: 'Eye',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
],
edges: [
diff --git a/apps/ai-studio/src/data/support-triage-flow.ts b/apps/ai-studio/src/data/support-triage-flow.ts
index 9569a46e1..71dfc4b92 100644
--- a/apps/ai-studio/src/data/support-triage-flow.ts
+++ b/apps/ai-studio/src/data/support-triage-flow.ts
@@ -34,8 +34,6 @@ Head of Ops, Brightwave`,
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 63 },
- dragging: false,
},
{
id: 'classify-1',
@@ -65,8 +63,6 @@ Use the exact lowercase keyword on the Type line - it drives downstream routing.
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'decision-1',
@@ -117,8 +113,6 @@ Use the exact lowercase keyword on the Type line - it drives downstream routing.
icon: 'ArrowsSplit',
},
selected: false,
- measured: { width: 258, height: 236 },
- dragging: false,
},
{
id: 'billing-1',
@@ -142,8 +136,6 @@ You handle billing issues. Draft a reply to the customer:
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'bug-1',
@@ -167,8 +159,6 @@ You triage product bugs. Draft a reply to the customer:
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'general-1',
@@ -191,8 +181,6 @@ You answer how-to and general questions. Draft a friendly reply:
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'qa-1',
@@ -218,8 +206,6 @@ If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`,
icon: 'AiAgent',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
{
id: 'visualize-1',
@@ -236,8 +222,6 @@ If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`,
icon: 'Eye',
},
selected: false,
- measured: { width: 258, height: 123 },
- dragging: false,
},
],
edges: [
diff --git a/apps/ai-studio/src/hooks/use-backend-execution.test.tsx b/apps/ai-studio/src/hooks/use-backend-execution.test.tsx
new file mode 100644
index 000000000..0c32f40eb
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-backend-execution.test.tsx
@@ -0,0 +1,518 @@
+import { StrictMode, act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { openFromUrl } from '../app/open-from-url';
+import { BACKEND_URL } from '../config';
+import { refundReviewFlow } from '../data/refund-review-flow';
+import { type RunStatus, resetExecution, setExecutionStarted, useExecutionStore } from '../stores/use-execution-store';
+import { deferred } from '../test/deferred';
+import { cancelledEvent, parkedRunHistory, snapshotFrame } from '../test/execution-history';
+import { FakeEventSource, installFakeEventSource, latestStream, openStreams } from '../test/fake-event-source';
+import { jsonResponse, unparsableResponse } from '../test/json-response';
+import { useBackendExecution } from './use-backend-execution';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+let hook: ReturnType | undefined;
+
+function Host() {
+ hook = useBackendExecution();
+ return null;
+}
+
+function api() {
+ if (!hook) throw new Error('hook is not mounted');
+ return hook;
+}
+
+// StrictMode like the app: effects run twice.
+function mountHook() {
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+ act(() =>
+ root.render(
+
+
+ ,
+ ),
+ );
+ return () => {
+ act(() => root.unmount());
+ container.remove();
+ };
+}
+
+const STREAM_URL = '/api/executions/exec-1/stream';
+
+// The store as openFromUrl leaves it before the editor mounts.
+function putRunInStore(status: RunStatus) {
+ useExecutionStore.setState({ executionId: 'exec-1', streamUrl: STREAM_URL, status });
+}
+
+// The parked run, resumed elsewhere and finished before Stop reached the server.
+const completedRunHistory: ExecutionEvent[] = [
+ ...parkedRunHistory,
+ {
+ executionId: 'exec-1',
+ timestamp: '2026-09-15T12:00:05.000Z',
+ sequence: 4,
+ type: 'node_completed',
+ nodeId: 'human-1',
+ payload: { output: {} },
+ },
+ { executionId: 'exec-1', timestamp: '2026-09-15T12:00:06.000Z', sequence: 5, type: 'execution_completed' },
+];
+
+let unmount: (() => void) | undefined;
+
+beforeEach(() => {
+ installFakeEventSource();
+ resetExecution();
+ hook = undefined;
+ globalThis.history.replaceState(null, '', '/');
+});
+
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('useBackendExecution: the run openFromUrl put in the store', () => {
+ it('keeps one stream open on its URL after the StrictMode double mount', () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+
+ expect(openStreams()).toHaveLength(1);
+ expect(latestStream().url).toBe(`${BACKEND_URL}${STREAM_URL}`);
+ });
+
+ it('shows its status before the stream answers, so Stop is available at once', () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+
+ expect(api().status).toBe('waiting');
+ expect(api().executionId).toBe('exec-1');
+ });
+
+ it('lets the snapshot rebuild the waiting marker', () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+
+ act(() => latestStream().emit(snapshotFrame('waiting')));
+
+ expect(useExecutionStore.getState().nodeStates['human-1']).toEqual({ status: 'waiting' });
+ expect(useExecutionStore.getState().status).toBe('waiting');
+ });
+
+ it('opens no stream for a run that already ended', () => {
+ putRunInStore('completed');
+ unmount = mountHook();
+
+ expect(FakeEventSource.instances).toHaveLength(0);
+ });
+
+ it('opens no stream for an idle canvas', () => {
+ unmount = mountHook();
+
+ expect(FakeEventSource.instances).toHaveLength(0);
+ });
+
+ it('closes the reopened stream when the controls unmount', () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+
+ unmount();
+ unmount = undefined;
+
+ expect(openStreams()).toHaveLength(0);
+ });
+});
+
+describe('useBackendExecution: what Stop does with the server answer', () => {
+ let fetchMock: ReturnType;
+
+ function serverAnswersDelete(status: number, body: unknown) {
+ fetchMock = vi.fn(async () => jsonResponse(status, body));
+ vi.stubGlobal('fetch', fetchMock);
+ }
+
+ function serverHoldsDelete() {
+ const answer = deferred();
+ fetchMock = vi.fn(() => answer.promise);
+ vi.stubGlobal('fetch', fetchMock);
+ return answer;
+ }
+
+ it('a run the server no longer has is forgotten: idle canvas, no stream left open', async () => {
+ putRunInStore('disconnected');
+ unmount = mountHook();
+ serverAnswersDelete(404, { code: 'execution_not_found', message: 'Execution not found' });
+
+ await act(() => api().cancel());
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${BACKEND_URL}/api/executions/exec-1`,
+ expect.objectContaining({ method: 'DELETE' }),
+ );
+ expect(useExecutionStore.getState().status).toBe('idle');
+ expect(useExecutionStore.getState().executionId).toBeUndefined();
+ expect(openStreams()).toHaveLength(0);
+ });
+
+ it('a run that already finished is caught up on, not forgotten: the reopened stream tells how it ended', async () => {
+ putRunInStore('disconnected');
+ unmount = mountHook();
+ const streamBefore = latestStream();
+ serverAnswersDelete(409, { code: 'execution_not_cancellable', message: 'Execution already finished' });
+
+ await act(() => api().cancel());
+
+ expect(streamBefore.closed).toBe(true);
+ expect(openStreams()).toHaveLength(1);
+ expect(latestStream()).not.toBe(streamBefore);
+
+ act(() => latestStream().emit(snapshotFrame('completed')));
+
+ expect(useExecutionStore.getState().status).toBe('completed');
+ });
+
+ it('a Stop refused because the run completed ends on completed, keeping the run and the Stop request', async () => {
+ putRunInStore('disconnected');
+ unmount = mountHook();
+ serverAnswersDelete(409, { code: 'execution_not_cancellable', message: 'Execution already finished' });
+
+ await act(() => api().cancel());
+ act(() => latestStream().emit(snapshotFrame('completed', completedRunHistory)));
+
+ expect(useExecutionStore.getState()).toMatchObject({
+ status: 'completed',
+ executionId: 'exec-1',
+ isStopRequested: true,
+ });
+ expect(openStreams()).toHaveLength(0);
+ });
+
+ it('a cancel the server accepted is followed over a fresh stream, never two at once', async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ const streamBefore = latestStream();
+ serverAnswersDelete(200, { id: 'exec-1', status: 'cancelling' });
+
+ await act(() => api().cancel());
+
+ expect(streamBefore.closed).toBe(true);
+ expect(openStreams()).toHaveLength(1);
+
+ act(() => latestStream().emit(snapshotFrame('cancelling')));
+
+ expect(useExecutionStore.getState().status).toBe('cancelling');
+ });
+
+ it('Stop marks the request before the server answers', () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ serverHoldsDelete();
+
+ void api().cancel();
+
+ expect(fetchMock).toHaveBeenCalled();
+ expect(useExecutionStore.getState().isStopRequested).toBe(true);
+ });
+
+ it('a run that ended over the old stream while Stop was in flight is not reopened', async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ const streamBefore = latestStream();
+ const deleteAnswer = serverHoldsDelete();
+
+ const cancelPromise = act(() => api().cancel());
+ act(() => streamBefore.emit(cancelledEvent));
+ deleteAnswer.resolve(jsonResponse(200, { id: 'exec-1', status: 'cancelling' }));
+ await cancelPromise;
+
+ expect(useExecutionStore.getState().status).toBe('cancelled');
+ expect(latestStream()).toBe(streamBefore);
+ expect(openStreams()).toHaveLength(0);
+ });
+
+ it('Stop with nothing remembered asks the server nothing', async () => {
+ unmount = mountHook();
+ serverAnswersDelete(200, {});
+
+ await act(() => api().cancel());
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('a request that never reaches the server leaves the run remembered and the request marked', async () => {
+ putRunInStore('disconnected');
+ unmount = mountHook();
+ const streamBefore = latestStream();
+ fetchMock = vi.fn(async () => {
+ throw new Error('connection refused');
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await act(() => api().cancel());
+
+ expect(useExecutionStore.getState().status).toBe('disconnected');
+ expect(useExecutionStore.getState().isStopRequested).toBe(true);
+ expect(latestStream()).toBe(streamBefore);
+ expect(streamBefore.closed).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+
+ it('a Stop answer arriving after a newer run started does not reopen a stream for the old one', async () => {
+ useExecutionStore.setState({ executionId: 'exec-1', streamUrl: STREAM_URL, status: 'idle' });
+ unmount = mountHook();
+ const deleteAnswer = serverHoldsDelete();
+
+ const cancelPromise = act(() => api().cancel());
+ act(() => {
+ setExecutionStarted('exec-2', '/api/executions/exec-2/stream');
+ });
+ deleteAnswer.resolve(jsonResponse(200, { id: 'exec-1', status: 'cancelling' }));
+ await cancelPromise;
+
+ expect(useExecutionStore.getState().executionId).toBe('exec-2');
+ expect(useExecutionStore.getState().status).toBe('pending');
+ expect(openStreams()).toHaveLength(0);
+ });
+
+ it.each([
+ ['a 404 that names no code', async () => jsonResponse(404, { message: 'Not Found' })],
+ ['a 404 whose body will not parse', async () => unparsableResponse(404)],
+ ['a 500', async () => jsonResponse(500, { message: 'Internal Server Error' })],
+ ])('%s is not the server forgetting the run: it stays, and Reset becomes the way out', async (_, answer) => {
+ putRunInStore('disconnected');
+ unmount = mountHook();
+ fetchMock = vi.fn(answer);
+ vi.stubGlobal('fetch', fetchMock);
+
+ await act(() => api().cancel());
+
+ expect(useExecutionStore.getState().executionId).toBe('exec-1');
+ expect(useExecutionStore.getState().isStopRequested).toBe(true);
+ });
+
+ it('a 404 the owner changed during neither forgets nor marks the newer run', async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ // The owner has to change while the body is being read, and a real Response offers no hook for that.
+ const bodyThatStartsANewRun = {
+ ok: false,
+ status: 404,
+ json: async () => {
+ setExecutionStarted('exec-2', '/api/executions/exec-2/stream');
+ return { code: 'execution_not_found', message: 'Execution not found' };
+ },
+ } as Response;
+ fetchMock = vi.fn(async () => bodyThatStartsANewRun);
+ vi.stubGlobal('fetch', fetchMock);
+
+ await act(() => api().cancel());
+
+ expect(useExecutionStore.getState().executionId).toBe('exec-2');
+ expect(useExecutionStore.getState().isStopRequested).toBe(false);
+ });
+
+ it('a Stop that fails after Reset leaves the clean canvas unmarked', async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ const deleteAnswer = serverHoldsDelete();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const cancelPromise = act(() => api().cancel());
+ act(() => api().reset());
+ deleteAnswer.reject(new Error('connection refused'));
+ await cancelPromise;
+
+ expect(useExecutionStore.getState()).toMatchObject({
+ status: 'idle',
+ executionId: undefined,
+ isStopRequested: false,
+ });
+ });
+
+ it('a Stop answered after the controls unmounted opens no stream nobody is left to close', async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ const deleteAnswer = serverHoldsDelete();
+
+ const cancelPromise = api().cancel();
+ unmount();
+ unmount = undefined;
+ deleteAnswer.resolve(jsonResponse(200, { id: 'exec-1', status: 'cancelling' }));
+ await act(async () => {
+ await cancelPromise;
+ });
+
+ expect(openStreams()).toHaveLength(0);
+ });
+});
+
+function serverAcceptsExecute() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) =>
+ url.endsWith('/api/workflows')
+ ? jsonResponse(201, { id: 'wf-1' })
+ : jsonResponse(202, { executionId: 'exec-2', streamUrl: '/api/executions/exec-2/stream' }),
+ ),
+ );
+}
+
+describe('useBackendExecution: starting a run from the canvas', () => {
+ it('a new run closes the stream a reload reopened before the server answers, and never joins it', async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ const reconnected = latestStream();
+ serverAcceptsExecute();
+
+ await act(async () => {
+ const run = api().executeFromCanvas([], []);
+ expect(reconnected.closed).toBe(true);
+ await run;
+ });
+
+ expect(reconnected.closed).toBe(true);
+ expect(openStreams()).toHaveLength(1);
+ expect(latestStream().url).toBe(`${BACKEND_URL}/api/executions/exec-2/stream`);
+ });
+
+ it("a new run starts without the previous run's Stop request", async () => {
+ putRunInStore('waiting');
+ unmount = mountHook();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse(500, { message: 'Internal Server Error' })),
+ );
+ await act(() => api().cancel());
+ expect(useExecutionStore.getState().isStopRequested).toBe(true);
+ serverAcceptsExecute();
+
+ await act(async () => {
+ await api().executeFromCanvas([], []);
+ });
+
+ expect(useExecutionStore.getState()).toMatchObject({ executionId: 'exec-2', isStopRequested: false });
+ });
+
+ it('puts the new run in the address', async () => {
+ unmount = mountHook();
+ serverAcceptsExecute();
+
+ await act(async () => {
+ await api().executeFromCanvas([], []);
+ });
+
+ expect(globalThis.location.search).toBe('?executionId=exec-2');
+ });
+
+ it('a workflow that will not save opens no stream and leaves the canvas idle', async () => {
+ unmount = mountHook();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse(500, { message: 'Failed to save workflow' })),
+ );
+
+ await act(async () => {
+ await expect(api().executeFromCanvas([], [])).rejects.toThrow('Failed to save workflow');
+ });
+
+ expect(useExecutionStore.getState().status).toBe('idle');
+ expect(FakeEventSource.instances).toHaveLength(0);
+ });
+});
+
+describe('useBackendExecution: starting a run on the workflow the link opened', () => {
+ const workflow = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+ const nodes = [{ id: 'n-1' }];
+
+ it("saves the canvas into that workflow's draft, runs that workflow and adds the run to its address", async () => {
+ globalThis.history.replaceState(null, '', `/?workflowId=${workflow}`);
+ unmount = mountHook();
+ const request = vi.fn(async (url) =>
+ String(url).endsWith('/draft')
+ ? jsonResponse(200, { id: workflow, name: 'Refund review' })
+ : jsonResponse(202, { executionId: 'exec-2', streamUrl: '/api/executions/exec-2/stream' }),
+ );
+ vi.stubGlobal('fetch', request);
+
+ await act(async () => {
+ await api().executeFromCanvas(nodes, [], {}, workflow);
+ });
+
+ expect(request.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
+ [`${BACKEND_URL}/api/workflows/${workflow}/draft`, 'PATCH'],
+ [`${BACKEND_URL}/api/workflows/${workflow}/execute`, 'POST'],
+ ]);
+ expect(JSON.parse(request.mock.calls[0]![1]!.body as string)).toEqual({
+ draftJson: { nodes, edges: [] },
+ });
+ expect(globalThis.location.search).toBe(`?workflowId=${workflow}&executionId=exec-2`);
+ });
+
+ it('a workflow the server no longer has starts nothing and leaves the address alone', async () => {
+ globalThis.history.replaceState(null, '', `/?workflowId=${workflow}`);
+ unmount = mountHook();
+ const request = vi.fn(async () => jsonResponse(404, { code: 'workflow_not_found', message: 'Workflow not found' }));
+ vi.stubGlobal('fetch', request);
+
+ await act(async () => {
+ await expect(api().executeFromCanvas(nodes, [], {}, workflow)).rejects.toThrow('Workflow not found');
+ });
+
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(useExecutionStore.getState().status).toBe('idle');
+ expect(globalThis.location.search).toBe(`?workflowId=${workflow}`);
+ });
+});
+
+describe('useBackendExecution: Reset', () => {
+ it('takes the run out of the address and keeps the workflow', () => {
+ const workflow = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+ globalThis.history.replaceState(null, '', `/?workflowId=${workflow}&executionId=exec-1`);
+ putRunInStore('completed');
+ unmount = mountHook();
+
+ act(() => api().reset());
+
+ expect(globalThis.location.search).toBe(`?workflowId=${workflow}`);
+ });
+});
+
+describe('useBackendExecution: a run opened from the link', () => {
+ const run = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+ const graph = { nodes: refundReviewFlow.value.diagram.nodes, edges: refundReviewFlow.value.diagram.edges };
+
+ it('opens exactly one stream, on that run, after the StrictMode double mount', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ jsonResponse(200, {
+ workflowId: '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11',
+ sourceVersion: 'draft',
+ snapshot: graph,
+ }),
+ ),
+ );
+
+ await openFromUrl(`?executionId=${run}`);
+ unmount = mountHook();
+
+ expect(openStreams()).toHaveLength(1);
+ expect(latestStream().url).toBe(`${BACKEND_URL}/api/executions/${run}/stream`);
+ });
+});
diff --git a/apps/ai-studio/src/hooks/use-backend-execution.ts b/apps/ai-studio/src/hooks/use-backend-execution.ts
index 0e3d5bc3a..9e30d650b 100644
--- a/apps/ai-studio/src/hooks/use-backend-execution.ts
+++ b/apps/ai-studio/src/hooks/use-backend-execution.ts
@@ -1,31 +1,82 @@
-import { useCallback, useRef } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { connectExecutionStream } from '../adapters/execution-stream-adapter';
+import { patchDraft } from '../adapters/save-workflow-draft';
import { BACKEND_URL } from '../config';
import { getTurnstileToken } from '../security/turnstile';
-import { resetExecution, setExecutionStarted, useExecutionStore } from '../stores/use-execution-store';
+import {
+ applyStopRequested,
+ isRunAlive,
+ resetExecution,
+ setExecutionStarted,
+ useExecutionStore,
+} from '../stores/use-execution-store';
+import { syncExecutionIdToAddress } from '../utils/open-from-url/address-execution-id';
+
+// A proxy 404 is not JSON, and only the backend's own code means the server forgot the run.
+async function isExecutionNotFound(response: Response): Promise {
+ const body = (await response.json().catch(() => null)) as { code?: string } | null;
+ return body?.code === 'execution_not_found';
+}
+
+async function workflowToRun(nodes: unknown[], edges: unknown[], targetWorkflowId?: string): Promise {
+ const response = await (targetWorkflowId === undefined
+ ? fetch(`${BACKEND_URL}/api/workflows`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'AI Studio Draft', draftJson: { nodes, edges } }),
+ })
+ : patchDraft(targetWorkflowId, nodes, edges));
+
+ if (!response.ok) {
+ const error = (await response.json()) as { message?: string };
+ throw new Error(error.message ?? 'Failed to save workflow');
+ }
+
+ const { id } = (await response.json()) as { id: string };
+ return id;
+}
-export function useBackendExecution() {
+/** `onForget` runs once the client forgets the run: on Reset, and when Stop learns the server no longer has it. */
+export function useBackendExecution(onForget?: () => void) {
const disconnectRef = useRef<(() => void) | null>(null);
+ const isUnmountedRef = useRef(false);
const status = useExecutionStore((s) => s.status);
const executionId = useExecutionStore((s) => s.executionId);
+ const streamUrl = useExecutionStore((s) => s.streamUrl);
- const executeFromCanvas = useCallback(
- async (nodes: unknown[], edges: unknown[], triggerPayload: Record = {}) => {
- disconnectRef.current?.();
+ // Sole opener of the EventSource. A request still in flight at unmount resolves in here afterwards,
+ // and nothing would be left to close what it opened.
+ const openStream = useCallback((runId: string, runStreamUrl: string) => {
+ disconnectRef.current?.();
+ disconnectRef.current = isUnmountedRef.current ? null : connectExecutionStream(runId, runStreamUrl);
+ }, []);
- const wfResponse = await fetch(`${BACKEND_URL}/api/workflows`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: 'AI Studio Draft', draftJson: { nodes, edges } }),
- });
+ useEffect(() => {
+ isUnmountedRef.current = false;
+ // The run a link opened is in the store before the editor mounts; this is where its stream opens.
+ const { executionId: runId, streamUrl: runStreamUrl, status: runStatus } = useExecutionStore.getState();
+ if (runId && runStreamUrl && isRunAlive(runStatus)) openStream(runId, runStreamUrl);
+ return () => {
+ isUnmountedRef.current = true;
+ disconnectRef.current?.();
+ disconnectRef.current = null;
+ };
+ }, [openStream]);
- if (!wfResponse.ok) {
- const error = (await wfResponse.json()) as { message?: string };
- throw new Error(error.message ?? 'Failed to save workflow');
- }
+ const executeFromCanvas = useCallback(
+ async (
+ nodes: unknown[],
+ edges: unknown[],
+ triggerPayload: Record = {},
+ /** Saves the canvas into this workflow's draft instead of creating a workflow per run. */
+ targetWorkflowId?: string,
+ ) => {
+ // Not redundant with openStream's own close: left open, a stale stream could still mutate the
+ // store through the two round trips below.
+ disconnectRef.current?.();
- const { id: workflowId } = (await wfResponse.json()) as { id: string };
+ const workflowId = await workflowToRun(nodes, edges, targetWorkflowId);
const turnstileToken = await getTurnstileToken();
@@ -49,31 +100,49 @@ export function useBackendExecution() {
};
setExecutionStarted(execId, streamUrl);
- disconnectRef.current = connectExecutionStream(execId, streamUrl);
+ syncExecutionIdToAddress(execId);
+ openStream(execId, streamUrl);
return execId;
},
- [],
+ [openStream],
);
- const cancel = useCallback(async () => {
- if (!executionId) return;
-
- // The stream stays open on purpose. Cancelling is asynchronous: the backend only
- // asks Temporal to cancel, and the worker emits `execution_cancelled` a moment
- // later. That event is what moves the UI to 'cancelled', and the adapter closes
- // the EventSource once it sees it. Disconnecting here dropped the event, so the
- // panel stayed in 'running' forever with no terminal log line.
- await fetch(`${BACKEND_URL}/api/executions/${executionId}`, {
- method: 'DELETE',
- });
- }, [executionId]);
-
const reset = useCallback(() => {
disconnectRef.current?.();
disconnectRef.current = null;
resetExecution();
- }, []);
+ syncExecutionIdToAddress(null);
+ onForget?.();
+ }, [onForget]);
+
+ const cancel = useCallback(async () => {
+ if (!executionId) return;
+ applyStopRequested();
+
+ let response: Response;
+ try {
+ response = await fetch(`${BACKEND_URL}/api/executions/${executionId}`, {
+ method: 'DELETE',
+ });
+ } catch (error) {
+ console.error('Stop request failed:', error);
+ return;
+ }
+
+ // A Reset or a new run while the request was in flight owns the store now.
+ if (useExecutionStore.getState().executionId !== executionId) return;
+
+ if (response.ok || response.status === 409) {
+ // The run may have ended over the old stream while the request was in flight.
+ if (streamUrl && isRunAlive(useExecutionStore.getState().status)) openStream(executionId, streamUrl);
+ return;
+ }
+
+ if (response.status !== 404 || !(await isExecutionNotFound(response))) return;
+ // Reading the body reopened the window the check above closed.
+ if (useExecutionStore.getState().executionId === executionId) reset();
+ }, [executionId, streamUrl, openStream, reset]);
return { executeFromCanvas, cancel, reset, status, executionId };
}
diff --git a/apps/ai-studio/src/hooks/use-decision-submit.test.tsx b/apps/ai-studio/src/hooks/use-decision-submit.test.tsx
new file mode 100644
index 000000000..3cd87e150
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-decision-submit.test.tsx
@@ -0,0 +1,178 @@
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The texts a person reads, through the backend's own module, as submit-decision.test.ts reads its schema.
+import { DECISION_REFUSALS } from '../../../backend/src/routes/decision-refusals';
+import { type SubmitDecisionResult, submitDecision } from '../adapters/submit-decision';
+import { resetExecution, setExecutionStarted } from '../stores/use-execution-store';
+import { useDecisionSubmit } from './use-decision-submit';
+
+vi.mock('../adapters/submit-decision', () => ({ submitDecision: vi.fn() }));
+const decide = vi.mocked(submitDecision);
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const wait = { executionId: 'exec-1', nodeId: 'human-1', attempt: 1 };
+let unmount: (() => void) | undefined;
+
+function renderSubmit() {
+ let latest: ReturnType | undefined;
+ function Probe() {
+ latest = useDecisionSubmit(wait);
+ return null;
+ }
+ const root = createRoot(document.createElement('div'));
+ act(() => root.render( ));
+ unmount = () => act(() => root.unmount());
+ return () => latest!;
+}
+
+async function send(state: () => ReturnType) {
+ await act(async () => {
+ await state().submit({ action: 'approve' });
+ });
+}
+
+const refusal = (result: Partial>): SubmitDecisionResult => ({
+ ok: false,
+ status: 409,
+ code: 'decision_already_made',
+ message: 'Refused',
+ ...result,
+});
+
+describe('useDecisionSubmit', () => {
+ beforeEach(() => {
+ decide.mockReset();
+ resetExecution();
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ });
+
+ afterEach(() => unmount?.());
+
+ it('sends the decision for its wait and stays busy once it is accepted, with nothing to say', async () => {
+ decide.mockResolvedValue({ ok: true });
+ const state = renderSubmit();
+
+ await send(state);
+
+ expect(decide).toHaveBeenCalledWith(wait, { action: 'approve' });
+ expect(state().isBusy).toBe(true);
+ expect(state().isAccepted).toBe(true);
+ expect(state().message).toBeUndefined();
+ });
+
+ it('sends once however often it is pressed while the decision is on its way or accepted', async () => {
+ let answer!: (result: SubmitDecisionResult) => void;
+ decide.mockReturnValue(new Promise((resolve) => (answer = resolve)));
+ const state = renderSubmit();
+
+ await act(async () => {
+ void state().submit({ action: 'approve' });
+ void state().submit({ action: 'reject' });
+ });
+ await act(async () => answer({ ok: true }));
+ await send(state);
+
+ expect(decide).toHaveBeenCalledTimes(1);
+ expect(state().isAccepted).toBe(true);
+ });
+
+ it('sends again after a refusal', async () => {
+ decide.mockResolvedValueOnce(refusal({})).mockResolvedValueOnce({ ok: true });
+ const state = renderSubmit();
+
+ await send(state);
+ await send(state);
+
+ expect(decide).toHaveBeenCalledTimes(2);
+ expect(state().isAccepted).toBe(true);
+ });
+
+ it('frees the form after a refusal and names the wait it now has to answer', async () => {
+ decide.mockResolvedValue(
+ refusal({
+ code: 'decision_attempt_mismatch',
+ message: DECISION_REFUSALS.attempt_mismatch.message,
+ currentAttempt: 2,
+ }),
+ );
+ const state = renderSubmit();
+
+ await send(state);
+
+ expect(state().isBusy).toBe(false);
+ expect(state().message).toBe('The decision names a wait that is not the current one. The wait to answer is now 2.');
+ });
+
+ it('tells when to try again after a delivery timeout', async () => {
+ decide.mockResolvedValue(
+ refusal({ status: 503, message: DECISION_REFUSALS.delivery_timeout.message, retryAfterSeconds: 5 }),
+ );
+ const state = renderSubmit();
+
+ await send(state);
+
+ expect(state().isBusy).toBe(false);
+ expect(state().message).toBe(`${DECISION_REFUSALS.delivery_timeout.message} Try again in 5 s.`);
+ });
+
+ it('adds the first detail of a validation refusal', async () => {
+ decide.mockResolvedValue(
+ refusal({
+ status: 400,
+ message: DECISION_REFUSALS.decision_invalid.message,
+ detail: "action 'reject' requires a reason",
+ }),
+ );
+ const state = renderSubmit();
+
+ await send(state);
+
+ expect(state().message).toBe("Decision failed validation. action 'reject' requires a reason.");
+ });
+
+ it('frees the form when the adapter throws, which it is written not to do', async () => {
+ decide.mockRejectedValue(new Error('boom'));
+ const state = renderSubmit();
+
+ await send(state);
+
+ expect(state().isBusy).toBe(false);
+ expect(state().message).toBe('boom');
+ });
+
+ it('clears the previous message when it sends again', async () => {
+ decide.mockResolvedValueOnce(refusal({ message: 'First.' })).mockReturnValueOnce(new Promise(() => {}));
+ const state = renderSubmit();
+ await send(state);
+
+ act(() => {
+ void state().submit({ action: 'approve' });
+ });
+
+ expect(state().message).toBeUndefined();
+ expect(state().isBusy).toBe(true);
+ });
+
+ it('keeps an answer that arrives after the form is gone, for when the person comes back', async () => {
+ let answer!: (result: SubmitDecisionResult) => void;
+ decide.mockReturnValue(new Promise((resolve) => (answer = resolve)));
+ const first = renderSubmit();
+ act(() => {
+ void first().submit({ action: 'approve' });
+ });
+ unmount?.();
+
+ await act(async () => answer(refusal({ message: 'Someone decided first' })));
+ const again = renderSubmit();
+
+ expect(again().message).toBe('Someone decided first.');
+ expect(again().isBusy).toBe(false);
+ });
+});
diff --git a/apps/ai-studio/src/hooks/use-decision-submit.ts b/apps/ai-studio/src/hooks/use-decision-submit.ts
new file mode 100644
index 000000000..8e8b5f42e
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-decision-submit.ts
@@ -0,0 +1,54 @@
+import { type DecisionInput, type SubmitDecisionResult, submitDecision } from '../adapters/submit-decision';
+import { type DecisionWait, saveDecisionSend, useExecutionStore, waitKey } from '../stores/use-execution-store';
+
+function refusalMessage(result: Extract): string {
+ return [
+ result.message,
+ result.detail,
+ result.currentAttempt === undefined ? undefined : `The wait to answer is now ${result.currentAttempt}.`,
+ result.retryAfterSeconds === undefined ? undefined : `Try again in ${result.retryAfterSeconds} s.`,
+ ]
+ .filter((part) => part !== undefined)
+ .map((part) => (part.endsWith('.') ? part : `${part}.`))
+ .join(' ');
+}
+
+function holdsTheForm(send: { status: string } | undefined): boolean {
+ return send?.status === 'sending' || send?.status === 'accepted';
+}
+
+/**
+ * Sends the decision for a wait and keeps where it stands in the store, so a person who leaves the node and comes back
+ * finds it still on its way, accepted or refused. An accepted decision keeps the form busy until the run records it and
+ * the panel shows the decision instead; a refusal frees the form and says why.
+ */
+export function useDecisionSubmit(wait: DecisionWait) {
+ const send = useExecutionStore((state) => state.decisionSends[waitKey(wait)]);
+
+ const submit = async (input: DecisionInput) => {
+ // Read from the store, not the render: a second press in the same frame would be refused and hide the acceptance.
+ if (holdsTheForm(useExecutionStore.getState().decisionSends[waitKey(wait)])) {
+ return;
+ }
+ saveDecisionSend(wait, { status: 'sending' });
+
+ let result: SubmitDecisionResult;
+ try {
+ result = await submitDecision(wait, input);
+ } catch (error) {
+ // The adapter answers with a result instead of throwing; a throw would be its bug, not a dead form.
+ const message = error instanceof Error ? error.message : 'The decision could not be sent.';
+ saveDecisionSend(wait, { status: 'refused', message });
+ return;
+ }
+
+ saveDecisionSend(wait, result.ok ? { status: 'accepted' } : { status: 'refused', message: refusalMessage(result) });
+ };
+
+ return {
+ isBusy: holdsTheForm(send),
+ isAccepted: send?.status === 'accepted',
+ message: send?.status === 'refused' ? send.message : undefined,
+ submit,
+ };
+}
diff --git a/apps/ai-studio/src/hooks/use-node-decision.test.ts b/apps/ai-studio/src/hooks/use-node-decision.test.ts
new file mode 100644
index 000000000..56d6fed91
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-node-decision.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+
+import { executionEvent as event } from '../stores/execution-event.fixture';
+import { attemptOf, proposalSourceIdOf } from './use-node-decision';
+
+describe('attemptOf', () => {
+ it('is zero when the node never parked', () => {
+ expect(attemptOf([event({ type: 'node_started', nodeId: 'human-1' })], 'human-1')).toBe(0);
+ });
+
+ it('counts node_waiting events of that node only', () => {
+ const events = [
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+ event({ type: 'node_waiting', nodeId: 'human-2' }),
+ event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }),
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+ ];
+ expect(attemptOf(events, 'human-1')).toBe(2);
+ expect(attemptOf(events, 'human-2')).toBe(1);
+ });
+});
+
+describe('proposalSourceIdOf', () => {
+ const edges = [
+ { source: 'draft-1', target: 'human-1' },
+ { source: 'human-1', target: 'send-1' },
+ ];
+
+ it('prefers a declared proposalSourceNodeId', () => {
+ expect(proposalSourceIdOf('other-1', edges, 'human-1')).toBe('other-1');
+ });
+
+ it('falls back to the source of the single incoming edge', () => {
+ expect(proposalSourceIdOf(undefined, edges, 'human-1')).toBe('draft-1');
+ });
+
+ it('does not count a self-loop as a predecessor, as the backend does not', () => {
+ expect(proposalSourceIdOf(undefined, [...edges, { source: 'human-1', target: 'human-1' }], 'human-1')).toBe(
+ 'draft-1',
+ );
+ });
+
+ it('counts one source once when two edges come from the same node', () => {
+ const twoHandles = [...edges, { source: 'draft-1', target: 'human-1' }];
+ expect(proposalSourceIdOf(undefined, twoHandles, 'human-1')).toBe('draft-1');
+ });
+
+ it.each([
+ ['no incoming edge', []],
+ ['two different sources', [...edges, { source: 'draft-2', target: 'human-1' }]],
+ ])('is undefined with %s', (_name, candidates) => {
+ expect(proposalSourceIdOf(undefined, candidates, 'human-1')).toBeUndefined();
+ });
+});
diff --git a/apps/ai-studio/src/hooks/use-node-decision.ts b/apps/ai-studio/src/hooks/use-node-decision.ts
new file mode 100644
index 000000000..88ee59d8a
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-node-decision.ts
@@ -0,0 +1,64 @@
+import { useStore } from '@workflowbuilder/sdk';
+
+import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { type DecisionDraft, type DecisionWait, useExecutionStore, waitKey } from '../stores/use-execution-store';
+
+type EdgeLike = { source: string; target: string };
+
+type DecisionRun = {
+ wait: DecisionWait;
+ /** The output of the proposal source: what the person judges. */
+ sourceOutput: unknown;
+};
+
+/** Where a decision node stands in the current run: nothing to show, waiting for a person, or decided. */
+type NodeDecision =
+ | { phase: 'none' }
+ | ({ phase: 'waiting'; draft: DecisionDraft | undefined } & DecisionRun)
+ | ({ phase: 'decided'; output: unknown } & DecisionRun);
+
+export function attemptOf(events: ExecutionEvent[], nodeId: string): number {
+ return events.filter((event) => event.type === 'node_waiting' && event.nodeId === nodeId).length;
+}
+
+// The backend's rule at execute: the declared source, else the one predecessor other than the node itself. It reads
+// the canvas, which is read-only while a run is shown (see useRunLocksCanvas for the exceptions).
+export function proposalSourceIdOf(
+ declared: string | undefined,
+ edges: EdgeLike[],
+ nodeId: string,
+): string | undefined {
+ if (declared !== undefined) {
+ return declared;
+ }
+ const incoming = edges.filter((edge) => edge.target === nodeId && edge.source !== nodeId);
+ const sources = new Set(incoming.map((edge) => edge.source));
+ return sources.size === 1 ? [...sources][0] : undefined;
+}
+
+export function useNodeDecision(nodeId: string | undefined, proposalSourceNodeId: string | undefined): NodeDecision {
+ const nodeState = useExecutionStore((state) => (nodeId === undefined ? undefined : state.nodeStates[nodeId]));
+ const executionId = useExecutionStore((state) => state.executionId);
+ const attempt = useExecutionStore((state) => (nodeId === undefined ? 0 : attemptOf(state.events, nodeId)));
+ const edges = useStore((state) => state.edges);
+ const sourceId = nodeId === undefined ? undefined : proposalSourceIdOf(proposalSourceNodeId, edges, nodeId);
+ const sourceOutput = useExecutionStore((state) =>
+ sourceId === undefined ? undefined : state.nodeStates[sourceId]?.output,
+ );
+ const wait = executionId === undefined || nodeId === undefined ? undefined : { executionId, nodeId, attempt };
+ const draft = useExecutionStore((state) => (wait === undefined ? undefined : state.decisionDrafts[waitKey(wait)]));
+
+ // A node that never parked in this run has no decision to show.
+ if (nodeState === undefined || wait === undefined || attempt < 1) {
+ return { phase: 'none' };
+ }
+ const run = { wait, sourceOutput };
+ if (nodeState.status === 'waiting') {
+ return { phase: 'waiting', draft, ...run };
+ }
+ if (nodeState.status === 'completed') {
+ return { phase: 'decided', output: nodeState.output, ...run };
+ }
+ return { phase: 'none' };
+}
diff --git a/apps/ai-studio/src/hooks/use-run-locks-canvas.test.tsx b/apps/ai-studio/src/hooks/use-run-locks-canvas.test.tsx
new file mode 100644
index 000000000..6bd4e0450
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-run-locks-canvas.test.tsx
@@ -0,0 +1,64 @@
+import { useStore } from '@workflowbuilder/sdk';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { executionEvent as event } from '../stores/execution-event.fixture';
+import { applyEvent, applySnapshot, resetExecution, setExecutionStarted } from '../stores/use-execution-store';
+import { useRunLocksCanvas } from './use-run-locks-canvas';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const isReadOnly = () => useStore.getState().isReadOnlyMode;
+
+function Probe() {
+ useRunLocksCanvas();
+ return null;
+}
+
+describe('useRunLocksCanvas', () => {
+ let root: ReturnType;
+
+ beforeEach(() => {
+ resetExecution();
+ useStore.getState().setToggleReadOnlyMode(false);
+ root = createRoot(document.createElement('div'));
+ act(() => root.render( ));
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ });
+
+ it('locks the canvas when a run starts and keeps it locked after the run ends, until Reset', () => {
+ expect(isReadOnly()).toBe(false);
+
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ expect(isReadOnly()).toBe(true);
+
+ act(() => applyEvent(event({ type: 'execution_completed', payload: undefined })));
+ expect(isReadOnly()).toBe(true);
+
+ act(() => resetExecution());
+ expect(isReadOnly()).toBe(false);
+ });
+
+ it('locks each new run, even after the read-only switch lifted the lock during the one before', () => {
+ act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream'));
+ act(() => useStore.getState().setToggleReadOnlyMode(false));
+ expect(isReadOnly()).toBe(false);
+
+ act(() => setExecutionStarted('exec-2', '/api/executions/exec-2/stream'));
+ expect(isReadOnly()).toBe(true);
+ });
+
+ it('locks the canvas for a run restored from its snapshot', () => {
+ act(() => applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: 0, events: [] }));
+
+ expect(isReadOnly()).toBe(true);
+ });
+});
diff --git a/apps/ai-studio/src/hooks/use-run-locks-canvas.ts b/apps/ai-studio/src/hooks/use-run-locks-canvas.ts
new file mode 100644
index 000000000..942311bc2
--- /dev/null
+++ b/apps/ai-studio/src/hooks/use-run-locks-canvas.ts
@@ -0,0 +1,17 @@
+import { useWorkflowBuilderActions } from '@workflowbuilder/sdk';
+import { useEffect } from 'react';
+
+import { useExecutionStore } from '../stores/use-execution-store';
+
+/** From the moment the backend starts a run until Reset the canvas is read-only, as the graph the run executes. */
+export function useRunLocksCanvas() {
+ const executionId = useExecutionStore((state) => state.executionId);
+ const { setReadOnly } = useWorkflowBuilderActions();
+
+ // Set whenever the shown run changes; the SDK app bar's read-only switch can lift it for that run, on purpose, as the
+ // switch demonstrates the SDK. Import (follow-up: sdk-import-read-only) and the branching node's Add branch
+ // (follow-up: sdk-node-buttons-read-only) ignore read-only.
+ useEffect(() => {
+ setReadOnly(executionId !== undefined);
+ }, [executionId, setReadOnly]);
+}
diff --git a/apps/ai-studio/src/main.tsx b/apps/ai-studio/src/main.tsx
index 76491a0a0..9cc665104 100644
--- a/apps/ai-studio/src/main.tsx
+++ b/apps/ai-studio/src/main.tsx
@@ -1,12 +1,20 @@
-import { StrictMode } from 'react';
+import { StrictMode, Suspense } from 'react';
import * as ReactDOM from 'react-dom/client';
-import { App } from './app/app';
+import { openFromUrl } from './app/open-from-url';
+import { OpenedApp } from './app/opened-app';
+import { AppBoundary } from './components/open-from-url/app-boundary';
+import { LoadingScreen } from './components/open-from-url/loading-screen';
-const root = ReactDOM.createRoot(document.querySelector('#root') as HTMLElement);
+// Started once, outside render: the editor fixes its diagram at mount, and StrictMode would repeat an effect.
+const opening = openFromUrl(globalThis.location.search);
-root.render(
+ReactDOM.createRoot(document.querySelector('#root') as HTMLElement).render(
-
+
+ }>
+
+
+
,
);
diff --git a/apps/ai-studio/src/nodes/ai-agent/index.ts b/apps/ai-studio/src/nodes/ai-agent/index.ts
index d74e375c6..db7ed88f0 100644
--- a/apps/ai-studio/src/nodes/ai-agent/index.ts
+++ b/apps/ai-studio/src/nodes/ai-agent/index.ts
@@ -14,6 +14,7 @@ export const aiAgentPaletteItem: PaletteItem = {
schema,
uischema,
// Lets `{{ nodes..response }}` references resolve to a real mention instead of a "missing mention" pill.
+ // Only a plain-text node has `response`; a structured node's fields are not listed here.
outputSchema: {
type: 'default',
properties: {
diff --git a/apps/ai-studio/src/nodes/ai-agent/schema.ts b/apps/ai-studio/src/nodes/ai-agent/schema.ts
index 969af44b5..5cc024301 100644
--- a/apps/ai-studio/src/nodes/ai-agent/schema.ts
+++ b/apps/ai-studio/src/nodes/ai-agent/schema.ts
@@ -11,6 +11,8 @@ export const schema = {
webSearch: {
type: 'boolean',
},
+ // The JSON Schema asked of the model. Unrelated to the palette item's `outputSchema`, the editor's variables.
+ outputSchema: { type: 'object', properties: {} },
},
} satisfies NodeSchema;
diff --git a/apps/ai-studio/src/nodes/ai-agent/uischema.ts b/apps/ai-studio/src/nodes/ai-agent/uischema.ts
index 06abc4aa4..e2f3892ed 100644
--- a/apps/ai-studio/src/nodes/ai-agent/uischema.ts
+++ b/apps/ai-studio/src/nodes/ai-agent/uischema.ts
@@ -22,6 +22,12 @@ export const uischema: UISchema = {
minRows: 5,
maxRows: 14,
},
+ // The `UISchema` union is closed (follow-up: uischema-custom-element-typing).
+ {
+ type: 'ResponseSelect',
+ scope: scope('properties.outputSchema'),
+ label: 'Response format',
+ } as unknown as UISchema,
{
type: 'Switch',
scope: scope('properties.webSearch'),
diff --git a/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts b/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts
new file mode 100644
index 000000000..ed78d50c6
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/decision-request-contract.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+
+// The real receiver, not a copy: the backend owns the publish contract. Resolved through the
+// backend's own node_modules, so AI Studio takes no dependency on it. Where this test should
+// live is still open (follow-up: decision-request-contract-test-home).
+import { decisionRequestSchema } from '../../../../backend/src/domain/decision/decision-request-schema';
+import { refundReviewRequest } from '../../data/refund-review-flow';
+import { defaultDecisionRequest } from './default-properties-data';
+
+describe('the decision requests AI Studio ships, against the backend contract', () => {
+ it.each([
+ ['the palette preset', defaultDecisionRequest],
+ ['the "Refund Review" template', refundReviewRequest],
+ ])('%s parses with decisionRequestSchema', (_name, request) => {
+ const parsed = decisionRequestSchema.safeParse(request);
+
+ expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);
+ });
+});
diff --git a/apps/ai-studio/src/nodes/human-decision/default-properties-data.test.ts b/apps/ai-studio/src/nodes/human-decision/default-properties-data.test.ts
new file mode 100644
index 000000000..07152726c
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/default-properties-data.test.ts
@@ -0,0 +1,33 @@
+import { getHandleId } from '@workflowbuilder/sdk';
+import { describe, expect, it } from 'vitest';
+
+import { defaultDecisionRequest, defaultPropertiesData } from './default-properties-data';
+
+describe('defaultDecisionRequest', () => {
+ const ports = defaultDecisionRequest.actions.map((action) => action.port);
+
+ it('routes on the SDK handle ids the template renders', () => {
+ expect(ports).toEqual([
+ getHandleId({ handleType: 'source', innerId: 'approved' }),
+ getHandleId({ handleType: 'source', innerId: 'rejected' }),
+ ]);
+ });
+
+ it('gives reject a port of its own and never the reserved error route', () => {
+ expect(new Set(ports).size).toBe(ports.length);
+ expect(ports).not.toContain('errorRoute');
+ });
+
+ it('is a version 1 request: one resume, one reject that needs a reason, no rerun, an empty form, no proposal source', () => {
+ expect(defaultDecisionRequest.version).toBe(1);
+ expect(defaultDecisionRequest.actions.map((action) => action.effect)).toEqual(['resume', 'reject']);
+ expect(defaultDecisionRequest.actions[1]).toMatchObject({ effect: 'reject', reasonRequired: true });
+ expect(defaultDecisionRequest.schema).toEqual({ type: 'object', properties: {} });
+ expect(defaultDecisionRequest).not.toHaveProperty('proposalSourceNodeId');
+ expect(defaultDecisionRequest).not.toHaveProperty('deadline');
+ });
+
+ it('is what a node dropped from the palette carries', () => {
+ expect(defaultPropertiesData.decisionRequest).toBe(defaultDecisionRequest);
+ });
+});
diff --git a/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts
new file mode 100644
index 000000000..3fd7398d2
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts
@@ -0,0 +1,32 @@
+import { getHandleId } from '@workflowbuilder/sdk';
+import type { NodeDataProperties } from '@workflowbuilder/sdk';
+
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import type { HumanDecisionSchema } from './schema';
+
+export const defaultDecisionRequest = {
+ version: 1,
+ actions: [
+ {
+ name: 'approve',
+ label: 'Approve',
+ effect: 'resume',
+ port: getHandleId({ handleType: 'source', innerId: 'approved' }),
+ },
+ {
+ name: 'reject',
+ label: 'Reject',
+ effect: 'reject',
+ port: getHandleId({ handleType: 'source', innerId: 'rejected' }),
+ reasonRequired: true,
+ },
+ ],
+ schema: { type: 'object', properties: {} },
+} satisfies DecisionRequest;
+
+export const defaultPropertiesData: NodeDataProperties = {
+ label: 'Human decision',
+ description: '',
+ decisionRequest: defaultDecisionRequest,
+};
diff --git a/apps/ai-studio/src/nodes/human-decision/index.test.ts b/apps/ai-studio/src/nodes/human-decision/index.test.ts
new file mode 100644
index 000000000..9e9cbc16d
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/index.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+
+import type { Decision } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { humanDecisionNodeType, humanDecisionPaletteItem } from '.';
+import { aiStudioNodeTypes } from '../../data/node-types';
+
+describe('humanDecisionPaletteItem', () => {
+ it('is registered in the AI Studio palette exactly once, under the type the template is keyed by', () => {
+ const items = aiStudioNodeTypes.flatMap((entry) => ('groupItems' in entry ? entry.groupItems : [entry]));
+ const registered = items.filter((item) => item.type === humanDecisionNodeType);
+
+ expect(registered).toEqual([humanDecisionPaletteItem]);
+ });
+
+ it('offers the variable picker every field a recorded decision can carry', () => {
+ const recorded = {
+ action: 'reject',
+ effect: 'reject',
+ edits: {},
+ reason: '',
+ comment: '',
+ resolvedBy: 'human',
+ } satisfies Required;
+ const { outputSchema } = humanDecisionPaletteItem;
+
+ expect(outputSchema?.type).toBe('default');
+ const properties = outputSchema?.type === 'default' ? outputSchema.properties : {};
+ expect(Object.keys(properties).sort()).toEqual(Object.keys(recorded).sort());
+ });
+});
diff --git a/apps/ai-studio/src/nodes/human-decision/index.ts b/apps/ai-studio/src/nodes/human-decision/index.ts
new file mode 100644
index 000000000..4efa0601c
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/index.ts
@@ -0,0 +1,37 @@
+import type { PaletteItem } from '@workflowbuilder/sdk';
+
+import type { Decision } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { defaultPropertiesData } from './default-properties-data';
+import { type HumanDecisionSchema, schema } from './schema';
+import { uischema } from './uischema';
+
+// Also the key of the node's template in `nodeTemplates`; a custom template keyed by the palette type wins.
+export const humanDecisionNodeType = 'ai-studio/human-decision';
+
+type OutputProperty = Extract<
+ NonNullable['outputSchema']>,
+ { type: 'default' }
+>['properties'][string];
+
+export const humanDecisionPaletteItem: PaletteItem = {
+ label: 'Human decision',
+ description: 'A person decides before the run continues',
+ type: humanDecisionNodeType,
+ icon: 'UserCheck',
+ defaultPropertiesData,
+ schema,
+ uischema,
+ // The completion's output is the decision, so `{{ nodes..action }}` resolves downstream.
+ outputSchema: {
+ type: 'default',
+ properties: {
+ action: { type: 'string', label: 'Action', description: 'The name of the action the person chose' },
+ effect: { type: 'string', label: 'Effect', description: 'resume, resume-with-edits or reject' },
+ edits: { type: 'object', label: 'Edits', description: 'Field values the person corrected before approving' },
+ reason: { type: 'string', label: 'Reason', description: 'Why the person rejected, when the action requires one' },
+ comment: { type: 'string', label: 'Comment', description: 'A note the person left with the decision' },
+ resolvedBy: { type: 'string', label: 'Resolved by', description: 'Who settled the decision, such as human' },
+ } satisfies Record, OutputProperty>,
+ },
+};
diff --git a/apps/ai-studio/src/nodes/human-decision/schema.ts b/apps/ai-studio/src/nodes/human-decision/schema.ts
new file mode 100644
index 000000000..866f97af2
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/schema.ts
@@ -0,0 +1,14 @@
+import { sharedProperties } from '@workflowbuilder/sdk';
+import type { NodeSchema } from '@workflowbuilder/sdk';
+
+// Opaque to the editor: the backend parses the request, and the decision form reads it with its own reader.
+export const schema = {
+ type: 'object',
+ properties: {
+ ...sharedProperties,
+ decisionRequest: { type: 'object', properties: {} },
+ },
+ required: ['decisionRequest'],
+} satisfies NodeSchema;
+
+export type HumanDecisionSchema = typeof schema;
diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.ts b/apps/ai-studio/src/nodes/human-decision/uischema.ts
new file mode 100644
index 000000000..877b3a190
--- /dev/null
+++ b/apps/ai-studio/src/nodes/human-decision/uischema.ts
@@ -0,0 +1,27 @@
+import { getScope } from '@workflowbuilder/sdk';
+import type { UISchema } from '@workflowbuilder/sdk';
+
+import type { HumanDecisionSchema } from './schema';
+
+const scope = getScope;
+
+// Authoring the rest of the request lands later (follow-up: decision-request-properties-ui).
+export const uischema: UISchema = {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'Text',
+ scope: scope('properties.label'),
+ label: 'Title',
+ placeholder: 'Node Title...',
+ },
+ // Custom elements: the `UISchema` union is closed (follow-up: uischema-custom-element-typing).
+ {
+ type: 'DecisionFields',
+ scope: scope('properties.decisionRequest'),
+ label: 'Fields the decider sees',
+ } as unknown as UISchema,
+ // The run-time decision form.
+ { type: 'DecisionForm', scope: scope('properties.decisionRequest') } as unknown as UISchema,
+ ],
+};
diff --git a/apps/ai-studio/src/plugin.ts b/apps/ai-studio/src/plugin.ts
index 1fc5c8c6c..642613673 100644
--- a/apps/ai-studio/src/plugin.ts
+++ b/apps/ai-studio/src/plugin.ts
@@ -1,4 +1,4 @@
-import { type OptionalNodeContent, registerComponentDecorator } from '@workflowbuilder/sdk';
+import { type OptionalNodeContent, type PropertiesBarProps, registerComponentDecorator } from '@workflowbuilder/sdk';
import { ExecutionNodeMarkers } from './components/execution/node-markers';
import { VisualizeCard } from './components/visualize/visualize-card';
@@ -13,4 +13,9 @@ export function plugin(): void {
content: VisualizeCard,
place: 'after',
});
+ // Deliberately, for now: no Delete button in the properties panel for any selection; deleting stays on the keys.
+ registerComponentDecorator('PropertiesBar', {
+ name: 'ai-studio-no-delete-button',
+ modifyProps: (props) => ({ ...props, onDeleteClick: undefined }),
+ });
}
diff --git a/apps/ai-studio/src/plugins/run-view/plugin.ts b/apps/ai-studio/src/plugins/run-view/plugin.ts
new file mode 100644
index 000000000..21cf7453a
--- /dev/null
+++ b/apps/ai-studio/src/plugins/run-view/plugin.ts
@@ -0,0 +1,12 @@
+import { registerComponentDecorator } from '@workflowbuilder/sdk';
+
+// The editor's Save button is also where autosave and the save on close live, so hiding it stops all three.
+const Nothing = () => null;
+
+export function plugin(): void {
+ registerComponentDecorator('OptionalAppBarTools', {
+ name: 'run-view-hides-save',
+ place: 'wrapper',
+ content: Nothing,
+ });
+}
diff --git a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx
index 7472ffbca..b65347b69 100644
--- a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx
+++ b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx
@@ -12,12 +12,20 @@ export function ButtonsUndoRedo() {
return (
<>
-
-
-
-
-
-
+ }
+ />
+ }
+ />
>
);
}
diff --git a/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.test.tsx b/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.test.tsx
new file mode 100644
index 000000000..ecb86a30f
--- /dev/null
+++ b/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.test.tsx
@@ -0,0 +1,52 @@
+import { useStore } from '@workflowbuilder/sdk';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { undo } from '../stores/use-undo-redo-store';
+import { useUndoRedoKeyboardHandler } from './use-undo-redo-keyboard-handler';
+
+vi.mock('../stores/use-undo-redo-store', () => ({ undo: vi.fn(), redo: vi.fn() }));
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+function Probe() {
+ useUndoRedoKeyboardHandler();
+ return null;
+}
+
+const pressUndo = () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true }));
+
+describe('useUndoRedoKeyboardHandler', () => {
+ let root: ReturnType;
+
+ beforeEach(() => {
+ vi.mocked(undo).mockReset();
+ useStore.getState().setToggleReadOnlyMode(false);
+ root = createRoot(document.createElement('div'));
+ act(() => root.render( ));
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ useStore.getState().setToggleReadOnlyMode(false);
+ });
+
+ it('undoes on Ctrl+Z', () => {
+ pressUndo();
+
+ expect(undo).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves the diagram alone while it is read-only', () => {
+ useStore.getState().setToggleReadOnlyMode(true);
+
+ pressUndo();
+
+ expect(undo).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx b/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx
index 77f872f9b..18d686c2c 100644
--- a/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx
+++ b/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx
@@ -1,3 +1,4 @@
+import { useStore } from '@workflowbuilder/sdk';
import { useEffect } from 'react';
import { redo, undo } from '../stores/use-undo-redo-store';
@@ -14,7 +15,12 @@ export const useUndoRedoKeyboardHandler = () => {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
// `event.repeat` guards held-key OS auto-repeat; text fields keep their native undo.
- if (!(event.ctrlKey || event.metaKey) || event.repeat || isTextTarget(event.target)) {
+ if (
+ !(event.ctrlKey || event.metaKey) ||
+ event.repeat ||
+ isTextTarget(event.target) ||
+ useStore.getState().isReadOnlyMode
+ ) {
return;
}
const key = event.key.toLowerCase();
diff --git a/apps/ai-studio/src/stores/execution-event.fixture.ts b/apps/ai-studio/src/stores/execution-event.fixture.ts
new file mode 100644
index 000000000..29386c067
--- /dev/null
+++ b/apps/ai-studio/src/stores/execution-event.fixture.ts
@@ -0,0 +1,16 @@
+import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';
+
+let sequence = 0;
+
+// `Omit` over the union keeps only the shared keys, so `nodeId` has to be admitted by hand.
+export function executionEvent(
+ partial: Omit & { nodeId?: string },
+): ExecutionEvent {
+ sequence += 1;
+ return { executionId: 'exec-1', sequence, timestamp: '2026-09-15T12:00:00.000Z', ...partial } as ExecutionEvent;
+}
+
+/** The highest sequence handed out so far: what a snapshot taken now would report. */
+export function lastSequence(): number {
+ return sequence;
+}
diff --git a/apps/ai-studio/src/stores/use-execution-store.test.ts b/apps/ai-studio/src/stores/use-execution-store.test.ts
new file mode 100644
index 000000000..a76d7aae2
--- /dev/null
+++ b/apps/ai-studio/src/stores/use-execution-store.test.ts
@@ -0,0 +1,516 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ type ExecutionEvent,
+ type ExecutionStatus,
+ TERMINAL_EVENT_TO_STATUS,
+ TERMINAL_EXECUTION_STATUSES,
+ type TerminalExecutionEventType,
+} from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { executionEvent as event, lastSequence } from './execution-event.fixture';
+import {
+ type RunStatus,
+ applyConnectionLost,
+ applyEvent,
+ applySnapshot,
+ applyStopRequested,
+ isRunAlive,
+ requestDecisionFocus,
+ resetExecution,
+ saveDecisionDraft,
+ saveDecisionSend,
+ setExecutionStarted,
+ setLogCollapsed,
+ useExecutionStore,
+ waitKey,
+} from './use-execution-store';
+
+const nodeState = (nodeId: string) => useExecutionStore.getState().nodeStates[nodeId];
+
+const drafts = () => useExecutionStore.getState().decisionDrafts;
+const sends = () => useExecutionStore.getState().decisionSends;
+
+const focusRequest = () => useExecutionStore.getState().decisionFocusRequest;
+const waitOn = (nodeId: string) => applyEvent(event({ type: 'node_waiting', nodeId }));
+
+const terminalPayload: { [T in TerminalExecutionEventType]: Extract['payload'] } = {
+ execution_completed: undefined,
+ execution_incomplete: { deadEnds: [{ nodeId: 'human-1', port: 'source:inner:rejected' }] },
+ execution_failed: { error: { message: 'boom' } },
+ execution_cancelled: {},
+};
+
+const terminalEvent = (type: TerminalExecutionEventType) => event({ type, payload: terminalPayload[type] });
+
+const terminalCases = Object.entries(TERMINAL_EVENT_TO_STATUS) as [TerminalExecutionEventType, ExecutionStatus][];
+
+beforeEach(() => {
+ resetExecution();
+ sessionStorage.clear();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('use-execution-store: a node waiting for a person', () => {
+ beforeEach(() => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ });
+
+ it('node_waiting marks the node waiting, and the completion that follows marks it completed', () => {
+ applyEvent(event({ type: 'node_started', nodeId: 'human-1' }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+
+ expect(nodeState('human-1')).toEqual({ status: 'waiting' });
+
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: { action: 'approve' } } }));
+
+ expect(nodeState('human-1')).toEqual({ status: 'completed', output: { action: 'approve' } });
+ });
+
+ it('a failure after the wait marks the node failed, not waiting', () => {
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ applyEvent(event({ type: 'node_failed', nodeId: 'human-1', payload: { error: { message: 'boom' } } }));
+
+ expect(nodeState('human-1')).toEqual({ status: 'failed', error: { message: 'boom' } });
+ });
+
+ it('the run is waiting while a node waits, and running again once the node resolves', () => {
+ applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }));
+ expect(useExecutionStore.getState().status).toBe('running');
+
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ expect(useExecutionStore.getState().status).toBe('waiting');
+
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }));
+ expect(useExecutionStore.getState().status).toBe('running');
+ });
+
+ it('with two nodes waiting, the first verdict keeps the run waiting', () => {
+ applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-2' }));
+
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }));
+ expect(useExecutionStore.getState().status).toBe('waiting');
+
+ applyEvent(event({ type: 'node_failed', nodeId: 'human-2', payload: { error: { message: 'boom' } } }));
+ expect(useExecutionStore.getState().status).toBe('running');
+ });
+
+ it.each(terminalCases)('%s closes a waiting run and settles the nodes still in flight', (type, status) => {
+ applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }));
+ applyEvent(event({ type: 'node_completed', nodeId: 'trigger-1', payload: { output: { input: 'refund' } } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ applyEvent(event({ type: 'node_started', nodeId: 'agent-1' }));
+
+ applyEvent(terminalEvent(type));
+
+ expect(useExecutionStore.getState().status).toBe(status);
+ expect(nodeState('human-1')).toEqual({ status: 'idle' });
+ expect(nodeState('agent-1')).toEqual({ status: 'idle' });
+ expect(nodeState('trigger-1')).toEqual({ status: 'completed', output: { input: 'refund' } });
+ });
+
+ it('a snapshot whose row still says pending shows the run waiting, because the events say so', () => {
+ const events = [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_started', nodeId: 'human-1' }),
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+ ];
+
+ applySnapshot({ executionId: 'exec-1', status: 'pending', lastSequence: lastSequence(), events });
+
+ expect(useExecutionStore.getState().status).toBe('waiting');
+ });
+
+ it('a snapshot of a run that already resolved its wait shows running, whatever the row says', () => {
+ const events = [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+ event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }),
+ event({ type: 'node_started', nodeId: 'send-1' }),
+ ];
+
+ applySnapshot({ executionId: 'exec-1', status: 'pending', lastSequence: lastSequence(), events });
+
+ expect(useExecutionStore.getState().status).toBe('running');
+ });
+
+ it('node_waiting delivered twice for one node does not drift the run status', () => {
+ applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ expect(useExecutionStore.getState().status).toBe('waiting');
+
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }));
+ expect(useExecutionStore.getState().status).toBe('running');
+ });
+
+ it('a snapshot replayed after a reload rebuilds the waiting node and the run status', () => {
+ const events = [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_started', nodeId: 'trigger-1' }),
+ event({ type: 'node_completed', nodeId: 'trigger-1', payload: { output: {} } }),
+ event({ type: 'node_started', nodeId: 'human-1' }),
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+ ];
+
+ applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: lastSequence(), events });
+
+ const state = useExecutionStore.getState();
+ expect(state.status).toBe('waiting');
+ expect(state.nodeStates['trigger-1']?.status).toBe('completed');
+ expect(state.nodeStates['human-1']?.status).toBe('waiting');
+ expect(state.events).toHaveLength(events.length);
+ });
+
+ it.each(terminalCases)(
+ '%s closes the run for good: a node event that arrives after it does not reopen it',
+ (type, status) => {
+ applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }));
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-1' }));
+ applyEvent(terminalEvent(type));
+ expect(useExecutionStore.getState().status).toBe(status);
+
+ applyEvent(event({ type: 'node_waiting', nodeId: 'human-2' }));
+ expect(useExecutionStore.getState().status).toBe(status);
+
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-2', payload: { output: {} } }));
+ expect(useExecutionStore.getState().status).toBe(status);
+ },
+ );
+
+ it.each(terminalCases)(
+ 'a snapshot whose row still says waiting but whose events end in %s shows %s',
+ (type, status) => {
+ const events = [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+ terminalEvent(type),
+ ];
+
+ applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: lastSequence(), events });
+
+ expect(useExecutionStore.getState().status).toBe(status);
+ },
+ );
+});
+
+describe('use-execution-store: decision drafts', () => {
+ const wait = { executionId: 'exec-1', nodeId: 'human-1', attempt: 1 };
+
+ beforeEach(() => {
+ resetExecution();
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ });
+
+ it('merges what is saved for one wait and keeps the waits apart', () => {
+ saveDecisionDraft(wait, { values: { refundAmount: 120 } });
+ saveDecisionDraft(wait, { reason: 'Checked' });
+ saveDecisionDraft({ ...wait, attempt: 2 }, { reason: 'Second wait' });
+
+ expect(drafts()[waitKey(wait)]).toEqual({ values: { refundAmount: 120 }, reason: 'Checked' });
+ expect(drafts()[waitKey({ ...wait, attempt: 2 })]).toEqual({ reason: 'Second wait' });
+ });
+
+ it('keeps the drafts when a snapshot replays the same run', () => {
+ saveDecisionDraft(wait, { reason: 'Checked' });
+
+ applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: 0, events: [] });
+
+ expect(drafts()[waitKey(wait)]).toEqual({ reason: 'Checked' });
+ });
+
+ it('drops a draft saved for a run that is no longer the current one', () => {
+ setExecutionStarted('exec-2', '/api/executions/exec-2/stream');
+ saveDecisionDraft(wait, { values: { refundAmount: 120 } });
+
+ expect(drafts()).toEqual({});
+ });
+
+ it('starts a new run and a reset without drafts', () => {
+ saveDecisionDraft(wait, { reason: 'Checked' });
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ expect(drafts()).toEqual({});
+
+ saveDecisionDraft(wait, { reason: 'Checked' });
+ resetExecution();
+ expect(drafts()).toEqual({});
+ });
+});
+
+describe('use-execution-store: the focus Decide asks for', () => {
+ beforeEach(() => {
+ resetExecution();
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ waitOn('human-1');
+ requestDecisionFocus('human-1');
+ });
+
+ it('keeps the request while its node waits', () => {
+ waitOn('human-2');
+
+ expect(focusRequest()).toBe('human-1');
+ });
+
+ it('drops it once the node stops waiting, so a later wait of the same node does not inherit it', () => {
+ applyEvent(event({ type: 'node_completed', nodeId: 'human-1', payload: { output: {} } }));
+ expect(focusRequest()).toBeUndefined();
+
+ waitOn('human-1');
+ expect(focusRequest()).toBeUndefined();
+ });
+
+ it('drops it when a snapshot shows the node no longer waiting', () => {
+ applySnapshot({ executionId: 'exec-1', status: 'running', lastSequence: 0, events: [] });
+
+ expect(focusRequest()).toBeUndefined();
+ });
+});
+
+describe('use-execution-store: decision sends', () => {
+ const wait = { executionId: 'exec-1', nodeId: 'human-1', attempt: 1 };
+
+ beforeEach(() => {
+ resetExecution();
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ });
+
+ it('keeps where the decision for each wait stands', () => {
+ saveDecisionSend(wait, { status: 'sending' });
+ saveDecisionSend({ ...wait, attempt: 2 }, { status: 'refused', message: 'Refused.' });
+ saveDecisionSend(wait, { status: 'accepted' });
+
+ expect(sends()[waitKey(wait)]).toEqual({ status: 'accepted' });
+ expect(sends()[waitKey({ ...wait, attempt: 2 })]).toEqual({ status: 'refused', message: 'Refused.' });
+ });
+
+ it('drops an answer that arrives for a run that is no longer the current one, and starts a run without any', () => {
+ saveDecisionSend(wait, { status: 'sending' });
+ setExecutionStarted('exec-2', '/api/executions/exec-2/stream');
+ saveDecisionSend(wait, { status: 'accepted' });
+
+ expect(sends()).toEqual({});
+ });
+});
+
+const startedHistory = () => [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_started', nodeId: 'agent-1' }),
+];
+
+const inFlightHistory = () => [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_started', nodeId: 'agent-1' }),
+ event({ type: 'node_waiting', nodeId: 'human-1' }),
+];
+
+describe('use-execution-store: facts only the row carries', () => {
+ beforeEach(() => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ });
+
+ it.each(TERMINAL_EXECUTION_STATUSES)(
+ 'a row that says %s wins over a history whose terminal event never landed',
+ (status) => {
+ applySnapshot({ executionId: 'exec-1', status, lastSequence: 2, events: startedHistory() });
+
+ expect(useExecutionStore.getState().status).toBe(status);
+ },
+ );
+
+ it('a cancelling row stays cancelling over a history that replays to running', () => {
+ applySnapshot({ executionId: 'exec-1', status: 'cancelling', lastSequence: 2, events: startedHistory() });
+
+ expect(useExecutionStore.getState().status).toBe('cancelling');
+ });
+
+ it('a cancelling row whose history already ends in execution_cancelled ends cancelled', () => {
+ const events = [...startedHistory(), terminalEvent('execution_cancelled')];
+
+ applySnapshot({ executionId: 'exec-1', status: 'cancelling', lastSequence: lastSequence(), events });
+
+ expect(useExecutionStore.getState().status).toBe('cancelled');
+ });
+
+ it('a waiting row with no parked node still derives running from the events', () => {
+ applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: 2, events: startedHistory() });
+
+ expect(useExecutionStore.getState().status).toBe('running');
+ });
+
+ it.each(TERMINAL_EXECUTION_STATUSES)('a row that says %s settles the nodes the history left in flight', (status) => {
+ applySnapshot({ executionId: 'exec-1', status, lastSequence: 3, events: inFlightHistory() });
+
+ expect(useExecutionStore.getState().status).toBe(status);
+ expect(useExecutionStore.getState().nodeStates).toEqual({
+ 'agent-1': { status: 'idle' },
+ 'human-1': { status: 'idle' },
+ });
+ });
+
+ it('a cancelling row keeps the markers of the nodes still in flight', () => {
+ applySnapshot({ executionId: 'exec-1', status: 'cancelling', lastSequence: 3, events: inFlightHistory() });
+
+ expect(useExecutionStore.getState().status).toBe('cancelling');
+ expect(useExecutionStore.getState().nodeStates).toEqual({
+ 'agent-1': { status: 'running' },
+ 'human-1': { status: 'waiting' },
+ });
+ });
+
+ it('a completed row whose history ends in execution_completed shows the nodes as the events left them', () => {
+ const events = [
+ event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }),
+ event({ type: 'node_started', nodeId: 'agent-1' }),
+ event({ type: 'node_completed', nodeId: 'agent-1', payload: { output: { answer: 'refund' } } }),
+ event({ type: 'node_skipped', nodeId: 'human-1' }),
+ terminalEvent('execution_completed'),
+ ];
+
+ applySnapshot({ executionId: 'exec-1', status: 'completed', lastSequence: lastSequence(), events });
+
+ expect(useExecutionStore.getState().status).toBe('completed');
+ expect(useExecutionStore.getState().nodeStates).toEqual({
+ 'agent-1': { status: 'completed', output: { answer: 'refund' } },
+ 'human-1': { status: 'skipped' },
+ });
+ });
+});
+
+describe('use-execution-store: a lost stream after the run ended', () => {
+ it.each(['idle', ...TERMINAL_EXECUTION_STATUSES] as RunStatus[])(
+ 'a lost stream leaves the status %s: there is no run left to lose',
+ (status) => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ useExecutionStore.setState({ status });
+
+ applyConnectionLost();
+
+ expect(useExecutionStore.getState().status).toBe(status);
+ },
+ );
+});
+
+describe('use-execution-store: a stop the user asked for', () => {
+ it.each([
+ ['a new run', () => setExecutionStarted('exec-2', '/api/executions/exec-2/stream')],
+ ['a reset', () => resetExecution()],
+ ])('%s clears the request, so Reset stops being offered', (_, moveOn) => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ applyStopRequested();
+
+ moveOn();
+
+ expect(useExecutionStore.getState().isStopRequested).toBe(false);
+ });
+
+ it.each([
+ ['a snapshot', () => applySnapshot({ executionId: 'exec-1', status: 'waiting', lastSequence: 0, events: [] })],
+ ['a live event', () => applyEvent(event({ type: 'node_started', nodeId: 'human-1' }))],
+ ['a lost stream', () => applyConnectionLost()],
+ ])('%s leaves the request standing: none of them says the cancel landed', (_, moveOn) => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ applyStopRequested();
+
+ moveOn();
+
+ expect(useExecutionStore.getState().isStopRequested).toBe(true);
+ });
+});
+
+describe('use-execution-store: which runs may still be alive on the server', () => {
+ it.each(['pending', 'running', 'waiting', 'cancelling', 'disconnected'] as RunStatus[])(
+ '%s: the server may still hold the run',
+ (status) => {
+ expect(isRunAlive(status)).toBe(true);
+ },
+ );
+
+ it.each(['idle', ...TERMINAL_EXECUTION_STATUSES] as RunStatus[])('%s: nothing to reconnect or cancel', (status) => {
+ expect(isRunAlive(status)).toBe(false);
+ });
+});
+
+describe('use-execution-store: what a reload keeps', () => {
+ it('keeps the log preference for the tab and nothing of the run, which the address names', () => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ setLogCollapsed(true);
+
+ const entry = JSON.parse(sessionStorage.getItem('ai-studio:execution-log')!) as { state: Record };
+ expect(entry.state).toEqual({ isLogCollapsed: true });
+ expect(localStorage.getItem('ai-studio:execution')).toBeNull();
+ });
+
+ it('gives a run the address reopens the log as the tab left it, and opens it for a run started here', async () => {
+ sessionStorage.setItem('ai-studio:execution-log', JSON.stringify({ state: { isLogCollapsed: true }, version: 0 }));
+ await useExecutionStore.persist.rehydrate();
+
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream', { keepLogChoice: true });
+ expect(useExecutionStore.getState().isLogCollapsed).toBe(true);
+
+ setExecutionStarted('exec-2', '/api/executions/exec-2/stream');
+ expect(useExecutionStore.getState().isLogCollapsed).toBe(false);
+ });
+});
+
+describe('use-execution-store: storage is best effort', () => {
+ const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
+
+ beforeEach(() => {
+ setExecutionStarted('exec-1', '/api/executions/exec-1/stream');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ if (sessionStorageDescriptor) {
+ Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
+ }
+ });
+
+ it.each([
+ [
+ 'a new run',
+ () => setExecutionStarted('exec-2', '/api/executions/exec-2/stream'),
+ { executionId: 'exec-2', status: 'pending' },
+ ],
+ [
+ 'a live event',
+ () => applyEvent(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })),
+ { status: 'running' },
+ ],
+ ['a reset', () => resetExecution(), { executionId: undefined, status: 'idle' }],
+ ])('%s still lands in memory when the storage write throws', (_, action, expected) => {
+ const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('The quota has been exceeded.', 'QuotaExceededError');
+ });
+
+ expect(action).not.toThrow();
+
+ expect(setItem).toHaveBeenCalled();
+ expect(useExecutionStore.getState()).toMatchObject(expected);
+ });
+
+ it.each([
+ ['is null', { value: null }],
+ [
+ 'throws on access',
+ {
+ get: () => {
+ throw new DOMException('The operation is insecure.', 'SecurityError');
+ },
+ },
+ ],
+ ])('a run still starts when sessionStorage %s from the first load', async (_, descriptor) => {
+ Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, ...descriptor });
+ vi.resetModules();
+ const store = await import('./use-execution-store');
+
+ expect(() => store.setExecutionStarted('exec-2', '/api/executions/exec-2/stream')).not.toThrow();
+
+ expect(store.useExecutionStore.getState()).toMatchObject({ executionId: 'exec-2', status: 'pending' });
+ });
+});
diff --git a/apps/ai-studio/src/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts
index aaa571136..743f8cd4b 100644
--- a/apps/ai-studio/src/stores/use-execution-store.ts
+++ b/apps/ai-studio/src/stores/use-execution-store.ts
@@ -1,13 +1,14 @@
import { create } from 'zustand';
-import { createJSONStorage, devtools, persist } from 'zustand/middleware';
+import { type StateStorage, createJSONStorage, devtools, persist } from 'zustand/middleware';
-import type {
- ExecutionEvent,
- ExecutionSnapshot,
- ExecutionStatus,
+import {
+ type ExecutionEvent,
+ type ExecutionSnapshot,
+ type ExecutionStatus,
+ TERMINAL_EXECUTION_STATUSES,
} from '@workflow-builder/types/workflow-execution/execution-events';
-type NodeExecutionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'skipped';
+type NodeExecutionStatus = 'idle' | 'running' | 'waiting' | 'completed' | 'failed' | 'skipped';
export type NodeExecutionState = {
status: NodeExecutionStatus;
@@ -15,13 +16,36 @@ export type NodeExecutionState = {
error?: { message: string; code?: string };
};
+export type RunStatus = ExecutionStatus | 'idle' | 'disconnected';
+
+/** One wait of a decision node: the run, the node, and which time the node parked in it. */
+export type DecisionWait = { executionId: string; nodeId: string; attempt: number };
+
+/** What a person has entered for a wait and not yet sent. */
+export type DecisionDraft = {
+ values?: Record;
+ /** The fields the form showed, so a field the draft was not taken under starts from the proposal. */
+ fields?: string[];
+ reason?: string;
+};
+
+/** Where the decision sent for a wait stands until the run records it. */
+type DecisionSend = { status: 'sending' } | { status: 'accepted' } | { status: 'refused'; message: string };
+
type ExecutionStore = {
executionId: string | undefined;
- status: ExecutionStatus | 'idle' | 'disconnected';
+ status: RunStatus;
streamUrl: string | undefined;
nodeStates: Record;
events: ExecutionEvent[];
isLogCollapsed: boolean;
+ isStopRequested: boolean;
+ /** By {@link waitKey}. */
+ decisionDrafts: Record;
+ /** By {@link waitKey}. */
+ decisionSends: Record;
+ /** The waiting node whose decision form takes the focus when it next renders; Decide asks for it. */
+ decisionFocusRequest: string | undefined;
};
const emptyStore: ExecutionStore = {
@@ -31,13 +55,34 @@ const emptyStore: ExecutionStore = {
nodeStates: {},
events: [],
isLogCollapsed: false,
+ isStopRequested: false,
+ decisionDrafts: {},
+ decisionSends: {},
+ decisionFocusRequest: undefined,
};
+const TERMINAL_STATUSES: ReadonlySet = new Set(TERMINAL_EXECUTION_STATUSES);
+
+// Every store write persists, so a storage that throws would break the run; it costs only the log preference.
+const bestEffortSessionStorage: StateStorage = {
+ getItem: (name) => bestEffort(() => sessionStorage.getItem(name)) ?? null,
+ setItem: (name, value) => bestEffort(() => sessionStorage.setItem(name, value)),
+ removeItem: (name) => bestEffort(() => sessionStorage.removeItem(name)),
+};
+
+function bestEffort(action: () => T): T | undefined {
+ try {
+ return action();
+ } catch {
+ return;
+ }
+}
+
export const useExecutionStore = create()(
devtools(
persist(() => ({ ...emptyStore }), {
name: 'ai-studio:execution-log',
- storage: createJSONStorage(() => sessionStorage),
+ storage: createJSONStorage(() => bestEffortSessionStorage),
partialize: (state) => ({ isLogCollapsed: state.isLogCollapsed }),
}),
{ name: 'aiStudioExecutionStore' },
@@ -48,33 +93,96 @@ export function resetExecution() {
useExecutionStore.setState((state) => ({ ...emptyStore, isLogCollapsed: state.isLogCollapsed }));
}
-export function setExecutionStarted(executionId: string, streamUrl: string) {
- useExecutionStore.setState({
+// `disconnected` counts: a lost stream says nothing about the run on the server.
+export function isRunAlive(status: RunStatus): boolean {
+ return status !== 'idle' && !TERMINAL_STATUSES.has(status);
+}
+
+/** A run started here opens the log; a run the address reopens keeps the tab's choice. */
+export function setExecutionStarted(executionId: string, streamUrl: string, { keepLogChoice = false } = {}) {
+ useExecutionStore.setState((state) => ({
executionId,
status: 'pending',
streamUrl,
nodeStates: {},
events: [],
- isLogCollapsed: false,
- });
+ isLogCollapsed: keepLogChoice && state.isLogCollapsed,
+ isStopRequested: false,
+ decisionDrafts: {},
+ decisionSends: {},
+ decisionFocusRequest: undefined,
+ }));
+}
+
+// The backend refuses a decision while the run is cancelling, though the replay still shows the node waiting.
+export function isDecidable(status: RunStatus): boolean {
+ return status !== 'cancelling';
}
+export function requestDecisionFocus(nodeId: string) {
+ useExecutionStore.setState({ decisionFocusRequest: nodeId });
+}
+
+export function clearDecisionFocusRequest() {
+ useExecutionStore.setState({ decisionFocusRequest: undefined });
+}
+
+export function waitKey({ executionId, nodeId, attempt }: DecisionWait): string {
+ return `${executionId}:${nodeId}:${attempt}`;
+}
+
+// A form that closes, or an answer that arrives, after its run was replaced leaves nothing in the new one.
+function updateWait(wait: DecisionWait, update: (state: ExecutionStore, key: string) => Partial) {
+ useExecutionStore.setState((state) =>
+ state.executionId === wait.executionId ? update(state, waitKey(wait)) : state,
+ );
+}
+
+export function saveDecisionDraft(wait: DecisionWait, change: DecisionDraft) {
+ updateWait(wait, (state, key) => ({
+ decisionDrafts: { ...state.decisionDrafts, [key]: { ...state.decisionDrafts[key], ...change } },
+ }));
+}
+
+export function saveDecisionSend(wait: DecisionWait, send: DecisionSend) {
+ updateWait(wait, (state, key) => ({ decisionSends: { ...state.decisionSends, [key]: send } }));
+}
+
+// Keeps the run id for Stop; any other caller must probe it first (follow-up: stale-execution-id-probe).
export function applyConnectionLost() {
- useExecutionStore.setState({ status: 'disconnected' });
+ useExecutionStore.setState((state) => (isRunAlive(state.status) ? { status: 'disconnected' } : {}));
}
+export function applyStopRequested() {
+ useExecutionStore.setState({ isStopRequested: true });
+}
+
+// Replayed through the same rule as live events, so a reload shows what live showed. The row seeds
+// the replay: the engine never writes `running` at start and its `waiting` write is advisory.
export function applySnapshot(snapshot: ExecutionSnapshot) {
const nodeStates: Record = {};
+ let status: RunStatus = snapshot.status;
for (const event of snapshot.events) {
applyEventToNodeStates(event, nodeStates);
+ status = nextRunStatus(status, event, nodeStates);
+ }
+
+ // Two facts only the row carries: a cancel the backend accepted, and a terminal status whose
+ // event never landed. No event expresses either, so the row wins over an alive replay.
+ if ((snapshot.status === 'cancelling' || TERMINAL_STATUSES.has(snapshot.status)) && isRunAlive(status)) {
+ status = snapshot.status;
+ if (TERMINAL_STATUSES.has(status)) {
+ settleNodesInFlight(nodeStates);
+ }
}
useExecutionStore.setState({
executionId: snapshot.executionId,
- status: snapshot.status,
+ status,
nodeStates,
events: snapshot.events,
+ decisionFocusRequest: whileWaiting(useExecutionStore.getState().decisionFocusRequest, nodeStates),
});
}
@@ -83,22 +191,54 @@ export function applyEvent(event: ExecutionEvent) {
const nodeStates = { ...state.nodeStates };
applyEventToNodeStates(event, nodeStates);
- const status = eventToExecutionStatus(event) ?? state.status;
-
return {
nodeStates,
events: [...state.events, event],
- status,
+ status: nextRunStatus(state.status, event, nodeStates),
+ decisionFocusRequest: whileWaiting(state.decisionFocusRequest, nodeStates),
};
});
}
+// A focus request lasts only while its node waits, so a later wait of the same node does not inherit it.
+function whileWaiting(nodeId: string | undefined, nodeStates: Record): string | undefined {
+ return nodeId !== undefined && nodeStates[nodeId]?.status === 'waiting' ? nodeId : undefined;
+}
+
+function nextRunStatus(
+ current: RunStatus,
+ event: ExecutionEvent,
+ nodeStates: Record,
+): RunStatus {
+ return eventToExecutionStatus(event) ?? deriveRunStatus(current, nodeStates);
+}
+
+// No event carries the run's waiting status, so it is derived the way the engine derives it:
+// waiting while any node is parked, running again once the last one resolves.
+function deriveRunStatus(current: RunStatus, nodeStates: Record) {
+ if (current !== 'running' && current !== 'waiting') {
+ return current;
+ }
+ return Object.values(nodeStates).some((node) => node.status === 'waiting') ? 'waiting' : 'running';
+}
+
function applyEventToNodeStates(event: ExecutionEvent, states: Record) {
switch (event.type) {
+ case 'execution_completed':
+ case 'execution_incomplete':
+ case 'execution_failed':
+ case 'execution_cancelled': {
+ settleNodesInFlight(states);
+ break;
+ }
case 'node_started': {
states[event.nodeId] = { status: 'running' };
break;
}
+ case 'node_waiting': {
+ states[event.nodeId] = { status: 'waiting' };
+ break;
+ }
case 'node_completed': {
states[event.nodeId] = { status: 'completed', output: event.payload.output };
break;
@@ -114,6 +254,15 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record) {
+ for (const [nodeId, state] of Object.entries(states)) {
+ if (state.status === 'running' || state.status === 'waiting') {
+ states[nodeId] = { status: 'idle' };
+ }
+ }
+}
+
export function setLogCollapsed(isLogCollapsed: boolean) {
useExecutionStore.setState({ isLogCollapsed });
}
diff --git a/apps/ai-studio/src/stores/use-notices-store.ts b/apps/ai-studio/src/stores/use-notices-store.ts
new file mode 100644
index 000000000..82466a732
--- /dev/null
+++ b/apps/ai-studio/src/stores/use-notices-store.ts
@@ -0,0 +1,21 @@
+import { create } from 'zustand';
+
+type NoticeVariant = 'warning' | 'error' | 'success';
+
+export type Notice = { text: string; variant: NoticeVariant };
+
+/** Notices not yet handed to the editor's snackbars, which show nothing until the editor has mounted. */
+export const useNoticesStore = create<{ notices: Notice[] }>()(() => ({ notices: [] }));
+
+export function addNotice(text: string, variant: NoticeVariant = 'warning'): void {
+ useNoticesStore.setState((state) => ({ notices: [...state.notices, { text, variant }] }));
+}
+
+export function takeNotices(): Notice[] {
+ const { notices } = useNoticesStore.getState();
+ if (notices.length > 0) {
+ useNoticesStore.setState({ notices: [] });
+ }
+
+ return notices;
+}
diff --git a/apps/ai-studio/src/test/deferred.ts b/apps/ai-studio/src/test/deferred.ts
new file mode 100644
index 000000000..5b1d8a829
--- /dev/null
+++ b/apps/ai-studio/src/test/deferred.ts
@@ -0,0 +1,10 @@
+// Promise.withResolvers is ES2024, and this app compiles against lib ES2022.
+export function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((onResolve, onReject) => {
+ resolve = onResolve;
+ reject = onReject;
+ });
+ return { promise, resolve, reject };
+}
diff --git a/apps/ai-studio/src/test/execution-history.ts b/apps/ai-studio/src/test/execution-history.ts
new file mode 100644
index 000000000..ac6260dc8
--- /dev/null
+++ b/apps/ai-studio/src/test/execution-history.ts
@@ -0,0 +1,33 @@
+import type { ExecutionEvent, ExecutionStatus } from '@workflow-builder/types/workflow-execution/execution-events';
+
+const base = { executionId: 'exec-1', timestamp: '2026-09-15T12:00:00.000Z' };
+
+// The engine records execution_started first, so a started run never has an empty history.
+export const parkedRunHistory: ExecutionEvent[] = [
+ { ...base, sequence: 1, type: 'execution_started', payload: { workflowId: 'wf-1' } },
+ { ...base, sequence: 2, type: 'node_started', nodeId: 'human-1' },
+ { ...base, sequence: 3, type: 'node_waiting', nodeId: 'human-1' },
+];
+
+export const cancelledEvent: ExecutionEvent = {
+ ...base,
+ sequence: 4,
+ type: 'execution_cancelled',
+ payload: { reason: 'user_request' },
+};
+
+export function snapshotFrame(status: ExecutionStatus, events: ExecutionEvent[] = parkedRunHistory) {
+ return {
+ type: 'execution_snapshot' as const,
+ executionId: 'exec-1',
+ status,
+ lastSequence: events.at(-1)?.sequence ?? 0,
+ events,
+ };
+}
+
+/** One node event of the test run, for `applyEvent`. */
+export function nodeEvent(type: 'node_started' | 'node_waiting' | 'node_completed', nodeId: string): ExecutionEvent {
+ const event = { ...base, sequence: 1, nodeId };
+ return type === 'node_completed' ? { ...event, type, payload: { output: {} } } : { ...event, type };
+}
diff --git a/apps/ai-studio/src/test/fake-event-source.ts b/apps/ai-studio/src/test/fake-event-source.ts
new file mode 100644
index 000000000..524242914
--- /dev/null
+++ b/apps/ai-studio/src/test/fake-event-source.ts
@@ -0,0 +1,60 @@
+import { vi } from 'vitest';
+
+// jsdom has no EventSource.
+export class FakeEventSource extends EventTarget {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSED = 2;
+ static instances: FakeEventSource[] = [];
+
+ readyState: number = FakeEventSource.CONNECTING;
+
+ constructor(readonly url: string) {
+ super();
+ FakeEventSource.instances.push(this);
+ }
+
+ get closed() {
+ return this.readyState === FakeEventSource.CLOSED;
+ }
+
+ close() {
+ this.readyState = FakeEventSource.CLOSED;
+ }
+
+ emit(data: unknown) {
+ this.fire(new MessageEvent('message', { data: JSON.stringify(data) }), FakeEventSource.OPEN);
+ }
+
+ // Network error: the browser keeps the source and retries.
+ blip() {
+ this.fire(new Event('error'), FakeEventSource.CONNECTING);
+ }
+
+ // Non-200 or wrong MIME type: the browser closes the source, no retry.
+ refuse() {
+ if (this.closed) return;
+ this.readyState = FakeEventSource.CLOSED;
+ this.dispatchEvent(new Event('error'));
+ }
+
+ // A closed EventSource dispatches nothing, so neither may the double.
+ private fire(event: Event, nextReadyState: number) {
+ if (this.closed) return;
+ this.readyState = nextReadyState;
+ this.dispatchEvent(event);
+ }
+}
+
+export function installFakeEventSource() {
+ FakeEventSource.instances = [];
+ vi.stubGlobal('EventSource', FakeEventSource);
+}
+
+export const openStreams = () => FakeEventSource.instances.filter((stream) => !stream.closed);
+
+export function latestStream(): FakeEventSource {
+ const stream = FakeEventSource.instances.at(-1);
+ if (!stream) throw new Error('no EventSource was opened');
+ return stream;
+}
diff --git a/apps/ai-studio/src/test/json-response.ts b/apps/ai-studio/src/test/json-response.ts
new file mode 100644
index 000000000..769225a44
--- /dev/null
+++ b/apps/ai-studio/src/test/json-response.ts
@@ -0,0 +1,6 @@
+export const jsonResponse = (status: number, body: unknown): Response =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+
+// A proxy's HTML error page: a real body that json() rejects on.
+export const unparsableResponse = (status: number): Response =>
+ new Response('Not Found', { status, headers: { 'content-type': 'text/html' } });
diff --git a/apps/ai-studio/src/utils/ai-agent/response-options.test.ts b/apps/ai-studio/src/utils/ai-agent/response-options.test.ts
new file mode 100644
index 000000000..63d8ad13c
--- /dev/null
+++ b/apps/ai-studio/src/utils/ai-agent/response-options.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import { outputSchemaFor, refundReviewOutputSchema, responseOptionOf, responseOptions } from './response-options';
+
+describe('responseOptions', () => {
+ it('lists plain text first, then the refund review preset', () => {
+ expect(responseOptions.map((option) => option.label)).toEqual(['Plain text', 'Structured: refund review']);
+ });
+});
+
+describe('responseOptionOf', () => {
+ it('is plain text when the node carries no output schema', () => {
+ const properties: { outputSchema?: unknown } = {};
+
+ expect(responseOptionOf(properties.outputSchema)).toBe('text');
+ expect(responseOptionOf(null)).toBe('text');
+ });
+
+ it('is the refund review preset for that schema, also for a copy of it', () => {
+ expect(responseOptionOf(refundReviewOutputSchema)).toBe('refund-review');
+ expect(responseOptionOf(structuredClone(refundReviewOutputSchema))).toBe('refund-review');
+ });
+
+ it('is a custom schema for a schema no preset matches', () => {
+ expect(responseOptionOf({})).toBe('custom');
+ expect(responseOptionOf({ type: 'object', properties: { score: { type: 'number' } } })).toBe('custom');
+ });
+});
+
+describe('outputSchemaFor', () => {
+ it('clears the schema for plain text', () => {
+ expect(outputSchemaFor('text')).toBeUndefined();
+ });
+
+ it('seeds the shared refund review schema, the same object the template uses', () => {
+ expect(outputSchemaFor('refund-review')).toBe(refundReviewOutputSchema);
+ });
+
+ it('treats an option it does not know as plain text', () => {
+ expect(outputSchemaFor(null)).toBeUndefined();
+ expect(outputSchemaFor('something-else')).toBeUndefined();
+ });
+});
diff --git a/apps/ai-studio/src/utils/ai-agent/response-options.ts b/apps/ai-studio/src/utils/ai-agent/response-options.ts
new file mode 100644
index 000000000..a010b3a38
--- /dev/null
+++ b/apps/ai-studio/src/utils/ai-agent/response-options.ts
@@ -0,0 +1,47 @@
+import type { SelectItem } from '@workflowbuilder/ui';
+
+// Strict structured outputs want every field required and no extra keys.
+export const refundReviewOutputSchema = {
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount', description: 'In USD, under the refund policy.' },
+ orderDate: { type: 'string', title: 'Order date', description: 'YYYY-MM-DD, as given in the message.' },
+ replyDraft: {
+ type: 'string',
+ title: 'Reply draft',
+ description: 'The body of the reply to the customer, no subject line: under 120 words, signed "Lumen Support".',
+ },
+ internalReasoning: {
+ type: 'string',
+ title: 'Internal reasoning',
+ description: 'Why this amount, for the team. Never sent to the customer.',
+ },
+ },
+ required: ['refundAmount', 'orderDate', 'replyDraft', 'internalReasoning'],
+ additionalProperties: false,
+};
+
+type OutputSchema = typeof refundReviewOutputSchema;
+
+type ResponseOption = 'text' | 'refund-review' | 'custom';
+
+export const responseOptions: SelectItem[] = [
+ { value: 'text', label: 'Plain text' },
+ { value: 'refund-review', label: 'Structured: refund review' },
+];
+
+// Never chosen, only shown: a schema no preset matches must not read as Plain text.
+export const customResponseOption: SelectItem = { value: 'custom', label: 'Structured: custom schema', disabled: true };
+
+// String equality, not identity: a preset that went through a JSON round trip (reload, import) still matches.
+const isSameSchema = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
+
+// Read off the data on purpose: a stored mode could disagree with the schema that is actually on the node.
+export function responseOptionOf(outputSchema: unknown): ResponseOption {
+ if (outputSchema == null) return 'text';
+ return isSameSchema(outputSchema, refundReviewOutputSchema) ? 'refund-review' : 'custom';
+}
+
+export function outputSchemaFor(option: unknown): OutputSchema | undefined {
+ return option === 'refund-review' ? refundReviewOutputSchema : undefined;
+}
diff --git a/apps/ai-studio/src/utils/detect-format.ts b/apps/ai-studio/src/utils/detect-format.ts
index 330b7023f..d617e12c6 100644
--- a/apps/ai-studio/src/utils/detect-format.ts
+++ b/apps/ai-studio/src/utils/detect-format.ts
@@ -1,3 +1,5 @@
+import { isPlainObject } from './is-plain-object';
+
export type VisualizeRenderer = 'markdown' | 'text' | 'json' | 'table' | 'stat-cards' | 'chart' | 'diagram';
type DetectResult = {
@@ -18,10 +20,6 @@ function isScalar(value: unknown): boolean {
return value === null || ['string', 'number', 'boolean'].includes(typeof value);
}
-function isPlainObject(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
function looksLikeChartArray(array: unknown[]): boolean {
if (array.length === 0) {
return false;
diff --git a/apps/ai-studio/src/utils/editor-form/editor-layout.test.ts b/apps/ai-studio/src/utils/editor-form/editor-layout.test.ts
new file mode 100644
index 000000000..8e7039aa8
--- /dev/null
+++ b/apps/ai-studio/src/utils/editor-form/editor-layout.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+
+import { editableFields, editorLayout } from './editor-layout';
+
+describe('editorLayout', () => {
+ it('lays out one editor control per field, labelled by its title or its key, and leaves out an array', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ amount: { type: 'number', title: 'Amount' },
+ note: { type: 'string' },
+ tags: { type: 'array' },
+ },
+ };
+
+ expect(editorLayout(schema)).toEqual({
+ type: 'VerticalLayout',
+ elements: [
+ { type: 'Text', scope: '#/properties/amount', label: 'Amount' },
+ { type: 'TextArea', scope: '#/properties/note', label: 'note' },
+ ],
+ });
+ });
+
+ it('maps boolean to a switch and leaves integer out', () => {
+ const { elements } = editorLayout({
+ type: 'object',
+ properties: { count: { type: 'integer' }, urgent: { type: 'boolean' } },
+ });
+
+ expect(elements.map((element) => element.type)).toEqual(['Switch']);
+ });
+
+ it('shows an optional string or boolean that structured output types with null, but not a nullable number', () => {
+ const { elements } = editorLayout({
+ type: 'object',
+ properties: {
+ note: { type: ['string', 'null'] },
+ urgent: { type: ['null', 'boolean'] },
+ amount: { type: ['number', 'null'] },
+ either: { type: ['string', 'number'] },
+ },
+ });
+
+ expect(elements.map((element) => [element.label, element.type])).toEqual([
+ ['note', 'TextArea'],
+ ['urgent', 'Switch'],
+ ]);
+ });
+
+ it('escapes a key that JSON Pointer reserves', () => {
+ const [element] = editorLayout({ type: 'object', properties: { 'a/b~c': { type: 'string' } } }).elements;
+
+ expect(element.scope).toBe('#/properties/a~1b~0c');
+ });
+});
+
+describe('editableFields', () => {
+ it('names the shown fields that are not read-only', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ amount: { type: 'number' },
+ orderDate: { type: 'string', readOnly: true },
+ count: { type: 'integer' },
+ tags: { type: 'array' },
+ urgent: { type: 'boolean' },
+ },
+ };
+
+ expect([...editableFields(schema)]).toEqual(['amount', 'urgent']);
+ });
+
+ it('does not take a type named after an Object.prototype member for one it shows', () => {
+ const schema = { type: 'object', properties: { odd: { type: 'constructor' } } };
+
+ expect(editableFields(schema).size).toBe(0);
+ expect(editorLayout(schema).elements).toEqual([]);
+ });
+});
diff --git a/apps/ai-studio/src/utils/editor-form/editor-layout.ts b/apps/ai-studio/src/utils/editor-form/editor-layout.ts
new file mode 100644
index 000000000..536c3a699
--- /dev/null
+++ b/apps/ai-studio/src/utils/editor-form/editor-layout.ts
@@ -0,0 +1,68 @@
+import { hasText } from '../has-text';
+import { encodePointerSegment, schemaFields } from './form-schema';
+
+type EditorControl = { type: 'Text' | 'TextArea' | 'Switch'; scope: string; label: string };
+
+type EditorLayout = { type: 'VerticalLayout'; elements: EditorControl[] };
+
+// The editor's controls are keyed on its own element types, not on JsonForms' generic `Control`. No `integer`:
+// the SDK text box hands it over as a string, which fails its schema (follow-up: sdk-text-control-integer).
+const CONTROL_BY_TYPE = new Map([
+ ['number', 'Text'],
+ ['string', 'TextArea'],
+ ['boolean', 'Switch'],
+]);
+
+const UNSETTABLE_KEYS: ReadonlySet = new Set(['constructor', 'prototype', '__proto__']);
+
+// JsonForms reads '' as the whole form, joins data paths with dots and decodes one `~0` per segment; lodash reads
+// brackets as a path and refuses to set the names above.
+function isAddressable(key: string): boolean {
+ return key !== '' && !/[.[\]]/.test(key) && key.split('~').length <= 2 && !UNSETTABLE_KEYS.has(key);
+}
+
+// Structured output types an optional field as `[type, 'null']`. The text area and the switch hand over their own
+// type, so those two are shown; the text box parses only `number`, so a nullable number is not.
+function shownType(type: unknown): string | undefined {
+ if (typeof type === 'string') {
+ return type;
+ }
+ const types: unknown[] = Array.isArray(type) ? type.filter((entry) => entry !== 'null') : [];
+ const [only] = types;
+ return types.length === 1 && (only === 'string' || only === 'boolean') ? only : undefined;
+}
+
+/** The control a field is shown with, if the editor can show it at all. */
+function controlOf(key: string, field: Record): EditorControl['type'] | undefined {
+ const type = shownType(field['type']);
+ return type !== undefined && isAddressable(key) ? CONTROL_BY_TYPE.get(type) : undefined;
+}
+
+/** The fields the editor shows, in declaration order; a field of another type is left out. */
+export function shownFields(schema: unknown): [string, Record][] {
+ return schemaFields(schema).filter(([key, field]) => controlOf(key, field) !== undefined);
+}
+
+/** The fields a person can change: shown by the editor and not read-only. Any other field passes through untouched. */
+export function editableFields(schema: unknown): Set {
+ return new Set(
+ shownFields(schema)
+ .filter(([, field]) => field['readOnly'] !== true)
+ .map(([key]) => key),
+ );
+}
+
+/** One editor control per field whose type the editor can edit; a field of another type is not shown. */
+export function editorLayout(schema: unknown): EditorLayout {
+ const elements = schemaFields(schema).flatMap(([key, field]): EditorControl[] => {
+ const type = controlOf(key, field);
+ if (type === undefined) {
+ return [];
+ }
+ const title = field['title'];
+ // The SDK label translates any text that is an i18n key, so an untitled key like `validation` shows
+ // i18next's notice instead (follow-up: sdk-label-i18n-object-keys).
+ return [{ type, scope: `#/properties/${encodePointerSegment(key)}`, label: hasText(title) ? title : key }];
+ });
+ return { type: 'VerticalLayout', elements };
+}
diff --git a/apps/ai-studio/src/utils/editor-form/form-schema.test.ts b/apps/ai-studio/src/utils/editor-form/form-schema.test.ts
new file mode 100644
index 000000000..b8b03380f
--- /dev/null
+++ b/apps/ai-studio/src/utils/editor-form/form-schema.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+
+import { workflowBuilderValidator } from '../../../../../packages/sdk/src/utils/validation/workflow-builder-validator';
+import { decodePointerSegment, encodePointerSegment, invalidFieldsOf, isFormSchema, schemaFields } from './form-schema';
+
+describe('isFormSchema', () => {
+ it('accepts an object schema with properties', () => {
+ expect(isFormSchema({ type: 'object', properties: {} })).toBe(true);
+ });
+
+ it.each([
+ ['no properties', { type: 'object' }],
+ ['properties that are not an object', { properties: [] }],
+ ['a string', 'schema'],
+ ])('refuses %s', (_name, value) => {
+ expect(isFormSchema(value)).toBe(false);
+ });
+});
+
+describe('schemaFields', () => {
+ it('lists each declared field with its declaration and drops a declaration that is not an object', () => {
+ expect(schemaFields({ type: 'object', properties: { amount: { type: 'number' }, broken: 'number' } })).toEqual([
+ ['amount', { type: 'number' }],
+ ]);
+ });
+
+ it.each([
+ ['no properties', { type: 'object' }],
+ ['a value that is not a schema', 'schema'],
+ ])('lists nothing for %s', (_name, value) => {
+ expect(schemaFields(value)).toEqual([]);
+ });
+});
+
+describe('encodePointerSegment and decodePointerSegment', () => {
+ it('round-trip a key that JSON Pointer reserves characters in', () => {
+ expect(encodePointerSegment('a/b~c')).toBe('a~1b~0c');
+ expect(decodePointerSegment(encodePointerSegment('a/b~c'))).toBe('a/b~c');
+ });
+});
+
+describe('invalidFieldsOf', () => {
+ it('names the field an error points into, decoded', () => {
+ expect(invalidFieldsOf([{ instancePath: '/a~1b/0', params: {} }])).toEqual(new Set(['a/b']));
+ });
+
+ it("names the field of each key the editor's validator reports percent-encoded", () => {
+ const keys = ['reply draft', 'kwota_zł', '50%', 'a/b', 'a~b'];
+ const field = { type: 'string', maxLength: 3 };
+ const validate = workflowBuilderValidator.compile({
+ type: 'object',
+ properties: Object.fromEntries(keys.map((key) => [key, field])),
+ });
+ validate(Object.fromEntries(keys.map((key) => [key, 'too long'])));
+
+ expect(invalidFieldsOf(validate.errors ?? undefined)).toEqual(new Set(keys));
+ });
+
+ it('names a missing required field from the error its parent object carries', () => {
+ expect(invalidFieldsOf([{ instancePath: '', params: { missingProperty: 'refundAmount' } }])).toEqual(
+ new Set(['refundAmount']),
+ );
+ });
+
+ it('names the enclosing field when a nested object misses a required key', () => {
+ expect(invalidFieldsOf([{ instancePath: '/address', params: { missingProperty: 'street' } }])).toEqual(
+ new Set(['address']),
+ );
+ });
+
+ it('names nothing for an error on the whole object, or for no errors', () => {
+ expect(invalidFieldsOf([{ instancePath: '', params: {} }]).size).toBe(0);
+ expect(invalidFieldsOf().size).toBe(0);
+ });
+});
diff --git a/apps/ai-studio/src/utils/editor-form/form-schema.ts b/apps/ai-studio/src/utils/editor-form/form-schema.ts
new file mode 100644
index 000000000..cf685ef61
--- /dev/null
+++ b/apps/ai-studio/src/utils/editor-form/form-schema.ts
@@ -0,0 +1,50 @@
+import type { JsonSchema } from '@workflowbuilder/sdk';
+
+import { isPlainObject } from '../is-plain-object';
+
+/** A form schema the editor can render: an object whose `properties` are the fields. */
+export function isFormSchema(value: unknown): value is JsonSchema {
+ return isPlainObject(value) && isPlainObject(value['properties']);
+}
+
+/** Each declared field of an object schema, with its declaration; anything else declares none. */
+export function schemaFields(schema: unknown): [string, Record][] {
+ const properties = isPlainObject(schema) && isPlainObject(schema['properties']) ? schema['properties'] : {};
+ return Object.entries(properties).flatMap(([key, field]): [string, Record][] =>
+ isPlainObject(field) ? [[key, field]] : [],
+ );
+}
+
+/** The fields an object schema lists under `required`; anything else requires none. */
+export function requiredFields(schema: unknown): Set {
+ const required = isPlainObject(schema) ? schema['required'] : undefined;
+ return new Set(Array.isArray(required) ? required.filter((name): name is string => typeof name === 'string') : []);
+}
+
+export function encodePointerSegment(key: string): string {
+ return key.replaceAll('~', '~0').replaceAll('/', '~1');
+}
+
+export function decodePointerSegment(segment: string): string {
+ return segment.replaceAll('~1', '/').replaceAll('~0', '~');
+}
+
+export type SchemaError = { instancePath: string; params: Record };
+
+/** The top-level fields a set of validation errors is about. */
+export function invalidFieldsOf(errors?: readonly SchemaError[]): ReadonlySet {
+ const fields = new Set();
+ for (const error of errors ?? []) {
+ const [, segment] = error.instancePath.split('/');
+ const missing = error.params['missingProperty'];
+ // A field's own error points into it; a missing required field is reported on the object that holds it.
+ if (segment !== undefined) {
+ // The editor's validator reports a URI fragment, so a key with a space or a letter like `ł` arrives
+ // percent-encoded. Decoded here until the SDK does it (follow-up: sdk-validator-instance-path).
+ fields.add(decodePointerSegment(decodeURI(segment)));
+ } else if (typeof missing === 'string') {
+ fields.add(missing);
+ }
+ }
+ return fields;
+}
diff --git a/apps/ai-studio/src/utils/extract-output-text.ts b/apps/ai-studio/src/utils/extract-output-text.ts
index af9bf84fc..37a4efdb5 100644
--- a/apps/ai-studio/src/utils/extract-output-text.ts
+++ b/apps/ai-studio/src/utils/extract-output-text.ts
@@ -1,4 +1,4 @@
-// AI agents return { response }, trigger returns { input } — others fall back to JSON
+// AI agents without an output schema return { response }, trigger returns { input } — others fall back to JSON
export function extractOutputText(output: unknown): string {
if (output === undefined || output === null) return '';
if (typeof output === 'string') return output;
diff --git a/apps/ai-studio/src/utils/has-text.test.ts b/apps/ai-studio/src/utils/has-text.test.ts
new file mode 100644
index 000000000..84ff5ff3f
--- /dev/null
+++ b/apps/ai-studio/src/utils/has-text.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+
+import { hasText } from './has-text';
+
+describe('hasText', () => {
+ it('accepts a string with text around its whitespace', () => {
+ expect(hasText(' Too high ')).toBe(true);
+ });
+
+ it.each([
+ ['an empty string', ''],
+ ['whitespace', ' \n\t'],
+ ['a number', 120],
+ ['undefined', undefined],
+ ])('refuses %s', (_name, value) => {
+ expect(hasText(value)).toBe(false);
+ });
+});
diff --git a/apps/ai-studio/src/utils/has-text.ts b/apps/ai-studio/src/utils/has-text.ts
new file mode 100644
index 000000000..94e3efa92
--- /dev/null
+++ b/apps/ai-studio/src/utils/has-text.ts
@@ -0,0 +1,4 @@
+/** A string with something in it besides whitespace. */
+export function hasText(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0;
+}
diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts
new file mode 100644
index 000000000..d067ceb54
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+
+import { offeredActions } from './decision-actions';
+import { reviewRequest } from './review-request.fixture';
+
+const [approve, reject] = reviewRequest.actions;
+
+describe('offeredActions', () => {
+ it('offers the resume and the reject the request declares', () => {
+ expect(offeredActions(reviewRequest.actions)).toEqual({
+ resume: { name: 'approve', label: 'Approve' },
+ reject: { name: 'reject', label: 'Reject', reasonRequired: false },
+ });
+ });
+
+ it('carries reasonRequired from the reject action', () => {
+ expect(offeredActions([approve, { ...reject, reasonRequired: true }])?.reject?.reasonRequired).toBe(true);
+ });
+
+ it('offers no reject when the request declares none', () => {
+ expect(offeredActions([approve])?.reject).toBeUndefined();
+ });
+
+ it('leaves out a rerun-source action, which the endpoint refuses with 501', () => {
+ const actions = [approve, { name: 'redraft', label: 'Ask again', effect: 'rerun-source' }];
+
+ expect(offeredActions(actions)).toEqual({ resume: { name: 'approve', label: 'Approve' }, reject: undefined });
+ });
+
+ it('finds the resume and the reject wherever the request lists them', () => {
+ const rerun = { name: 'redraft', label: 'Ask again', effect: 'rerun-source' };
+
+ expect(offeredActions([rerun, reject, approve])).toEqual(offeredActions([approve, reject]));
+ });
+
+ it('falls back to the action name when the label is blank', () => {
+ expect(offeredActions([{ ...approve, label: ' ' }])?.resume.label).toBe('approve');
+ });
+
+ it('offers nothing without a usable resume action, whatever else the request carries', () => {
+ expect(offeredActions([reject])).toBeUndefined();
+ expect(offeredActions([null, { name: '', effect: 'resume' }])).toBeUndefined();
+ });
+});
diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.ts
new file mode 100644
index 000000000..c8f283cb5
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-actions.ts
@@ -0,0 +1,31 @@
+import { hasText } from '../has-text';
+import { isPlainObject } from '../is-plain-object';
+
+type Offer = { name: string; label: string };
+
+export type RejectOffer = Offer & { reasonRequired: boolean };
+
+/** What the decider can do. */
+export type OfferedActions = { resume: Offer; reject: RejectOffer | undefined };
+
+function offerOf(entry: Record | undefined): Offer | undefined {
+ const name = entry?.['name'];
+ if (!hasText(name)) {
+ return undefined;
+ }
+ const label = entry?.['label'];
+ return { name, label: hasText(label) ? label : name };
+}
+
+function rejectOfferOf(entry: Record | undefined): RejectOffer | undefined {
+ const offer = offerOf(entry);
+ return offer === undefined ? undefined : { ...offer, reasonRequired: entry?.['reasonRequired'] === true };
+}
+
+// Authored node data: only the array is proven. A `rerun-source` action is left out: the endpoint answers 501 for it.
+export function offeredActions(actions: readonly unknown[]): OfferedActions | undefined {
+ const entries = actions.filter(isPlainObject);
+ const withEffect = (effect: string) => entries.find((entry) => entry['effect'] === effect);
+ const resume = offerOf(withEffect('resume'));
+ return resume === undefined ? undefined : { resume, reject: rejectOfferOf(withEffect('reject')) };
+}
diff --git a/apps/ai-studio/src/utils/human-decision/decision-fields.test.ts b/apps/ai-studio/src/utils/human-decision/decision-fields.test.ts
new file mode 100644
index 000000000..3eca7c631
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-fields.test.ts
@@ -0,0 +1,296 @@
+import type { JsonSchema, WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk';
+import { describe, expect, it } from 'vitest';
+
+// The real receivers, not copies, as in ../../nodes/human-decision/decision-request-contract.test.ts
+// (follow-up: decision-request-contract-test-home).
+import { decisionRequestSchema } from '../../../../backend/src/domain/decision/decision-request-schema';
+import { findDecisionRequest } from '../../../../backend/src/domain/decision/find-decision-request';
+import { validateSubmittedDecision } from '../../../../backend/src/domain/decision/validate-submitted-decision';
+import { workflowSnapshotSchema } from '../../../../backend/src/domain/mapper/snapshot-schema';
+import { refundReviewRequest } from '../../data/refund-review-flow';
+import { humanDecisionNodeType } from '../../nodes/human-decision';
+import { defaultDecisionRequest } from '../../nodes/human-decision/default-properties-data';
+import {
+ FIELD_MODES,
+ type FieldMode,
+ type FieldRow,
+ fieldModeOf,
+ fieldRows,
+ sourceHintOf,
+ withFieldMode,
+} from './decision-fields';
+
+const outputSchema = {
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ orderDate: { type: 'string', title: 'Order date', description: 'Taken from the message', 'x-pii': true },
+ replyDraft: { type: 'string', title: 'Reply draft' },
+ internalReasoning: { type: 'string', title: 'Internal reasoning' },
+ note: { type: ['string', 'null'] },
+ lines: { type: 'array', items: { type: 'string' } },
+ quantity: { type: 'integer' },
+ },
+};
+
+const empty: JsonSchema = defaultDecisionRequest.schema;
+const refundReview: JsonSchema = refundReviewRequest.schema;
+
+const parsed = (schema: JsonSchema) => decisionRequestSchema.safeParse({ ...defaultDecisionRequest, schema });
+const issuesOf = (result: ReturnType) => (result.success ? '' : JSON.stringify(result.error.issues));
+
+function pick(schema: JsonSchema, key: string, mode: FieldMode): JsonSchema {
+ return withFieldMode(schema, fieldRows(outputSchema, schema), key, mode);
+}
+
+const keysOf = (schema: unknown) => fieldRows(outputSchema, schema).map((row) => row.key);
+
+describe('fieldRows', () => {
+ it("lists the fields the decider's form can show, in the source's order, titled or named by key", () => {
+ expect(fieldRows(outputSchema, empty).map(({ key, title }) => [key, title])).toEqual([
+ ['refundAmount', 'Refund amount'],
+ ['orderDate', 'Order date'],
+ ['replyDraft', 'Reply draft'],
+ ['internalReasoning', 'Internal reasoning'],
+ ['note', 'note'],
+ ]);
+ });
+
+ it('lists a field the request stores and the source no longer declares last, without a declaration', () => {
+ const stored = { type: 'object', properties: { summary: { type: 'string', title: 'Summary' } } };
+
+ expect(fieldRows(outputSchema, stored).at(-1)).toEqual({
+ key: 'summary',
+ title: 'Summary',
+ declaration: undefined,
+ });
+ });
+
+ it('with no source, lists only what the request stores', () => {
+ const stored = { type: 'object', properties: { replyDraft: { type: 'string', title: 'Reply draft' } } };
+
+ expect(fieldRows(undefined, stored)).toEqual([{ key: 'replyDraft', title: 'Reply draft', declaration: undefined }]);
+ });
+});
+
+describe('sourceHintOf', () => {
+ const declared: FieldRow = { key: 'replyDraft', title: 'Reply draft', declaration: { type: 'string' } };
+ const storedOnly: FieldRow = { key: 'replyDraft', title: 'Reply draft', declaration: undefined };
+
+ it.each([
+ ['nothing connected', undefined, 0, [storedOnly], 'unconnected'],
+ ['one predecessor, not resolved as the source', undefined, 1, [storedOnly], undefined],
+ ['several predecessors, none declared the source', undefined, 2, [storedOnly], 'ambiguous'],
+ ['a source that declares fields', 'draft-1', 1, [declared, storedOnly], undefined],
+ ['a source that declares none', 'draft-1', 1, [storedOnly], 'noFields'],
+ ['a source that declares none, nothing stored', 'draft-1', 1, [], 'noFields'],
+ ] as const)('%s', (_case, sourceId, predecessorCount, rows, hint) => {
+ expect(sourceHintOf(sourceId, predecessorCount, rows)).toBe(hint);
+ });
+
+ it('a source that declares only fields the form cannot show', () => {
+ const unshowable = {
+ type: 'object',
+ properties: { lines: { type: 'array', items: { type: 'string' } }, quantity: { type: 'integer' } },
+ };
+
+ expect(sourceHintOf('draft-1', 1, fieldRows(unshowable, empty))).toBe('noFields');
+ });
+});
+
+describe('fieldModeOf', () => {
+ it('reads every field of the palette preset as Hidden', () => {
+ expect(keysOf(empty).map((key) => fieldModeOf(empty, key))).toEqual(Array.from({ length: 5 }, () => 'hidden'));
+ });
+
+ it('reads the contract: left out is Hidden, readOnly is Read-only, listed in required is Required', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number' },
+ orderDate: { type: 'string', readOnly: true },
+ replyDraft: { type: 'string' },
+ },
+ required: ['refundAmount'],
+ };
+
+ expect(keysOf(schema).map((key) => fieldModeOf(schema, key))).toEqual([
+ 'required',
+ 'readOnly',
+ 'editable',
+ 'hidden',
+ 'hidden',
+ ]);
+ });
+});
+
+describe('withFieldMode', () => {
+ it('writes the Refund Review picks in the contract shape, carrying the keywords the source declares', () => {
+ let schema = pick(empty, 'refundAmount', 'required');
+ schema = pick(schema, 'orderDate', 'readOnly');
+ schema = pick(schema, 'replyDraft', 'editable');
+
+ expect(schema).toEqual({
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ orderDate: {
+ type: 'string',
+ title: 'Order date',
+ description: 'Taken from the message',
+ 'x-pii': true,
+ readOnly: true,
+ },
+ replyDraft: { type: 'string', title: 'Reply draft' },
+ },
+ required: ['refundAmount'],
+ });
+ expect(defaultDecisionRequest.schema).toEqual({ type: 'object', properties: {} });
+ });
+
+ it('round trip: a pick reads back as picked and leaves every other field as it was', () => {
+ const start = pick(pick(empty, 'refundAmount', 'required'), 'orderDate', 'readOnly');
+
+ for (const key of keysOf(start)) {
+ for (const mode of FIELD_MODES) {
+ const next = pick(start, key, mode);
+ expect(fieldModeOf(next, key), `${key}=${mode}`).toBe(mode);
+ for (const other of keysOf(start).filter((candidate) => candidate !== key)) {
+ expect(fieldModeOf(next, other), `${key}=${mode}, ${other}`).toBe(fieldModeOf(start, other));
+ }
+ }
+ }
+ });
+
+ it('drops null from a nullable type picked Required, and Editable brings it back', () => {
+ const required = pick(empty, 'note', 'required');
+
+ expect(required.properties?.['note']).toEqual({ type: 'string' });
+ expect(pick(required, 'note', 'editable').properties?.['note']).toEqual({ type: ['string', 'null'] });
+ });
+
+ it('leaves required out once no field is required', () => {
+ expect(pick(pick(empty, 'refundAmount', 'required'), 'refundAmount', 'editable')).not.toHaveProperty('required');
+ });
+
+ it('hiding a required field leaves required, and the result still parses', () => {
+ const next = pick(pick(empty, 'refundAmount', 'required'), 'refundAmount', 'hidden');
+
+ expect(next).not.toHaveProperty('required');
+ const result = parsed(next);
+ expect(result.success, issuesOf(result)).toBe(true);
+ });
+
+ it('keeps a field the source no longer declares until the author hides it', () => {
+ const stored: JsonSchema = { type: 'object', properties: { summary: { type: 'string', title: 'Summary' } } };
+
+ const kept = pick(stored, 'refundAmount', 'editable');
+ expect(Object.keys(kept.properties ?? {})).toEqual(['refundAmount', 'summary']);
+ expect(Object.keys(pick(kept, 'summary', 'hidden').properties ?? {})).toEqual(['refundAmount']);
+ });
+
+ it('drops a stored field of a type the editor cannot show once another field is picked', () => {
+ const stored: JsonSchema = {
+ type: 'object',
+ properties: { quantity: { type: 'integer' }, refundAmount: { type: 'number', title: 'Refund amount' } },
+ required: ['quantity'],
+ };
+
+ const next = pick(stored, 'refundAmount', 'editable');
+
+ expect(Object.keys(next.properties ?? {})).toEqual(['refundAmount']);
+ expect(next).not.toHaveProperty('required');
+ const result = parsed(next);
+ expect(result.success, issuesOf(result)).toBe(true);
+ });
+});
+
+function outcomeOf(schema: JsonSchema, edits: Record): string {
+ const nodes: WorkflowBuilderNode[] = [
+ {
+ id: 'draft-1',
+ type: 'node',
+ position: { x: 0, y: 0 },
+ data: {
+ segments: [],
+ properties: { label: 'Draft', description: '', systemPrompt: '', webSearch: false },
+ type: 'ai-studio/ai-agent',
+ icon: 'AiAgent',
+ },
+ },
+ {
+ id: 'human-1',
+ type: humanDecisionNodeType,
+ position: { x: 350, y: 0 },
+ data: {
+ segments: [],
+ properties: { label: 'Review', description: '', decisionRequest: { ...defaultDecisionRequest, schema } },
+ type: humanDecisionNodeType,
+ icon: 'UserCheck',
+ },
+ },
+ ];
+ const edges: WorkflowBuilderEdge[] = [
+ {
+ id: 'edge-1',
+ source: 'draft-1',
+ sourceHandle: 'source',
+ target: 'human-1',
+ targetHandle: 'target',
+ type: 'labelEdge',
+ data: {},
+ },
+ ];
+ const parsed = workflowSnapshotSchema.safeParse(structuredClone({ nodes, edges }));
+ if (!parsed.success) throw new Error(`snapshot refused: ${JSON.stringify(parsed.error.issues)}`);
+ const found = findDecisionRequest(parsed.data, 'human-1');
+ if (found.error !== undefined) throw new Error(found.error);
+ return validateSubmittedDecision(found.request, { action: 'approve', edits }).error?.code ?? 'accepted';
+}
+
+describe('the backend takes the stored schema as it is', () => {
+ const EDIT_OUTCOME: Record = {
+ hidden: 'unknown_field',
+ readOnly: 'field_not_editable',
+ editable: 'accepted',
+ required: 'accepted',
+ };
+
+ it.each([
+ ['the palette preset', empty],
+ ['the "Refund Review" request', refundReview],
+ ])('every pick from %s parses with decisionRequestSchema', (_name, start) => {
+ for (const key of keysOf(start)) {
+ for (const mode of FIELD_MODES) {
+ const result = parsed(pick(start, key, mode));
+ expect(result.success, `${key}=${mode}: ${issuesOf(result)}`).toBe(true);
+ }
+ }
+ });
+
+ it('a sequence of picks parses at every step', () => {
+ const steps: [string, FieldMode][] = [
+ ['refundAmount', 'required'],
+ ['orderDate', 'readOnly'],
+ ['refundAmount', 'hidden'],
+ ];
+
+ let schema = empty;
+ for (const [key, mode] of steps) {
+ schema = pick(schema, key, mode);
+ const result = parsed(schema);
+ expect(result.success, `${key}=${mode}: ${issuesOf(result)}`).toBe(true);
+ }
+ });
+
+ it.each(FIELD_MODES)('an edit to a field picked %s gets the answer the pick promises', (mode) => {
+ for (const key of keysOf(empty)) {
+ expect(outcomeOf(pick(empty, key, mode), { [key]: 'x' }), key).toBe(EDIT_OUTCOME[mode]);
+ }
+ });
+
+ it('emptying a Required field is refused; emptying an Editable one is not', () => {
+ expect(outcomeOf(pick(empty, 'replyDraft', 'required'), { replyDraft: null })).toBe('required_field_missing');
+ expect(outcomeOf(pick(empty, 'replyDraft', 'editable'), { replyDraft: null })).toBe('accepted');
+ });
+});
diff --git a/apps/ai-studio/src/utils/human-decision/decision-fields.ts b/apps/ai-studio/src/utils/human-decision/decision-fields.ts
new file mode 100644
index 000000000..e547b5f0c
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-fields.ts
@@ -0,0 +1,90 @@
+import type { JsonSchema } from '@workflowbuilder/sdk';
+
+import { shownFields } from '../editor-form/editor-layout';
+import { requiredFields, schemaFields } from '../editor-form/form-schema';
+import { hasText } from '../has-text';
+
+/** What the author picks per field, in dropdown order. */
+export const FIELD_MODES = ['hidden', 'readOnly', 'editable', 'required'] as const;
+export type FieldMode = (typeof FIELD_MODES)[number];
+
+/** A field the decider's form can show; `declaration` is the source's, absent for a field only the request stores. */
+export type FieldRow = { key: string; title: string; declaration: Record | undefined };
+
+export function isFieldMode(value: unknown): value is FieldMode {
+ return (FIELD_MODES as readonly unknown[]).includes(value);
+}
+
+function titleOf(key: string, field: Record): string {
+ const title = field['title'];
+ return hasText(title) ? title : key;
+}
+
+/** The source's fields in its order, then the fields the request stores and the source no longer declares. */
+export function fieldRows(outputSchema: unknown, schema: unknown): FieldRow[] {
+ const declared = shownFields(outputSchema);
+ const declaredKeys = new Set(declared.map(([key]) => key));
+ const storedOnly = shownFields(schema).filter(([key]) => !declaredKeys.has(key));
+ return [
+ ...declared.map(([key, field]) => ({ key, title: titleOf(key, field), declaration: field })),
+ ...storedOnly.map(([key, field]) => ({ key, title: titleOf(key, field), declaration: undefined })),
+ ];
+}
+
+/** Why no source supplies the list: nothing connected, several blocks with none declared, or no field the form can show. */
+export type SourceHint = 'unconnected' | 'ambiguous' | 'noFields';
+
+export function sourceHintOf(
+ sourceId: string | undefined,
+ predecessorCount: number,
+ rows: readonly FieldRow[],
+): SourceHint | undefined {
+ if (sourceId === undefined) {
+ if (predecessorCount === 0) {
+ return 'unconnected';
+ }
+ return predecessorCount > 1 ? 'ambiguous' : undefined;
+ }
+ return rows.some((row) => row.declaration !== undefined) ? undefined : 'noFields';
+}
+
+// The contract's own encoding, so the stored schema is the decider's form: a field it leaves out is Hidden.
+export function fieldModeOf(schema: unknown, key: string): FieldMode {
+ const declaration = schemaFields(schema).find(([name]) => name === key)?.[1];
+ if (declaration === undefined) {
+ return 'hidden';
+ }
+ if (declaration['readOnly'] === true) {
+ return 'readOnly';
+ }
+ return requiredFields(schema).has(key) ? 'required' : 'editable';
+}
+
+function withoutNull(type: unknown[]): unknown {
+ const types = type.filter((entry) => entry !== 'null');
+ return types.length === 1 ? types[0] : types;
+}
+
+/** The schema after one pick: rebuilt from `rows`, so a stored field the editor's form cannot show is dropped. */
+export function withFieldMode(schema: JsonSchema, rows: readonly FieldRow[], key: string, mode: FieldMode): JsonSchema {
+ const stored = new Map(schemaFields(schema));
+ const modeOf = (row: FieldRow) => (row.key === key ? mode : fieldModeOf(schema, row.key));
+ const shown = rows.filter((row) => modeOf(row) !== 'hidden');
+ const properties = Object.fromEntries(
+ shown.map((row) => {
+ const entry: Record = { ...stored.get(row.key), ...row.declaration };
+ delete entry['readOnly'];
+ // The form lets a present `null` through `required`; without it, a null the model left holds Approve back.
+ if (modeOf(row) === 'required' && Array.isArray(entry['type'])) {
+ entry['type'] = withoutNull(entry['type']);
+ }
+ return [row.key, modeOf(row) === 'readOnly' ? { ...entry, readOnly: true } : entry];
+ }),
+ );
+ const required = shown.filter((row) => modeOf(row) === 'required').map((row) => row.key);
+ const next: Record = { ...schema, type: 'object', properties, required };
+ if (required.length === 0) {
+ delete next['required'];
+ }
+ return next as JsonSchema;
+}
diff --git a/apps/ai-studio/src/utils/human-decision/decision-outcome.test.ts b/apps/ai-studio/src/utils/human-decision/decision-outcome.test.ts
new file mode 100644
index 000000000..eca1b5e43
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-outcome.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { readDecisionOutcome } from './decision-outcome';
+
+describe('readDecisionOutcome', () => {
+ it('reads the edits and the reason of a recorded decision', () => {
+ expect(
+ readDecisionOutcome({
+ action: 'reject',
+ effect: 'reject',
+ edits: {},
+ reason: 'Outside the policy',
+ resolvedBy: 'human',
+ }),
+ ).toEqual({ edits: {}, reason: 'Outside the policy' });
+ });
+
+ it('treats a blank reason as none and missing edits as empty', () => {
+ expect(readDecisionOutcome({ action: 'approve', reason: ' ' })).toEqual({ edits: {}, reason: undefined });
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['a string', 'approve'],
+ ['an object without an action', { effect: 'resume' }],
+ ])('reads nothing from %s', (_name, output) => {
+ expect(readDecisionOutcome(output)).toBeUndefined();
+ });
+});
diff --git a/apps/ai-studio/src/utils/human-decision/decision-outcome.ts b/apps/ai-studio/src/utils/human-decision/decision-outcome.ts
new file mode 100644
index 000000000..f3be4d571
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-outcome.ts
@@ -0,0 +1,16 @@
+import { hasText } from '../has-text';
+import { isPlainObject } from '../is-plain-object';
+
+/** What the person decided, read back from the node's completion. */
+type DecisionOutcome = { edits: Record; reason: string | undefined };
+
+export function readDecisionOutcome(output: unknown): DecisionOutcome | undefined {
+ if (!isPlainObject(output) || typeof output['action'] !== 'string') {
+ return undefined;
+ }
+ const reason = output['reason'];
+ return {
+ edits: isPlainObject(output['edits']) ? output['edits'] : {},
+ reason: hasText(reason) ? reason : undefined,
+ };
+}
diff --git a/apps/ai-studio/src/utils/human-decision/decision-request.test.ts b/apps/ai-studio/src/utils/human-decision/decision-request.test.ts
new file mode 100644
index 000000000..e23a96630
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-request.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+
+import { readDecisionRequest } from './decision-request';
+import { reviewRequest } from './review-request.fixture';
+
+describe('readDecisionRequest', () => {
+ it('reads the schema, the offered actions and the declared proposal source', () => {
+ expect(readDecisionRequest({ ...reviewRequest, proposalSourceNodeId: 'draft-1' })).toEqual({
+ schema: reviewRequest.schema,
+ actions: {
+ resume: { name: 'approve', label: 'Approve' },
+ reject: { name: 'reject', label: 'Reject', reasonRequired: false },
+ },
+ proposalSourceNodeId: 'draft-1',
+ });
+ });
+
+ it.each([
+ ['not declared', undefined],
+ ['blank', ''],
+ ['not a string', 7],
+ ])('reads no proposal source when it is %s', (_name, proposalSourceNodeId) => {
+ expect(readDecisionRequest({ ...reviewRequest, proposalSourceNodeId })?.proposalSourceNodeId).toBeUndefined();
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['a string', 'request'],
+ ['an array', [reviewRequest]],
+ ['actions that are not an array', { ...reviewRequest, actions: 'approve' }],
+ ['no resume action', { ...reviewRequest, actions: reviewRequest.actions.slice(1) }],
+ ['a schema that is not an object', { ...reviewRequest, schema: 'form' }],
+ ['a schema without properties', { ...reviewRequest, schema: { type: 'object' } }],
+ ])('reads nothing from %s', (_name, value) => {
+ expect(readDecisionRequest(value)).toBeUndefined();
+ });
+});
diff --git a/apps/ai-studio/src/utils/human-decision/decision-request.ts b/apps/ai-studio/src/utils/human-decision/decision-request.ts
new file mode 100644
index 000000000..d7bdcb442
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-request.ts
@@ -0,0 +1,23 @@
+import type { JsonSchema } from '@workflowbuilder/sdk';
+
+import { isFormSchema } from '../editor-form/form-schema';
+import { isPlainObject } from '../is-plain-object';
+import { type OfferedActions, offeredActions } from './decision-actions';
+
+/** What the form takes from the node's authored request. */
+type DecisionFormRequest = { schema: JsonSchema; actions: OfferedActions; proposalSourceNodeId: string | undefined };
+
+// Publish refuses a request without a resume action or a form schema, so a parked node has both.
+// The request's own `uiSchema` is not used yet (follow-up: decision-form-authored-ui-schema).
+export function readDecisionRequest(data: unknown): DecisionFormRequest | undefined {
+ if (!isPlainObject(data)) {
+ return undefined;
+ }
+ const { actions, schema, proposalSourceNodeId } = data;
+ const offered = Array.isArray(actions) ? offeredActions(actions) : undefined;
+ if (offered === undefined || !isFormSchema(schema)) {
+ return undefined;
+ }
+ const declared = typeof proposalSourceNodeId === 'string' && proposalSourceNodeId.length > 0;
+ return { schema, actions: offered, proposalSourceNodeId: declared ? proposalSourceNodeId : undefined };
+}
diff --git a/apps/ai-studio/src/utils/human-decision/decision-values.test.ts b/apps/ai-studio/src/utils/human-decision/decision-values.test.ts
new file mode 100644
index 000000000..cc9ba2c6c
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-values.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, it } from 'vitest';
+
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+// The real receiver, not a copy, as in ../../nodes/human-decision/decision-request-contract.test.ts
+// (follow-up: decision-request-contract-test-home).
+import { validateSubmittedDecision } from '../../../../backend/src/domain/decision/validate-submitted-decision';
+import { blocksApproval, editsOf, proposedValues, withEdits } from './decision-values';
+import { reviewRequest } from './review-request.fixture';
+
+const { schema } = reviewRequest;
+const proposal = {
+ refundAmount: 80,
+ orderDate: '2026-09-01',
+ replyDraft: 'Dear customer',
+ internalReasoning: 'hidden',
+};
+
+describe('proposedValues', () => {
+ it('takes the declared fields from the proposal and leaves an undeclared one out', () => {
+ expect(proposedValues(proposal, schema)).toEqual({
+ refundAmount: 80,
+ orderDate: '2026-09-01',
+ replyDraft: 'Dear customer',
+ });
+ });
+
+ it('omits a declared field the proposal does not carry, so a required one reads as missing', () => {
+ expect(Object.keys(proposedValues({ orderDate: '2026-09-01' }, schema))).toEqual(['orderDate']);
+ });
+
+ it.each([
+ ['a string proposal', 'Refund amount: 80'],
+ ['undefined', undefined],
+ ['an array', [80]],
+ ])('is empty for %s', (_name, output) => {
+ expect(proposedValues(output, schema)).toEqual({});
+ });
+});
+
+describe('editsOf', () => {
+ const proposed = proposedValues(proposal, schema);
+
+ it('is empty when nothing changed', () => {
+ expect(editsOf(proposed, { ...proposed }, schema)).toEqual({});
+ });
+
+ it('carries every declared editable field that changed', () => {
+ expect(editsOf(proposed, { ...proposed, refundAmount: 120, replyDraft: 'Refunded' }, schema)).toEqual({
+ refundAmount: 120,
+ replyDraft: 'Refunded',
+ });
+ });
+
+ it('never carries a read-only field, even when the value differs', () => {
+ expect(editsOf(proposed, { ...proposed, orderDate: '2000-01-01' }, schema)).toEqual({});
+ });
+
+ it('never carries a field the form does not show, even as a new but equal object after a reconnect', () => {
+ const withTags = { ...proposed, tags: ['vip'] };
+
+ expect(editsOf(withTags, { ...withTags, tags: ['vip'] }, schema)).toEqual({});
+ });
+
+ it('never carries a field of a type the form leaves out, even when its value differs', () => {
+ expect(editsOf({ ...proposed, itemCount: 3 }, { ...proposed, itemCount: 4 }, schema)).toEqual({});
+ });
+
+ it('never carries a field the form does not declare', () => {
+ expect(editsOf(proposed, { ...proposed, internalReasoning: 'changed' }, schema)).toEqual({});
+ });
+
+ it('sends an emptied editable field as null', () => {
+ expect(editsOf(proposed, { ...proposed, refundAmount: undefined }, schema)).toEqual({ refundAmount: null });
+ });
+
+ it('carries a value typed into a field that started empty', () => {
+ expect(editsOf({}, { refundAmount: 120 }, schema)).toEqual({ refundAmount: 120 });
+ });
+
+ it('reads the empty text a text area hands back for a null as no edit, where the type allows null', () => {
+ const nullable = { type: 'object', properties: { note: { type: ['string', 'null'] } } };
+
+ expect(editsOf({ note: null }, { note: '' }, nullable)).toEqual({});
+ });
+
+ it('carries the empty text over a null where the type does not allow null', () => {
+ expect(editsOf({ ...proposed, replyDraft: null }, { ...proposed, replyDraft: '' }, schema)).toEqual({
+ replyDraft: '',
+ });
+ });
+});
+
+describe('withEdits', () => {
+ const proposed = proposedValues(proposal, schema);
+
+ it('applies the edits over the proposal', () => {
+ expect(withEdits(proposed, { refundAmount: 120 })).toEqual({ ...proposed, refundAmount: 120 });
+ });
+
+ it('leaves an emptied field without a value', () => {
+ expect(withEdits(proposed, { replyDraft: null })).not.toHaveProperty('replyDraft');
+ });
+
+ it('undoes editsOf: the values it settles are the ones the person left', () => {
+ const current = { ...proposed, refundAmount: 120, replyDraft: undefined };
+
+ expect(withEdits(proposed, editsOf(proposed, current, schema))).toEqual({
+ refundAmount: 120,
+ orderDate: '2026-09-01',
+ });
+ });
+});
+
+describe('blocksApproval', () => {
+ it('holds the decision back for a fault in a field the person can edit', () => {
+ expect(blocksApproval(new Set(['refundAmount']), schema, {})).toBe(true);
+ });
+
+ it.each([
+ ['a read-only field', 'orderDate'],
+ ['a field of a type the form leaves out', 'itemCount'],
+ ['a field the form does not declare', 'internalReasoning'],
+ ])('lets it through for a fault in %s', (_name, field) => {
+ expect(blocksApproval(new Set([field]), schema, {})).toBe(false);
+ });
+
+ it('lets it through when nothing is at fault', () => {
+ expect(blocksApproval(new Set(), schema, { refundAmount: 120 })).toBe(false);
+ });
+});
+
+describe('blocksApproval against the backend validator', () => {
+ const noteRequest: DecisionRequest = {
+ ...reviewRequest,
+ schema: {
+ type: 'object',
+ properties: { note: { type: ['string', 'null'] }, remark: { type: 'string' } },
+ required: ['note'],
+ },
+ };
+ const replyRequest: DecisionRequest = {
+ ...reviewRequest,
+ schema: { ...reviewRequest.schema, required: ['refundAmount', 'replyDraft'] },
+ };
+ const proposed = proposedValues(proposal, schema);
+
+ it.each<[string, DecisionRequest, Record, Record, boolean]>([
+ ['an edited amount', reviewRequest, proposed, { ...proposed, refundAmount: 120 }, false],
+ ['a required amount cleared', reviewRequest, proposed, { ...proposed, refundAmount: undefined }, true],
+ ['a required reply emptied to an empty string', replyRequest, proposed, { ...proposed, replyDraft: '' }, true],
+ ['a required reply emptied to whitespace', replyRequest, proposed, { ...proposed, replyDraft: ' ' }, true],
+ [
+ 'a required reply the model left null, typed and cleared',
+ replyRequest,
+ { ...proposed, replyDraft: null },
+ { ...proposed, replyDraft: '' },
+ true,
+ ],
+ ['a nullable required note emptied to an empty string', noteRequest, { note: 'Call back' }, { note: '' }, true],
+ ['a nullable required note emptied to whitespace', noteRequest, { note: 'Call back' }, { note: ' ' }, true],
+ ['a nullable required note emptied to a tab and a newline', noteRequest, { note: 'x' }, { note: '\t\n' }, true],
+ [
+ 'a required note the model left null and the person left alone',
+ noteRequest,
+ { note: null },
+ { note: null },
+ false,
+ ],
+ ['a required note the model left null, typed and cleared', noteRequest, { note: null }, { note: '' }, false],
+ ['an emptied optional remark', noteRequest, { note: 'a', remark: 'b' }, { note: 'a', remark: '' }, false],
+ ])(
+ 'blocks Approve for the edits the backend refuses, and only those: %s',
+ (_name, request, before, after, blocked) => {
+ const edits = editsOf(before, after, request.schema);
+ const refusal = validateSubmittedDecision(request, { action: 'approve', edits });
+
+ expect(blocksApproval(new Set(), request.schema, edits)).toBe(blocked);
+ expect(refusal.error?.code).toBe(blocked ? 'required_field_missing' : undefined);
+ },
+ );
+});
diff --git a/apps/ai-studio/src/utils/human-decision/decision-values.ts b/apps/ai-studio/src/utils/human-decision/decision-values.ts
new file mode 100644
index 000000000..204adb0e7
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/decision-values.ts
@@ -0,0 +1,91 @@
+import { editableFields } from '../editor-form/editor-layout';
+import { requiredFields, schemaFields } from '../editor-form/form-schema';
+import { hasText } from '../has-text';
+import { isPlainObject } from '../is-plain-object';
+
+/** The proposal's values for the fields the form declares. A hidden field is not declared, so it is not here. */
+export function proposedValues(proposal: unknown, schema: unknown): Record {
+ const source = isPlainObject(proposal) ? proposal : {};
+ return Object.fromEntries(
+ schemaFields(schema)
+ .filter(([key]) => Object.hasOwn(source, key))
+ .map(([key]) => [key, source[key]]),
+ );
+}
+
+/**
+ * Where the form starts: the draft, except that a field the person cannot change, or one the draft was not taken
+ * under, shows the proposal.
+ */
+export function startingValues(
+ proposed: Record,
+ draft: Record | undefined,
+ schema: unknown,
+ draftedFields?: readonly string[],
+): Record {
+ if (draft === undefined) {
+ return proposed;
+ }
+ // A draft can outlive its schema: undo takes a pick back under an open decision when the canvas lock is lifted.
+ const editable = editableFields(schema);
+ const drafted = new Set(draftedFields ?? Object.keys(draft));
+ const values = { ...draft };
+ for (const [key] of schemaFields(schema).filter(([name]) => !editable.has(name) || !drafted.has(name))) {
+ // The editor's validator throws on a key that holds `undefined`, so a value the proposal lacks is left out.
+ if (Object.hasOwn(proposed, key)) {
+ values[key] = proposed[key];
+ } else {
+ delete values[key];
+ }
+ }
+ return values;
+}
+
+function allowsNull(declaration: Record | undefined): boolean {
+ const type = declaration?.['type'];
+ return Array.isArray(type) && type.includes('null');
+}
+
+// An emptied field travels as null, or as '' from a text area; the backend reads both as emptied, and a dropped key
+// would keep the old value. A text area shows a null as empty and hands back '' once touched, which is no edit where
+// the type allows null.
+export function editsOf(
+ proposed: Record,
+ current: Record,
+ schema: unknown,
+): Record {
+ const declarations = new Map(schemaFields(schema));
+ const edits: Record = {};
+ for (const key of editableFields(schema)) {
+ const isNullShownEmpty = proposed[key] === null && current[key] === '' && allowsNull(declarations.get(key));
+ if (!Object.is(current[key], proposed[key]) && !isNullShownEmpty) {
+ edits[key] = current[key] === undefined ? null : current[key];
+ }
+ }
+ return edits;
+}
+
+// Must stay equal to `isEmptied` in the backend's validate-submitted-decision.ts; the parity table in the tests checks it.
+function isEmptied(value: unknown): boolean {
+ return value === undefined || value === null || (typeof value === 'string' && !hasText(value));
+}
+
+// Only a fault the person can correct holds the decision back: an editable field the schema faults, which the backend
+// does not check yet, or a required field the edits empty, which it refuses even where the type accepts the value.
+export function blocksApproval(
+ invalidFields: ReadonlySet,
+ schema: unknown,
+ edits: Record,
+): boolean {
+ const editable = editableFields(schema);
+ if ([...invalidFields].some((field) => editable.has(field))) {
+ return true;
+ }
+ const required = requiredFields(schema);
+ return Object.entries(edits).some(([key, value]) => required.has(key) && isEmptied(value));
+}
+
+/** The values a decision settled: the proposal with the edits applied, an emptied field left without a value. */
+export function withEdits(proposed: Record, edits: Record): Record {
+ return Object.fromEntries(Object.entries({ ...proposed, ...edits }).filter(([, value]) => value !== null));
+}
diff --git a/apps/ai-studio/src/utils/human-decision/review-request.fixture.ts b/apps/ai-studio/src/utils/human-decision/review-request.fixture.ts
new file mode 100644
index 000000000..b3fe025ca
--- /dev/null
+++ b/apps/ai-studio/src/utils/human-decision/review-request.fixture.ts
@@ -0,0 +1,22 @@
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+// Every type the form shows (number, string, boolean) and two it leaves out (integer, array).
+export const reviewRequest = {
+ version: 1,
+ actions: [
+ { name: 'approve', label: 'Approve', effect: 'resume', port: 'source:inner:approved' },
+ { name: 'reject', label: 'Reject', effect: 'reject', port: 'source:inner:rejected', reasonRequired: false },
+ ],
+ schema: {
+ type: 'object',
+ properties: {
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ orderDate: { type: 'string', readOnly: true },
+ replyDraft: { type: 'string', title: 'Reply draft' },
+ itemCount: { type: 'integer', title: 'Item count' },
+ expedite: { type: 'boolean', title: 'Expedite' },
+ tags: { type: 'array' },
+ },
+ required: ['refundAmount'],
+ },
+} satisfies DecisionRequest;
diff --git a/apps/ai-studio/src/utils/is-plain-object.ts b/apps/ai-studio/src/utils/is-plain-object.ts
new file mode 100644
index 000000000..4c5c70d3c
--- /dev/null
+++ b/apps/ai-studio/src/utils/is-plain-object.ts
@@ -0,0 +1,3 @@
+export function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/apps/ai-studio/src/utils/open-from-url/address-execution-id.test.ts b/apps/ai-studio/src/utils/open-from-url/address-execution-id.test.ts
new file mode 100644
index 000000000..e8bc1eb93
--- /dev/null
+++ b/apps/ai-studio/src/utils/open-from-url/address-execution-id.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { leaveRunView, syncExecutionIdToAddress, withExecutionId } from './address-execution-id';
+
+const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const NEXT_RUN = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+const WORKFLOW = '11111111-2222-4333-8444-555555555555';
+
+describe('withExecutionId', () => {
+ it('adds the run to an address without one', () => {
+ expect(withExecutionId('https://studio.test/', RUN)).toBe(`https://studio.test/?executionId=${RUN}`);
+ });
+
+ it('replaces the run and keeps the workflow and any other parameter', () => {
+ expect(withExecutionId(`https://studio.test/?workflowId=${WORKFLOW}&executionId=${RUN}&tab=log`, NEXT_RUN)).toBe(
+ `https://studio.test/?workflowId=${WORKFLOW}&executionId=${NEXT_RUN}&tab=log`,
+ );
+ });
+
+ it('removes the run and keeps the workflow', () => {
+ expect(withExecutionId(`https://studio.test/?workflowId=${WORKFLOW}&executionId=${RUN}`, null)).toBe(
+ `https://studio.test/?workflowId=${WORKFLOW}`,
+ );
+ });
+
+ it('leaves an address unchanged when it already says the same', () => {
+ const href = `https://studio.test/?executionId=${RUN}`;
+
+ expect(withExecutionId(href, RUN)).toBe(href);
+ expect(withExecutionId('https://studio.test/', null)).toBe('https://studio.test/');
+ });
+});
+
+describe('syncExecutionIdToAddress', () => {
+ afterEach(() => {
+ globalThis.history.replaceState(null, '', '/');
+ vi.restoreAllMocks();
+ });
+
+ it('rewrites the address in place, without a new history entry', () => {
+ const replaceState = vi.spyOn(globalThis.history, 'replaceState');
+ const pushState = vi.spyOn(globalThis.history, 'pushState');
+
+ syncExecutionIdToAddress(RUN);
+
+ expect(globalThis.location.search).toBe(`?executionId=${RUN}`);
+ expect(replaceState).toHaveBeenCalledTimes(1);
+ expect(pushState).not.toHaveBeenCalled();
+ });
+
+ it('does not touch history when the address already names the run', () => {
+ globalThis.history.replaceState(null, '', `/?executionId=${RUN}`);
+ const replaceState = vi.spyOn(globalThis.history, 'replaceState');
+
+ syncExecutionIdToAddress(RUN);
+
+ expect(replaceState).not.toHaveBeenCalled();
+ });
+});
+
+describe('leaveRunView', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ // Assigning the current URL again loads nothing when it carries a fragment.
+ it('drops the run from the address and reloads, a fragment included', () => {
+ const replaceState = vi.spyOn(globalThis.history, 'replaceState').mockImplementation(() => {});
+ const reload = vi.fn();
+ vi.stubGlobal('location', { href: `https://studio.test/?workflowId=${WORKFLOW}&executionId=${RUN}#log`, reload });
+
+ leaveRunView();
+
+ expect(replaceState).toHaveBeenCalledWith(null, '', `https://studio.test/?workflowId=${WORKFLOW}#log`);
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/ai-studio/src/utils/open-from-url/address-execution-id.ts b/apps/ai-studio/src/utils/open-from-url/address-execution-id.ts
new file mode 100644
index 000000000..3547ebf67
--- /dev/null
+++ b/apps/ai-studio/src/utils/open-from-url/address-execution-id.ts
@@ -0,0 +1,27 @@
+const PARAMETER = 'executionId';
+
+export function withExecutionId(href: string, executionId: string | null): string {
+ const url = new URL(href);
+ const current = url.searchParams.get(PARAMETER);
+ if (current === executionId) return href;
+
+ if (executionId === null) {
+ url.searchParams.delete(PARAMETER);
+ } else {
+ url.searchParams.set(PARAMETER, executionId);
+ }
+ return url.toString();
+}
+
+/** A full reload: the editor fixes its diagram at mount. `reload`, as assigning the same URL with a fragment loads nothing. */
+export function leaveRunView(): void {
+ syncExecutionIdToAddress(null);
+ globalThis.location.reload();
+}
+
+export function syncExecutionIdToAddress(executionId: string | null): void {
+ const next = withExecutionId(globalThis.location.href, executionId);
+ if (next !== globalThis.location.href) {
+ globalThis.history.replaceState(globalThis.history.state, '', next);
+ }
+}
diff --git a/apps/ai-studio/tsconfig.json b/apps/ai-studio/tsconfig.json
index c45765729..87b0c3ae6 100644
--- a/apps/ai-studio/tsconfig.json
+++ b/apps/ai-studio/tsconfig.json
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
- "lib": ["DOM", "ES2022"],
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
"baseUrl": ".",
"paths": {
"@workflowbuilder/sdk": ["../../packages/sdk/src/index.ts"]
diff --git a/apps/ai-studio/types.d.ts b/apps/ai-studio/types.d.ts
new file mode 100644
index 000000000..7d5b04108
--- /dev/null
+++ b/apps/ai-studio/types.d.ts
@@ -0,0 +1,7 @@
+declare global {
+ // React reads this flag to know the test environment wraps updates in act(); vitest.setup.ts sets it.
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+
+export {};
diff --git a/apps/ai-studio/vite.config.mts b/apps/ai-studio/vite.config.mts
index d2b9039ac..0741fa4ce 100644
--- a/apps/ai-studio/vite.config.mts
+++ b/apps/ai-studio/vite.config.mts
@@ -37,6 +37,7 @@ export default defineConfig(() => {
test: {
globals: true,
environment: 'jsdom',
+ setupFiles: ['./vitest.setup.ts'],
},
};
});
diff --git a/apps/ai-studio/vitest.setup.ts b/apps/ai-studio/vitest.setup.ts
new file mode 100644
index 000000000..ef46f7b26
--- /dev/null
+++ b/apps/ai-studio/vitest.setup.ts
@@ -0,0 +1 @@
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
diff --git a/apps/backend/.env.example b/apps/backend/.env.example
index ab1034d78..8c24f34ab 100644
--- a/apps/backend/.env.example
+++ b/apps/backend/.env.example
@@ -32,6 +32,10 @@ HOST=127.0.0.1
# exposing the API. Remove this line when wiring a real AuthPort. See:
# apps/backend/auth-port.decision-log.md
WB_AUTH_PORT=allow-all
+# Under the AllowAllAuthPort, turns on GET /api/workflows and GET /api/executions, which list every
+# workflow and every run; unset or any value but true answers 403 listing_disabled. A real AuthPort
+# authorizes listing itself and never reads this. Remove the line where a random id is the only secret.
+ENABLE_WB_LISTING=true
# Cloudflare Turnstile secret key (server-side). Leave empty to disable bot
# verification (local dev). When set, POST /api/workflows/:id/execute requires a
# valid Turnstile token sent by the frontend as the cf-turnstile-token header.
diff --git a/apps/backend/README.md b/apps/backend/README.md
index 936bbdadf..0963c2eed 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -29,6 +29,59 @@ Frontend (React)
- **Domain** (`packages/execution-core`) — pure graph runner + ports + node executors. No Temporal, no HTTP. See the [execution-core README](../../packages/execution-core/README.md).
- **Frontend** (`apps/ai-studio`) — full AI workflow product. Composes `@workflowbuilder/sdk` directly via JSX, with a slim plugin only for per-node execution markers. Owns Play/Stop controls, log panel, node detail, and execution highlighting.
+## Decision request on a node
+
+A node asks a human for a decision by carrying `data.properties.decisionRequest`: the actions offered, the JSON Schema of the form, the node whose output is judged, and an optional deadline. Any node type may carry one: the backend and the decision endpoint find the request by this field, never by `type`. The mapper lifts it to `BaseNode.decisionRequest`, out of `config`.
+
+The runner does not read the field, deliberately: it learns no product's vocabulary, so a run stops where a node's executor returns a waiting result. A request on a node that never parks therefore validates, reaches the worker and asks nobody anything. The node whose executor does nothing but park is `ai-studio/human-decision`: `apps/execution-worker/src/executors/human-decision.ts` returns `{ waiting: true }`, and `apps/ai-studio/src/nodes/human-decision/` renders one output handle per action that carries a port.
+
+The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. A `resume` or `reject` action always names its `port`: a port is the id of an output handle on the canvas, and the backend supplies no default for it. A `rerun-source` action takes no port, because it does not route. A node that carries a request may not set `errorPolicy: 'continue'`, since a failure would then light every port at once; `fail` and `errorRoute` are accepted. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. Each such detail also carries `domainCode`, its key in that dictionary, and `params` with the value the message interpolates, so a client branches and translates on the identifier and never on the wording; `code` stays zod's own.
+
+One key is refused outright, wherever it sits. An own `__proto__` anywhere in the snapshot answers `invalid_snapshot` 400 naming its path: `JSON.parse` turns it into an ordinary key, and a loose object copies unknown keys by assignment, which for that one swaps the parsed output's prototype and hands the engine a request no schema ever saw. The check does not weigh position, so it also refuses a `__proto__` buried inside an opaque node property, where zod never copies keys one by one and the key is inert. A node type that keeps a raw JSON document in `data.properties` therefore cannot carry one.
+
+A submitted decision is checked against the request by `validateSubmittedDecision` in `src/domain/decision/` and delivered by the endpoint below. Shape, rules and the reasoning are in [`decision-request.decision-log.md`](./decision-request.decision-log.md).
+
+### Deciding: `POST /api/executions/:id/decision`
+
+Body: `{ nodeId, attempt, action, edits?, reason?, comment? }`. `action` is the `name` of one of the node's actions. `attempt` is how many times the node has parked in this run (its `node_waiting` count; today always 1). `edits` are a patch of the proposal for whoever reads the decision to apply; the node's output carries them unapplied. Nothing applies them yet, so a step after the decision that reads the source's output sees the proposal without the corrections (follow-up: decision-settled-values). An object merges field by field and a list element by element, so a field whose schema declares `properties` or `items` must keep that shape; only `null` also passes, where the field's `type` allows it. Checks run in this order, each answering before the next: row, authorization (`executions:decide` with the row's `{ workflowId, tenantId, status }`; a deny wins over 404), status, body, node, decision, `attempt`, effect, engine. The engine is asked once; nothing is retried. Success: `200 { executionId, nodeId, attempt, action, effect }`. Codes and messages live in `src/routes/decision-refusals.ts`.
+
+The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiator is ignored. A `reject` also declares the run's outcome, edge or no edge: the run closes `completed` unless another branch ends it `incomplete` or `failed`, `GET /api/executions/:id` answers `outcome: 'rejected'` and `resolvedBy: 'human'` (`null` otherwise), and `execution_completed` carries `{ outcome: { value, resolvedBy, nodeId } }`. Publish requires no edge on a reject port.
+
+| Status | Code | When |
+| ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| 400 | `validation_error` | Body shape |
+| 400 | `invalid_decision` | Submission against the request; `details[0].code` is a `SUBMITTED_DECISION_ERRORS` key |
+| 404 | `execution_not_found` | |
+| 404 | `node_not_found` | Not in the run's snapshot |
+| 409 | `execution_not_waiting` | Terminal or cancelling run, or the engine no longer has it |
+| 409 | `node_not_waiting` | No request on the node, never parked, or not waiting now. Final |
+| 409 | `decision_already_made` | The first decision won, whoever sent it |
+| 409 | `decision_attempt_mismatch` | Body carries the current `attempt` |
+| 501 | `effect_not_supported` | `rerun-source`, until the engine can re-run a source |
+| 503 | `decision_delivery_timeout` | No worker accepted it in time. It may still land: resend (`Retry-After`); `decision_already_made` then names the wait, not the sender |
+
+## Listing executions: `GET /api/executions`
+
+Newest first, filtered and paged. Query: `status` (one `ExecutionStatus`), `workflowId` (a UUID), `limit` (default 50, capped at 200; a larger value is clamped, not refused), `cursor` (opaque, taken from the previous page's `nextCursor`). Success: `200 { items, nextCursor }`, `nextCursor` is `null` on the last page. Items carry summary fields only (`id`, `workflowId`, `sourceVersion`, `status`, `startedAt`, `finishedAt`, `createdAt`): no snapshot, no trigger payload, no outputs. Authorization is `executions:list` on `{ kind: 'executions' }`, checked before the query string is read. With a tenant context the list holds the caller's rows plus untenanted rows, the stream route's rule applied as a filter; without one, every row. An empty value counts as absent. Paging is keyset on `(date_trunc('milliseconds', created_at), id)`, not on the raw column: the cursor carries the millisecond `createdAt` the client saw, so both sides of the comparison are truncated the same way. A run submitted between two requests lands on top and never repeats or shifts the pages that follow. A run whose insert committed after a page was read but whose `created_at` predates the cursor is returned mid-walk, on a later page — the predicate only asks for rows older than the cursor. The reverse is the case a walk cannot show: a row whose `created_at` is newer than the cursor stays invisible until the client restarts from a fresh first page. No index serves this order: every page sorts the whole matching set, and `status` or `workflowId` narrow what is scanned but not what is sorted. The two-argument `date_trunc` used here depends on the session `TimeZone` and is therefore STABLE, so it cannot appear in an expression index; the three-argument `date_trunc('milliseconds', created_at, 'UTC')` is IMMUTABLE and returns the same values, so switching to it allows an index on `(date_trunc('milliseconds', created_at, 'UTC'), id)`. Storing `created_at` as `timestamptz(3)` is the other route; it rounds where the current key truncates, so a cursor minted before that migration repeats one row once (follow-up: executions-created-at-millis). A cursor minted under one filter stays valid under another.
+
+| Status | Code | When |
+| ------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| 400 | `invalid_status` | Not an `ExecutionStatus`; a typo never yields an empty list |
+| 400 | `invalid_workflow_id` | Not a UUID |
+| 400 | `invalid_limit` | Not a positive integer |
+| 400 | `invalid_cursor` | Not a canonical `\|` pair in base64url: the cursor is an opaque window position, validated on shape, not on origin |
+| 400 | `tenant_required` | A tenant context resolved without an id: a broken `TenantContextPort` adapter, refused rather than read as single-tenant |
+
+Under the `AllowAllAuthPort` the route answers `403 listing_disabled` unless `ENABLE_WB_LISTING=true`, like `GET /api/workflows`; see [Environment](#environment).
+
+## The graph a run executed: `GET /api/executions/:id/snapshot`
+
+Success: `200 { workflowId, sourceVersion, snapshot }`. `snapshot` is the workflow JSON the execute route copied into the run, as it was stored: editing or publishing the workflow afterwards leaves it unchanged. It is a route of its own, not a field of `GET /api/executions/:id`, because pollers call that one. Authorization is `executions:read` on `{ kind: 'execution', executionId }`, the same as `GET /api/executions/:id`, checked before the id is read.
+
+| Status | Code | When |
+| ------ | --------------------- | ------------------- |
+| 404 | `execution_not_found` | No run with this id |
+
## Running individual processes
For debugging, the parts that `pnpm dev:ai-studio` orchestrates can also be run separately:
@@ -62,6 +115,8 @@ of that contract. Each side degrades on its own when they are missing: the backe
returns 501, and the worker runs everything except AI Agent nodes. See
[`apps/execution-worker/README.md`](../execution-worker/README.md).
+When `server.ts` runs the `AllowAllAuthPort`, the two collection routes, `GET /api/workflows` and `GET /api/executions`, answer `403 listing_disabled` unless `ENABLE_WB_LISTING=true`: a workflow or a run is then private only while its random id stays unlisted, so a forgotten variable must not list every id. With any other `AuthPort` the variable is not read, and the port authorizes `workflows:list` and `executions:list` itself. The local `.env.example` sets it; the deploy leaves it unset. A new collection route goes into `LISTING_PATHS` in `src/middleware/listing-guard.ts`.
+
### Connecting to a secured Temporal cluster
The defaults above open a plaintext connection to the bundled dev cluster. Everything about the
diff --git a/apps/backend/auth-port.decision-log.md b/apps/backend/auth-port.decision-log.md
index dd12a4546..699884905 100644
--- a/apps/backend/auth-port.decision-log.md
+++ b/apps/backend/auth-port.decision-log.md
@@ -21,19 +21,21 @@ This decision log records the structural piece (scope L from [`local-dev-binding
## Actions covered today
-| Action | Resource |
-| ------------------- | ------------------------------------ |
-| `workflows:create` | `{ kind: 'workflows' }` |
-| `workflows:list` | `{ kind: 'workflows' }` |
-| `workflows:read` | `{ kind: 'workflow', workflowId }` |
-| `workflows:update` | `{ kind: 'workflow', workflowId }` |
-| `workflows:publish` | `{ kind: 'workflow', workflowId }` |
-| `workflows:execute` | `{ kind: 'workflow', workflowId }` |
-| `executions:read` | `{ kind: 'execution', executionId }` |
-| `executions:stream` | `{ kind: 'execution', executionId }` |
-| `executions:cancel` | `{ kind: 'execution', executionId }` |
-
-Per-row resource kinds (`workflow`, `execution`) also accept an optional `attributes: Record`. Routes that already loaded the row can pass it through so ABAC ports do not need to refetch. Pure RBAC ports ignore the field. Routes that load before authorize is wired (see follow-ups on data scoping) will start using it without a breaking change.
+| Action | Resource |
+| ------------------- | ------------------------------------------------- |
+| `workflows:create` | `{ kind: 'workflows' }` |
+| `workflows:list` | `{ kind: 'workflows' }` |
+| `workflows:read` | `{ kind: 'workflow', workflowId }` |
+| `workflows:update` | `{ kind: 'workflow', workflowId }` |
+| `workflows:publish` | `{ kind: 'workflow', workflowId }` |
+| `workflows:execute` | `{ kind: 'workflow', workflowId }` |
+| `executions:list` | `{ kind: 'executions' }` |
+| `executions:read` | `{ kind: 'execution', executionId }` |
+| `executions:stream` | `{ kind: 'execution', executionId }` |
+| `executions:cancel` | `{ kind: 'execution', executionId }` |
+| `executions:decide` | `{ kind: 'execution', executionId, attributes? }` |
+
+Per-row resource kinds (`workflow`, `execution`) also accept an optional `attributes: Record`. Routes that already loaded the row can pass it through so ABAC ports do not need to refetch. Pure RBAC ports ignore the field. The decision route is the first to load before it authorizes: `attributes` is `{ workflowId, tenantId, status }`, absent when the row does not exist, and a deny is answered before the 404. Hiding which ids exist depends on the port: it must deny when `attributes` is absent too, or a caller learns that 404 means unknown and 403 means someone else's.
## Alternative Options Considered
@@ -156,7 +158,7 @@ export class JwtAuthPort implements AuthPort {
const roles = (caller.attributes?.roles as string[] | undefined) ?? [];
// Reads are open to any authenticated user.
- if (action.endsWith(':read') || action === 'workflows:list' || action === 'executions:stream') {
+ if (action.endsWith(':read') || action.endsWith(':list') || action === 'executions:stream') {
return true;
}
diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md
new file mode 100644
index 000000000..aead2739b
--- /dev/null
+++ b/apps/backend/decision-request.decision-log.md
@@ -0,0 +1,93 @@
+### Title: Decision request as versioned data on a node
+
+### Proposed by: Piotr Błaszczyk
+
+### Date: 07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome), 29.09.2026 (failure policy, edit shape)
+
+## Context
+
+A run can park at a node until a person decides. The backend needs to know what a decision at that node looks like: the actions offered, the fields the decider sees and may correct, whose output is judged, how long to wait. Products bring their own vocabulary; the engine has a closed set of things it can do; the backend knows no product's node types.
+
+The shape itself is documented on the type (`packages/types/src/workflow-execution/decision-request.ts`) and enforced by `decisionRequestSchema` in `apps/backend/src/domain/decision/`. This log keeps only what the code cannot say. A complete example, the refund story from the design workshop, is the `workedExample()` fixture in `decision-request-schema.test.ts`.
+
+## Decision
+
+1. **Data on the node, not a node kind.** The request lives under the reserved key `data.properties.decisionRequest` and is lifted to `BaseNode.decisionRequest`, as `errorPolicy` is. Its presence is the only marker; nothing detects such a node by `type`. Present or absent, never `null`: a `null` value is refused, so an editor that clears the request must remove the key. A client adding its own node type never has to teach the backend about it.
+2. **Name and effect are split.** `name` and `label` are the client's words ("Escalate to finance"); `effect` is the engine's closed set. A new business vocabulary is data, not a code change.
+3. **Edit is not an action.** The decider corrects fields and approves. Whether a field may be edited is already said by `readOnly` in the schema; a second switch would be a second source of truth. `resume-with-edits` is therefore derived, never declared.
+4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. Shape only means: an object with a `properties` map, `required` naming declared fields, and `readOnly`, `x-pii` and `type` well-typed where present. `type` is optional, as JSON Schema makes it and as JsonForms renders without it. Every other keyword passes through unread.
+5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. Each domain issue in `details` carries `domainCode` and `params` beside zod's `code` and the English `message`, so a client keys on the identifier and the wording stays free to change.
+6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. Publishing refuses a request whose source does not resolve: a published decision with nothing to judge is not what the author meant, and with no predecessor at all the node is an orphan the runner fails anyway. Only the rule that the source must not carry its own request stays tied to `rerun-source`, the one effect that re-runs it. A resolved source can still yield no proposal at decision time, when that branch was skipped, so the pending-decision resource keeps its no-proposal path.
+7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`reasonRequired`, `maxIterations`). The stored snapshot stays raw.
+8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what is recorded on the node's completion and audited; it names the chosen action and carries the effect, edits, reason and comment. The matched `DecisionAction` is returned beside it for routing, never inside it, so the port and label live once, on the request. The shape of a submission is parsed with `submittedDecisionSchema` at the endpoint; `validateSubmittedDecision` assumes it and checks only the rules. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person.
+9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says.
+10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design.
+
+11. **An own `__proto__` key anywhere in a snapshot is refused before parsing.** `JSON.parse` makes it an ordinary key, and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype: everything under it then reads back as validated, and the mapper would copy an inherited request into a real field on the way to the engine. Both parsers that preserve unknown keys are wrapped in a preprocess that rejects the key at its path: `workflowSnapshotSchema`, which answers the usual `invalid_snapshot` 400, and `decisionRequestSchema`, which guards itself so a caller parsing raw JSON with it cannot inherit a request no schema checked. `z.record` is immune by construction but cannot type known keys beside unknown ones, and it protects only its own level, so it is no substitute here.
+
+## The endpoint (10.09.2026)
+
+What the endpoint does and answers is in the README. Only the reasons are here.
+
+12. **`nodeId` in the body, not the path**, so the pending-decision resource addresses the same node the same way. The route owns the body shape; `validateSubmittedDecision` judges only the rules, the workflow's validator only engine integrity.
+13. **The row is read before authorization** so a port can scope by it; a deny therefore wins over 404 and reveals no id.
+14. **Only terminal and `cancelling` runs are refused by status.** The status write is best-effort, so a parked run may read `pending`; the engine is the arbiter.
+15. **`attempt` is the node's `node_waiting` count.** The engine has no attempt yet, so the check is not atomic with delivery; it holds only while a node parks at most once. The rerun loop has to carry the wait instance into the engine `(follow-up: decision-attempt-in-engine)`.
+16. **A second submission is always 409 `decision_already_made`.** The backend stores nothing about a decision, so it cannot tell a repeat from a contradiction; a byte-identical replay needs a caller key `(follow-up: decision-idempotency-key)`. The Temporal update id stays random: a deterministic one would hand a second decider the first one's outcome.
+17. **`rerun-source` is 501** until the engine can re-run a source; the LLM budget guard and rate limit move there with the verb `(follow-up: decision-rerun-source)`.
+18. **`node_not_waiting` is final** because the runner registers the wait before announcing it. **`delivery_timeout` is 503 with a hedged message** because an update nobody accepted is not durable, yet the server may still hand it to the next worker.
+
+## Explicit ports (17.09.2026)
+
+19. **A port is never defaulted.** It is the id of an output handle on one canvas, so no value chosen without that canvas can be right, as with `deadline.policy`. The former defaults `approved` and `rejected` let a request publish with no handle to draw an edge from, and the run ended `incomplete` after the person had decided. `rerun-source` refuses a port from the other side: it does not route, so a handle for it would never fire.
+20. **A missing port is a structural issue**, like any other missing key: zod's wording, no domain code. The editor always writes ports, so no interface ever shows it. A structural failure on an action suspends the request-level rules for that round; a domain issue does not. That is why the `rerun-source` port is refused on the field: its issue survives a structural failure beside it, while the request-level rules wait for the action to parse.
+21. This was the first tightening of the snapshot schema since the decision route began re-parsing stored snapshots: a run parked with a port-less action, or with a `rerun-source` action carrying a `port` (the loose object used to keep it), would answer 500 until its snapshot was fixed. Accepted, because the feature lived on its branch with no run in flight.
+
+## The outcome of a rejection (21.09.2026)
+
+22. **A rejection is the run's result, not a status.** The run closes `completed`, in our status and in Temporal's, unless another branch ends it `incomplete` or `failed`; `outcome` (`rejected`) and `resolvedBy` (`human`) are nullable open strings on the row, never a fifth terminal status or an enum. `toNodeResolution` declares the outcome on every `reject`, edge or no edge: it says what the run's result was, not that an edge was missing. The runner reads it for presence only, so `rejected` is written here and nowhere else.
+23. **A reject port with no edge is a deliberate end.** The runner records no dead end for a completion with an outcome. Publish requires no edge there, a test pins it, and a future rule wiring every handle must keep the exception.
+24. **The route stamps the initiator.** The validator judges the body against the request and returns the decision without `resolvedBy`; the route adds `human`, since only it knows who called, and the schema strips an initiator sent in the body. Identity will be stamped at the same line; a deadline that decides writes its own inside the workflow. Note what the initiator reaches: the row's `resolved_by` (readable by any `executions:read` caller), the `execution_completed` payload, and the node's `output`, which downstream nodes read and the node's `outputSchema` declares. None is on the `x-pii` path `(follow-up: decision-initiator-identity-exposure)`.
+
+## Failure policy and edit shape (29.09.2026)
+
+25. **A node that carries a request may not use `errorPolicy: 'continue'`.** The runner absorbs such a failure with no port, which lights every non-error edge: a worker without the node's executor, or a `node_completed` write that fails after an accepted verdict, would run approve and reject together. Publish and execute refuse it with `error_policy_continue`; `fail` and `errorRoute` keep failures visible. The rule lives in the backend, not the runner, because the runner deliberately reads no request. Like decision 21 it tightens a schema the decision route re-parses, so a run parked on such a node would answer 500; accepted, because the feature lives on its branch with no run in flight.
+26. **Edits are a patch of the proposal.** The node's output carries them unapplied; whoever reads the decision merges an object field by field and a list element by element. That is why the walk checks only the keys an edit names. A level with `properties` or `items` must therefore keep its shape: `null`, a primitive or the other container could drop the read-only and required children it may hold, so it answers `field_shape_changed`, except `null` where the level's `type` allows it. `null` is the patch's own way to empty a field, and listing it in `type` is the author's consent; any other value would replace the level rather than patch it, even one its `type` lists. Replacement was rejected: it would make every object with a read-only child uneditable as a whole.
+
+## Rejected
+
+- Detecting the node by its type string: the backend would have to learn every product's vocabulary.
+- A home-grown field list instead of JSON Schema: a second standard to render and validate.
+- An `ignore` verb: a disguised "abandon the run".
+- Defaulting `deadline.policy` to `reject`: a timer that rejects is audit-relevant and must be written down, not implied.
+- Recording the whole action object on the decision: the port and label would then live twice, on the request and in every completion, with two sources of truth about where a verdict routes.
+- Exporting the duration pattern from the Temporal plugin: a published API widened for one regex; duplicated with a pointer instead `(follow-up: shared-duration-format)`.
+- Persisting the first submission only to turn one 409 into a 200.
+- Requiring `executions.status === 'waiting'`: it would lock the route to a best-effort write.
+- Retrying `node_not_waiting`: the race was fixed at its root, in the runner's order of registering and announcing.
+- A default port elsewhere: in the template it would be a handle id outside the SDK's shape, kept in two places; in the backend it would teach the backend the editor's handle format.
+
+## Known gaps
+
+- A workflow with a `null` draft still publishes `null`, unvalidated, as it did before. Changing that is its own decision.
+- A draft may store an own `__proto__` key; it goes nowhere but the database, and publish and execute refuse it. Rejecting it at save time was judged not worth touching the draft route.
+- The submission validator returns the first refusal, not a list.
+- It checks editability, presence and shape at every level the form describes inline, following `properties` and `items`. A level reached only through `$ref` or a composition keyword describes nothing there, so an edit into it is refused as an unknown field rather than checked `(follow-up: decision-edit-schema-composition)`.
+- The snapshot schema does not check that edge endpoints exist, so an explicit source with a dangling edge passes. This predates the change.
+- Node ids are not checked for uniqueness either; with a duplicate, the graph rules see the first node of that id. Also pre-existing `(follow-up: snapshot-node-id-uniqueness)`.
+- The route re-parses the stored snapshot with today's `workflowSnapshotSchema`, and a run can wait for days across deploys. A schema tightened in between makes every parked run whose snapshot no longer parses undecidable: the route answers 500 until the snapshot is migrated or the rule relaxed.
+- Edits as a patch: a list edit longer than the proposal appends elements that carry none of the read-only fields, since the validator never reads the proposal; a list cannot be shortened, since `[]` patches no element; an object whose `type` does not allow `null` cannot be cleared. AI Studio's `withEdits` only renders a decided record and merges top-level keys, all its form can edit.
+- Nothing applies the edits yet: the node's output is the decision alone, so a step after it that reads the source's output sees the proposal without the corrections. Refund Review merges them in its prompt `(follow-up: decision-settled-values)`.
+- The `errorPolicy` rule keys on the request, so a node that routes by port without one (a condition node, or a decision node missing its request) still lights every branch on a failure under `continue` `(follow-up: port-routing-continue-broadcast)`.
+
+## Open points, closed 10.09.2026
+
+A whitespace-only `reason` counts as missing, and "emptied" means `undefined`, `null` or whitespace: both kept. Edits on a non-`resume` action are now refused with `edits_not_allowed`, before the field rules; dropping them silently was the worse failure.
+
+## Not in this change
+
+Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, the pending-decision resource, the rerun loop, the deadline timer, authoring the request in the editor `(follow-up: decision-request-properties-ui)`, and the node that actually parks. The runner learns no product's vocabulary by design, so a run stops where a node's executor returns a waiting result, never because a field is present. The node type whose executor does only that, and therefore waits without side effects of its own, was its own task; it shipped as `apps/execution-worker/src/executors/human-decision.ts`.
+
+## Status
+
+Accepted
diff --git a/apps/backend/drizzle/0002_crazy_hobgoblin.sql b/apps/backend/drizzle/0002_crazy_hobgoblin.sql
new file mode 100644
index 000000000..1bdcf2c6a
--- /dev/null
+++ b/apps/backend/drizzle/0002_crazy_hobgoblin.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "executions" ADD COLUMN "outcome" text;--> statement-breakpoint
+ALTER TABLE "executions" ADD COLUMN "resolved_by" text;
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json
new file mode 100644
index 000000000..013832b08
--- /dev/null
+++ b/apps/backend/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,371 @@
+{
+ "id": "3cc9878d-bcbe-4ec5-b5b1-0e6e29269d3a",
+ "prevId": "a831f5bf-efaf-47d7-8a84-9845bdf309de",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.execution_events": {
+ "name": "execution_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "execution_id": {
+ "name": "execution_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "node_id": {
+ "name": "node_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "path_id": {
+ "name": "path_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "execution_events_execution_sequence_idx": {
+ "name": "execution_events_execution_sequence_idx",
+ "columns": [
+ {
+ "expression": "execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sequence",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "execution_events_execution_id_idx": {
+ "name": "execution_events_execution_id_idx",
+ "columns": [
+ {
+ "expression": "execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "execution_events_tenant_id_idx": {
+ "name": "execution_events_tenant_id_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "execution_events_execution_id_executions_id_fk": {
+ "name": "execution_events_execution_id_executions_id_fk",
+ "tableFrom": "execution_events",
+ "tableTo": "executions",
+ "columnsFrom": ["execution_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.executions": {
+ "name": "executions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_snapshot_json": {
+ "name": "workflow_snapshot_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "trigger_payload_json": {
+ "name": "trigger_payload_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by": {
+ "name": "resolved_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "executions_workflow_id_idx": {
+ "name": "executions_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "executions_status_idx": {
+ "name": "executions_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "executions_tenant_id_idx": {
+ "name": "executions_tenant_id_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "executions_workflow_id_workflows_id_fk": {
+ "name": "executions_workflow_id_workflows_id_fk",
+ "tableFrom": "executions",
+ "tableTo": "workflows",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflows": {
+ "name": "workflows",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_json": {
+ "name": "draft_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_json": {
+ "name": "published_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index 503fd982b..6bd8ada2e 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1780490055890,
"tag": "0001_odd_iron_lad",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1789998034189,
+ "tag": "0002_crazy_hobgoblin",
+ "breakpoints": true
}
]
}
diff --git a/apps/backend/multi-tenancy.md b/apps/backend/multi-tenancy.md
index df69d486e..350264812 100644
--- a/apps/backend/multi-tenancy.md
+++ b/apps/backend/multi-tenancy.md
@@ -12,6 +12,7 @@ How to make the reference backend multi-tenant. For **why** it is shaped this wa
| 2 | Stamp `tenantId` onto the execution row at submit | shipped (no-op default) | `src/routes/workflows.ts` | ✅ |
| 3 | Worker inherits `tenant_id` for each event from its parent execution | shipped (no-op default) | `apps/execution-worker/src/database.ts` | ⚠️ none |
| 4 | SSE stream cross-check (defence-in-depth) | shipped (no-op default) | `src/routes/executions.ts` | ✅ |
+| 4b | Tenant filter on the executions collection | shipped (no-op default) | `src/routes/list-executions-query.ts` | ✅ |
| 5 | Postgres Row-Level Security | **documented pattern, not shipped** | — | — |
"No-op default" means: under `NoopTenantContextPort` the tenant is `null` on every request, every seam degrades to single-tenant behaviour, and the reference runs with zero tenancy ceremony. Swap the port instance to turn seams 1–4 on; enable seam 5 yourself.
@@ -70,6 +71,12 @@ On mismatch the response is a **404 byte-identical to not-found, not a 403** —
→ `src/routes/executions.ts`
+#### Seam 4b — the collection route filters in `WHERE`
+
+`GET /api/executions` cannot lean on `AuthPort` for scoping: the port answers one resource at a time and never sees a result set. `listExecutionsWhere` therefore adds `tenant_id = caller OR tenant_id IS NULL` to the query whenever `c.var.tenant` is set, and no clause at all when it is `null`. Untenanted rows stay visible to every tenant, the same trade-off as seam 4; RLS (seam 5) is the backstop for deployments that need them hidden.
+
+→ `src/routes/list-executions-query.ts`
+
### Seam 5 — Postgres Row-Level Security (you enable this)
App-level scoping (`AuthPort` + seams 2–4) is the primary enforcement. RLS is the safety net for the day someone forgets a `WHERE tenant_id`. It is **not** shipped: under the no-op default a `NULL` session variable compared to a `NULL` column silently returns zero rows for every query, and the right policy (admin bypass? parent-tenant visibility? DB-per-tenant?) is consumer-specific.
diff --git a/apps/backend/src/auth/auth-port.ts b/apps/backend/src/auth/auth-port.ts
index e977341a1..781b663de 100644
--- a/apps/backend/src/auth/auth-port.ts
+++ b/apps/backend/src/auth/auth-port.ts
@@ -34,9 +34,11 @@ export type AuthAction =
| 'workflows:update'
| 'workflows:publish'
| 'workflows:execute'
+ | 'executions:list'
| 'executions:read'
| 'executions:stream'
- | 'executions:cancel';
+ | 'executions:cancel'
+ | 'executions:decide';
/**
* Resources passed to `authorize`. The per-row kinds carry an optional
@@ -46,6 +48,7 @@ export type AuthAction =
*/
export type AuthResource =
| { kind: 'workflows' }
+ | { kind: 'executions' }
| { kind: 'workflow'; workflowId: string; attributes?: Record }
| { kind: 'execution'; executionId: string; attributes?: Record };
diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts
index 4151f83e0..18c35fffd 100644
--- a/apps/backend/src/db/schema.ts
+++ b/apps/backend/src/db/schema.ts
@@ -30,6 +30,9 @@ export const executions = pgTable(
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
errorMessage: text('error_message'),
+ // Open strings, not enums: the vocabulary is the product's.
+ outcome: text('outcome'),
+ resolvedBy: text('resolved_by'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
diff --git a/apps/backend/src/domain/decision/decision-issues.test.ts b/apps/backend/src/domain/decision/decision-issues.test.ts
new file mode 100644
index 000000000..7c421b0db
--- /dev/null
+++ b/apps/backend/src/domain/decision/decision-issues.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ DECISION_ISSUE_MESSAGES,
+ decisionIssue,
+ decisionIssueMessage,
+ decisionIssueOf,
+ decisionRefinement,
+} from './decision-issues';
+
+describe('decisionIssueMessage', () => {
+ it('fills the placeholder', () => {
+ expect(decisionIssueMessage('duplicate_action_name', 'approve')).toBe(
+ "action name 'approve' is used more than once",
+ );
+ });
+
+ it('keeps replacement patterns in the value verbatim', () => {
+ expect(decisionIssueMessage('source_has_decision_request', '$&-$1')).toBe(
+ "proposal source '$&-$1' carries its own decision request and cannot be re-run",
+ );
+ });
+
+ it('ignores a value for a message without a placeholder', () => {
+ expect(decisionIssueMessage('resume_required', 'ignored')).toBe(DECISION_ISSUE_MESSAGES.resume_required);
+ });
+
+ it('builds the issue shape a superRefine adds, carrying its identifier', () => {
+ expect(decisionIssue('duplicate_action_name', ['actions', 1, 'name'], 'approve')).toEqual({
+ code: 'custom',
+ message: "action name 'approve' is used more than once",
+ path: ['actions', 1, 'name'],
+ params: { issue: 'duplicate_action_name', value: 'approve' },
+ });
+ });
+
+ it('leaves `value` out of the identifier when the message has none', () => {
+ expect(decisionIssue('port_empty', ['actions', 0, 'port']).params).toEqual({ issue: 'port_empty' });
+ expect(decisionRefinement('port_empty')).toEqual({
+ error: 'port must not be blank',
+ params: { issue: 'port_empty' },
+ });
+ });
+});
+
+describe('decisionIssueOf', () => {
+ it('reads the identifier back off an issue, with and without a value', () => {
+ expect(decisionIssueOf(decisionIssue('duplicate_effect', ['actions'], 'resume'))).toEqual({
+ issue: 'duplicate_effect',
+ value: 'resume',
+ });
+ expect(decisionIssueOf(decisionIssue('resume_required', ['actions']))).toEqual({ issue: 'resume_required' });
+ });
+
+ it.each([
+ { name: "zod's own structural issue", issue: { code: 'invalid_type', path: ['nodes'], message: 'x' } },
+ { name: 'params that are not ours', issue: { code: 'custom', params: { minimum: 1 } } },
+ { name: 'an identifier not in the dictionary', issue: { code: 'custom', params: { issue: 'made_up' } } },
+ {
+ name: 'an identifier that exists only on Object.prototype',
+ issue: { code: 'custom', params: { issue: 'constructor' } },
+ },
+ { name: 'no object at all', issue: null },
+ ])('answers undefined for $name', ({ issue }) => {
+ expect(decisionIssueOf(issue)).toBeUndefined();
+ });
+});
diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts
new file mode 100644
index 000000000..cacfb6c5f
--- /dev/null
+++ b/apps/backend/src/domain/decision/decision-issues.ts
@@ -0,0 +1,85 @@
+// Every message the decision-request validation can produce. `{value}` is the one
+// interpolation slot. Structural failures (wrong type, missing key) keep zod's wording.
+export const DECISION_ISSUE_MESSAGES = {
+ actions_empty: 'at least one action is required',
+ name_empty: 'name must not be blank',
+ label_empty: 'label must not be blank',
+ unknown_effect: 'effect must be one of {value}',
+ duplicate_action_name: "action name '{value}' is used more than once",
+ duplicate_effect: "only one action may have effect '{value}'",
+ resume_required: "an action with effect 'resume' is required",
+ port_empty: 'port must not be blank',
+ port_reserved: "port must not be the reserved 'errorRoute'",
+ port_not_allowed: 'a rerun-source action does not route and takes no port',
+ reject_port_equals_resume_port: "reject port '{value}' must differ from the resume port",
+ required_field_undeclared: "required field '{value}' is not declared in properties",
+ deadline_format:
+ "must be a duration such as '30s' or '3d' (number plus ms, s, m, h or d), above zero and at most '3652500d'",
+ deadline_policy: "policy must be 'reject'",
+ source_node_without_decision_request: 'this node carries no decision request',
+ source_not_a_predecessor: "proposalSourceNodeId '{value}' is not a direct predecessor of this node",
+ source_missing: 'a rerun-source action needs a proposal source, but this node has no predecessor',
+ source_ambiguous: 'several predecessors; set proposalSourceNodeId to say which one rerun-source re-runs',
+ source_has_decision_request: "proposal source '{value}' carries its own decision request and cannot be re-run",
+ error_policy_continue:
+ "errorPolicy 'continue' would send a failure of this node down every port; use 'fail' or 'errorRoute'",
+} as const;
+
+export type DecisionIssueCode = keyof typeof DECISION_ISSUE_MESSAGES;
+
+// Every way a submitted decision can be refused against the node's decision request.
+export const SUBMITTED_DECISION_ERRORS = {
+ unknown_action: "the decision request offers no action named '{value}'",
+ reason_required: "action '{value}' requires a reason",
+ comment_required: "action '{value}' requires a comment",
+ edits_not_allowed: "action '{value}' does not take edits",
+ unknown_field: "field '{value}' is not in the decision schema",
+ field_not_editable: "field '{value}' is read-only",
+ required_field_missing: "required field '{value}' must not be emptied",
+ field_shape_changed: "field '{value}' must keep its shape: an object stays an object and a list stays a list",
+} as const;
+
+export type SubmittedDecisionErrorCode = keyof typeof SUBMITTED_DECISION_ERRORS;
+
+export function fill(template: string, value: string | undefined): string {
+ // A function replacer, so a value containing `$&` or `$1` lands verbatim.
+ return template.replace('{value}', () => value ?? '');
+}
+
+export function decisionIssueMessage(code: DecisionIssueCode, value?: string): string {
+ return fill(DECISION_ISSUE_MESSAGES[code], value);
+}
+
+export function submittedDecisionErrorMessage(code: SubmittedDecisionErrorCode, value?: string): string {
+ return fill(SUBMITTED_DECISION_ERRORS[code], value);
+}
+
+// Rides on the zod issue as `params` and is read back by the HTTP serializer, so a client
+// branches and translates on the identifier and never on the wording of `message`.
+export type DecisionIssueParams = { issue: DecisionIssueCode; value?: string };
+
+function decisionIssueParams(code: DecisionIssueCode, value: string | undefined): DecisionIssueParams {
+ return value === undefined ? { issue: code } : { issue: code, value };
+}
+
+export function decisionIssue(code: DecisionIssueCode, path: PropertyKey[], value?: string) {
+ return {
+ code: 'custom' as const,
+ message: decisionIssueMessage(code, value),
+ path,
+ params: decisionIssueParams(code, value),
+ };
+}
+
+// The same issue, shaped as the options a `.refine` takes.
+export function decisionRefinement(code: DecisionIssueCode, value?: string) {
+ return { error: decisionIssueMessage(code, value), params: decisionIssueParams(code, value) };
+}
+
+export function decisionIssueOf(issue: unknown): DecisionIssueParams | undefined {
+ const params = typeof issue === 'object' && issue !== null ? (issue as { params?: unknown }).params : undefined;
+ if (typeof params !== 'object' || params === null) return undefined;
+ const { issue: code, value } = params as { issue?: unknown; value?: unknown };
+ if (typeof code !== 'string' || !Object.hasOwn(DECISION_ISSUE_MESSAGES, code)) return undefined;
+ return decisionIssueParams(code as DecisionIssueCode, typeof value === 'string' ? value : undefined);
+}
diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts
new file mode 100644
index 000000000..93faeca1d
--- /dev/null
+++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts
@@ -0,0 +1,490 @@
+import { describe, expect, expectTypeOf, it } from 'vitest';
+import type { z } from 'zod';
+
+import {
+ DECLARABLE_DECISION_EFFECTS,
+ type DecisionRequest,
+} from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { type DecisionIssueCode, decisionIssueMessage, decisionIssueOf } from './decision-issues';
+import { decisionRequestSchema } from './decision-request-schema';
+
+const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' };
+const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false };
+const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 };
+
+const refundForm = {
+ type: 'object',
+ properties: {
+ orderDate: { type: 'string', title: 'Order date', readOnly: true },
+ customerEmail: { type: 'string', title: 'Customer e-mail', readOnly: true, 'x-pii': true },
+ refundAmount: { type: 'number', title: 'Refund amount' },
+ emailDraft: { type: 'string', title: 'E-mail draft' },
+ },
+ required: ['refundAmount'],
+};
+
+// The refund story from the design workshop.
+function workedExample() {
+ return {
+ version: 1,
+ actions: [approve, reject, askAgain],
+ schema: refundForm,
+ uiSchema: { type: 'VerticalLayout', elements: [] },
+ proposalSourceNodeId: 'source-1',
+ deadline: { after: '3d', policy: 'reject' },
+ };
+}
+
+function request(overrides: Record = {}): unknown {
+ return { ...workedExample(), ...overrides };
+}
+
+function issuesOf(input: unknown): { path: string; message: string; domain?: { issue: string; value?: string } }[] {
+ const result = decisionRequestSchema.safeParse(input);
+ return result.success
+ ? []
+ : result.error.issues.map((issue) => ({
+ path: issue.path.join('.'),
+ message: issue.message,
+ domain: decisionIssueOf(issue),
+ }));
+}
+
+const declarableEffects = DECLARABLE_DECISION_EFFECTS.join(', ');
+
+describe('decisionRequestSchema', () => {
+ it('accepts the refund worked example', () => {
+ expect(decisionRequestSchema.safeParse(workedExample()).success).toBe(true);
+ });
+
+ it('accepts a minimal request: one resume action and an empty form', () => {
+ const minimal = {
+ version: 1,
+ actions: [{ name: 'ok', label: 'OK', effect: 'resume', port: 'ok' }],
+ schema: { type: 'object', properties: {} },
+ };
+
+ expect(decisionRequestSchema.safeParse(minimal).success).toBe(true);
+ });
+
+ // Shapes JsonForms 3.5.1 generates a control for. The parser reads none of their
+ // keywords, so each has to reach the renderer exactly as authored.
+ it.each([
+ { shape: 'a list of type names', property: { type: ['string', 'null'] } },
+ { shape: 'enum alone', property: { enum: ['open', 'closed'] } },
+ { shape: 'a local $ref', property: { $ref: '#/$defs/money' } },
+ { shape: 'anyOf alone', property: { anyOf: [{ type: 'string' }, { type: 'number' }] } },
+ { shape: 'readOnly beside no type', property: { enum: ['open'], readOnly: true, 'x-pii': true } },
+ ])('accepts a form property declared with $shape, untouched', ({ property }) => {
+ const parsed = decisionRequestSchema.safeParse(
+ request({ schema: { type: 'object', properties: { field: property } } }),
+ );
+
+ expect(parsed.success).toBe(true);
+ expect(parsed.success ? parsed.data.schema['properties'] : undefined).toEqual({ field: property });
+ });
+
+ it.each([...DECLARABLE_DECISION_EFFECTS])('accepts a declared %s action', (effect) => {
+ const declared = { resume: approve, reject, 'rerun-source': askAgain }[effect];
+ const actions = effect === 'resume' ? [declared] : [approve, declared];
+
+ const parsed = decisionRequestSchema.parse(request({ actions }));
+
+ expect(parsed.actions.at(-1)?.effect).toBe(effect);
+ });
+
+ it('materialises the defaults for reasonRequired and maxIterations', () => {
+ const parsed = decisionRequestSchema.parse(
+ request({
+ actions: [
+ approve,
+ { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected' },
+ { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' },
+ ],
+ }),
+ );
+
+ expect(parsed.actions).toEqual([approve, reject, askAgain]);
+ });
+
+ it('keeps unknown keys at every level', () => {
+ const input = {
+ ...workedExample(),
+ audience: 'finance',
+ actions: [{ ...approve, icon: 'check' }],
+ schema: {
+ ...refundForm,
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ properties: {
+ ...refundForm.properties,
+ customerEmail: { ...refundForm.properties.customerEmail, 'x-mask': 'email' },
+ },
+ },
+ uiSchema: { type: 'VerticalLayout', elements: [{ type: 'Control', scope: '#/properties/refundAmount' }] },
+ deadline: { after: '3d', policy: 'reject', warnAfter: '2d' },
+ };
+
+ const parsed = decisionRequestSchema.parse(input);
+
+ expect(parsed).toMatchObject({
+ audience: 'finance',
+ actions: [{ icon: 'check' }],
+ schema: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ properties: { customerEmail: { title: 'Customer e-mail', 'x-mask': 'email' } },
+ },
+ uiSchema: input.uiSchema,
+ deadline: { warnAfter: '2d' },
+ });
+ });
+
+ it('accepts an explicit readOnly: false', () => {
+ const schema = { type: 'object', properties: { amount: { type: 'number', readOnly: false } } };
+
+ const parsed = decisionRequestSchema.parse(request({ schema }));
+
+ expect(parsed.schema.properties['amount']).toEqual({ type: 'number', readOnly: false });
+ });
+
+ it.each(['100ms', '30s', '10m', '1.5h', '24h', '3d', '7d', '3652500d'])('accepts a deadline of %s', (after) => {
+ expect(decisionRequestSchema.safeParse(request({ deadline: { after, policy: 'reject' } })).success).toBe(true);
+ });
+
+ it('accepts a request without deadline, uiSchema or proposalSourceNodeId', () => {
+ const { version, actions, schema } = workedExample();
+
+ expect(decisionRequestSchema.safeParse({ version, actions, schema }).success).toBe(true);
+ });
+
+ // `issue` names the dictionary entry expected at `path`; rows without one fail on zod's
+ // own structural check.
+ it.each<{ name: string; input: unknown; path: string; issue?: { code: DecisionIssueCode; value?: string } }>([
+ { name: 'a version other than 1', input: request({ version: 2 }), path: 'version' },
+ {
+ name: 'an empty action list',
+ input: request({ actions: [] }),
+ path: 'actions',
+ issue: { code: 'actions_empty' },
+ },
+ {
+ name: 'a duplicate action name',
+ input: request({ actions: [approve, { ...reject, name: 'approve' }] }),
+ path: 'actions.1.name',
+ issue: { code: 'duplicate_action_name', value: 'approve' },
+ },
+ {
+ name: 'an effect outside the declarable set',
+ input: request({ actions: [{ ...approve, effect: 'escalate' }] }),
+ path: 'actions.0.effect',
+ issue: { code: 'unknown_effect', value: declarableEffects },
+ },
+ {
+ name: "a declared 'resume-with-edits'",
+ input: request({ actions: [approve, { ...reject, effect: 'resume-with-edits' }] }),
+ path: 'actions.1.effect',
+ issue: { code: 'unknown_effect', value: declarableEffects },
+ },
+ {
+ name: 'no resume action',
+ input: request({ actions: [reject] }),
+ path: 'actions',
+ issue: { code: 'resume_required' },
+ },
+ {
+ name: 'two resume actions',
+ input: request({ actions: [approve, { ...approve, name: 'approve-2' }] }),
+ path: 'actions.1.effect',
+ issue: { code: 'duplicate_effect', value: 'resume' },
+ },
+ {
+ name: 'two reject actions',
+ input: request({ actions: [approve, reject, { ...reject, name: 'decline' }] }),
+ path: 'actions.2.effect',
+ issue: { code: 'duplicate_effect', value: 'reject' },
+ },
+ {
+ name: 'two rerun-source actions',
+ input: request({ actions: [approve, askAgain, { ...askAgain, name: 'retry' }] }),
+ path: 'actions.2.effect',
+ issue: { code: 'duplicate_effect', value: 'rerun-source' },
+ },
+ {
+ name: 'a whitespace-only action name',
+ input: request({ actions: [{ ...approve, name: ' ' }] }),
+ path: 'actions.0.name',
+ issue: { code: 'name_empty' },
+ },
+ {
+ name: 'a whitespace-only action label',
+ input: request({ actions: [{ ...approve, label: ' ' }] }),
+ path: 'actions.0.label',
+ issue: { code: 'label_empty' },
+ },
+ {
+ name: 'a whitespace-only resume port',
+ input: request({ actions: [{ ...approve, port: '\t' }] }),
+ path: 'actions.0.port',
+ issue: { code: 'port_empty' },
+ },
+ {
+ name: 'an empty action name',
+ input: request({ actions: [{ ...approve, name: '' }] }),
+ path: 'actions.0.name',
+ issue: { code: 'name_empty' },
+ },
+ {
+ name: 'an empty action label',
+ input: request({ actions: [{ ...approve, label: '' }] }),
+ path: 'actions.0.label',
+ issue: { code: 'label_empty' },
+ },
+ {
+ name: 'an empty resume port',
+ input: request({ actions: [{ ...approve, port: '' }] }),
+ path: 'actions.0.port',
+ issue: { code: 'port_empty' },
+ },
+ {
+ name: 'a resume action without a port',
+ input: request({ actions: [{ name: 'approve', label: 'Approve', effect: 'resume' }] }),
+ path: 'actions.0.port',
+ },
+ {
+ name: 'a reject action without a port',
+ input: request({ actions: [approve, { name: 'reject', label: 'Reject', effect: 'reject' }] }),
+ path: 'actions.1.port',
+ },
+ {
+ name: 'a resume port of null',
+ input: request({ actions: [{ ...approve, port: null }] }),
+ path: 'actions.0.port',
+ },
+ {
+ name: 'a rerun-source action with a port',
+ input: request({ actions: [approve, { ...askAgain, port: 'again' }] }),
+ path: 'actions.1.port',
+ issue: { code: 'port_not_allowed' },
+ },
+ {
+ name: "a resume port of 'errorRoute'",
+ input: request({ actions: [{ ...approve, port: 'errorRoute' }] }),
+ path: 'actions.0.port',
+ issue: { code: 'port_reserved' },
+ },
+ {
+ name: "a reject port of 'errorRoute'",
+ input: request({ actions: [approve, { ...reject, port: 'errorRoute' }] }),
+ path: 'actions.1.port',
+ issue: { code: 'port_reserved' },
+ },
+ {
+ name: 'a reject port equal to the resume port',
+ input: request({ actions: [approve, { ...reject, port: 'approved' }] }),
+ path: 'actions.1.port',
+ issue: { code: 'reject_port_equals_resume_port', value: 'approved' },
+ },
+ {
+ name: 'a non-boolean reasonRequired',
+ input: request({ actions: [approve, { ...reject, reasonRequired: 'yes' }] }),
+ path: 'actions.1.reasonRequired',
+ },
+ {
+ name: 'maxIterations below 1',
+ input: request({ actions: [approve, { ...askAgain, maxIterations: 0 }] }),
+ path: 'actions.1.maxIterations',
+ },
+ {
+ name: 'a fractional maxIterations',
+ input: request({ actions: [approve, { ...askAgain, maxIterations: 1.5 }] }),
+ path: 'actions.1.maxIterations',
+ },
+ {
+ name: "a form schema whose type is not 'object'",
+ input: request({ schema: { ...refundForm, type: 'array' } }),
+ path: 'schema.type',
+ },
+ { name: 'no form schema at all', input: request({ schema: undefined }), path: 'schema' },
+ {
+ name: 'a form schema without properties',
+ input: request({ schema: { type: 'object' } }),
+ path: 'schema.properties',
+ },
+ {
+ name: 'a form property whose type is neither a name nor a list of names',
+ input: request({ schema: { type: 'object', properties: { refundAmount: { type: 7 } } } }),
+ path: 'schema.properties.refundAmount.type',
+ },
+ {
+ name: 'a form property whose type list holds something other than a name',
+ input: request({ schema: { type: 'object', properties: { refundAmount: { type: ['string', 7] } } } }),
+ path: 'schema.properties.refundAmount.type',
+ },
+ {
+ name: 'a non-boolean readOnly',
+ input: request({ schema: { type: 'object', properties: { orderDate: { type: 'string', readOnly: 'true' } } } }),
+ path: 'schema.properties.orderDate.readOnly',
+ },
+ {
+ name: 'a non-boolean x-pii',
+ input: request({ schema: { type: 'object', properties: { email: { type: 'string', 'x-pii': 'yes' } } } }),
+ path: 'schema.properties.email.x-pii',
+ },
+ {
+ name: 'a required field that is not declared',
+ input: request({ schema: { ...refundForm, required: ['discount'] } }),
+ path: 'schema.required.0',
+ issue: { code: 'required_field_undeclared', value: 'discount' },
+ },
+ {
+ name: 'a required field that exists only on Object.prototype',
+ input: request({ schema: { ...refundForm, required: ['constructor'] } }),
+ path: 'schema.required.0',
+ issue: { code: 'required_field_undeclared', value: 'constructor' },
+ },
+ {
+ name: 'a deadline without a unit',
+ input: request({ deadline: { after: '3', policy: 'reject' } }),
+ path: 'deadline.after',
+ issue: { code: 'deadline_format' },
+ },
+ {
+ name: 'a deadline of zero',
+ input: request({ deadline: { after: '0s', policy: 'reject' } }),
+ path: 'deadline.after',
+ issue: { code: 'deadline_format' },
+ },
+ {
+ name: 'a negative deadline',
+ input: request({ deadline: { after: '-5m', policy: 'reject' } }),
+ path: 'deadline.after',
+ issue: { code: 'deadline_format' },
+ },
+ {
+ name: 'a deadline beyond the protobuf Duration range',
+ input: request({ deadline: { after: '3652501d', policy: 'reject' } }),
+ path: 'deadline.after',
+ issue: { code: 'deadline_format' },
+ },
+ { name: 'a deadline without a policy', input: request({ deadline: { after: '3d' } }), path: 'deadline.policy' },
+ {
+ name: "a deadline policy other than 'reject'",
+ input: request({ deadline: { after: '3d', policy: 'escalate' } }),
+ path: 'deadline.policy',
+ issue: { code: 'deadline_policy' },
+ },
+ { name: 'a uiSchema that is not an object', input: request({ uiSchema: 'vertical' }), path: 'uiSchema' },
+ {
+ name: 'a non-string proposalSourceNodeId',
+ input: request({ proposalSourceNodeId: 42 }),
+ path: 'proposalSourceNodeId',
+ },
+ ])('rejects $name', ({ input, path, issue }) => {
+ const issues = issuesOf(input);
+ const atPath = issues.filter((candidate) => candidate.path === path);
+
+ expect(decisionRequestSchema.safeParse(input).success).toBe(false);
+ expect(atPath.length).toBeGreaterThan(0);
+ if (issue === undefined) {
+ expect(atPath.every((candidate) => candidate.domain === undefined)).toBe(true);
+ } else {
+ expect(atPath.map((candidate) => candidate.message)).toContain(decisionIssueMessage(issue.code, issue.value));
+ // The identifier, not the wording, is what a client keys on.
+ expect(atPath.map((candidate) => candidate.domain)).toContainEqual(
+ issue.value === undefined ? { issue: issue.code } : { issue: issue.code, value: issue.value },
+ );
+ }
+ });
+
+ // Two absent ports would compare equal, so the request-level rule must not run on them.
+ it('reports each missing port on its own, without a port-equality issue riding along', () => {
+ const issues = issuesOf(
+ request({
+ actions: [
+ { name: 'approve', label: 'Approve', effect: 'resume' },
+ { name: 'reject', label: 'Reject', effect: 'reject' },
+ ],
+ }),
+ );
+
+ expect(issues.map((issue) => issue.path)).toEqual(['actions.0.port', 'actions.1.port']);
+ expect(issues.map((issue) => issue.domain)).toEqual([undefined, undefined]);
+ });
+
+ it('reports two blank ports as two empty-port issues, without a port-equality issue riding along', () => {
+ const issues = issuesOf(
+ request({
+ actions: [
+ { ...approve, port: ' ' },
+ { ...reject, port: ' ' },
+ ],
+ }),
+ );
+
+ expect(issues.map((issue) => issue.path)).toEqual(['actions.0.port', 'actions.1.port']);
+ expect(issues.map((issue) => issue.domain)).toEqual([{ issue: 'port_empty' }, { issue: 'port_empty' }]);
+ });
+
+ it('reports a stray rerun-source port beside a structural failure of the same action', () => {
+ const issues = issuesOf(
+ request({ actions: [approve, { label: 'Ask again', effect: 'rerun-source', port: 'again' }] }),
+ );
+
+ expect(issues.map((issue) => issue.path).sort()).toEqual(['actions.1.name', 'actions.1.port']);
+ expect(issues.map((issue) => issue.domain)).toContainEqual({ issue: 'port_not_allowed' });
+ });
+
+ it('keeps the request-level rules running after a stray rerun-source port', () => {
+ const issues = issuesOf(request({ actions: [approve, { ...askAgain, name: 'approve', port: 'again' }] }));
+
+ expect(issues.map((issue) => issue.domain)).toEqual(
+ expect.arrayContaining([{ issue: 'port_not_allowed' }, { issue: 'duplicate_action_name', value: 'approve' }]),
+ );
+ });
+
+ // The effect check aborts the action the way the union's own failure did; a second issue
+ // about a missing resume action for an action that never parsed would only mislead.
+ it('reports an unknown effect once, without a missing-resume issue riding along', () => {
+ const issues = issuesOf(request({ actions: [{ name: 'a', label: 'A', effect: 'zzz' }] }));
+
+ expect(issues.map((issue) => issue.path)).toEqual(['actions.0.effect']);
+ expect(issues[0]?.domain).toEqual({ issue: 'unknown_effect', value: declarableEffects });
+ });
+
+ it('parses into a value assignable to DecisionRequest', () => {
+ expectTypeOf>().toMatchTypeOf();
+ });
+});
+
+// Every level of a request is a loose object, and a loose object copies an unknown key by
+// assignment, which for this one swaps the parsed output's prototype. The schema guards
+// itself rather than relying on the snapshot it is usually nested in.
+describe('decisionRequestSchema: own __proto__ keys, parsed on its own', () => {
+ const action = '{"name":"approve","label":"Approve","effect":"resume","port":"approved"}';
+ const form = '{"type":"object","properties":{}}';
+
+ it.each([
+ {
+ where: 'at the request level',
+ json: `{"version":1,"actions":[${action}],"schema":${form},"__proto__":{"deadline":{"after":"nonsense"}}}`,
+ path: '__proto__',
+ },
+ {
+ where: 'inside an action',
+ json: `{"version":1,"actions":[{"name":"a","label":"A","effect":"resume","__proto__":{"port":"stolen"}}],"schema":${form}}`,
+ path: 'actions.0.__proto__',
+ },
+ {
+ where: 'inside the form schema',
+ json: `{"version":1,"actions":[${action}],"schema":{"type":"object","properties":{},"__proto__":{"required":["x"]}}}`,
+ path: 'schema.__proto__',
+ },
+ {
+ where: 'inside a form property',
+ json: `{"version":1,"actions":[${action}],"schema":{"type":"object","properties":{"amount":{"type":"number","__proto__":{"readOnly":true}}}}}`,
+ path: 'schema.properties.amount.__proto__',
+ },
+ ])('refuses one $where', ({ json, path }) => {
+ expect(issuesOf(JSON.parse(json))).toEqual([{ path, message: "the key '__proto__' is not allowed" }]);
+ });
+});
diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts
new file mode 100644
index 000000000..902c572ea
--- /dev/null
+++ b/apps/backend/src/domain/decision/decision-request-schema.ts
@@ -0,0 +1,158 @@
+import { z } from 'zod';
+
+import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { rejectingOwnProtoKey } from '../schema/own-proto-key';
+import { decisionIssue, decisionRefinement } from './decision-issues';
+
+// Mirrors DURATION_PATTERN and the protobuf Duration range in
+// packages/temporal/src/workflow/profile-validation.ts (follow-up: shared-duration-format)
+const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
+const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const;
+const MIN_DURATION_MS = 0.000_001;
+const MAX_DURATION_MS = 315_576_000_000 * UNIT_MS.s;
+
+function isDurationString(value: string): boolean {
+ const match = DURATION_PATTERN.exec(value);
+ if (match === null) return false;
+ const milliseconds = Number.parseFloat(match[1]) * UNIT_MS[match[2] as keyof typeof UNIT_MS];
+ return milliseconds >= MIN_DURATION_MS && milliseconds <= MAX_DURATION_MS;
+}
+
+const durationSchema = z.string().refine(isDurationString, decisionRefinement('deadline_format'));
+
+function isNotBlank(text: string): boolean {
+ return text.trim().length > 0;
+}
+
+// A port is the id of a handle on one canvas, so it has no default. 'errorRoute' is the
+// handle the runner reserves for the error policy.
+const portSchema = z
+ .string()
+ .refine(isNotBlank, decisionRefinement('port_empty'))
+ .refine((port) => port !== 'errorRoute', decisionRefinement('port_reserved'));
+
+const actionBase = {
+ name: z.string().refine(isNotBlank, decisionRefinement('name_empty')),
+ label: z.string().refine(isNotBlank, decisionRefinement('label_empty')),
+};
+
+const resumeActionSchema = z.looseObject({
+ ...actionBase,
+ effect: z.literal('resume'),
+ port: portSchema,
+});
+
+const rejectActionSchema = z.looseObject({
+ ...actionBase,
+ effect: z.literal('reject'),
+ port: portSchema,
+ reasonRequired: z.boolean().default(false),
+});
+
+const rerunSourceActionSchema = z.looseObject({
+ ...actionBase,
+ effect: z.literal('rerun-source'),
+ maxIterations: z.int().min(1).default(3),
+ // Refused on the field, not the object, so the issue survives a structural failure beside it.
+ port: z.unknown().refine((port) => port === undefined, decisionRefinement('port_not_allowed')),
+});
+
+// The effect picks the member that parses the rest, so it is checked on its own first: a
+// union that finds no member cannot name what was wrong, and a client needs the name.
+const declaredEffect = z.unknown().superRefine((action, context) => {
+ if (typeof action !== 'object' || action === null || Array.isArray(action)) return;
+ const effect = (action as { effect?: unknown }).effect;
+ if (typeof effect === 'string' && (DECLARABLE_DECISION_EFFECTS as readonly string[]).includes(effect)) return;
+ // Aborting, as the union's own failure was: the request-level rules must not go on to
+ // report a missing resume action for an action that never parsed.
+ context.addIssue({
+ ...decisionIssue('unknown_effect', ['effect'], DECLARABLE_DECISION_EFFECTS.join(', ')),
+ continue: false,
+ });
+});
+
+const decisionActionSchema = declaredEffect.pipe(
+ z.discriminatedUnion('effect', [resumeActionSchema, rejectActionSchema, rerunSourceActionSchema]),
+);
+
+const formPropertySchema = z.looseObject({
+ type: z.union([z.string(), z.array(z.string())]).optional(),
+ readOnly: z.boolean().optional(),
+ 'x-pii': z.boolean().optional(),
+});
+
+// Shape only.
+const formSchema = z
+ .looseObject({
+ type: z.literal('object'),
+ properties: z.record(z.string(), formPropertySchema),
+ required: z.array(z.string()).optional(),
+ })
+ .superRefine((form, context) => {
+ for (const [index, name] of (form.required ?? []).entries()) {
+ if (!Object.hasOwn(form.properties, name)) {
+ context.addIssue(decisionIssue('required_field_undeclared', ['required', index], name));
+ }
+ }
+ });
+
+const deadlineSchema = z.looseObject({
+ after: durationSchema,
+ policy: z.string().refine((policy) => policy === 'reject', decisionRefinement('deadline_policy')),
+});
+
+// Guarded on its own, not only through `workflowSnapshotSchema`: every level below is a
+// loose object, so a caller parsing raw JSON with this export would inherit a request no
+// schema checked. `../schema/own-proto-key.ts` says why a loose object needs that.
+export const decisionRequestSchema = rejectingOwnProtoKey(
+ z
+ .looseObject({
+ version: z.literal(1),
+ actions: z
+ .array(decisionActionSchema)
+ .refine((actions) => actions.length > 0, decisionRefinement('actions_empty')),
+ schema: formSchema,
+ uiSchema: z.record(z.string(), z.unknown()).optional(),
+ proposalSourceNodeId: z.string().optional(),
+ deadline: deadlineSchema.optional(),
+ })
+ .superRefine((request, context) => {
+ const seenNames = new Set();
+ const firstIndexByEffect = new Map();
+
+ for (const [index, action] of request.actions.entries()) {
+ if (seenNames.has(action.name)) {
+ context.addIssue(decisionIssue('duplicate_action_name', ['actions', index, 'name'], action.name));
+ }
+ seenNames.add(action.name);
+
+ if (firstIndexByEffect.has(action.effect)) {
+ context.addIssue(decisionIssue('duplicate_effect', ['actions', index, 'effect'], action.effect));
+ } else {
+ firstIndexByEffect.set(action.effect, index);
+ }
+ }
+
+ const resumeIndex = firstIndexByEffect.get('resume');
+ if (resumeIndex === undefined) {
+ context.addIssue(decisionIssue('resume_required', ['actions']));
+ return;
+ }
+
+ const rejectIndex = firstIndexByEffect.get('reject');
+ if (rejectIndex === undefined) return;
+ const resume = request.actions[resumeIndex];
+ const reject = request.actions[rejectIndex];
+ if (
+ resume.effect === 'resume' &&
+ reject.effect === 'reject' &&
+ resume.port === reject.port &&
+ isNotBlank(reject.port)
+ ) {
+ context.addIssue(
+ decisionIssue('reject_port_equals_resume_port', ['actions', rejectIndex, 'port'], reject.port),
+ );
+ }
+ }),
+);
diff --git a/apps/backend/src/domain/decision/find-decision-request.test.ts b/apps/backend/src/domain/decision/find-decision-request.test.ts
new file mode 100644
index 000000000..83f327794
--- /dev/null
+++ b/apps/backend/src/domain/decision/find-decision-request.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+
+import { workflowSnapshotSchema } from '../mapper/snapshot-schema';
+import { findDecisionRequest } from './find-decision-request';
+
+const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' };
+const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected' };
+
+const snapshot = workflowSnapshotSchema.parse({
+ nodes: [
+ { id: 'source-1', data: { type: 'product/any', properties: {} } },
+ { id: 'plain', data: { type: 'product/any' } },
+ {
+ id: 'review-1',
+ data: {
+ type: 'product/any',
+ properties: {
+ decisionRequest: { version: 1, actions: [approve, reject], schema: { type: 'object', properties: {} } },
+ },
+ },
+ },
+ ],
+ edges: [{ id: 'e1', source: 'source-1', target: 'review-1' }],
+});
+
+describe('findDecisionRequest', () => {
+ it('finds the request of the node, with reasonRequired materialised', () => {
+ const found = findDecisionRequest(snapshot, 'review-1');
+
+ expect(found.error).toBeUndefined();
+ expect(found.request?.actions).toEqual([approve, { ...reject, reasonRequired: false }]);
+ });
+
+ it('tells a missing node from a node that carries no request', () => {
+ expect(findDecisionRequest(snapshot, 'ghost')).toEqual({ error: 'node_not_found' });
+ expect(findDecisionRequest(snapshot, 'source-1')).toEqual({ error: 'node_without_decision_request' });
+ expect(findDecisionRequest(snapshot, 'plain')).toEqual({ error: 'node_without_decision_request' });
+ });
+});
diff --git a/apps/backend/src/domain/decision/find-decision-request.ts b/apps/backend/src/domain/decision/find-decision-request.ts
new file mode 100644
index 000000000..417cce609
--- /dev/null
+++ b/apps/backend/src/domain/decision/find-decision-request.ts
@@ -0,0 +1,17 @@
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import type { WorkflowSnapshot } from '../mapper/snapshot-schema';
+
+export type FindDecisionRequestError = 'node_not_found' | 'node_without_decision_request';
+
+export type FindDecisionRequestResult =
+ | { request: DecisionRequest; error?: undefined }
+ | { request?: undefined; error: FindDecisionRequestError };
+
+export function findDecisionRequest(snapshot: WorkflowSnapshot, nodeId: string): FindDecisionRequestResult {
+ const node = snapshot.nodes.find((candidate) => candidate.id === nodeId);
+ if (node === undefined) return { error: 'node_not_found' };
+ const request = node.data.properties?.decisionRequest;
+ if (request === undefined) return { error: 'node_without_decision_request' };
+ return { request };
+}
diff --git a/apps/backend/src/domain/decision/node-resolution.test.ts b/apps/backend/src/domain/decision/node-resolution.test.ts
new file mode 100644
index 000000000..d796d4b04
--- /dev/null
+++ b/apps/backend/src/domain/decision/node-resolution.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, expectTypeOf, it } from 'vitest';
+
+import type { Decision } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { type RoutedDecision, type RoutedDecisionAction, hasNodeResolution, toNodeResolution } from './node-resolution';
+
+const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' } as const;
+const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false } as const;
+
+describe('toNodeResolution', () => {
+ it.each<{ decision: RoutedDecision; action: RoutedDecisionAction; port: string }>([
+ {
+ decision: { action: 'approve', effect: 'resume', edits: {}, resolvedBy: 'human' },
+ action: approve,
+ port: 'approved',
+ },
+ {
+ decision: { action: 'approve', effect: 'resume-with-edits', edits: { refundAmount: 120 }, resolvedBy: 'human' },
+ action: approve,
+ port: 'approved',
+ },
+ ])('routes a $decision.effect decision on the action port and declares no outcome', ({ decision, action, port }) => {
+ const completion = toNodeResolution(decision, action);
+
+ expect(completion.nextPort).toBe(port);
+ expect(completion.output).toBe(decision);
+ expect(Object.keys(completion)).toEqual(['output', 'nextPort']);
+ });
+
+ it('routes a reject on its port and declares the rejection as the run outcome', () => {
+ const decision: RoutedDecision = {
+ action: 'reject',
+ effect: 'reject',
+ edits: {},
+ reason: 'late',
+ resolvedBy: 'human',
+ };
+
+ const completion = toNodeResolution(decision, reject);
+
+ expect(Object.keys(completion)).toEqual(['output', 'nextPort', 'outcome']);
+ expect(completion.output).toBe(decision);
+ expect(completion.nextPort).toBe('rejected');
+ expect(completion.outcome).toEqual({ value: 'rejected', resolvedBy: 'human' });
+ });
+
+ it("copies the decision's initiator onto the outcome, whatever it is", () => {
+ const completion = toNodeResolution(
+ { action: 'reject', effect: 'reject', edits: {}, resolvedBy: 'policy' },
+ reject,
+ );
+
+ expect(completion.outcome?.resolvedBy).toBe('policy');
+ });
+
+ it('hands the decision over as it is, with no key added or removed', () => {
+ const decision: RoutedDecision = {
+ action: 'approve',
+ effect: 'resume-with-edits',
+ edits: { refundAmount: 120 },
+ resolvedBy: 'human',
+ };
+ const before = structuredClone(decision);
+
+ const completion = toNodeResolution(decision, approve);
+
+ expect(completion.output).toBe(decision);
+ expect(decision).toEqual(before);
+ });
+
+ it('routes a reject on its port even when no reason was given', () => {
+ const decision: RoutedDecision = { action: 'reject', effect: 'reject', edits: {}, resolvedBy: 'human' };
+
+ const completion = toNodeResolution(decision, reject);
+
+ expect(completion).toEqual({
+ output: decision,
+ nextPort: 'rejected',
+ outcome: { value: 'rejected', resolvedBy: 'human' },
+ });
+ });
+
+ it('refuses the reserved errorRoute port, which the request parser already forbids', () => {
+ const decision: RoutedDecision = { action: 'approve', effect: 'resume', edits: {}, resolvedBy: 'human' };
+
+ expect(() => toNodeResolution(decision, { ...approve, port: 'errorRoute' })).toThrow("reserved 'errorRoute'");
+ });
+
+ it('has no completion for a rerun-source decision', () => {
+ expect(
+ hasNodeResolution({
+ action: 'ask-again',
+ effect: 'rerun-source',
+ edits: {},
+ comment: 'again',
+ resolvedBy: 'human',
+ }),
+ ).toBe(false);
+ expect(hasNodeResolution({ action: 'approve', effect: 'resume', edits: {}, resolvedBy: 'human' })).toBe(true);
+ expectTypeOf().toEqualTypeOf<'resume' | 'resume-with-edits' | 'reject'>();
+ expectTypeOf>().toEqualTypeOf();
+ expectTypeOf().toMatchTypeOf();
+ });
+});
diff --git a/apps/backend/src/domain/decision/node-resolution.ts b/apps/backend/src/domain/decision/node-resolution.ts
new file mode 100644
index 000000000..4d00ebede
--- /dev/null
+++ b/apps/backend/src/domain/decision/node-resolution.ts
@@ -0,0 +1,27 @@
+import type { CompletedNodeExecution } from '@workflow-builder/execution-core/workflow';
+import type {
+ Decision,
+ DecisionAction,
+ DecisionEffect,
+} from '@workflow-builder/types/workflow-execution/decision-request';
+
+// No completion exists for `rerun-source` yet (follow-up: decision-rerun-source).
+export type RoutedDecision = Decision & { effect: Exclude };
+export type RoutedDecisionAction = Exclude;
+
+export function hasNodeResolution(decision: Decision): decision is RoutedDecision {
+ return decision.effect !== 'rerun-source';
+}
+
+const REJECTED_OUTCOME = 'rejected';
+
+export function toNodeResolution(decision: RoutedDecision, action: RoutedDecisionAction): CompletedNodeExecution {
+ if (action.port === 'errorRoute') {
+ throw new Error(`action '${action.name}' routes to the reserved 'errorRoute' port`);
+ }
+ const completion: CompletedNodeExecution = { output: decision, nextPort: action.port };
+ if (action.effect === 'reject') {
+ completion.outcome = { value: REJECTED_OUTCOME, resolvedBy: decision.resolvedBy };
+ }
+ return completion;
+}
diff --git a/apps/backend/src/domain/decision/proposal-source.test.ts b/apps/backend/src/domain/decision/proposal-source.test.ts
new file mode 100644
index 000000000..86d77abd5
--- /dev/null
+++ b/apps/backend/src/domain/decision/proposal-source.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest';
+
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { resolveProposalSource } from './proposal-source';
+
+function request(proposalSourceNodeId?: string): DecisionRequest {
+ return {
+ version: 1,
+ actions: [{ name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }],
+ schema: { type: 'object', properties: {} },
+ ...(proposalSourceNodeId === undefined ? {} : { proposalSourceNodeId }),
+ };
+}
+
+function edge(sourceNodeId: string, targetNodeId: string) {
+ return { sourceNodeId, targetNodeId };
+}
+
+describe('resolveProposalSource', () => {
+ it('reports a node without a request, or an unknown id, as a node without a decision request', () => {
+ const nodes = [{ id: 'plain' }];
+
+ expect(resolveProposalSource(nodes, [], 'plain')).toEqual({ error: 'node_without_decision_request' });
+ expect(resolveProposalSource(nodes, [], 'missing')).toEqual({ error: 'node_without_decision_request' });
+ });
+
+ it('returns an explicit source that is a direct predecessor', () => {
+ const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decisionRequest: request('a') }];
+ const edges = [edge('a', 'review'), edge('b', 'review')];
+
+ expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' });
+ });
+
+ it('rejects an explicit source that is not a direct predecessor, including a successor', () => {
+ const nodes = [{ id: 'a' }, { id: 'after' }, { id: 'review', decisionRequest: request('after') }];
+ const edges = [edge('a', 'review'), edge('review', 'after')];
+
+ expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ error: 'explicit_source_not_a_predecessor' });
+ });
+
+ it('falls back to the only direct predecessor when no source is declared', () => {
+ const nodes = [{ id: 'a' }, { id: 'review', decisionRequest: request() }];
+
+ expect(resolveProposalSource(nodes, [edge('a', 'review')], 'review')).toEqual({ sourceNodeId: 'a' });
+ });
+
+ it('counts parallel edges from one node as a single predecessor', () => {
+ const nodes = [{ id: 'a' }, { id: 'review', decisionRequest: request() }];
+ const edges = [edge('a', 'review'), edge('a', 'review')];
+
+ expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' });
+ });
+
+ it('does not count a self-loop as a predecessor, explicit or implicit', () => {
+ const explicitSelf = [{ id: 'a' }, { id: 'review', decisionRequest: request('review') }];
+ const implicitSelf = [{ id: 'review', decisionRequest: request() }];
+
+ expect(resolveProposalSource(explicitSelf, [edge('a', 'review'), edge('review', 'review')], 'review')).toEqual({
+ error: 'explicit_source_not_a_predecessor',
+ });
+ expect(resolveProposalSource(implicitSelf, [edge('review', 'review')], 'review')).toEqual({
+ error: 'no_predecessor',
+ });
+ });
+
+ it('reports no predecessor when the node has only outgoing edges', () => {
+ const nodes = [{ id: 'review', decisionRequest: request() }, { id: 'after' }];
+
+ expect(resolveProposalSource(nodes, [edge('review', 'after')], 'review')).toEqual({ error: 'no_predecessor' });
+ });
+
+ it('reports ambiguity when several predecessors exist and none is declared', () => {
+ const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decisionRequest: request() }];
+ const edges = [edge('a', 'review'), edge('b', 'review')];
+
+ expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ error: 'ambiguous_predecessor' });
+ });
+});
diff --git a/apps/backend/src/domain/decision/proposal-source.ts b/apps/backend/src/domain/decision/proposal-source.ts
new file mode 100644
index 000000000..755842470
--- /dev/null
+++ b/apps/backend/src/domain/decision/proposal-source.ts
@@ -0,0 +1,44 @@
+import { unique } from 'remeda';
+
+import type { BaseNode, WorkflowEdgeDefinition } from '@workflow-builder/types/workflow-execution/execution-model';
+
+type GraphNode = Pick;
+type GraphEdge = Pick;
+
+export type UnresolvedSourceReason =
+ | 'node_without_decision_request'
+ | 'explicit_source_not_a_predecessor'
+ | 'no_predecessor'
+ | 'ambiguous_predecessor';
+
+// Result shape: apps/backend/decision-request.decision-log.md, decision 9.
+export type ProposalSourceResolution =
+ | { sourceNodeId: string; error?: undefined }
+ | { sourceNodeId?: undefined; error: UnresolvedSourceReason };
+
+// Takes the execution-model shape so a caller holding a WorkflowDefinition passes its
+// nodes and edges straight in; the snapshot schema adapts before calling.
+export function resolveProposalSource(
+ nodes: readonly GraphNode[],
+ edges: readonly GraphEdge[],
+ nodeId: string,
+): ProposalSourceResolution {
+ const node = nodes.find((candidate) => candidate.id === nodeId);
+ if (node?.decisionRequest === undefined) return { error: 'node_without_decision_request' };
+
+ // A self-loop is not a predecessor.
+ const predecessors = unique(
+ edges
+ .filter((edge) => edge.targetNodeId === nodeId && edge.sourceNodeId !== nodeId)
+ .map((edge) => edge.sourceNodeId),
+ );
+ const explicit = node.decisionRequest.proposalSourceNodeId;
+ if (explicit !== undefined) {
+ return predecessors.includes(explicit)
+ ? { sourceNodeId: explicit }
+ : { error: 'explicit_source_not_a_predecessor' };
+ }
+ if (predecessors.length === 0) return { error: 'no_predecessor' };
+ if (predecessors.length > 1) return { error: 'ambiguous_predecessor' };
+ return { sourceNodeId: predecessors[0] };
+}
diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts
new file mode 100644
index 000000000..2a8995ee3
--- /dev/null
+++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts
@@ -0,0 +1,504 @@
+import { describe, expect, expectTypeOf, it } from 'vitest';
+
+import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues';
+import {
+ type SubmittedDecision,
+ type SubmittedDecisionResult,
+ submittedDecisionSchema,
+ validateSubmittedDecision,
+} from './validate-submitted-decision';
+
+const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' } as const;
+const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false } as const;
+const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 } as const;
+
+// As the parser leaves it: defaults present, every action explicit.
+function requestWith(overrides: Partial = {}): DecisionRequest {
+ return {
+ version: 1,
+ actions: [approve, reject, askAgain],
+ schema: {
+ type: 'object',
+ properties: {
+ orderDate: { type: 'string', readOnly: true },
+ customerEmail: { type: 'string', readOnly: true, 'x-pii': true },
+ refundAmount: { type: 'number' },
+ emailDraft: { type: 'string', readOnly: false },
+ note: { type: 'string' },
+ },
+ required: ['refundAmount'],
+ },
+ ...overrides,
+ };
+}
+
+// A form the SDK's own field model produces: an object with children and an array of
+// objects. Editability lives on the children, not on the wrapper.
+const nestedRequest = (): DecisionRequest =>
+ requestWith({
+ schema: {
+ type: 'object',
+ properties: {
+ profile: {
+ type: 'object',
+ required: ['nickname'],
+ properties: { id: { type: 'string', readOnly: true }, nickname: { type: 'string' } },
+ },
+ lines: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ sku: { type: 'string', readOnly: true },
+ qty: { type: 'number' },
+ origin: { type: 'object', properties: { warehouse: { type: 'string', readOnly: true } } },
+ },
+ },
+ },
+ },
+ },
+ });
+
+describe('validateSubmittedDecision', () => {
+ it.each<{ name: string; request?: DecisionRequest; call: SubmittedDecision; effect: string }>([
+ { name: 'a resume without edits resumes', call: { action: 'approve' }, effect: 'resume' },
+ { name: 'a resume with empty edits resumes', call: { action: 'approve', edits: {} }, effect: 'resume' },
+ {
+ name: 'a resume with an edit on an editable field resumes with edits',
+ call: { action: 'approve', edits: { refundAmount: 42 } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'an explicit readOnly: false is editable',
+ call: { action: 'approve', edits: { emailDraft: 'Dear customer' } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'a required field set to a value is fine',
+ call: { action: 'approve', edits: { refundAmount: 0 } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'an optional field may be emptied',
+ call: { action: 'approve', edits: { note: '' } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'editable children of an object and of an array item',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: { nickname: 'Ada' }, lines: [{ qty: 3 }] } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'an object edit that names no child, which patches nothing',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: {} } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'an empty list edit, which patches no element',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { lines: [] } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'null on a described object whose type allows null',
+ request: requestWith({
+ schema: {
+ type: 'object',
+ properties: { address: { type: ['object', 'null'], properties: { city: { type: 'string' } } } },
+ },
+ }),
+ call: { action: 'approve', edits: { address: null } },
+ effect: 'resume-with-edits',
+ },
+ {
+ name: 'null on a described list whose type allows null',
+ request: requestWith({
+ schema: { type: 'object', properties: { tags: { type: ['array', 'null'], items: { type: 'string' } } } },
+ }),
+ call: { action: 'approve', edits: { tags: null } },
+ effect: 'resume-with-edits',
+ },
+ { name: 'a reject without a reason when none is required', call: { action: 'reject' }, effect: 'reject' },
+ { name: 'a reject with empty edits', call: { action: 'reject', edits: {} }, effect: 'reject' },
+ {
+ name: 'a reject with a reason when one is required',
+ request: requestWith({ actions: [approve, { ...reject, reasonRequired: true }] }),
+ call: { action: 'reject', reason: 'Amount exceeds policy' },
+ effect: 'reject',
+ },
+ {
+ name: 'a rerun with a comment',
+ call: { action: 'ask-again', comment: 'Use the discounted price' },
+ effect: 'rerun-source',
+ },
+ {
+ name: 'a rerun with empty edits',
+ call: { action: 'ask-again', comment: 'again', edits: {} },
+ effect: 'rerun-source',
+ },
+ ])('accepts $name', ({ request = requestWith(), call, effect }) => {
+ const result = validateSubmittedDecision(request, call);
+
+ expect(result.error).toBeUndefined();
+ expect(result.decision?.effect).toBe(effect);
+ expect(result.decision?.action).toBe(call.action);
+ expect(result.action?.name).toBe(call.action);
+ });
+
+ it.each<{
+ name: string;
+ request?: DecisionRequest;
+ call: SubmittedDecision;
+ code: SubmittedDecisionErrorCode;
+ value: string;
+ path: string[];
+ }>([
+ {
+ name: 'an action the request does not offer',
+ call: { action: 'escalate' },
+ code: 'unknown_action',
+ value: 'escalate',
+ path: ['action'],
+ },
+ {
+ name: "an action addressed by its label instead of its name ('Approve')",
+ call: { action: 'Approve' },
+ code: 'unknown_action',
+ value: 'Approve',
+ path: ['action'],
+ },
+ {
+ name: 'a reject on a request without a reject action',
+ request: requestWith({ actions: [approve] }),
+ call: { action: 'reject', reason: 'no' },
+ code: 'unknown_action',
+ value: 'reject',
+ path: ['action'],
+ },
+ {
+ name: 'a rerun on a request without a rerun-source action',
+ request: requestWith({ actions: [approve, reject] }),
+ call: { action: 'ask-again', comment: 'again' },
+ code: 'unknown_action',
+ value: 'ask-again',
+ path: ['action'],
+ },
+ {
+ name: 'a reject without a reason when one is required',
+ request: requestWith({ actions: [approve, { ...reject, reasonRequired: true }] }),
+ call: { action: 'reject' },
+ code: 'reason_required',
+ value: 'reject',
+ path: ['reason'],
+ },
+ {
+ name: 'a reject with a blank reason when one is required',
+ request: requestWith({ actions: [approve, { ...reject, reasonRequired: true }] }),
+ call: { action: 'reject', reason: ' ' },
+ code: 'reason_required',
+ value: 'reject',
+ path: ['reason'],
+ },
+ {
+ name: 'a rerun without a comment',
+ call: { action: 'ask-again' },
+ code: 'comment_required',
+ value: 'ask-again',
+ path: ['comment'],
+ },
+ {
+ name: 'a rerun with a whitespace-only comment',
+ call: { action: 'ask-again', comment: ' \n ' },
+ code: 'comment_required',
+ value: 'ask-again',
+ path: ['comment'],
+ },
+ {
+ name: 'a reject carrying edits',
+ call: { action: 'reject', reason: 'late', edits: { refundAmount: 0 } },
+ code: 'edits_not_allowed',
+ value: 'reject',
+ path: ['edits'],
+ },
+ {
+ name: 'a rerun carrying edits',
+ call: { action: 'ask-again', comment: 'again', edits: { note: 'x' } },
+ code: 'edits_not_allowed',
+ value: 'ask-again',
+ path: ['edits'],
+ },
+ {
+ name: 'a reject carrying an edit on a read-only field, named for the real problem',
+ call: { action: 'reject', edits: { orderDate: '2026-01-01' } },
+ code: 'edits_not_allowed',
+ value: 'reject',
+ path: ['edits'],
+ },
+ {
+ name: 'an edit on a read-only field',
+ call: { action: 'approve', edits: { orderDate: '2026-01-01' } },
+ code: 'field_not_editable',
+ value: 'orderDate',
+ path: ['edits', 'orderDate'],
+ },
+ {
+ name: 'an edit on a field the schema does not declare',
+ call: { action: 'approve', edits: { discount: 10 } },
+ code: 'unknown_field',
+ value: 'discount',
+ path: ['edits', 'discount'],
+ },
+ {
+ name: 'an edit on a field that exists only on Object.prototype',
+ call: { action: 'approve', edits: { constructor: 1 } },
+ code: 'unknown_field',
+ value: 'constructor',
+ path: ['edits', 'constructor'],
+ },
+ {
+ name: 'a required field emptied with an empty string',
+ call: { action: 'approve', edits: { refundAmount: '' } },
+ code: 'required_field_missing',
+ value: 'refundAmount',
+ path: ['edits', 'refundAmount'],
+ },
+ {
+ name: 'a required field emptied with null',
+ call: { action: 'approve', edits: { refundAmount: null } },
+ code: 'required_field_missing',
+ value: 'refundAmount',
+ path: ['edits', 'refundAmount'],
+ },
+ {
+ name: 'a required field emptied with undefined',
+ call: { action: 'approve', edits: { refundAmount: undefined } },
+ code: 'required_field_missing',
+ value: 'refundAmount',
+ path: ['edits', 'refundAmount'],
+ },
+ {
+ name: 'a read-only child rewritten by replacing the object that holds it',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: { id: 'changed' } } },
+ code: 'field_not_editable',
+ value: 'id',
+ path: ['edits', 'profile', 'id'],
+ },
+ {
+ name: "a child the object's own required list names, emptied",
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: { nickname: null } } },
+ code: 'required_field_missing',
+ value: 'nickname',
+ path: ['edits', 'profile', 'nickname'],
+ },
+ {
+ name: 'a child the nested object does not declare',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: { ghost: 1 } } },
+ code: 'unknown_field',
+ value: 'ghost',
+ path: ['edits', 'profile', 'ghost'],
+ },
+ {
+ name: 'a read-only child of an array item, named with its index',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { lines: [{ qty: 2 }, { sku: 'swapped' }] } },
+ code: 'field_not_editable',
+ value: 'sku',
+ path: ['edits', 'lines', '1', 'sku'],
+ },
+ {
+ name: 'a child of an object the form declares but never describes',
+ request: requestWith({ schema: { type: 'object', properties: { opaque: { type: 'object' } } } }),
+ call: { action: 'approve', edits: { opaque: { anything: 1 } } },
+ code: 'unknown_field',
+ value: 'anything',
+ path: ['edits', 'opaque', 'anything'],
+ },
+ {
+ name: 'an element of an array the form declares but never describes',
+ request: requestWith({ schema: { type: 'object', properties: { rows: { type: 'array' } } } }),
+ call: { action: 'approve', edits: { rows: [{ anything: 1 }] } },
+ code: 'unknown_field',
+ value: '0',
+ path: ['edits', 'rows', '0'],
+ },
+ {
+ name: 'any element of an array whose items are read-only, named by its index',
+ request: requestWith({
+ schema: { type: 'object', properties: { rows: { type: 'array', items: { readOnly: true } } } },
+ }),
+ call: { action: 'approve', edits: { rows: ['a', 'b'] } },
+ code: 'field_not_editable',
+ value: '0',
+ path: ['edits', 'rows', '0'],
+ },
+ {
+ name: 'a read-only field three levels down, through an array item',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { lines: [{ qty: 1 }, { origin: { warehouse: 'moved' } }] } },
+ code: 'field_not_editable',
+ value: 'warehouse',
+ path: ['edits', 'lines', '1', 'origin', 'warehouse'],
+ },
+ {
+ name: 'a nested child that exists only on Object.prototype',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: { constructor: 1 } } },
+ code: 'unknown_field',
+ value: 'constructor',
+ path: ['edits', 'profile', 'constructor'],
+ },
+ ...[null, 'x', []].map((replacement) => ({
+ name: `a described object replaced by ${JSON.stringify(replacement)}`,
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { profile: replacement } },
+ code: 'field_shape_changed' as const,
+ value: 'profile',
+ path: ['edits', 'profile'],
+ })),
+ ...[null, { qty: 1 }].map((replacement) => ({
+ name: `a described list replaced by ${JSON.stringify(replacement)}`,
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { lines: replacement } },
+ code: 'field_shape_changed' as const,
+ value: 'lines',
+ path: ['edits', 'lines'],
+ })),
+ {
+ name: 'null on a described object with only editable children, whose type does not allow null',
+ request: requestWith({
+ schema: {
+ type: 'object',
+ properties: { address: { type: 'object', properties: { city: { type: 'string' } } } },
+ },
+ }),
+ call: { action: 'approve', edits: { address: null } },
+ code: 'field_shape_changed',
+ value: 'address',
+ path: ['edits', 'address'],
+ },
+ {
+ name: 'a string on a described object that allows null',
+ request: requestWith({
+ schema: {
+ type: 'object',
+ properties: { address: { type: ['object', 'null'], properties: { city: { type: 'string' } } } },
+ },
+ }),
+ call: { action: 'approve', edits: { address: 'Main St' } },
+ code: 'field_shape_changed',
+ value: 'address',
+ path: ['edits', 'address'],
+ },
+ {
+ name: 'a list element replaced by null, named by its index',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { lines: [null] } },
+ code: 'field_shape_changed',
+ value: '0',
+ path: ['edits', 'lines', '0'],
+ },
+ {
+ name: 'an object inside a list element replaced by a string',
+ request: nestedRequest(),
+ call: { action: 'approve', edits: { lines: [{ origin: 'dock' }] } },
+ code: 'field_shape_changed',
+ value: 'origin',
+ path: ['edits', 'lines', '0', 'origin'],
+ },
+ ])('refuses $name', ({ request = requestWith(), call, code, value, path }) => {
+ expect(validateSubmittedDecision(request, call)).toEqual({
+ error: { code, message: submittedDecisionErrorMessage(code, value), path },
+ });
+ });
+
+ // The decision log promises the first refusal, not a list. Submission order decides which.
+ it('reports only the first bad edit, in the order they were submitted', () => {
+ const readOnlyFirst = validateSubmittedDecision(requestWith(), {
+ action: 'approve',
+ edits: { orderDate: '2026-01-01', discount: 10 },
+ });
+ const unknownFirst = validateSubmittedDecision(requestWith(), {
+ action: 'approve',
+ edits: { discount: 10, orderDate: '2026-01-01' },
+ });
+
+ expect(readOnlyFirst.error).toMatchObject({ code: 'field_not_editable', path: ['edits', 'orderDate'] });
+ expect(unknownFirst.error).toMatchObject({ code: 'unknown_field', path: ['edits', 'discount'] });
+ });
+
+ it('records the action by name and returns the matched action beside the decision', () => {
+ const submitted = { action: 'approve', edits: { refundAmount: 12 }, comment: 'rounded down' };
+
+ const result = validateSubmittedDecision(requestWith(), submitted);
+
+ expect(result.decision).toEqual({
+ action: 'approve',
+ effect: 'resume-with-edits',
+ edits: { refundAmount: 12 },
+ comment: 'rounded down',
+ });
+ expect(result.action).toEqual(approve);
+ });
+
+ it('leaves the initiator to the route: a judged decision carries no resolvedBy', () => {
+ const result = validateSubmittedDecision(requestWith(), { action: 'approve' });
+
+ expect(result.decision).not.toHaveProperty('resolvedBy');
+ expectTypeOf>().not.toHaveProperty('resolvedBy');
+ });
+
+ it('defaults edits to an empty object when none were submitted', () => {
+ expect(validateSubmittedDecision(requestWith(), { action: 'reject', reason: 'late' }).decision).toEqual({
+ action: 'reject',
+ effect: 'reject',
+ edits: {},
+ reason: 'late',
+ });
+ });
+});
+
+describe('submittedDecisionSchema', () => {
+ it('accepts a full submission and strips keys it does not know', () => {
+ const parsed = submittedDecisionSchema.parse({
+ action: 'approve',
+ edits: { a: 1 },
+ reason: 'r',
+ comment: 'c',
+ extra: true,
+ });
+
+ expect(parsed).toEqual({ action: 'approve', edits: { a: 1 }, reason: 'r', comment: 'c' });
+ });
+
+ it('drops an own __proto__ key in edits instead of making it the prototype', () => {
+ const parsed = submittedDecisionSchema.parse(
+ JSON.parse('{"action":"approve","edits":{"__proto__":{"refundAmount":1}}}'),
+ );
+
+ expect(parsed.edits).toEqual({});
+ expect(Object.getPrototypeOf(parsed.edits)).toBe(Object.prototype);
+ });
+
+ it.each<{ name: string; body: unknown; path: string }>([
+ { name: 'edits as an array', body: { action: 'approve', edits: [] }, path: 'edits' },
+ { name: 'edits as a number', body: { action: 'approve', edits: 42 }, path: 'edits' },
+ { name: 'edits as null', body: { action: 'approve', edits: null }, path: 'edits' },
+ { name: 'edits as a string', body: { action: 'approve', edits: 'x' }, path: 'edits' },
+ { name: 'a non-string action', body: { action: 42 }, path: 'action' },
+ { name: 'a non-string reason', body: { action: 'reject', reason: 42 }, path: 'reason' },
+ { name: 'a non-string comment', body: { action: 'ask-again', comment: {} }, path: 'comment' },
+ { name: 'a missing action', body: { edits: {} }, path: 'action' },
+ ])('rejects $name before the rules ever run', ({ body, path }) => {
+ const result = submittedDecisionSchema.safeParse(body);
+
+ expect(result.success).toBe(false);
+ expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))).toEqual([path]);
+ });
+});
diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts
new file mode 100644
index 000000000..bb4e31689
--- /dev/null
+++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts
@@ -0,0 +1,153 @@
+import { z } from 'zod';
+
+import type {
+ Decision,
+ DecisionAction,
+ DecisionEffect,
+ DecisionRequest,
+} from '@workflow-builder/types/workflow-execution/decision-request';
+
+import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues';
+
+// Parsed at the endpoint, which extends it with `nodeId` and `attempt`, before
+// `validateSubmittedDecision` checks the rules on the parsed shape.
+export const submittedDecisionSchema = z.object({
+ action: z.string(),
+ edits: z.record(z.string(), z.unknown()).optional(),
+ reason: z.string().optional(),
+ comment: z.string().optional(),
+});
+
+export type SubmittedDecision = z.infer;
+
+export type SubmittedDecisionError = { code: SubmittedDecisionErrorCode; message: string; path?: string[] };
+
+// `action` is the matched action, for routing; the decision itself records only its name.
+// The initiator is the route's to add: only it knows who called.
+export type SubmittedDecisionResult =
+ | { decision: Omit; action: DecisionAction; error?: undefined }
+ | { decision?: undefined; action?: undefined; error: SubmittedDecisionError };
+
+function refuse(code: SubmittedDecisionErrorCode, value: string, path: string[]): SubmittedDecisionResult {
+ return { error: { code, message: submittedDecisionErrorMessage(code, value), path } };
+}
+
+function isBlank(text: string | undefined): boolean {
+ return text === undefined || text.trim().length === 0;
+}
+
+// "Emptied" means the decider cleared the field, not that they typed something invalid.
+function isEmptied(value: unknown): boolean {
+ return value === undefined || value === null || (typeof value === 'string' && value.trim().length === 0);
+}
+
+// The request arrived through the parser, so `schema` has the shape checked there; the
+// reads below only narrow what `Record` hides.
+function asObject(value: unknown): Record | undefined {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+type EditedChild = { key: string; value: unknown; declared: Record | undefined };
+
+// What an edited value's children are and which schema declares each. Undefined for a leaf,
+// which has none. An array's elements are all declared by `items`, so an index carries no
+// rules of its own; a level the form does not describe inline declares nothing at all.
+function childrenOf(
+ schema: Record,
+ edited: unknown,
+): { children: EditedChild[]; required: Set } | undefined {
+ const fields = asObject(edited);
+ if (fields !== undefined) {
+ const properties = asObject(schema['properties']) ?? {};
+ const names = schema['required'];
+ return {
+ children: Object.entries(fields).map(([key, value]) => ({
+ key,
+ value,
+ declared: Object.hasOwn(properties, key) ? (asObject(properties[key]) ?? {}) : undefined,
+ })),
+ required: new Set(Array.isArray(names) ? (names as string[]) : []),
+ };
+ }
+
+ if (!Array.isArray(edited)) return undefined;
+ const items = asObject(schema['items']);
+ return {
+ children: edited.map((value, index) => ({ key: `${index}`, value, declared: items })),
+ required: new Set(),
+ };
+}
+
+function allowsNull(declared: Record): boolean {
+ const type = declared['type'];
+ return type === 'null' || (Array.isArray(type) && type.includes('null'));
+}
+
+// A level the form describes. Edits patch it, so a value of another shape could drop the
+// read-only and required children it may hold; `null` passes where the level's `type` allows it.
+function changesShape(declared: Record, value: unknown): boolean {
+ if (value === null && allowsNull(declared)) return false;
+ if (asObject(declared['properties']) !== undefined) return asObject(value) === undefined;
+ if (asObject(declared['items']) !== undefined) return !Array.isArray(value);
+ return false;
+}
+
+// Every level the form describes inline: edits patch the proposal, so a `readOnly` child is refused
+// wherever an edit names it. A level behind `$ref` or a composition keyword describes nothing, so an
+// edit into it is refused as unknown (follow-up: decision-edit-schema-composition).
+function validateEdits(
+ schema: Record,
+ edited: unknown,
+ path: string[],
+): SubmittedDecisionResult | undefined {
+ const level = childrenOf(schema, edited);
+ if (level === undefined) return undefined;
+
+ for (const { key, value, declared } of level.children) {
+ const here = [...path, key];
+ if (declared === undefined) return refuse('unknown_field', key, here);
+ if (declared['readOnly'] === true) return refuse('field_not_editable', key, here);
+ if (level.required.has(key) && isEmptied(value)) return refuse('required_field_missing', key, here);
+ if (changesShape(declared, value)) return refuse('field_shape_changed', key, here);
+
+ const refused = validateEdits(declared, value, here);
+ if (refused !== undefined) return refused;
+ }
+
+ return undefined;
+}
+
+// Presence, editability and shape only. Whether an edited value fits its declared type is a
+// later concern with its own validator (follow-up: decision-edit-value-validation)
+export function validateSubmittedDecision(
+ request: DecisionRequest,
+ submitted: SubmittedDecision,
+): SubmittedDecisionResult {
+ const action = request.actions.find((candidate) => candidate.name === submitted.action);
+ if (action === undefined) return refuse('unknown_action', submitted.action, ['action']);
+
+ if (action.effect === 'reject' && action.reasonRequired && isBlank(submitted.reason)) {
+ return refuse('reason_required', action.name, ['reason']);
+ }
+ if (action.effect === 'rerun-source' && isBlank(submitted.comment)) {
+ return refuse('comment_required', action.name, ['comment']);
+ }
+
+ // Before the walk, so the refusal names the edits and not one field.
+ const edits = submitted.edits ?? {};
+ if (action.effect !== 'resume' && Object.keys(edits).length > 0) {
+ return refuse('edits_not_allowed', action.name, ['edits']);
+ }
+
+ const refused = validateEdits(request.schema, edits, ['edits']);
+ if (refused !== undefined) return refused;
+
+ const withEdits = Object.keys(edits).length > 0;
+ const effect: DecisionEffect = action.effect === 'resume' && withEdits ? 'resume-with-edits' : action.effect;
+ return {
+ decision: { action: action.name, effect, edits, reason: submitted.reason, comment: submitted.comment },
+ action,
+ };
+}
diff --git a/apps/backend/src/domain/mapper/from-integration-data.ts b/apps/backend/src/domain/mapper/from-integration-data.ts
index 803ff5de5..5a20969f8 100644
--- a/apps/backend/src/domain/mapper/from-integration-data.ts
+++ b/apps/backend/src/domain/mapper/from-integration-data.ts
@@ -28,11 +28,14 @@ export function mapToExecutionModel(workflowId: string, data: WorkflowSnapshot):
return { workflowId, nodes, edges };
}
-// Lifts the three fields an engine reads out of `data.properties`, where the SDK's
-// `sharedProperties` put them. `role` comes from `data.isStartNode` instead, which sits
-// beside the properties. `description` stays in `config`: no engine reads it.
+// Lifts what an engine reads out of `data.properties`: `label`, `errorPolicy` (from the SDK's
+// `sharedProperties`) and `decisionRequest` (validated and defaulted by the parse, unchecked here).
+// `role` comes from `data.isStartNode` beside the properties; `description` stays in `config`.
function mapNode(node: FrontendNode): BaseNode {
- const { errorPolicy: rawErrorPolicy, label: rawLabel, ...config } = node.data.properties ?? {};
+ // Spread first, so only own keys are read. The parse keeps unknown keys, and an own
+ // `__proto__` among them would leave the properties inheriting fields no schema saw;
+ // this is the one read that would turn such a field into a real one on the way out.
+ const { errorPolicy: rawErrorPolicy, label: rawLabel, decisionRequest, ...config } = { ...node.data.properties };
const errorPolicy = isErrorPolicy(rawErrorPolicy) ? rawErrorPolicy : undefined;
const label = isNonEmptyString(rawLabel) ? rawLabel.trim() : undefined;
const role: NodeRole | undefined = node.data.isStartNode === true ? 'start' : undefined;
@@ -40,7 +43,7 @@ function mapNode(node: FrontendNode): BaseNode {
id: node.id,
type: node.data.type,
config,
- ...pickBy({ label, errorPolicy, role }, isDefined),
+ ...pickBy({ label, errorPolicy, decisionRequest, role }, isDefined),
};
}
diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts
index e0841f3c5..c69ea0598 100644
--- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts
+++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
+import { type DecisionIssueCode, decisionIssueMessage } from '../decision/decision-issues';
import { mapToExecutionModel } from './from-integration-data';
import { workflowSnapshotSchema } from './snapshot-schema';
@@ -115,6 +116,302 @@ describe('workflowSnapshotSchema', () => {
});
});
+function node(id: string, properties?: Record) {
+ return { id, data: { type: 'product/any', properties } };
+}
+
+function edge(source: string, target: string, sourceHandle?: string) {
+ return { id: `${source}->${target}${sourceHandle ?? ''}`, source, target, sourceHandle };
+}
+
+function issuesOf(snapshot: unknown): { path: string; message: string }[] {
+ const result = workflowSnapshotSchema.safeParse(snapshot);
+ return result.success
+ ? []
+ : result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }));
+}
+
+function issuePaths(snapshot: unknown): string[] {
+ return issuesOf(snapshot).map((issue) => issue.path);
+}
+
+describe('workflowSnapshotSchema: decision requests', () => {
+ const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' };
+ const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' };
+ const emptyForm = { type: 'object', properties: {} };
+
+ function decisionNode(id: string, decisionRequest: Record) {
+ return {
+ id,
+ data: {
+ type: 'product/any',
+ properties: { decisionRequest: { version: 1, schema: emptyForm, ...decisionRequest } },
+ },
+ };
+ }
+
+ it('parses a decision request inside properties and materialises the rerun default', () => {
+ const parsed = workflowSnapshotSchema.parse({
+ nodes: [node('src'), decisionNode('review', { actions: [approve, askAgain] })],
+ edges: [edge('src', 'review')],
+ });
+
+ expect(parsed.nodes[1]!.data.properties?.decisionRequest?.actions).toEqual([
+ approve,
+ { ...askAgain, maxIterations: 3 },
+ ]);
+ });
+
+ it('accepts a request whose reject port has no edge: publish never requires a rejection path', () => {
+ const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected' };
+ const snapshot = {
+ nodes: [
+ node('src'),
+ decisionNode('review', { actions: [{ ...approve, port: 'approved' }, reject] }),
+ node('next'),
+ ],
+ edges: [edge('src', 'review'), edge('review', 'next', 'approved')],
+ };
+
+ expect(issuePaths(snapshot)).toEqual([]);
+ const definition = mapToExecutionModel('wf-1', workflowSnapshotSchema.parse(snapshot));
+ expect(definition.edges.filter((candidate) => candidate.sourceNodeId === 'review')).toEqual([
+ expect.objectContaining({ targetNodeId: 'next', sourceHandle: 'approved' }),
+ ]);
+ });
+
+ it('leaves the properties of a node without a request untouched', () => {
+ const properties = { label: 'Plain', decisionBranches: [{ x: 1 }], meta: { deep: { nested: true } } };
+
+ const parsed = workflowSnapshotSchema.parse({ nodes: [node('n1', properties)], edges: [] });
+
+ expect(parsed.nodes[0]!.data.properties).toEqual(properties);
+ });
+
+ it('points a request issue at the node index and field', () => {
+ const snapshot = {
+ nodes: [node('src'), decisionNode('review', { actions: [approve, { ...approve, name: 'approve-2' }] })],
+ edges: [edge('src', 'review')],
+ };
+
+ expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decisionRequest.actions.1.effect');
+ });
+
+ it('rejects `decisionRequest: null`; absent is the only way to carry no request', () => {
+ const snapshot = { nodes: [node('n1', { decisionRequest: null })], edges: [] };
+
+ expect(issuePaths(snapshot)).toContain('nodes.0.data.properties.decisionRequest');
+ });
+
+ it.each<{ name: string; snapshot: unknown }>([
+ {
+ name: 'an explicit source that is a direct predecessor',
+ snapshot: {
+ nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve], proposalSourceNodeId: 'a' })],
+ edges: [edge('a', 'review'), edge('b', 'review')],
+ },
+ },
+ {
+ name: 'a rerun-source node with exactly one predecessor and no explicit source',
+ snapshot: {
+ nodes: [node('a'), decisionNode('review', { actions: [approve, askAgain] })],
+ edges: [edge('a', 'review')],
+ },
+ },
+ {
+ name: 'a rerun-source node with several predecessors when the explicit source picks one',
+ snapshot: {
+ nodes: [
+ node('a'),
+ node('b'),
+ decisionNode('review', { actions: [approve, askAgain], proposalSourceNodeId: 'b' }),
+ ],
+ edges: [edge('a', 'review'), edge('b', 'review')],
+ },
+ },
+ {
+ name: 'a rerun-source node whose single predecessor connects through two handles',
+ snapshot: {
+ nodes: [node('a'), decisionNode('review', { actions: [approve, askAgain] })],
+ edges: [edge('a', 'review', 'left'), edge('a', 'review', 'right')],
+ },
+ },
+ {
+ name: 'a node without rerun-source whose explicit source carries its own decision request',
+ snapshot: {
+ nodes: [
+ node('a'),
+ decisionNode('first', { actions: [approve] }),
+ decisionNode('second', { actions: [approve], proposalSourceNodeId: 'first' }),
+ ],
+ edges: [edge('a', 'first'), edge('first', 'second')],
+ },
+ },
+ {
+ name: 'two independent deciding nodes in one snapshot',
+ snapshot: {
+ nodes: [
+ node('a'),
+ decisionNode('review-1', { actions: [approve, askAgain] }),
+ node('b'),
+ decisionNode('review-2', { actions: [approve, askAgain] }),
+ ],
+ edges: [edge('a', 'review-1'), edge('review-1', 'b'), edge('b', 'review-2')],
+ },
+ },
+ ])('accepts $name', ({ snapshot }) => {
+ expect(workflowSnapshotSchema.safeParse(snapshot).success).toBe(true);
+ });
+
+ it.each<{ name: string; snapshot: unknown; path: string; issue: { code: DecisionIssueCode; value?: string } }>([
+ {
+ name: 'an explicit source with no edge into the deciding node',
+ snapshot: {
+ nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve], proposalSourceNodeId: 'b' })],
+ edges: [edge('a', 'review')],
+ },
+ path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_not_a_predecessor', value: 'b' },
+ },
+ {
+ name: 'an explicit source that is a successor, not a predecessor',
+ snapshot: {
+ nodes: [
+ node('a'),
+ decisionNode('review', { actions: [approve], proposalSourceNodeId: 'after' }),
+ node('after'),
+ ],
+ edges: [edge('a', 'review'), edge('review', 'after')],
+ },
+ path: 'nodes.1.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_not_a_predecessor', value: 'after' },
+ },
+ {
+ name: 'a rerun-source node with no predecessor',
+ snapshot: {
+ nodes: [decisionNode('review', { actions: [approve, askAgain] }), node('after')],
+ edges: [edge('review', 'after')],
+ },
+ path: 'nodes.0.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_missing' },
+ },
+ {
+ name: 'a rerun-source node with several predecessors and no explicit source',
+ snapshot: {
+ nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve, askAgain] })],
+ edges: [edge('a', 'review'), edge('b', 'review')],
+ },
+ path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_ambiguous' },
+ },
+ {
+ name: 'a rerun-source node whose implicit source carries its own decision request',
+ snapshot: {
+ nodes: [
+ node('a'),
+ decisionNode('first', { actions: [approve] }),
+ decisionNode('second', { actions: [approve, askAgain] }),
+ ],
+ edges: [edge('a', 'first'), edge('first', 'second')],
+ },
+ path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_has_decision_request', value: 'first' },
+ },
+ {
+ name: 'a node with no predecessor, so its proposal source cannot be resolved',
+ snapshot: { nodes: [decisionNode('review', { actions: [approve] })], edges: [] },
+ path: 'nodes.0.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_missing' },
+ },
+ {
+ name: 'a node with several predecessors and no explicit source',
+ snapshot: {
+ nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve] })],
+ edges: [edge('a', 'review'), edge('b', 'review')],
+ },
+ path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_ambiguous' },
+ },
+ {
+ name: 'a rerun-source node whose explicit source carries its own decision request',
+ snapshot: {
+ nodes: [
+ node('a'),
+ decisionNode('first', { actions: [approve] }),
+ decisionNode('second', { actions: [approve, askAgain], proposalSourceNodeId: 'first' }),
+ ],
+ edges: [edge('a', 'first'), edge('a', 'second'), edge('first', 'second')],
+ },
+ path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_has_decision_request', value: 'first' },
+ },
+ {
+ name: 'only the broken node when another deciding node in the snapshot is fine',
+ snapshot: {
+ nodes: [
+ node('a'),
+ decisionNode('review-1', { actions: [approve, askAgain] }),
+ decisionNode('review-2', { actions: [approve, askAgain] }),
+ ],
+ edges: [edge('a', 'review-1'), edge('a', 'review-2'), edge('review-1', 'review-2')],
+ },
+ path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId',
+ issue: { code: 'source_ambiguous' },
+ },
+ ])('rejects $name', ({ snapshot, path, issue }) => {
+ const sourceIssues = issuesOf(snapshot).filter((candidate) => candidate.path.endsWith('proposalSourceNodeId'));
+
+ expect(sourceIssues).toEqual([{ path, message: decisionIssueMessage(issue.code, issue.value) }]);
+ });
+
+ function withErrorPolicy(deciding: ReturnType, errorPolicy: string) {
+ return { ...deciding, data: { ...deciding.data, properties: { ...deciding.data.properties, errorPolicy } } };
+ }
+
+ it("rejects errorPolicy 'continue' on a node that carries a decision request", () => {
+ const snapshot = {
+ nodes: [node('src'), withErrorPolicy(decisionNode('review', { actions: [approve] }), 'continue')],
+ edges: [edge('src', 'review')],
+ };
+ const result = workflowSnapshotSchema.safeParse(snapshot);
+
+ expect(result.error?.issues).toEqual([
+ expect.objectContaining({
+ path: ['nodes', 1, 'data', 'properties', 'errorPolicy'],
+ message: decisionIssueMessage('error_policy_continue'),
+ params: { issue: 'error_policy_continue' },
+ }),
+ ]);
+ });
+
+ it('reports the errorPolicy issue beside a source issue on the same node', () => {
+ const snapshot = {
+ nodes: [withErrorPolicy(decisionNode('review', { actions: [approve] }), 'continue')],
+ edges: [],
+ };
+
+ expect(issuePaths(snapshot)).toEqual([
+ 'nodes.0.data.properties.errorPolicy',
+ 'nodes.0.data.properties.decisionRequest.proposalSourceNodeId',
+ ]);
+ });
+
+ it.each(['fail', 'errorRoute'])("accepts errorPolicy '%s' on a node that carries a decision request", (policy) => {
+ const snapshot = {
+ nodes: [node('src'), withErrorPolicy(decisionNode('review', { actions: [approve] }), policy)],
+ edges: [edge('src', 'review')],
+ };
+
+ expect(issuePaths(snapshot)).toEqual([]);
+ });
+
+ it("leaves errorPolicy 'continue' alone on a node without a request", () => {
+ const snapshot = { nodes: [node('src', { errorPolicy: 'continue' })], edges: [] };
+
+ expect(issuePaths(snapshot)).toEqual([]);
+ });
+});
+
describe('mapToExecutionModel', () => {
it('copies every property the runner does not lift into `config`', () => {
const snapshot = workflowSnapshotSchema.parse({
@@ -143,6 +440,24 @@ describe('mapToExecutionModel', () => {
]);
});
+ // `workflowSnapshotSchema` refuses an own `__proto__`, so this reaches the mapper only if
+ // that guard is bypassed. The mapper is exported, so it can be.
+ it('ignores properties that are only inherited, however they got there', () => {
+ const snapshot = workflowSnapshotSchema.parse({
+ nodes: [{ id: 'n1', data: { type: 'product/foo', properties: { own: 1 } } }],
+ edges: [],
+ });
+ Object.setPrototypeOf(snapshot.nodes[0]?.data.properties ?? {}, {
+ decisionRequest: { version: 99, actions: [] },
+ label: 'Inherited',
+ errorPolicy: 'continue',
+ });
+
+ const result = mapToExecutionModel('wf-1', snapshot);
+
+ expect(result.nodes).toEqual([{ id: 'n1', type: 'product/foo', config: { own: 1 } }]);
+ });
+
it('defaults `config` to `{}` when properties are absent', () => {
const snapshot = workflowSnapshotSchema.parse({
nodes: [{ id: 'n1', data: { type: 'product/empty' } }],
@@ -284,6 +599,24 @@ describe('mapToExecutionModel', () => {
expect(result.nodes[0]!.config).toEqual({ foo: 1 });
});
+ it('keeps an AI agent output schema on the way to `config`, like any key it does not know', () => {
+ const outputSchema = {
+ type: 'object',
+ properties: { refundAmount: { type: 'number' } },
+ required: ['refundAmount'],
+ };
+ const snapshot = workflowSnapshotSchema.parse({
+ nodes: [
+ { id: 'n1', data: { type: 'ai-studio/ai-agent', properties: { systemPrompt: 'Decide.', outputSchema } } },
+ ],
+ edges: [],
+ });
+
+ const result = mapToExecutionModel('wf-1', snapshot);
+
+ expect(result.nodes[0]!.config).toEqual({ systemPrompt: 'Decide.', outputSchema });
+ });
+
it('passes unknown node types through unchanged — backend does not know any vocabulary', () => {
// The whole point of the structural mapper: a type the backend has never
// heard of reaches the worker, where the registry-miss becomes a
@@ -297,4 +630,82 @@ describe('mapToExecutionModel', () => {
expect(result.nodes[0]?.type).toBe('never-seen-before/v3');
});
+
+ it('lifts a validated decision request out of `config` onto `decisionRequest`', () => {
+ const request = {
+ version: 1,
+ actions: [
+ { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' },
+ { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' },
+ ],
+ schema: { type: 'object', properties: {} },
+ };
+ const snapshot = workflowSnapshotSchema.parse({
+ nodes: [
+ { id: 'a', data: { type: 'product/any' } },
+ {
+ id: 'review',
+ data: { type: 'product/any', properties: { label: 'Review', foo: 1, decisionRequest: request } },
+ },
+ ],
+ edges: [{ id: 'e1', source: 'a', target: 'review' }],
+ });
+
+ const result = mapToExecutionModel('wf-1', snapshot);
+
+ expect(result.nodes[1]!.decisionRequest).toEqual({
+ ...request,
+ actions: [request.actions[0], { ...request.actions[1], maxIterations: 3 }],
+ });
+ expect(result.nodes[1]!.config).toEqual({ foo: 1 });
+ expect(result.nodes[1]!.label).toBe('Review');
+ });
+
+ it('gives a node without a request no `decisionRequest` key', () => {
+ const snapshot = workflowSnapshotSchema.parse({
+ nodes: [
+ { id: 'n1', data: { type: 'product/any', properties: { foo: 1 } } },
+ { id: 'n2', data: { type: 'product/any' } },
+ ],
+ edges: [],
+ });
+
+ const result = mapToExecutionModel('wf-1', snapshot);
+
+ expect(result.nodes[0]).not.toHaveProperty('decisionRequest');
+ expect(result.nodes[1]).not.toHaveProperty('decisionRequest');
+ });
+});
+
+function snapshotJson(properties: string) {
+ return JSON.parse(`{"nodes":[{"id":"n1","data":{"type":"product/any","properties":${properties}}}],"edges":[]}`);
+}
+
+describe('workflowSnapshotSchema: own __proto__ keys', () => {
+ it('rejects a well-shaped decision request smuggled through properties.__proto__', () => {
+ const smuggled = snapshotJson(
+ '{"label":"ok","__proto__":{"decisionRequest":{"version":99,"actions":[{"effect":"bogus"}],"schema":"x"}}}',
+ );
+
+ expect(issuesOf(smuggled)).toEqual([
+ { path: 'nodes.0.data.properties.__proto__', message: "the key '__proto__' is not allowed" },
+ ]);
+ });
+
+ it('rejects one inside a decision request instead of inheriting the deadline it smuggles', () => {
+ const poisoned = snapshotJson(
+ '{"decisionRequest":{"version":1,"actions":[{"name":"approve","label":"Approve","effect":"resume","port":"approved"}],' +
+ '"schema":{"type":"object","properties":{}},' +
+ '"__proto__":{"deadline":{"after":"garbage","policy":"nuke"},"uiSchema":"x"}}}',
+ );
+
+ expect(issuePaths(poisoned)).toEqual(['nodes.0.data.properties.decisionRequest.__proto__']);
+ });
+
+ it('answers with an issue, not a throw, when the smuggled request has no actions array', () => {
+ const smuggled = snapshotJson('{"__proto__":{"decisionRequest":{"actions":"x"}}}');
+
+ expect(() => workflowSnapshotSchema.safeParse(smuggled)).not.toThrow();
+ expect(issuePaths(smuggled)).toEqual(['nodes.0.data.properties.__proto__']);
+ });
});
diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts
index a7acf610b..9ac6432ca 100644
--- a/apps/backend/src/domain/mapper/snapshot-schema.ts
+++ b/apps/backend/src/domain/mapper/snapshot-schema.ts
@@ -1,11 +1,13 @@
-// Validates the workflow snapshot at the HTTP boundary structurally only:
-// every node has `id` and `data.type`; every edge has `id`, `source`, `target`.
-// `data.properties` is opaque here — the backend does not know any product's
-// node vocabulary. Per-type validation belongs to whichever worker registers
-// executors for that vocabulary; an unknown node type surfaces at runtime as
-// a `node_failed` event with the missing-executor message.
+// Structural validation of the editor snapshot at the HTTP boundary. `data.properties` is
+// opaque except for the reserved `decisionRequest` key: the backend knows no product's node
+// vocabulary, so an unknown node type fails at runtime as `node_failed`, not here.
import { z } from 'zod';
+import { type DecisionIssueCode, decisionIssue } from '../decision/decision-issues';
+import { decisionRequestSchema } from '../decision/decision-request-schema';
+import { type UnresolvedSourceReason, resolveProposalSource } from '../decision/proposal-source';
+import { rejectingOwnProtoKey } from '../schema/own-proto-key';
+
const frontendNodeSchema = z.object({
id: z.string(),
data: z.object({
@@ -15,7 +17,7 @@ const frontendNodeSchema = z.object({
// starts from. The editor's node kind (`start-node`, `node`, ...) is a
// rendering detail and deliberately not read here.
isStartNode: z.boolean().optional(),
- properties: z.record(z.string(), z.unknown()).optional(),
+ properties: z.looseObject({ decisionRequest: decisionRequestSchema.optional() }).optional(),
}),
});
@@ -26,9 +28,51 @@ const frontendEdgeSchema = z.object({
sourceHandle: z.string().nullable().optional(),
});
-export const workflowSnapshotSchema = z.object({
- nodes: z.array(frontendNodeSchema),
- edges: z.array(frontendEdgeSchema),
-});
+const SOURCE_ISSUE_BY_REASON = {
+ node_without_decision_request: 'source_node_without_decision_request',
+ explicit_source_not_a_predecessor: 'source_not_a_predecessor',
+ no_predecessor: 'source_missing',
+ ambiguous_predecessor: 'source_ambiguous',
+} as const satisfies Record;
+
+export const workflowSnapshotSchema = rejectingOwnProtoKey(
+ z
+ .object({
+ nodes: z.array(frontendNodeSchema),
+ edges: z.array(frontendEdgeSchema),
+ })
+ .superRefine((snapshot, context) => {
+ const nodes = snapshot.nodes.map((node) => ({
+ id: node.id,
+ decisionRequest: node.data.properties?.decisionRequest,
+ }));
+ const edges = snapshot.edges.map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target }));
+
+ for (const [index, node] of snapshot.nodes.entries()) {
+ const request = node.data.properties?.decisionRequest;
+ if (request === undefined) continue;
+ // 'continue' propagates a failure with no port, which lights every non-error edge: approve and reject alike.
+ if (node.data.properties?.['errorPolicy'] === 'continue') {
+ context.addIssue(
+ decisionIssue('error_policy_continue', ['nodes', index, 'data', 'properties', 'errorPolicy']),
+ );
+ }
+ const declaresRerun = request.actions.some((action) => action.effect === 'rerun-source');
+
+ const path = ['nodes', index, 'data', 'properties', 'decisionRequest', 'proposalSourceNodeId'];
+ const resolution = resolveProposalSource(nodes, edges, node.id);
+ if (resolution.error !== undefined) {
+ context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, request.proposalSourceNodeId));
+ continue;
+ }
+ const sourceHasRequest = nodes.some(
+ (other) => other.id === resolution.sourceNodeId && other.decisionRequest !== undefined,
+ );
+ if (declaresRerun && sourceHasRequest) {
+ context.addIssue(decisionIssue('source_has_decision_request', path, resolution.sourceNodeId));
+ }
+ }
+ }),
+);
export type WorkflowSnapshot = z.infer;
diff --git a/apps/backend/src/domain/schema/own-proto-key.test.ts b/apps/backend/src/domain/schema/own-proto-key.test.ts
new file mode 100644
index 000000000..c34077cf6
--- /dev/null
+++ b/apps/backend/src/domain/schema/own-proto-key.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+
+import { findOwnProtoKey, rejectingOwnProtoKey } from './own-proto-key';
+
+describe('findOwnProtoKey', () => {
+ it('reports the path of an own key created by JSON.parse, through objects and arrays', () => {
+ expect(findOwnProtoKey({ a: { b: [1, { c: null }] } })).toBeUndefined();
+ expect(findOwnProtoKey(JSON.parse('{"a": {"b": {"__proto__": {}}}}'))).toEqual(['a', 'b', '__proto__']);
+ expect(findOwnProtoKey(JSON.parse('{"items": [1, {"__proto__": {}}]}'))).toEqual(['items', 1, '__proto__']);
+ });
+
+ it('terminates on a cyclic object', () => {
+ const cyclic: Record = { a: 1 };
+ cyclic['self'] = cyclic;
+
+ expect(findOwnProtoKey(cyclic)).toBeUndefined();
+ });
+
+ // Depth is the client's to pick and only the 1 MB body limit caps it: 50k levels of
+ // `[` is 100 KB, and a recursive walk died long before that.
+ it('walks a snapshot nested far deeper than a recursive scan could', () => {
+ const depth = 50_000;
+ const nested = (leaf: string): unknown => JSON.parse('['.repeat(depth) + leaf + ']'.repeat(depth));
+
+ expect(findOwnProtoKey(nested(''))).toBeUndefined();
+
+ const found = findOwnProtoKey(nested('{"__proto__":{}}'));
+
+ expect(found?.at(-1)).toBe('__proto__');
+ expect(found).toHaveLength(depth + 1);
+ });
+});
+
+describe('rejectingOwnProtoKey', () => {
+ const schema = rejectingOwnProtoKey(z.looseObject({ a: z.number(), deadline: z.string().optional() }));
+
+ it('passes a clean value through and rejects an own __proto__ key at its path', () => {
+ expect(schema.parse({ a: 1, extra: true })).toEqual({ a: 1, extra: true });
+
+ const result = schema.safeParse(JSON.parse('{"a": 1, "__proto__": {"deadline": "smuggled"}}'));
+
+ expect(result.success).toBe(false);
+ expect(result.success ? [] : result.error.issues.map((issue) => [issue.path.join('.'), issue.message])).toEqual([
+ ['__proto__', "the key '__proto__' is not allowed"],
+ ]);
+ });
+});
diff --git a/apps/backend/src/domain/schema/own-proto-key.ts b/apps/backend/src/domain/schema/own-proto-key.ts
new file mode 100644
index 000000000..7f929dd04
--- /dev/null
+++ b/apps/backend/src/domain/schema/own-proto-key.ts
@@ -0,0 +1,59 @@
+import { z } from 'zod';
+
+const OWN_PROTO_KEY_MESSAGE = "the key '__proto__' is not allowed";
+
+function isWalkable(value: unknown): value is object {
+ return typeof value === 'object' && value !== null;
+}
+
+// Arrays yield numeric indices, objects string keys, which is the shape a JSON path takes.
+function ownEntries(value: object): Iterator<[PropertyKey, unknown]> {
+ return Array.isArray(value) ? value.entries() : Object.entries(value).values();
+}
+
+// `JSON.parse` turns "__proto__" into an ordinary own key. zod's loose objects copy unknown
+// keys with a plain assignment, which for that key swaps the output's prototype instead,
+// so anything under it is read back as if validated. Refuse it before parsing.
+//
+// Walked with an explicit stack, never recursion: a snapshot's nesting depth is whatever the
+// client sent, and a blown call stack would answer 500 where this promises a 400.
+export function findOwnProtoKey(value: unknown): PropertyKey[] | undefined {
+ if (!isWalkable(value)) return undefined;
+ if (Object.hasOwn(value, '__proto__')) return ['__proto__'];
+
+ const seen = new Set([value]);
+ // One segment per stacked iterator below the root, so the path is copied once, on a hit.
+ const path: PropertyKey[] = [];
+ const stack: Iterator<[PropertyKey, unknown]>[] = [ownEntries(value)];
+
+ while (stack.length > 0) {
+ const step = stack.at(-1)!.next();
+ if (step.done === true) {
+ stack.pop();
+ // A no-op for the root, which owns no segment.
+ path.pop();
+ continue;
+ }
+
+ const [key, child] = step.value;
+ if (!isWalkable(child) || seen.has(child)) continue;
+ if (Object.hasOwn(child, '__proto__')) return [...path, key, '__proto__'];
+
+ seen.add(child);
+ path.push(key);
+ stack.push(ownEntries(child));
+ }
+
+ return undefined;
+}
+
+// Refuses the key outright, at its path. Wrap a payload where it enters: every loose
+// object below is then safe without each one having to defend itself.
+export function rejectingOwnProtoKey(schema: T) {
+ return z.preprocess((value, context) => {
+ const path = findOwnProtoKey(value);
+ if (path === undefined) return value;
+ context.addIssue({ code: 'custom', message: OWN_PROTO_KEY_MESSAGE, path });
+ return z.NEVER;
+ }, schema);
+}
diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts
index 8b0272b45..6c7076387 100644
--- a/apps/backend/src/env.test.ts
+++ b/apps/backend/src/env.test.ts
@@ -106,6 +106,27 @@ describe('execute rate limits', () => {
});
});
+describe('ENABLE_WB_LISTING', () => {
+ // Off unless opted in: a deployment whose only secret is the random id must not list every id.
+ it('lists on true', async () => {
+ const env = await loadEnv({ ENABLE_WB_LISTING: 'true' });
+
+ expect(env.ENABLE_WB_LISTING).toBe(true);
+ });
+
+ it.each(['TRUE', 'True', '1', 'yes', 'enabled', ''])('does not list on %s', async (value) => {
+ const env = await loadEnv({ ENABLE_WB_LISTING: value });
+
+ expect(env.ENABLE_WB_LISTING).toBe(false);
+ });
+
+ it('does not list when unset', async () => {
+ const env = await loadEnv({});
+
+ expect(env.ENABLE_WB_LISTING).toBe(false);
+ });
+});
+
describe('TURNSTILE_SECRET_KEY', () => {
it('reads a secret that is set', async () => {
const env = await loadEnv({ TURNSTILE_SECRET_KEY: 'secret' });
diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts
index b36d8f5ce..49eb87507 100644
--- a/apps/backend/src/env.ts
+++ b/apps/backend/src/env.ts
@@ -17,6 +17,9 @@ export const env = {
RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')),
RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')),
TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true',
+ // Read only under the AllowAllAuthPort (server.ts): unset keeps the collection routes off there, so a
+ // forgotten variable never exposes every id. A real port authorizes listing by itself.
+ ENABLE_WB_LISTING: envOr('ENABLE_WB_LISTING', 'false') === 'true',
// Null = Turnstile verification disabled (local dev runs unprotected).
TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null,
};
diff --git a/apps/backend/src/events/count-node-waits.ts b/apps/backend/src/events/count-node-waits.ts
new file mode 100644
index 000000000..3a78b7dd0
--- /dev/null
+++ b/apps/backend/src/events/count-node-waits.ts
@@ -0,0 +1,21 @@
+import { and, count, eq } from 'drizzle-orm';
+
+import type { ExecutionEventType } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { database } from '../db/client';
+import { executionEvents } from '../db/schema';
+
+// How many times the node has parked in this run: the wait instance a decision addresses.
+export async function countNodeWaits(executionId: string, nodeId: string): Promise {
+ const [row] = await database
+ .select({ waits: count() })
+ .from(executionEvents)
+ .where(
+ and(
+ eq(executionEvents.executionId, executionId),
+ eq(executionEvents.nodeId, nodeId),
+ eq(executionEvents.type, 'node_waiting' satisfies ExecutionEventType),
+ ),
+ );
+ return row?.waits ?? 0;
+}
diff --git a/apps/backend/src/middleware/listing-guard.test.ts b/apps/backend/src/middleware/listing-guard.test.ts
new file mode 100644
index 000000000..329eec132
--- /dev/null
+++ b/apps/backend/src/middleware/listing-guard.test.ts
@@ -0,0 +1,86 @@
+import { Hono } from 'hono';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { AllowAllAuthPort, type AuthPort } from '../auth';
+import type { BackendEnv } from '../routes/backend-env';
+import { isListingRefused, refuseListing } from './listing-guard';
+
+const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+
+// Stand-ins for the real routers, mounted the way server.ts mounts them: after the guard.
+function makeApp() {
+ const app = new Hono();
+ refuseListing(app);
+ const listed = vi.fn();
+ for (const base of ['/api/workflows', '/api/executions']) {
+ const routes = new Hono();
+ routes.get('/', (c) => {
+ listed(base);
+ return c.json([]);
+ });
+ routes.post('/', (c) => c.json({ created: true }, 201));
+ routes.get('/:id', (c) => c.json({ id: c.req.param('id') }));
+ routes.get('/:id/stream', (c) => c.text('stream'));
+ app.route(base, routes);
+ }
+ return { app, listed };
+}
+
+describe('refuseListing', () => {
+ it.each(['/api/workflows', '/api/executions', '/api/executions?status=running&limit=1', '/api/workflows/'])(
+ 'refuses GET %s without reaching the list',
+ async (path) => {
+ const { app, listed } = makeApp();
+
+ const response = await app.request(path);
+
+ expect(response.status).toBeGreaterThanOrEqual(400);
+ expect(listed).not.toHaveBeenCalled();
+ },
+ );
+
+ it('answers 403 listing_disabled', async () => {
+ const { app } = makeApp();
+
+ const response = await app.request('/api/executions');
+
+ expect(response.status).toBe(403);
+ expect(await response.json()).toMatchObject({ code: 'listing_disabled' });
+ });
+
+ it.each([
+ ['POST', '/api/workflows', 201],
+ ['GET', `/api/workflows/${RUN}`, 200],
+ ['GET', `/api/executions/${RUN}`, 200],
+ ['GET', `/api/executions/${RUN}/stream`, 200],
+ ])('lets %s %s through', async (method, path, status) => {
+ const { app } = makeApp();
+
+ const response = await app.request(path, { method });
+
+ expect(response.status).toBe(status);
+ });
+});
+
+describe('isListingRefused', () => {
+ const realPort: AuthPort = { identify: async () => null, authorize: async () => true };
+
+ beforeEach(() => {
+ vi.stubEnv('WB_AUTH_PORT', 'allow-all');
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it('refuses under the allow-all port until the switch is set', () => {
+ expect(isListingRefused(new AllowAllAuthPort(), false)).toBe(true);
+ expect(isListingRefused(new AllowAllAuthPort(), true)).toBe(false);
+ });
+
+ it('never refuses under another port, which authorizes listing by itself', () => {
+ expect(isListingRefused(realPort, false)).toBe(false);
+ });
+});
diff --git a/apps/backend/src/middleware/listing-guard.ts b/apps/backend/src/middleware/listing-guard.ts
new file mode 100644
index 000000000..cf463f5a4
--- /dev/null
+++ b/apps/backend/src/middleware/listing-guard.ts
@@ -0,0 +1,27 @@
+import type { Hono } from 'hono';
+
+import { AllowAllAuthPort, type AuthPort } from '../auth';
+import type { BackendEnv } from '../routes/backend-env';
+
+// Any future collection route belongs here too: the random id is a run's only secret on a public deployment.
+const LISTING_PATHS = ['/api/workflows', '/api/executions'] as const;
+
+/** Under the permissive port the random id is the only secret; any other port authorizes listing by itself. */
+export function isListingRefused(authPort: AuthPort, enableListing: boolean): boolean {
+ return authPort instanceof AllowAllAuthPort && !enableListing;
+}
+
+/** Register before the routers: a handler that answers ends the chain, so the list handlers never run. */
+export function refuseListing(app: Hono): void {
+ for (const path of LISTING_PATHS) {
+ app.get(path, (c) =>
+ c.json(
+ {
+ code: 'listing_disabled',
+ message: 'Listing is disabled on this deployment; ENABLE_WB_LISTING=true turns it on',
+ },
+ 403,
+ ),
+ );
+ }
+}
diff --git a/apps/backend/src/routes/backend-env.ts b/apps/backend/src/routes/backend-env.ts
new file mode 100644
index 000000000..ebfde66e8
--- /dev/null
+++ b/apps/backend/src/routes/backend-env.ts
@@ -0,0 +1,4 @@
+import type { AuthVariables } from '../auth';
+import type { TenantVariables } from '../tenant';
+
+export type BackendEnv = { Variables: AuthVariables & TenantVariables };
diff --git a/apps/backend/src/routes/decision-refusals.test.ts b/apps/backend/src/routes/decision-refusals.test.ts
new file mode 100644
index 000000000..f63212117
--- /dev/null
+++ b/apps/backend/src/routes/decision-refusals.test.ts
@@ -0,0 +1,49 @@
+import { Hono } from 'hono';
+import { describe, expect, it } from 'vitest';
+
+import { DECISION_REFUSALS, DECISION_REFUSAL_STATUS, type DecisionRefusal, refuse } from './decision-refusals';
+
+async function answer(refusal: DecisionRefusal, value?: string, extra?: Record) {
+ const app = new Hono().get('/', (c) => refuse(c, refusal, { value, extra }));
+ const response = await app.request('/');
+ return {
+ status: response.status,
+ retryAfter: response.headers.get('retry-after'),
+ body: (await response.json()) as Record,
+ };
+}
+
+describe('decision refusals', () => {
+ it('every code is answered in at least one situation', () => {
+ const answered = new Set(Object.values(DECISION_REFUSALS).map((refusal) => refusal.code));
+
+ expect([...answered].sort()).toEqual(Object.keys(DECISION_REFUSAL_STATUS).sort());
+ });
+
+ it('answers with the code, its status, the filled message and whatever else the route adds', async () => {
+ expect(await answer('node_not_found', '$&-$1')).toEqual({
+ status: 404,
+ retryAfter: null,
+ body: { code: 'node_not_found', message: "No node '$&-$1' in this execution" },
+ });
+ expect(await answer('attempt_mismatch', undefined, { attempt: 1 })).toEqual({
+ status: 409,
+ retryAfter: null,
+ body: {
+ code: 'decision_attempt_mismatch',
+ message: 'The decision names a wait that is not the current one',
+ attempt: 1,
+ },
+ });
+ });
+
+ it('a delivery timeout asks for a retry in five seconds and says the decision may have landed', async () => {
+ const answered = await answer('delivery_timeout');
+
+ expect(answered.status).toBe(503);
+ expect(answered.retryAfter).toBe('5');
+ expect(answered.body.code).toBe('decision_delivery_timeout');
+ expect(answered.body.message).toContain('may or may not have landed');
+ expect(answered.body.message).toContain('decision_already_made');
+ });
+});
diff --git a/apps/backend/src/routes/decision-refusals.ts b/apps/backend/src/routes/decision-refusals.ts
new file mode 100644
index 000000000..10bedacb2
--- /dev/null
+++ b/apps/backend/src/routes/decision-refusals.ts
@@ -0,0 +1,88 @@
+import type { Context } from 'hono';
+
+import type { ResolveNodeRejection } from '@workflow-builder/execution-core/workflow';
+
+import { fill } from '../domain/decision/decision-issues';
+import type { FindDecisionRequestError } from '../domain/decision/find-decision-request';
+
+// Every code the decision endpoint refuses with, and its status. The one place a code is spelled out.
+export const DECISION_REFUSAL_STATUS = {
+ validation_error: 400,
+ invalid_decision: 400,
+ execution_not_found: 404,
+ node_not_found: 404,
+ execution_not_waiting: 409,
+ node_not_waiting: 409,
+ decision_already_made: 409,
+ decision_attempt_mismatch: 409,
+ effect_not_supported: 501,
+ decision_delivery_timeout: 503,
+} as const;
+
+export type DecisionRefusalCode = keyof typeof DECISION_REFUSAL_STATUS;
+
+// One entry per situation; several situations may answer with the same code.
+// `{value}` is the one interpolation slot.
+export const DECISION_REFUSALS = {
+ body_invalid: { code: 'validation_error', message: 'Request body failed validation' },
+ decision_invalid: { code: 'invalid_decision', message: 'Decision failed validation' },
+ execution_not_found: { code: 'execution_not_found', message: 'Execution not found' },
+ execution_not_waiting: { code: 'execution_not_waiting', message: 'Execution is not waiting for a decision' },
+ run_gone: { code: 'execution_not_waiting', message: 'Execution is no longer running' },
+ node_not_found: { code: 'node_not_found', message: "No node '{value}' in this execution" },
+ node_without_request: { code: 'node_not_waiting', message: "Node '{value}' carries no decision request" },
+ node_never_parked: { code: 'node_not_waiting', message: "Node '{value}' has not asked for a decision" },
+ node_not_waiting: { code: 'node_not_waiting', message: "Node '{value}' is not waiting for a decision" },
+ decision_already_made: {
+ code: 'decision_already_made',
+ message: "Node '{value}' already has a decision for this wait",
+ },
+ attempt_mismatch: {
+ code: 'decision_attempt_mismatch',
+ message: 'The decision names a wait that is not the current one',
+ },
+ effect_not_supported: {
+ code: 'effect_not_supported',
+ message: "Action '{value}' re-runs the proposal source, which is not supported yet",
+ },
+ // The deadline says nothing about the decision's fate, and nothing here tells two
+ // senders apart, so the resend answer names the wait and not the sender.
+ delivery_timeout: {
+ code: 'decision_delivery_timeout',
+ message:
+ 'The decision was not confirmed within the deadline and may or may not have landed. Send it again: a decision_already_made answer means some decision won this wait, not necessarily yours.',
+ headers: { 'Retry-After': '5' },
+ },
+} as const satisfies Record }>;
+
+export type DecisionRefusal = keyof typeof DECISION_REFUSALS;
+
+export const LOOKUP_REFUSALS = {
+ node_not_found: 'node_not_found',
+ node_without_decision_request: 'node_without_request',
+} as const satisfies Record;
+
+// The route built the envelope, so a fault is its own bug, or a worker whose validator predates a key
+// the route now sends (worker README, deploy order): it surfaces as 500.
+export const ENGINE_REFUSALS = {
+ node_not_waiting: 'node_not_waiting',
+ verdict_already_delivered: 'decision_already_made',
+ run_not_found: 'run_gone',
+ verdict_for_unknown_node: 'fault',
+ verdict_malformed: 'fault',
+ delivery_timeout: 'delivery_timeout',
+} as const satisfies Record;
+
+export function refuse(
+ c: Context,
+ refusal: DecisionRefusal,
+ { value, extra }: { value?: string; extra?: Record } = {},
+) {
+ const entry = DECISION_REFUSALS[refusal];
+ const headers = 'headers' in entry ? entry.headers : undefined;
+ return c.json(
+ { code: entry.code, message: fill(entry.message, value), ...extra },
+ DECISION_REFUSAL_STATUS[entry.code],
+ headers,
+ );
+}
diff --git a/apps/backend/src/routes/decision.test.ts b/apps/backend/src/routes/decision.test.ts
new file mode 100644
index 000000000..1261d1b3b
--- /dev/null
+++ b/apps/backend/src/routes/decision.test.ts
@@ -0,0 +1,621 @@
+import { Hono } from 'hono';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ResolveNodeRejection } from '@workflow-builder/execution-core/workflow';
+import { TERMINAL_EXECUTION_STATUSES } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import {
+ AllowAllAuthPort,
+ AuthDeniedError,
+ type AuthPort,
+ type AuthVariables,
+ createAuthMiddleware,
+ makeAssertAuthorized,
+} from '../auth';
+import { SUBMITTED_DECISION_ERRORS, type SubmittedDecisionErrorCode } from '../domain/decision/decision-issues';
+import { createDecisionRoutes } from './decision';
+
+// ---- module mocks -----------------------------------------------------------
+
+const { databaseMock, getEngineMock, engineMock } = vi.hoisted(() => ({
+ databaseMock: { select: vi.fn() },
+ engineMock: { submit: vi.fn(), cancel: vi.fn(), resolveNode: vi.fn() },
+ getEngineMock: vi.fn(),
+}));
+
+vi.mock('../db/client', () => ({ database: databaseMock }));
+vi.mock('../engine', () => ({ getWorkflowEngine: getEngineMock }));
+
+// ---- helpers ----------------------------------------------------------------
+
+function chainProxyHandler(value: T): ProxyHandler> {
+ return {
+ get(target, property, receiver) {
+ if (property === 'then' || property === 'catch' || property === 'finally') {
+ const v = Reflect.get(target, property, receiver);
+ return typeof v === 'function' ? v.bind(target) : v;
+ }
+ return () => chainResolving(value);
+ },
+ };
+}
+
+function chainResolving(value: T): Promise {
+ return new Proxy(Promise.resolve(value), chainProxyHandler(value));
+}
+
+function buildApp(port: AuthPort) {
+ const app = new Hono<{ Variables: AuthVariables }>();
+ app.use('*', createAuthMiddleware(port));
+ app.onError((error, c) => {
+ if (error instanceof AuthDeniedError) {
+ if (!error.caller) {
+ return c.json({ code: 'unauthenticated', message: 'Authentication required' }, 401);
+ }
+ return c.json({ code: 'forbidden', message: error.message }, 403);
+ }
+ return c.json({ code: 'internal_error', message: 'Internal server error' }, 500);
+ });
+ app.route('/api/executions', createDecisionRoutes(makeAssertAuthorized(port)));
+ return app;
+}
+
+function allowAll(spy = vi.fn(async () => true)): AuthPort {
+ return { identify: vi.fn(async () => null), authorize: spy };
+}
+
+function denyAll(): AuthPort {
+ return { identify: vi.fn(async () => null), authorize: vi.fn(async () => false) };
+}
+
+async function decide(app: ReturnType, body: unknown): Promise {
+ return app.request('/api/executions/e-1/decision', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+}
+
+type Body = {
+ code?: string;
+ message?: string;
+ details?: { code: string; path: (string | number)[] }[];
+ attempt?: number;
+ effect?: string;
+};
+
+function bodyOf(response: Response): Promise {
+ return response.json() as Promise;
+}
+
+async function codeOf(response: Response): Promise {
+ const body = await bodyOf(response);
+ return body.code;
+}
+
+// 1st select: the execution row (none when `execution` is undefined). 2nd: the wait count.
+function program(execution?: unknown, waits = 1) {
+ databaseMock.select.mockReturnValueOnce(chainResolving(execution === undefined ? [] : [execution]));
+ databaseMock.select.mockReturnValue(chainResolving([{ waits }]));
+}
+
+// ---- fixtures ---------------------------------------------------------------
+
+const refundForm = {
+ type: 'object',
+ properties: {
+ orderDate: { type: 'string', readOnly: true },
+ refundAmount: { type: 'number' },
+ note: { type: 'string' },
+ customer: { type: 'object', properties: { id: { type: 'string', readOnly: true } } },
+ },
+ required: ['refundAmount'],
+};
+// Not the strings the former defaults produced: the nextPort assertions must prove the port came from the request.
+const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'source:inner:approved' };
+const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'source:inner:rejected' };
+const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' };
+
+// source-1 feeds two deciding nodes. review-1 offers all three effects and takes a reject
+// without a reason; review-2 requires one. after-1 hangs off review-1's approved port.
+const snapshot = {
+ nodes: [
+ { id: 'source-1', data: { type: 'product/any', properties: {} } },
+ {
+ id: 'review-1',
+ data: {
+ type: 'product/any',
+ properties: {
+ decisionRequest: {
+ version: 1,
+ actions: [approve, reject, askAgain],
+ schema: refundForm,
+ proposalSourceNodeId: 'source-1',
+ },
+ },
+ },
+ },
+ {
+ id: 'review-2',
+ data: {
+ type: 'product/any',
+ properties: {
+ decisionRequest: { version: 1, actions: [approve, { ...reject, reasonRequired: true }], schema: refundForm },
+ },
+ },
+ },
+ { id: 'after-1', data: { type: 'product/any', properties: {} } },
+ ],
+ edges: [
+ { id: 'e1', source: 'source-1', target: 'review-1' },
+ { id: 'e2', source: 'source-1', target: 'review-2' },
+ { id: 'e3', source: 'review-1', target: 'after-1', sourceHandle: 'source:inner:approved' },
+ ],
+};
+
+const waitingExecution = {
+ id: 'e-1',
+ workflowId: 'w-1',
+ sourceVersion: 'published',
+ workflowSnapshotJson: snapshot,
+ status: 'waiting',
+ tenantId: 'acme',
+ triggerPayloadJson: null,
+ startedAt: new Date(0),
+ finishedAt: null,
+ errorMessage: null,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+};
+
+const approveBody = { nodeId: 'review-1', attempt: 1, action: 'approve', edits: { refundAmount: 120 } };
+const approvedDecision = {
+ action: 'approve',
+ effect: 'resume-with-edits',
+ edits: { refundAmount: 120 },
+ resolvedBy: 'human',
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ getEngineMock.mockReturnValue(engineMock);
+ engineMock.resolveNode.mockResolvedValue({});
+});
+
+// ---- authorization ------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - authorization', () => {
+ it('asserts executions:decide with the row attributes the port scopes by', async () => {
+ const authorizeSpy = vi.fn(async () => true);
+ program(waitingExecution);
+
+ await decide(buildApp(allowAll(authorizeSpy)), approveBody);
+
+ expect(authorizeSpy).toHaveBeenCalledWith(null, 'executions:decide', {
+ kind: 'execution',
+ executionId: 'e-1',
+ attributes: { workflowId: 'w-1', tenantId: 'acme', status: 'waiting' },
+ });
+ });
+
+ it('asserts without attributes when there is no row, and the deny still wins over the 404', async () => {
+ const authorizeSpy = vi.fn(async () => false);
+ program();
+
+ const response = await decide(buildApp(allowAll(authorizeSpy)), approveBody);
+
+ expect(response.status).toBe(401);
+ expect(authorizeSpy.mock.calls[0]?.[2]).toEqual({ kind: 'execution', executionId: 'e-1' });
+ expect(authorizeSpy.mock.calls[0]?.[2]).not.toHaveProperty('attributes');
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+
+ it('a deny answers 401 after the one row read, and the engine is never asked', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(denyAll()), approveBody);
+
+ expect(response.status).toBe(401);
+ expect(databaseMock.select).toHaveBeenCalledTimes(1);
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+
+ describe('under the reference AllowAllAuthPort', () => {
+ const originalAuthPort = process.env['WB_AUTH_PORT'];
+
+ beforeEach(() => {
+ process.env['WB_AUTH_PORT'] = 'allow-all';
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ if (originalAuthPort === undefined) delete process.env['WB_AUTH_PORT'];
+ else process.env['WB_AUTH_PORT'] = originalAuthPort;
+ });
+
+ it('an anonymous caller decides', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(new AllowAllAuthPort()), approveBody);
+
+ expect(response.status).toBe(200);
+ expect(engineMock.resolveNode).toHaveBeenCalledTimes(1);
+ });
+ });
+});
+
+// ---- the run ----------------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - the run', () => {
+ it('404 execution_not_found when the row is missing', async () => {
+ program();
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(404);
+ expect(await response.json()).toEqual({ code: 'execution_not_found', message: 'Execution not found' });
+ });
+
+ it('the row is checked before the body: a missing row with a bad body is a 404', async () => {
+ program();
+
+ const response = await decide(buildApp(allowAll()), { not: 'a decision' });
+
+ expect(response.status).toBe(404);
+ expect(await codeOf(response)).toBe('execution_not_found');
+ });
+
+ it.each([...TERMINAL_EXECUTION_STATUSES, 'cancelling'])(
+ '409 execution_not_waiting on a %s run, before the body is even read',
+ async (status) => {
+ program({ ...waitingExecution, status });
+
+ const response = await decide(buildApp(allowAll()), { not: 'a decision' });
+
+ expect(response.status).toBe(409);
+ expect(await codeOf(response)).toBe('execution_not_waiting');
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(['pending', 'running', 'waiting'])('a %s run goes through to the engine, the arbiter', async (status) => {
+ program({ ...waitingExecution, status });
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(200);
+ expect(engineMock.resolveNode).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ---- the body ---------------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - the body', () => {
+ it.each<{ name: string; body: unknown; path: string }>([
+ { name: 'a missing nodeId', body: { attempt: 1, action: 'approve' }, path: 'nodeId' },
+ { name: 'an empty nodeId', body: { nodeId: '', attempt: 1, action: 'approve' }, path: 'nodeId' },
+ { name: 'an attempt of 0', body: { nodeId: 'review-1', attempt: 0, action: 'approve' }, path: 'attempt' },
+ { name: 'a non-integer attempt', body: { nodeId: 'review-1', attempt: 1.5, action: 'approve' }, path: 'attempt' },
+ { name: 'a missing action', body: { nodeId: 'review-1', attempt: 1 }, path: 'action' },
+ ])('400 validation_error for $name', async ({ body, path }) => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), body);
+
+ expect(response.status).toBe(400);
+ const json = await bodyOf(response);
+ expect(json.code).toBe('validation_error');
+ expect(json.details?.map((detail) => detail.path.join('.'))).toEqual([path]);
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+});
+
+// ---- the node -----------------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - the node', () => {
+ it('404 node_not_found for a node the snapshot does not have', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), { ...approveBody, nodeId: 'ghost' });
+
+ expect(response.status).toBe(404);
+ expect(await response.json()).toEqual({ code: 'node_not_found', message: "No node 'ghost' in this execution" });
+ });
+
+ it('409 node_not_waiting for a node that carries no decision request', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), { ...approveBody, nodeId: 'source-1' });
+
+ expect(response.status).toBe(409);
+ expect(await response.json()).toEqual({
+ code: 'node_not_waiting',
+ message: "Node 'source-1' carries no decision request",
+ });
+ });
+
+ it('409 node_not_waiting for a node that has never parked', async () => {
+ program(waitingExecution, 0);
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(409);
+ expect(await response.json()).toEqual({
+ code: 'node_not_waiting',
+ message: "Node 'review-1' has not asked for a decision",
+ });
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+
+ it('the node is looked up before the decision is judged: an unknown node with a bad decision is a 404', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), { nodeId: 'ghost', attempt: 1, action: 'escalate' });
+
+ expect(response.status).toBe(404);
+ });
+});
+
+// ---- the decision -------------------------------------------------------------------
+
+const INVALID_DECISIONS = {
+ unknown_action: { nodeId: 'review-1', action: 'escalate' },
+ reason_required: { nodeId: 'review-2', action: 'reject' },
+ comment_required: { nodeId: 'review-1', action: 'ask-again' },
+ edits_not_allowed: { nodeId: 'review-1', action: 'reject', edits: { refundAmount: 1 } },
+ unknown_field: { nodeId: 'review-1', action: 'approve', edits: { discount: 10 } },
+ field_not_editable: { nodeId: 'review-1', action: 'approve', edits: { orderDate: '2026-01-01' } },
+ required_field_missing: { nodeId: 'review-1', action: 'approve', edits: { refundAmount: null } },
+ field_shape_changed: { nodeId: 'review-1', action: 'approve', edits: { customer: null } },
+} satisfies Record>;
+
+describe('POST /api/executions/:id/decision - the decision', () => {
+ it.each(Object.keys(SUBMITTED_DECISION_ERRORS) as SubmittedDecisionErrorCode[])(
+ '400 invalid_decision carrying %s',
+ async (code) => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), { attempt: 1, ...INVALID_DECISIONS[code] });
+
+ expect(response.status).toBe(400);
+ const json = await bodyOf(response);
+ expect(json.code).toBe('invalid_decision');
+ expect(json.details).toHaveLength(1);
+ expect(json.details?.[0]).toMatchObject({ code, message: expect.any(String), path: expect.any(Array) });
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ },
+ );
+
+ it('the decision is judged before the attempt: a bad decision with a stale attempt is a 400', async () => {
+ program(waitingExecution, 1);
+
+ const response = await decide(buildApp(allowAll()), { nodeId: 'review-1', attempt: 2, action: 'escalate' });
+
+ expect(response.status).toBe(400);
+ expect(await codeOf(response)).toBe('invalid_decision');
+ });
+});
+
+// ---- the wait instance ------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - the wait instance', () => {
+ it('409 decision_attempt_mismatch carrying the current attempt', async () => {
+ program(waitingExecution, 1);
+
+ const response = await decide(buildApp(allowAll()), { ...approveBody, attempt: 2 });
+
+ expect(response.status).toBe(409);
+ expect(await response.json()).toEqual({
+ code: 'decision_attempt_mismatch',
+ message: 'The decision names a wait that is not the current one',
+ attempt: 1,
+ });
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+
+ it('the attempt is checked before the effect: a rerun-source naming a stale attempt is a 409', async () => {
+ program(waitingExecution, 1);
+
+ const response = await decide(buildApp(allowAll()), {
+ nodeId: 'review-1',
+ attempt: 2,
+ action: 'ask-again',
+ comment: 'too generous',
+ });
+
+ expect(response.status).toBe(409);
+ expect(await codeOf(response)).toBe('decision_attempt_mismatch');
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+
+ it('a decision for the second of two waiting nodes is delivered while the first keeps waiting', async () => {
+ program(waitingExecution, 1);
+
+ const response = await decide(buildApp(allowAll()), { nodeId: 'review-2', attempt: 1, action: 'approve' });
+
+ expect(response.status).toBe(200);
+ expect(engineMock.resolveNode).toHaveBeenCalledTimes(1);
+ expect(engineMock.resolveNode).toHaveBeenCalledWith({
+ executionId: 'e-1',
+ nodeId: 'review-2',
+ resolution: {
+ output: { action: 'approve', effect: 'resume', edits: {}, resolvedBy: 'human' },
+ nextPort: 'source:inner:approved',
+ },
+ });
+ });
+});
+
+// ---- the effect ---------------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - the effect', () => {
+ it('501 effect_not_supported for rerun-source, engine never asked', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), {
+ nodeId: 'review-1',
+ attempt: 1,
+ action: 'ask-again',
+ comment: 'too generous',
+ });
+
+ expect(response.status).toBe(501);
+ expect(await response.json()).toEqual({
+ code: 'effect_not_supported',
+ message: "Action 'ask-again' re-runs the proposal source, which is not supported yet",
+ });
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ });
+});
+
+// ---- delivery -----------------------------------------------------------------------
+
+describe('POST /api/executions/:id/decision - delivery', () => {
+ it('hands the engine the decision as the output and the action port as the route, and answers 200', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ executionId: 'e-1',
+ nodeId: 'review-1',
+ attempt: 1,
+ action: 'approve',
+ effect: 'resume-with-edits',
+ });
+ expect(engineMock.resolveNode).toHaveBeenCalledWith({
+ executionId: 'e-1',
+ nodeId: 'review-1',
+ resolution: {
+ output: approvedDecision,
+ nextPort: 'source:inner:approved',
+ },
+ });
+ });
+
+ // Postgres accepts a non-canonical uuid and answers with the canonical row, so the url
+ // spelling and the row id can differ; the engine's workflow name is case-sensitive.
+ it('delivers to the id the row carries, not the spelling the url used', async () => {
+ const canonical = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
+ program({ ...waitingExecution, id: canonical });
+
+ const response = await buildApp(allowAll()).request(`/api/executions/${canonical.toUpperCase()}/decision`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(approveBody),
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({ executionId: canonical });
+ expect(engineMock.resolveNode).toHaveBeenCalledWith({
+ executionId: canonical,
+ nodeId: 'review-1',
+ resolution: {
+ output: approvedDecision,
+ nextPort: 'source:inner:approved',
+ },
+ });
+ });
+
+ it('a reject with no reason, none required, routes on the reject port and declares the rejection outcome', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), { nodeId: 'review-1', attempt: 1, action: 'reject' });
+
+ expect(response.status).toBe(200);
+ const body = await bodyOf(response);
+ expect(body.effect).toBe('reject');
+ expect(engineMock.resolveNode).toHaveBeenCalledWith({
+ executionId: 'e-1',
+ nodeId: 'review-1',
+ resolution: {
+ output: { action: 'reject', effect: 'reject', edits: {}, resolvedBy: 'human' },
+ nextPort: 'source:inner:rejected',
+ outcome: { value: 'rejected', resolvedBy: 'human' },
+ },
+ });
+ });
+
+ it('stamps the initiator itself: a body claiming one is stripped, and an approve declares no outcome', async () => {
+ program(waitingExecution);
+
+ const response = await decide(buildApp(allowAll()), { ...approveBody, resolvedBy: 'timer' });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ executionId: 'e-1',
+ nodeId: 'review-1',
+ attempt: 1,
+ action: 'approve',
+ effect: 'resume-with-edits',
+ });
+ const completion: unknown = engineMock.resolveNode.mock.calls[0]?.[0]?.resolution;
+ expect(completion).toEqual({ output: approvedDecision, nextPort: 'source:inner:approved' });
+ expect(completion).not.toHaveProperty('outcome');
+ });
+
+ it.each<{ code: ResolveNodeRejection; status: number; answer: string }>([
+ { code: 'node_not_waiting', status: 409, answer: 'node_not_waiting' },
+ { code: 'verdict_already_delivered', status: 409, answer: 'decision_already_made' },
+ { code: 'run_not_found', status: 409, answer: 'execution_not_waiting' },
+ ])('the engine answer $code becomes $status $answer on the first try, no retry', async ({ code, status, answer }) => {
+ program(waitingExecution);
+ engineMock.resolveNode.mockResolvedValue({ error: { code, message: 'engine said no' } });
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(status);
+ expect(await codeOf(response)).toBe(answer);
+ expect(engineMock.resolveNode).toHaveBeenCalledTimes(1);
+ });
+
+ it('the engine answer delivery_timeout becomes 503 with Retry-After, sent once, no retry', async () => {
+ program(waitingExecution);
+ engineMock.resolveNode.mockResolvedValue({ error: { code: 'delivery_timeout', message: 'Deadline exceeded' } });
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(503);
+ expect(response.headers.get('retry-after')).toBe('5');
+ const body = await bodyOf(response);
+ expect(body.code).toBe('decision_delivery_timeout');
+ expect(body.message).toContain('decision_already_made');
+ expect(engineMock.resolveNode).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(['verdict_malformed', 'verdict_for_unknown_node'])(
+ 'the engine answer %s is a backend fault: 500',
+ async (code) => {
+ program(waitingExecution);
+ engineMock.resolveNode.mockResolvedValue({ error: { code, message: 'engine said no' } });
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(500);
+ expect(await codeOf(response)).toBe('internal_error');
+ },
+ );
+
+ it('an error the engine throws instead of returning is a backend fault: 500', async () => {
+ program(waitingExecution);
+ engineMock.resolveNode.mockRejectedValue(new Error('connection lost'));
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(500);
+ expect(await codeOf(response)).toBe('internal_error');
+ });
+
+ it('a stored snapshot that no longer parses is a backend fault: 500', async () => {
+ program({ ...waitingExecution, workflowSnapshotJson: { nodes: 'broken' } });
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const response = await decide(buildApp(allowAll()), approveBody);
+
+ expect(response.status).toBe(500);
+ expect(engineMock.resolveNode).not.toHaveBeenCalled();
+ errorSpy.mockRestore();
+ });
+});
diff --git a/apps/backend/src/routes/decision.ts b/apps/backend/src/routes/decision.ts
new file mode 100644
index 000000000..a165faa18
--- /dev/null
+++ b/apps/backend/src/routes/decision.ts
@@ -0,0 +1,127 @@
+import { eq } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { z } from 'zod';
+
+import type { Decision } from '@workflow-builder/types/workflow-execution/decision-request';
+import {
+ type ExecutionStatus,
+ TERMINAL_EXECUTION_STATUSES,
+} from '@workflow-builder/types/workflow-execution/execution-events';
+
+import type { AssertAuthorized, AuthResource } from '../auth';
+import { database } from '../db/client';
+import { executions } from '../db/schema';
+import { findDecisionRequest } from '../domain/decision/find-decision-request';
+import { hasNodeResolution, toNodeResolution } from '../domain/decision/node-resolution';
+import { submittedDecisionSchema, validateSubmittedDecision } from '../domain/decision/validate-submitted-decision';
+import { workflowSnapshotSchema } from '../domain/mapper/snapshot-schema';
+import { getWorkflowEngine } from '../engine';
+import { countNodeWaits } from '../events/count-node-waits';
+import { logger as backendLogger } from '../logger';
+import type { BackendEnv } from './backend-env';
+import { ENGINE_REFUSALS, LOOKUP_REFUSALS, refuse } from './decision-refusals';
+import { formatValidationDetails } from './snapshot-validation';
+
+const logger = backendLogger.child({ component: 'decision-route' });
+
+// Every other status goes to the engine: the advisory status write is best-effort, so a
+// parked run can still read 'pending'.
+const NOT_DECIDABLE_STATUSES = new Set([
+ ...TERMINAL_EXECUTION_STATUSES,
+ 'cancelling' satisfies ExecutionStatus,
+]);
+
+const HUMAN_INITIATOR = 'human';
+
+const decisionBodySchema = submittedDecisionSchema.extend({
+ nodeId: z.string().min(1),
+ attempt: z.int().min(1),
+});
+
+export function createDecisionRoutes(assertAuthorized: AssertAuthorized): Hono {
+ const routes = new Hono();
+
+ routes.post('/:id/decision', async (c) => {
+ const executionId = c.req.param('id');
+
+ // Read before authorization so the port can scope by the row; a deny thus wins over 404.
+ const [execution] = await database.select().from(executions).where(eq(executions.id, executionId));
+ const resource: AuthResource = execution
+ ? {
+ kind: 'execution',
+ executionId: execution.id,
+ attributes: { workflowId: execution.workflowId, tenantId: execution.tenantId, status: execution.status },
+ }
+ : { kind: 'execution', executionId };
+ await assertAuthorized(c, 'executions:decide', resource);
+
+ if (!execution) return refuse(c, 'execution_not_found');
+ if (NOT_DECIDABLE_STATUSES.has(execution.status)) return refuse(c, 'execution_not_waiting');
+
+ // Postgres answers a non-canonical uuid with the canonical row, so the two can differ.
+ // Every id below is the row's: the engine builds a case-sensitive workflow name from it.
+ const { id: resolvedId } = execution;
+
+ const parsedBody = z.safeParse(decisionBodySchema, await c.req.json());
+ if (!parsedBody.success) {
+ return refuse(c, 'body_invalid', { extra: { details: formatValidationDetails(parsedBody.error) } });
+ }
+ const { nodeId, attempt, ...submitted } = parsedBody.data;
+
+ const parsedSnapshot = z.safeParse(workflowSnapshotSchema, execution.workflowSnapshotJson);
+ if (!parsedSnapshot.success) {
+ logger.error('stored snapshot no longer parses', {
+ executionId: resolvedId,
+ error: { issues: formatValidationDetails(parsedSnapshot.error) },
+ });
+ throw new Error(`stored snapshot of execution ${resolvedId} no longer parses`);
+ }
+ const found = findDecisionRequest(parsedSnapshot.data, nodeId);
+ if (found.error !== undefined) return refuse(c, LOOKUP_REFUSALS[found.error], { value: nodeId });
+
+ const validated = validateSubmittedDecision(found.request, submitted);
+ if (validated.error !== undefined) {
+ return refuse(c, 'decision_invalid', { extra: { details: [validated.error] } });
+ }
+ const { action } = validated;
+ const decision: Decision = { ...validated.decision, resolvedBy: HUMAN_INITIATOR };
+
+ // Not atomic with delivery, and safe only because a node parks at most once per run.
+ // A rerun re-parks it, and then the wait instance must reach the engine, which keys
+ // its waits by node id alone (follow-up: decision-attempt-in-engine).
+ const waits = await countNodeWaits(resolvedId, nodeId);
+ if (waits === 0) return refuse(c, 'node_never_parked', { value: nodeId });
+ if (waits !== attempt) return refuse(c, 'attempt_mismatch', { extra: { attempt: waits } });
+
+ // Refused until the engine can re-run a source (follow-up: decision-rerun-source).
+ if (!hasNodeResolution(decision) || action.effect === 'rerun-source') {
+ return refuse(c, 'effect_not_supported', { value: action.name });
+ }
+
+ const result = await getWorkflowEngine().resolveNode({
+ executionId: resolvedId,
+ nodeId,
+ resolution: toNodeResolution(decision, action),
+ });
+ if (result.error !== undefined) {
+ const refusal = ENGINE_REFUSALS[result.error.code];
+ if (refusal === 'fault') {
+ throw new Error(
+ `engine refused the completion for node '${nodeId}': ${result.error.code}: ${result.error.message}`,
+ );
+ }
+ return refuse(c, refusal, { value: nodeId });
+ }
+
+ logger.info('decision delivered', {
+ executionId: resolvedId,
+ nodeId,
+ attempt,
+ action: action.name,
+ effect: decision.effect,
+ });
+ return c.json({ executionId: resolvedId, nodeId, attempt, action: action.name, effect: decision.effect });
+ });
+
+ return routes;
+}
diff --git a/apps/backend/src/routes/executions.test.ts b/apps/backend/src/routes/executions.test.ts
index fbdffcaf8..66c78b6d5 100644
--- a/apps/backend/src/routes/executions.test.ts
+++ b/apps/backend/src/routes/executions.test.ts
@@ -1,3 +1,5 @@
+import type { SQL } from 'drizzle-orm';
+import { PgDialect } from 'drizzle-orm/pg-core';
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -10,8 +12,11 @@ import {
createAuthMiddleware,
makeAssertAuthorized,
} from '../auth';
-import { type TenantContext, type TenantVariables, createTenantMiddleware } from '../tenant';
+import { executions } from '../db/schema';
+import { type TenantContext, createTenantMiddleware } from '../tenant';
+import type { BackendEnv } from './backend-env';
import { createExecutionsRoutes } from './executions';
+import { decodeCursor, encodeCursor } from './list-executions-query';
// ---- module mocks -----------------------------------------------------------
//
@@ -93,7 +98,7 @@ function denyAll(): AuthPort {
// cross-check has a `c.var.tenant` to read. `tenant` is what the configured
// TenantContextPort resolves to (null = single-tenant reference default).
function buildAppWithTenant(port: AuthPort, tenant: TenantContext | null) {
- const app = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+ const app = new Hono();
app.use('*', createAuthMiddleware(port));
app.use('*', createTenantMiddleware({ resolve: vi.fn(async () => tenant) }));
app.route('/api/executions', createExecutionsRoutes(makeAssertAuthorized(port)));
@@ -107,6 +112,8 @@ const terminalExecution = {
workflowId: 'w-1',
sourceVersion: 'draft',
status: 'completed',
+ outcome: null,
+ resolvedBy: null,
startedAt: null,
finishedAt: new Date(0),
createdAt: new Date(0),
@@ -115,6 +122,9 @@ const terminalExecution = {
const pendingExecution = { ...terminalExecution, status: 'pending', finishedAt: null };
+const EXECUTION_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
+const STREAM_PATH = `/api/executions/${EXECUTION_ID}/stream`;
+
beforeEach(() => {
vi.clearAllMocks();
getEngineMock.mockReturnValue(engineMock);
@@ -124,6 +134,13 @@ beforeEach(() => {
describe('createExecutionsRoutes - authorize is called with the right shape per route', () => {
it.each<{ method: string; path: string; action: AuthAction; resource: AuthResource; execution: unknown }>([
+ {
+ method: 'GET',
+ path: '/api/executions',
+ action: 'executions:list',
+ resource: { kind: 'executions' },
+ execution: terminalExecution,
+ },
{
method: 'GET',
path: '/api/executions/e-1',
@@ -131,6 +148,13 @@ describe('createExecutionsRoutes - authorize is called with the right shape per
resource: { kind: 'execution', executionId: 'e-1' },
execution: terminalExecution,
},
+ {
+ method: 'GET',
+ path: '/api/executions/e-1/snapshot',
+ action: 'executions:read',
+ resource: { kind: 'execution', executionId: 'e-1' },
+ execution: terminalExecution,
+ },
{
method: 'DELETE',
path: '/api/executions/e-1',
@@ -165,11 +189,11 @@ describe('createExecutionsRoutes - authorize is called with the right shape per
databaseMock.select.mockReturnValueOnce(chainResolving([terminalExecution]));
databaseMock.select.mockReturnValue(chainResolving([]));
- await app.request('/api/executions/e-1/stream', { method: 'GET' });
+ await app.request(STREAM_PATH, { method: 'GET' });
expect(authorizeSpy).toHaveBeenCalledWith(null, 'executions:stream', {
kind: 'execution',
- executionId: 'e-1',
+ executionId: EXECUTION_ID,
});
// Subscribe is only wired for non-terminal executions; pin that the
// terminal short-circuit holds so the test does not pay for a postgres
@@ -182,8 +206,10 @@ describe('createExecutionsRoutes - authorize is called with the right shape per
describe('createExecutionsRoutes - deny short-circuits before DB or engine work', () => {
const cases: Array<{ method: string; path: string }> = [
+ { method: 'GET', path: '/api/executions' },
{ method: 'GET', path: '/api/executions/e-1' },
- { method: 'GET', path: '/api/executions/e-1/stream' },
+ { method: 'GET', path: STREAM_PATH },
+ { method: 'GET', path: '/api/executions/e-1/snapshot' },
{ method: 'DELETE', path: '/api/executions/e-1' },
];
@@ -225,7 +251,7 @@ describe('createExecutionsRoutes - stream tenant cross-check', () => {
const app = buildAppWithTenant(allowStream(), { tenantId: 'other' });
programStream(tenantedExecution);
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
// Byte-identical to the not-found branch: a foreign execution must be
// indistinguishable from one that does not exist, or the id is enumerable.
@@ -238,7 +264,7 @@ describe('createExecutionsRoutes - stream tenant cross-check', () => {
const app = buildAppWithTenant(allowStream(), { tenantId: 'acme' });
programStream(tenantedExecution);
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
expect(response.status).toBe(200);
});
@@ -247,7 +273,7 @@ describe('createExecutionsRoutes - stream tenant cross-check', () => {
const app = buildAppWithTenant(allowStream(), null);
programStream(tenantedExecution);
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
expect(response.status).toBe(200);
});
@@ -256,7 +282,7 @@ describe('createExecutionsRoutes - stream tenant cross-check', () => {
const app = buildAppWithTenant(allowStream(), { tenantId: 'acme' });
programStream(terminalExecution); // terminalExecution carries no tenantId
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
expect(response.status).toBe(200);
});
@@ -300,7 +326,7 @@ describe('createExecutionsRoutes - stream snapshot-window race', () => {
chainResolving([makeEventRow(1, 'execution_started'), makeEventRow(2, 'execution_completed')]),
);
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
// Pre-fix this text() never resolves: the handler holds the stream open on
// heartbeats forever, so a test timeout here is the regression signal.
const body = await response.text();
@@ -321,7 +347,7 @@ describe('createExecutionsRoutes - stream snapshot-window race', () => {
databaseMock.select.mockReturnValueOnce(chainResolving([pendingExecution]));
databaseMock.select.mockReturnValue(chainResolving([makeEventRow(1, type)]));
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
const body = await response.text();
expect(snapshotFrom(body).status).toBe(status);
@@ -336,7 +362,7 @@ describe('createExecutionsRoutes - stream snapshot-window race', () => {
databaseMock.select.mockReturnValueOnce(chainResolving([makeEventRow(1, 'execution_started')]));
databaseMock.select.mockReturnValue(chainResolving([makeEventRow(2, 'execution_completed')]));
- const response = await app.request('/api/executions/e-1/stream');
+ const response = await app.request(STREAM_PATH);
const body = await response.text();
// Negative half: 'execution_started' must not read as terminal.
@@ -348,6 +374,27 @@ describe('createExecutionsRoutes - stream snapshot-window race', () => {
});
});
+describe('createExecutionsRoutes - stream id', () => {
+ // Postgres reads these as the same uuid and answers with the stored row.
+ it.each([
+ { spelling: 'upper case', id: EXECUTION_ID.toUpperCase() },
+ { spelling: 'braces', id: `{${EXECUTION_ID}}` },
+ { spelling: 'no hyphens', id: EXECUTION_ID.replaceAll('-', '') },
+ ])('a request spelled with $spelling subscribes and reports under the stored id', async ({ id }) => {
+ const app = buildApp(allowStream());
+ subscribeMock.mockResolvedValue(() => {});
+ databaseMock.select.mockReturnValueOnce(chainResolving([{ ...pendingExecution, id: EXECUTION_ID }]));
+ databaseMock.select.mockReturnValueOnce(chainResolving([makeEventRow(1, 'execution_started')]));
+ databaseMock.select.mockReturnValue(chainResolving([makeEventRow(2, 'execution_completed')]));
+
+ const response = await app.request(`/api/executions/${encodeURIComponent(id)}/stream`);
+ const body = await response.text();
+
+ expect(snapshotFrom(body)).toMatchObject({ executionId: EXECUTION_ID });
+ expect(subscribeMock).toHaveBeenCalledWith(EXECUTION_ID, expect.any(Function));
+ });
+});
+
// ---- cancel race -------------------------------------------------------------
//
// The pre-check 409 reads the row, but the enforcement is the UPDATE's WHERE:
@@ -392,3 +439,258 @@ describe('createExecutionsRoutes - cancel enforcement in the UPDATE', () => {
expect(databaseMock.update).not.toHaveBeenCalled();
});
});
+
+describe('createExecutionsRoutes - GET /:id body', () => {
+ it('returns the outcome and its initiator from the row, null while the run has none', async () => {
+ const app = buildApp(allowAll(vi.fn(async () => true)));
+
+ databaseMock.select.mockReturnValueOnce(
+ chainResolving([{ ...terminalExecution, outcome: 'rejected', resolvedBy: 'human' }]),
+ );
+ const rejected = await app.request('/api/executions/e-1');
+ expect(rejected.status).toBe(200);
+ expect(await rejected.json()).toMatchObject({ status: 'completed', outcome: 'rejected', resolvedBy: 'human' });
+
+ databaseMock.select.mockReturnValueOnce(chainResolving([terminalExecution]));
+ const plain = await app.request('/api/executions/e-1');
+ expect(await plain.json()).toMatchObject({ status: 'completed', outcome: null, resolvedBy: null });
+ });
+});
+
+// ---- list -------------------------------------------------------------------
+//
+// The chain proxy discards call arguments, so the WHERE the route builds is
+// invisible through it. This mock records what reaches `.where()`, `.orderBy()`
+// and `.limit()` and renders the fragment to text, so tenant scoping and the page size are
+// asserted as SQL rather than inferred from the rows the mock returns.
+
+const dialect = new PgDialect();
+
+const listedExecution = {
+ ...terminalExecution,
+ id: '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11',
+ createdAt: new Date('2026-09-18T10:00:00.123Z'),
+};
+const olderExecution = {
+ ...listedExecution,
+ id: '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c10',
+ createdAt: new Date('2026-09-18T09:00:00.000Z'),
+};
+
+function captureSelect(rows: unknown[]) {
+ const captured: { where?: SQL; orderBy?: unknown[]; limit?: number } = {};
+ databaseMock.select.mockImplementation(() => ({
+ from: () => ({
+ where: (condition: SQL | undefined) => {
+ captured.where = condition;
+ return {
+ orderBy: (...terms: unknown[]) => {
+ captured.orderBy = terms;
+ return {
+ limit: (count: number) => {
+ captured.limit = count;
+ return chainResolving(rows);
+ },
+ };
+ },
+ };
+ },
+ }),
+ }));
+ return captured;
+}
+
+describe('createExecutionsRoutes - list', () => {
+ it('200 with the envelope: summary fields only, dates as ISO strings, no next page under the limit', async () => {
+ const app = buildApp(allowStream());
+ databaseMock.select.mockReturnValue(chainResolving([listedExecution, olderExecution]));
+
+ const response = await app.request('/api/executions');
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ items: [
+ {
+ id: listedExecution.id,
+ workflowId: 'w-1',
+ sourceVersion: 'draft',
+ status: 'completed',
+ startedAt: null,
+ finishedAt: '1970-01-01T00:00:00.000Z',
+ createdAt: '2026-09-18T10:00:00.123Z',
+ },
+ {
+ id: olderExecution.id,
+ workflowId: 'w-1',
+ sourceVersion: 'draft',
+ status: 'completed',
+ startedAt: null,
+ finishedAt: '1970-01-01T00:00:00.000Z',
+ createdAt: '2026-09-18T09:00:00.000Z',
+ },
+ ],
+ nextCursor: null,
+ });
+ });
+
+ it('limit + 1 rows -> limit items and a cursor for the last returned item', async () => {
+ const app = buildApp(allowStream());
+ captureSelect([listedExecution, olderExecution]);
+
+ const response = await app.request('/api/executions?limit=1');
+ const body = (await response.json()) as { items: unknown[]; nextCursor: string };
+
+ expect(body.items).toHaveLength(1);
+ expect(decodeCursor(body.nextCursor)).toEqual({ createdAt: '2026-09-18T10:00:00.123Z', id: listedExecution.id });
+ });
+
+ it.each([
+ { query: 'status=waitting', code: 'invalid_status' },
+ { query: 'cursor=%25%25%25', code: 'invalid_cursor' },
+ { query: 'limit=0', code: 'invalid_limit' },
+ { query: 'workflowId=nope', code: 'invalid_workflow_id' },
+ ])('?$query -> 400 $code and no select', async ({ query, code }) => {
+ const app = buildApp(allowStream());
+
+ const response = await app.request(`/api/executions?${query}`);
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({ code });
+ expect(databaseMock.select).not.toHaveBeenCalled();
+ });
+
+ // The invalid query pins the order: parsing first would answer 400, not 403.
+ it('an identified caller denied executions:list -> 403 before the query is parsed', async () => {
+ const app = buildApp({ identify: vi.fn(async () => ({ subject: 'u-1' })), authorize: vi.fn(async () => false) });
+
+ const response = await app.request('/api/executions?status=waitting');
+
+ expect(response.status).toBe(403);
+ expect(await response.json()).toMatchObject({ code: 'forbidden' });
+ expect(databaseMock.select).not.toHaveBeenCalled();
+ });
+
+ it('tenant present -> the WHERE scopes to the tenant and untenanted rows', async () => {
+ const app = buildAppWithTenant(allowStream(), { tenantId: 'acme' });
+ const captured = captureSelect([]);
+
+ await app.request('/api/executions?status=waiting');
+
+ const rendered = dialect.sqlToQuery(captured.where!);
+ expect(rendered.sql).toBe(
+ '("executions"."status" = $1 and ("executions"."tenant_id" = $2 or "executions"."tenant_id" is null))',
+ );
+ expect(rendered.params).toEqual(['waiting', 'acme']);
+ });
+
+ it('no tenant, no filters -> no WHERE at all (single-tenant default)', async () => {
+ const app = buildAppWithTenant(allowStream(), null);
+ const captured = captureSelect([]);
+
+ await app.request('/api/executions');
+
+ expect(captured.where).toBeUndefined();
+ });
+
+ // An adapter outside TypeScript can return what TenantContext forbids. Failing open here
+ // would hand one tenant every other tenant's rows, so the list refuses what the stream 404s on.
+ it.each([undefined, null])(
+ 'a resolved tenant with a %s id -> 400 tenant_required and no select',
+ async (tenantId) => {
+ const app = buildAppWithTenant(allowStream(), { tenantId } as unknown as TenantContext);
+
+ const response = await app.request('/api/executions');
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({ code: 'tenant_required' });
+ expect(databaseMock.select).not.toHaveBeenCalled();
+ },
+ );
+
+ it('an empty tenant id is a tenant, not a missing one: it still scopes the WHERE', async () => {
+ const app = buildAppWithTenant(allowStream(), { tenantId: '' });
+ const captured = captureSelect([]);
+
+ await app.request('/api/executions');
+
+ const rendered = dialect.sqlToQuery(captured.where!);
+ expect(rendered.sql).toBe('("executions"."tenant_id" = $1 or "executions"."tenant_id" is null)');
+ expect(rendered.params).toEqual(['']);
+ });
+
+ // list-executions-query.test.ts covers the predicate; this covers the wiring that feeds it,
+ // which no other test exercises with a cursor the route would actually accept.
+ it('a valid cursor reaches the WHERE as the keyset predicate', async () => {
+ const app = buildApp(allowStream());
+ const captured = captureSelect([]);
+ const cursor = encodeCursor({ createdAt: listedExecution.createdAt, id: listedExecution.id });
+
+ await app.request(`/api/executions?cursor=${cursor}`);
+
+ const rendered = dialect.sqlToQuery(captured.where!);
+ expect(rendered.sql).toBe(
+ `(date_trunc('milliseconds', "executions"."created_at"), "executions"."id") < ($1::timestamptz, $2::uuid)`,
+ );
+ expect(rendered.params).toEqual(['2026-09-18T10:00:00.123Z', listedExecution.id]);
+ });
+
+ it.each([
+ { query: '', limit: 51 },
+ { query: '?limit=500', limit: 201 },
+ ])('GET /api/executions$query asks for limit + 1 rows: $limit', async ({ query, limit }) => {
+ const app = buildApp(allowStream());
+ const captured = captureSelect([]);
+
+ await app.request(`/api/executions${query}`);
+
+ expect(captured.limit).toBe(limit);
+ expect(captured.orderBy).toHaveLength(2);
+ });
+});
+
+// ---- snapshot ----------------------------------------------------------------
+
+const SNAPSHOT_PATH = `/api/executions/${EXECUTION_ID}/snapshot`;
+
+const executedGraph = {
+ nodes: [{ id: 'n-1', type: 'start-node', position: { x: 0, y: 0 }, data: { type: 'trigger', properties: {} } }],
+ edges: [],
+};
+
+function captureSnapshotSelect(rows: unknown[]) {
+ const captured: { columns?: Record; table?: unknown } = {};
+ databaseMock.select.mockImplementation((columns: Record) => {
+ captured.columns = columns;
+ return {
+ from: (table: unknown) => {
+ captured.table = table;
+ return { where: () => chainResolving(rows) };
+ },
+ };
+ });
+ return captured;
+}
+
+describe('createExecutionsRoutes - GET /:id/snapshot', () => {
+ it('200 with the graph copied into the execution row at execute time, not the workflow draft', async () => {
+ const app = buildApp(allowStream());
+ const captured = captureSnapshotSelect([{ workflowId: 'w-1', sourceVersion: 'draft', snapshot: executedGraph }]);
+
+ const response = await app.request(SNAPSHOT_PATH);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ workflowId: 'w-1', sourceVersion: 'draft', snapshot: executedGraph });
+ expect(captured.table).toBe(executions);
+ expect(Object.values(captured.columns!)).toContain(executions.workflowSnapshotJson);
+ });
+
+ it('404 execution_not_found when no row has the id', async () => {
+ const app = buildApp(allowStream());
+ databaseMock.select.mockReturnValue(chainResolving([]));
+
+ const response = await app.request(SNAPSHOT_PATH);
+
+ expect(response.status).toBe(404);
+ expect(await response.json()).toEqual({ code: 'execution_not_found', message: 'Execution not found' });
+ });
+});
diff --git a/apps/backend/src/routes/executions.ts b/apps/backend/src/routes/executions.ts
index d502c14a5..f8ab7e490 100644
--- a/apps/backend/src/routes/executions.ts
+++ b/apps/backend/src/routes/executions.ts
@@ -8,7 +8,7 @@ import {
type TerminalExecutionEventType,
} from '@workflow-builder/types/workflow-execution/execution-events';
-import type { AssertAuthorized, AuthVariables } from '../auth';
+import type { AssertAuthorized } from '../auth';
import { database } from '../db/client';
import { executions } from '../db/schema';
import { getWorkflowEngine } from '../engine';
@@ -17,16 +17,54 @@ import { subscribe } from '../events/execution-event-bus';
import { type ExecutionEventRow, fetchEventsAfter } from '../events/fetch-events-after';
import { createSerializedDrainer } from '../events/serialized-drainer';
import { logger as backendLogger } from '../logger';
-import type { TenantVariables } from '../tenant';
+import type { BackendEnv } from './backend-env';
+import { LIST_ORDER, listExecutionsWhere, pageOf, parseListExecutionsQuery } from './list-executions-query';
const logger = backendLogger.child({ component: 'executions-route' });
const TERMINAL_STATUSES = new Set(TERMINAL_EXECUTION_STATUSES);
-export function createExecutionsRoutes(
- assertAuthorized: AssertAuthorized,
-): Hono<{ Variables: AuthVariables & TenantVariables }> {
- const routes = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+export function createExecutionsRoutes(assertAuthorized: AssertAuthorized): Hono {
+ const routes = new Hono();
+
+ routes.get('/', async (c) => {
+ await assertAuthorized(c, 'executions:list', { kind: 'executions' });
+
+ const parsed = parseListExecutionsQuery({
+ status: c.req.query('status'),
+ workflowId: c.req.query('workflowId'),
+ limit: c.req.query('limit'),
+ cursor: c.req.query('cursor'),
+ });
+ if (!parsed.ok) {
+ return c.json({ code: parsed.code, message: parsed.message }, 400);
+ }
+ const { query } = parsed;
+
+ // A resolved context whose id is not a string is a broken adapter, not single-tenant mode:
+ // `?? null` would read `null` or `undefined` as "no tenant" and list every tenant's rows.
+ const tenant = c.var.tenant;
+ if (tenant && typeof tenant.tenantId !== 'string') {
+ return c.json({ code: 'tenant_required', message: 'Tenant context required' }, 400);
+ }
+
+ const rows = await database
+ .select({
+ id: executions.id,
+ workflowId: executions.workflowId,
+ sourceVersion: executions.sourceVersion,
+ status: executions.status,
+ startedAt: executions.startedAt,
+ finishedAt: executions.finishedAt,
+ createdAt: executions.createdAt,
+ })
+ .from(executions)
+ .where(listExecutionsWhere(query, tenant?.tenantId ?? null))
+ .orderBy(...LIST_ORDER)
+ .limit(query.limit + 1);
+
+ return c.json(pageOf(rows, query.limit));
+ });
routes.get('/:id', async (c) => {
const executionId = c.req.param('id');
@@ -44,6 +82,8 @@ export function createExecutionsRoutes(
workflowId: execution.workflowId,
sourceVersion: execution.sourceVersion,
status: execution.status,
+ outcome: execution.outcome,
+ resolvedBy: execution.resolvedBy,
startedAt: execution.startedAt,
finishedAt: execution.finishedAt,
createdAt: execution.createdAt,
@@ -51,20 +91,44 @@ export function createExecutionsRoutes(
});
});
+ routes.get('/:id/snapshot', async (c) => {
+ const executionId = c.req.param('id');
+
+ await assertAuthorized(c, 'executions:read', { kind: 'execution', executionId });
+
+ // A malformed id reaches Postgres and answers 500, like every other /:id route (follow-up: malformed-id-404).
+ const [execution] = await database
+ .select({
+ workflowId: executions.workflowId,
+ sourceVersion: executions.sourceVersion,
+ snapshot: executions.workflowSnapshotJson,
+ })
+ .from(executions)
+ .where(eq(executions.id, executionId));
+
+ if (!execution) {
+ return c.json({ code: 'execution_not_found', message: 'Execution not found' }, 404);
+ }
+
+ return c.json(execution);
+ });
+
// EventSource cannot send custom request headers - JWT bearer adapters that
// rely on `Authorization` will not work for this endpoint out of the box.
// See `auth-port.decision-log.md` section "SSE / EventSource auth caveats"
// for the supported fallbacks (query-param token, cookie session).
routes.get('/:id/stream', async (c) => {
- const executionId = c.req.param('id');
+ const requestedId = c.req.param('id');
- await assertAuthorized(c, 'executions:stream', { kind: 'execution', executionId });
+ await assertAuthorized(c, 'executions:stream', { kind: 'execution', executionId: requestedId });
- const [execution] = await database.select().from(executions).where(eq(executions.id, executionId));
+ const [execution] = await database.select().from(executions).where(eq(executions.id, requestedId));
if (!execution) {
return c.json({ code: 'execution_not_found', message: 'Execution not found' }, 404);
}
+ // Postgres reads any spelling of a uuid; the worker notifies under the stored one.
+ const executionId = execution.id;
// Tenant cross-check, scoped to the stream on purpose. This is NOT the
// general per-resource tenant guard - resource-level scoping of GET/:id
diff --git a/apps/backend/src/routes/list-executions-query.test.ts b/apps/backend/src/routes/list-executions-query.test.ts
new file mode 100644
index 000000000..0bd6701ee
--- /dev/null
+++ b/apps/backend/src/routes/list-executions-query.test.ts
@@ -0,0 +1,223 @@
+import { PgDialect } from 'drizzle-orm/pg-core';
+import { describe, expect, it } from 'vitest';
+
+import {
+ DEFAULT_LIMIT,
+ EXECUTION_STATUSES,
+ LIST_ORDER,
+ MAX_LIMIT,
+ decodeCursor,
+ encodeCursor,
+ listExecutionsWhere,
+ pageOf,
+ parseListExecutionsQuery,
+} from './list-executions-query';
+
+// Renders a fragment to text and params without a connection, so the SQL is asserted as written.
+const dialect = new PgDialect();
+const render = (fragment: Parameters[0]) => dialect.sqlToQuery(fragment);
+
+const ID = '0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c11';
+const ISO = '2026-09-18T10:00:00.123Z';
+const token = encodeCursor({ createdAt: new Date(ISO), id: ID });
+const base64url = (text: string) => Buffer.from(text).toString('base64url');
+
+describe('parseListExecutionsQuery', () => {
+ it('no params - default limit, no filters, no cursor', () => {
+ expect(parseListExecutionsQuery({})).toEqual({ ok: true, query: { limit: DEFAULT_LIMIT } });
+ });
+
+ it.each([
+ { limit: '500', expected: MAX_LIMIT },
+ { limit: '200', expected: 200 },
+ { limit: '1', expected: 1 },
+ ])('limit=$limit -> $expected (clamped above the cap, never refused)', ({ limit, expected }) => {
+ expect(parseListExecutionsQuery({ limit })).toEqual({ ok: true, query: { limit: expected } });
+ });
+
+ it.each(['0', '-1', 'abc', '1.5'])('limit=%j -> invalid_limit', (limit) => {
+ expect(parseListExecutionsQuery({ limit })).toMatchObject({ ok: false, code: 'invalid_limit' });
+ });
+
+ it.each([...EXECUTION_STATUSES])('status=%s passes', (status) => {
+ expect(parseListExecutionsQuery({ status })).toEqual({ ok: true, query: { limit: DEFAULT_LIMIT, status } });
+ });
+
+ it('knows exactly the eight execution statuses', () => {
+ expect([...EXECUTION_STATUSES].sort()).toEqual(
+ ['cancelled', 'cancelling', 'completed', 'failed', 'incomplete', 'pending', 'running', 'waiting'].sort(),
+ );
+ });
+
+ it.each(['waitting', 'WAITING', 'constructor'])('status=%j -> invalid_status, never an empty list', (status) => {
+ expect(parseListExecutionsQuery({ status })).toMatchObject({ ok: false, code: 'invalid_status' });
+ });
+
+ it.each([ID, ID.toUpperCase()])('workflowId=%s passes through', (workflowId) => {
+ expect(parseListExecutionsQuery({ workflowId })).toEqual({ ok: true, query: { limit: DEFAULT_LIMIT, workflowId } });
+ });
+
+ it.each(['nope', '0b6e7d9c-4b1a-4c2e-9a3f'])(
+ 'workflowId=%j -> invalid_workflow_id, not a Postgres 22P02',
+ (workflowId) => {
+ expect(parseListExecutionsQuery({ workflowId })).toMatchObject({ ok: false, code: 'invalid_workflow_id' });
+ },
+ );
+
+ it('empty values are absent filters, not errors (an unset form field)', () => {
+ expect(parseListExecutionsQuery({ status: '', workflowId: '', limit: '', cursor: '' })).toEqual({
+ ok: true,
+ query: { limit: DEFAULT_LIMIT },
+ });
+ });
+
+ it('a valid cursor is decoded into the query', () => {
+ expect(parseListExecutionsQuery({ cursor: token })).toEqual({
+ ok: true,
+ query: { limit: DEFAULT_LIMIT, cursor: { createdAt: ISO, id: ID } },
+ });
+ });
+
+ it('a malformed cursor -> invalid_cursor', () => {
+ expect(parseListExecutionsQuery({ cursor: '%%%' })).toMatchObject({ ok: false, code: 'invalid_cursor' });
+ });
+});
+
+describe('cursor', () => {
+ it('round-trips', () => {
+ expect(decodeCursor(token)).toEqual({ createdAt: ISO, id: ID });
+ });
+
+ it('is base64url: no +, / or = to escape in a query string', () => {
+ expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
+ });
+
+ it.each([
+ { name: 'garbage bytes', cursor: '!!!not-base64!!!' },
+ { name: 'empty', cursor: '' },
+ { name: 'one part', cursor: base64url(ISO) },
+ { name: 'three parts', cursor: base64url(`${ISO}|${ID}|extra`) },
+ { name: 'empty timestamp', cursor: base64url(`|${ID}`) },
+ { name: 'empty id', cursor: base64url(`${ISO}|`) },
+ { name: 'impossible month', cursor: base64url(`2026-13-01T00:00:00.000Z|${ID}`) },
+ { name: 'non-canonical timestamp (no millis)', cursor: base64url(`2026-09-18T10:00:00Z|${ID}`) },
+ { name: 'non-canonical timestamp (offset)', cursor: base64url(`2026-09-18T12:00:00.123+02:00|${ID}`) },
+ { name: 'id not a uuid', cursor: base64url(`${ISO}|e-1`) },
+ {
+ name: 'signed extended year (round-trips in JS, rejected by Postgres)',
+ cursor: base64url(`+010000-01-01T00:00:00.000Z|${ID}`),
+ },
+ { name: 'negative extended year', cursor: base64url(`-000001-01-01T00:00:00.000Z|${ID}`) },
+ { name: 'year zero', cursor: base64url(`0000-01-01T00:00:00.000Z|${ID}`) },
+ { name: 'before the epoch', cursor: base64url(`1969-12-31T23:59:59.999Z|${ID}`) },
+ { name: 'a base64 character appended (decodes to a stray byte)', cursor: `${token}A` },
+ ])('$name -> undefined', ({ cursor }) => {
+ expect(decodeCursor(cursor)).toBeUndefined();
+ });
+
+ // Node skips characters outside the alphabet; pinned so a stricter decoder is a deliberate change.
+ it.each(['!', '=', ' '])(
+ 'a non-alphabet character appended (%j) is ignored and the token still decodes',
+ (suffix) => {
+ expect(decodeCursor(`${token}${suffix}`)).toEqual({ createdAt: ISO, id: ID });
+ },
+ );
+});
+
+describe('listExecutionsWhere', () => {
+ it('no filters, no tenant -> no WHERE at all', () => {
+ expect(listExecutionsWhere({}, null)).toBeUndefined();
+ });
+
+ it('tenant present -> own rows plus untenanted rows', () => {
+ const rendered = render(listExecutionsWhere({}, 'acme')!);
+ expect(rendered.sql).toBe('("executions"."tenant_id" = $1 or "executions"."tenant_id" is null)');
+ expect(rendered.params).toEqual(['acme']);
+ });
+
+ it('tenant null -> no tenant clause even with other filters', () => {
+ const rendered = render(listExecutionsWhere({ status: 'waiting' }, null)!);
+ expect(rendered.sql).toBe('"executions"."status" = $1');
+ expect(rendered.params).toEqual(['waiting']);
+ });
+
+ it('workflowId -> equality on workflow_id', () => {
+ const rendered = render(listExecutionsWhere({ workflowId: ID }, null)!);
+ expect(rendered.sql).toBe('"executions"."workflow_id" = $1');
+ expect(rendered.params).toEqual([ID]);
+ });
+
+ it('cursor -> row-value comparison on the truncated sort key, both values bound', () => {
+ const rendered = render(listExecutionsWhere({ cursor: { createdAt: ISO, id: ID } }, null)!);
+ expect(rendered.sql).toBe(
+ `(date_trunc('milliseconds', "executions"."created_at"), "executions"."id") < ($1::timestamptz, $2::uuid)`,
+ );
+ expect(rendered.params).toEqual([ISO, ID]);
+ });
+
+ it('all four compose with AND', () => {
+ const rendered = render(
+ listExecutionsWhere({ status: 'waiting', workflowId: ID, cursor: { createdAt: ISO, id: ID } }, 'acme')!,
+ );
+ expect(rendered.sql).toBe(
+ '("executions"."status" = $1 and "executions"."workflow_id" = $2 ' +
+ 'and ("executions"."tenant_id" = $3 or "executions"."tenant_id" is null) ' +
+ `and (date_trunc('milliseconds', "executions"."created_at"), "executions"."id") < ($4::timestamptz, $5::uuid))`,
+ );
+ expect(rendered.params).toEqual(['waiting', ID, 'acme', ISO, ID]);
+ });
+});
+
+describe('LIST_ORDER', () => {
+ it('sorts by the same truncated expression the cursor compares against, then id, both DESC', () => {
+ expect(LIST_ORDER.map((term) => render(term).sql)).toEqual([
+ `date_trunc('milliseconds', "executions"."created_at") desc`,
+ '"executions"."id" desc',
+ ]);
+ });
+});
+
+const row = (index: number) => ({
+ id: `0b6e7d9c-4b1a-4c2e-9a3f-2f7a1d8e5c${String(index).padStart(2, '0')}`,
+ workflowId: 'w-1',
+ sourceVersion: 'draft',
+ status: 'waiting',
+ startedAt: null,
+ finishedAt: null,
+ createdAt: new Date(Date.UTC(2026, 8, 18, 10, 0, 0, 100 - index)),
+ // Never projected by the route; here to prove pageOf would drop them anyway.
+ tenantId: 'acme',
+ workflowSnapshotJson: { nodes: [] },
+});
+const rows = (count: number) => Array.from({ length: count }, (_, index) => row(index));
+
+describe('pageOf', () => {
+ it('fewer rows than the limit -> all items, no next page', () => {
+ const page = pageOf(rows(3), 5);
+ expect(page.items).toHaveLength(3);
+ expect(page.nextCursor).toBeNull();
+ });
+
+ it('exactly limit rows -> no next page', () => {
+ expect(pageOf(rows(5), 5).nextCursor).toBeNull();
+ });
+
+ it('limit + 1 rows -> limit items and a cursor for the last returned item', () => {
+ const page = pageOf(rows(6), 5);
+ expect(page.items).toHaveLength(5);
+ expect(page.items.at(-1)!.id).toBe(row(4).id);
+ expect(decodeCursor(page.nextCursor!)).toEqual({ createdAt: row(4).createdAt.toISOString(), id: row(4).id });
+ });
+
+ it('items carry exactly the seven summary fields', () => {
+ for (const item of pageOf(rows(2), 5).items) {
+ expect(Object.keys(item).sort()).toEqual(
+ ['createdAt', 'finishedAt', 'id', 'sourceVersion', 'startedAt', 'status', 'workflowId'].sort(),
+ );
+ }
+ });
+
+ it('no rows -> empty page', () => {
+ expect(pageOf([], 5)).toEqual({ items: [], nextCursor: null });
+ });
+});
diff --git a/apps/backend/src/routes/list-executions-query.ts b/apps/backend/src/routes/list-executions-query.ts
new file mode 100644
index 000000000..9f06304e8
--- /dev/null
+++ b/apps/backend/src/routes/list-executions-query.ts
@@ -0,0 +1,137 @@
+import { type SQL, and, desc, eq, isNull, or, sql } from 'drizzle-orm';
+
+import type { ExecutionStatus } from '@workflow-builder/types/workflow-execution/execution-events';
+
+import { executions } from '../db/schema';
+
+const STATUS_KEYS = {
+ pending: true,
+ running: true,
+ waiting: true,
+ cancelling: true,
+ completed: true,
+ incomplete: true,
+ failed: true,
+ cancelled: true,
+} satisfies Record;
+
+export const EXECUTION_STATUSES: ReadonlySet = new Set(Object.keys(STATUS_KEYS));
+
+export const DEFAULT_LIMIT = 50;
+export const MAX_LIMIT = 200;
+
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+type Cursor = { createdAt: string; id: string };
+
+type ListExecutionsQuery = {
+ status?: ExecutionStatus;
+ workflowId?: string;
+ limit: number;
+ cursor?: Cursor;
+};
+
+type ListExecutionsQueryCode = 'invalid_status' | 'invalid_workflow_id' | 'invalid_limit' | 'invalid_cursor';
+
+type ParsedListExecutionsQuery =
+ | { ok: true; query: ListExecutionsQuery }
+ | { ok: false; code: ListExecutionsQueryCode; message: string };
+
+export function parseListExecutionsQuery(raw: {
+ status?: string;
+ workflowId?: string;
+ limit?: string;
+ cursor?: string;
+}): ParsedListExecutionsQuery {
+ // `?status=` is an unset form field, not a typo: an empty value counts as absent.
+ if (raw.status && !EXECUTION_STATUSES.has(raw.status)) {
+ return { ok: false, code: 'invalid_status', message: 'Unknown execution status' };
+ }
+ if (raw.workflowId && !UUID_PATTERN.test(raw.workflowId)) {
+ return { ok: false, code: 'invalid_workflow_id', message: 'workflowId must be a UUID' };
+ }
+ if (raw.limit && (!/^\d+$/.test(raw.limit) || Number(raw.limit) < 1)) {
+ return { ok: false, code: 'invalid_limit', message: 'limit must be a positive integer' };
+ }
+ const cursor = raw.cursor ? decodeCursor(raw.cursor) : undefined;
+ if (raw.cursor && cursor === undefined) {
+ return { ok: false, code: 'invalid_cursor', message: 'Malformed cursor' };
+ }
+
+ return {
+ ok: true,
+ query: {
+ status: (raw.status || undefined) as ExecutionStatus | undefined,
+ workflowId: raw.workflowId || undefined,
+ limit: raw.limit ? Math.min(Number(raw.limit), MAX_LIMIT) : DEFAULT_LIMIT,
+ cursor,
+ },
+ };
+}
+
+// Why base64url, not base64: the token rides a query string, and `+ / =` would need escaping.
+export function encodeCursor(item: { createdAt: Date; id: string }): string {
+ return Buffer.from(`${item.createdAt.toISOString()}|${item.id}`).toString('base64url');
+}
+
+// Node's decoder never throws, so validity is judged on the decoded content.
+export function decodeCursor(token: string): Cursor | undefined {
+ const parts = Buffer.from(token, 'base64url').toString('utf8').split('|');
+ if (parts.length !== 2) return;
+ const [createdAt, id] = parts as [string, string];
+ // Four-digit year at or after the epoch: toISOString also round-trips signed and year-0 forms Postgres rejects.
+ const time = Date.parse(createdAt);
+ if (!/^\d{4}-/.test(createdAt) || Number.isNaN(time) || time < 0 || new Date(time).toISOString() !== createdAt)
+ return;
+ if (!UUID_PATTERN.test(id)) return;
+ return { createdAt, id };
+}
+
+// Postgres keeps created_at in microseconds and Drizzle returns a millisecond Date; a cursor built
+// from that Date and compared against the raw column skips rows that share its millisecond.
+// Truncating both sides makes the cursor exact.
+const CREATED_AT_MILLIS = sql`date_trunc('milliseconds', ${executions.createdAt})`;
+
+export const LIST_ORDER: SQL[] = [desc(CREATED_AT_MILLIS), desc(executions.id)];
+
+export function listExecutionsWhere(
+ query: Pick,
+ tenantId: string | null,
+): SQL | undefined {
+ return and(
+ query.status === undefined ? undefined : eq(executions.status, query.status),
+ query.workflowId === undefined ? undefined : eq(executions.workflowId, query.workflowId),
+ // Untenanted rows stay visible to every tenant, the stream route's rule; see tenant-context-port.decision-log.md.
+ tenantId === null ? undefined : or(eq(executions.tenantId, tenantId), isNull(executions.tenantId)),
+ query.cursor === undefined
+ ? undefined
+ : sql`(${CREATED_AT_MILLIS}, ${executions.id}) < (${query.cursor.createdAt}::timestamptz, ${query.cursor.id}::uuid)`,
+ );
+}
+
+type ExecutionListRow = {
+ id: string;
+ workflowId: string;
+ sourceVersion: string;
+ status: string;
+ startedAt: Date | null;
+ finishedAt: Date | null;
+ createdAt: Date;
+};
+
+type ExecutionListPage = { items: ExecutionListRow[]; nextCursor: string | null };
+
+// Expects `limit + 1` rows: the extra one only tells whether a next page exists.
+export function pageOf(rows: ExecutionListRow[], limit: number): ExecutionListPage {
+ const items = rows.slice(0, limit).map((row) => ({
+ id: row.id,
+ workflowId: row.workflowId,
+ sourceVersion: row.sourceVersion,
+ status: row.status,
+ startedAt: row.startedAt,
+ finishedAt: row.finishedAt,
+ createdAt: row.createdAt,
+ }));
+ const last = items.at(-1);
+ return { items, nextCursor: rows.length > limit && last ? encodeCursor(last) : null };
+}
diff --git a/apps/backend/src/routes/snapshot-validation.ts b/apps/backend/src/routes/snapshot-validation.ts
new file mode 100644
index 000000000..242c7ca12
--- /dev/null
+++ b/apps/backend/src/routes/snapshot-validation.ts
@@ -0,0 +1,40 @@
+import type { Context } from 'hono';
+import { z } from 'zod';
+
+import type { SourceVersion } from '@workflow-builder/types/workflow-execution/api';
+
+import { decisionIssueOf } from '../domain/decision/decision-issues';
+import { type WorkflowSnapshot, workflowSnapshotSchema } from '../domain/mapper/snapshot-schema';
+import { logger as backendLogger } from '../logger';
+
+const logger = backendLogger.child({ component: 'snapshot-validation' });
+
+// `code` is zod's; a domain issue adds `domainCode` and `params` so a client never keys on `message`.
+export function formatValidationDetails(error: z.ZodError) {
+ return error.issues.map((issue) => {
+ const detail = { path: issue.path, message: issue.message, code: issue.code };
+ const domain = decisionIssueOf(issue);
+ if (domain === undefined) return detail;
+ return { ...detail, domainCode: domain.issue, params: domain.value === undefined ? {} : { value: domain.value } };
+ });
+}
+
+export type SnapshotParse =
+ | { snapshot: WorkflowSnapshot; response?: undefined }
+ | { snapshot?: undefined; response: Response };
+
+// Publish and execute reject a snapshot the same way, so the two never drift.
+export function parseSnapshot(
+ c: Context,
+ snapshotJson: unknown,
+ source: { workflowId: string; sourceVersion: SourceVersion },
+): SnapshotParse {
+ const parsed = z.safeParse(workflowSnapshotSchema, snapshotJson);
+ if (parsed.success) return { snapshot: parsed.data };
+
+ const details = formatValidationDetails(parsed.error);
+ logger.warn('snapshot invalid', { ...source, error: { issues: details } });
+ return {
+ response: c.json({ code: 'invalid_snapshot', message: 'Workflow snapshot failed validation', details }, 400),
+ };
+}
diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts
index f59e967dc..d2fd68eb0 100644
--- a/apps/backend/src/routes/visualize.ts
+++ b/apps/backend/src/routes/visualize.ts
@@ -5,10 +5,10 @@ import { z } from 'zod';
import { aiConfig, retiredAiVariables } from '@workflow-builder/ai-config';
-import type { AssertAuthorized, AuthVariables } from '../auth';
+import type { AssertAuthorized } from '../auth';
import { logger as backendLogger } from '../logger';
import { guardExecution } from '../security/execution-guard';
-import type { TenantVariables } from '../tenant';
+import type { BackendEnv } from './backend-env';
const logger = backendLogger.child({ component: 'visualize-route' });
@@ -38,10 +38,8 @@ Rules:
text: `Return the content as clean, readable plain text. Output ONLY the text.`,
};
-export function createVisualizeRoutes(
- assertAuthorized: AssertAuthorized,
-): Hono<{ Variables: AuthVariables & TenantVariables }> {
- const routes = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+export function createVisualizeRoutes(assertAuthorized: AssertAuthorized): Hono {
+ const routes = new Hono();
routes.post('/adapt', async (c) => {
await assertAuthorized(c, 'workflows:execute', { kind: 'workflows' });
diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts
index 5e6bacb54..b63680f38 100644
--- a/apps/backend/src/routes/workflows.test.ts
+++ b/apps/backend/src/routes/workflows.test.ts
@@ -10,7 +10,8 @@ import {
createAuthMiddleware,
makeAssertAuthorized,
} from '../auth';
-import { type TenantContext, type TenantVariables, createTenantMiddleware } from '../tenant';
+import { type TenantContext, createTenantMiddleware } from '../tenant';
+import type { BackendEnv } from './backend-env';
import { createWorkflowsRoutes } from './workflows';
// ---- module mocks -----------------------------------------------------------
@@ -99,7 +100,7 @@ function denyAll(): AuthPort {
// `c.var.tenant`. `tenant` is what the configured TenantContextPort resolves
// to (null = single-tenant reference default).
function buildAppWithTenant(port: AuthPort, tenant: TenantContext | null) {
- const app = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+ const app = new Hono();
app.use('*', createAuthMiddleware(port));
app.use('*', createTenantMiddleware({ resolve: vi.fn(async () => tenant) }));
app.route('/api/workflows', createWorkflowsRoutes(makeAssertAuthorized(port)));
@@ -230,6 +231,18 @@ describe('createWorkflowsRoutes - deny short-circuits before any DB access', ()
});
});
+describe('createWorkflowsRoutes - GET /:id', () => {
+ it('answers 200 with the row', async () => {
+ const app = buildApp(allowAll(vi.fn(async () => true)));
+ databaseMock.select.mockReturnValue(chainResolving([fakeWorkflow]));
+
+ const response = await app.request('/api/workflows/7c9e6679-7425-40de-944b-e07fc1f90ae7');
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({ id: 'w-1', name: 'demo' });
+ });
+});
+
// ---- tenant propagation on execute -----------------------------------------
//
// The resolved tenant is stamped onto the executions row (the worker's event
@@ -268,3 +281,258 @@ describe('createWorkflowsRoutes - execute propagates tenant identity', () => {
expect(engineMock.submit).toHaveBeenCalledWith(expect.objectContaining({ variables: {} }));
});
});
+
+// ---- snapshot validation on publish and execute -----------------------------
+// Never on draft save: a draft is legitimately mid-edit.
+
+function snapshotWithDecisionActions(actions: unknown[], properties: Record = {}) {
+ return {
+ nodes: [
+ { id: 'src', data: { type: 'product/any' } },
+ {
+ id: 'review',
+ data: {
+ type: 'product/any',
+ properties: { decisionRequest: { version: 1, actions, schema: { type: 'object', properties } } },
+ },
+ },
+ ],
+ edges: [{ id: 'e1', source: 'src', target: 'review' }],
+ };
+}
+
+const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' };
+const validDecisionSnapshot = snapshotWithDecisionActions([approve]);
+const twoResumesSnapshot = snapshotWithDecisionActions([approve, { ...approve, name: 'approve-2' }]);
+const portlessResumeSnapshot = snapshotWithDecisionActions([{ name: 'approve', label: 'Approve', effect: 'resume' }]);
+
+type InvalidSnapshotBody = {
+ code: string;
+ details: { path: (string | number)[]; code?: string; domainCode?: string; params?: Record }[];
+};
+
+function allowAllApp() {
+ return buildApp(allowAll(vi.fn(async () => true)));
+}
+
+function publish(app: ReturnType) {
+ return app.request('/api/workflows/w-1/publish', { method: 'POST' });
+}
+
+function jsonRequest(app: ReturnType, path: string, method: string, body: unknown) {
+ return app.request(path, { method, body: JSON.stringify(body), headers: { 'content-type': 'application/json' } });
+}
+
+describe('createWorkflowsRoutes - snapshot validation on publish', () => {
+ it('rejects a draft with a broken decision request and writes nothing', async () => {
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }]));
+
+ const response = await publish(allowAllApp());
+ const body = (await response.json()) as InvalidSnapshotBody;
+
+ expect(response.status).toBe(400);
+ expect(body.code).toBe('invalid_snapshot');
+ const detail = body.details.find(
+ (candidate) => candidate.path.join('.') === 'nodes.1.data.properties.decisionRequest.actions.1.effect',
+ );
+ // Beside zod's `code` and the English message, the identifier a client keys on and its value.
+ expect(detail).toMatchObject({ code: 'custom', domainCode: 'duplicate_effect', params: { value: 'resume' } });
+ expect(databaseMock.update).not.toHaveBeenCalled();
+ });
+
+ it('refuses a resume action without a port as a structural issue at the port, and writes nothing', async () => {
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: portlessResumeSnapshot }]));
+
+ const response = await publish(allowAllApp());
+ const body = (await response.json()) as InvalidSnapshotBody;
+
+ expect(response.status).toBe(400);
+ expect(body.code).toBe('invalid_snapshot');
+ const detail = body.details.find(
+ (candidate) => candidate.path.join('.') === 'nodes.1.data.properties.decisionRequest.actions.0.port',
+ );
+ expect(detail).toMatchObject({ code: 'invalid_type' });
+ expect(detail).not.toHaveProperty('domainCode');
+ expect(databaseMock.update).not.toHaveBeenCalled();
+ });
+
+ it('refuses a rerun-source action that carries a port, naming the port', async () => {
+ const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', port: 'again' };
+ databaseMock.select.mockReturnValue(
+ chainResolving([{ ...fakeWorkflow, draftJson: snapshotWithDecisionActions([approve, askAgain]) }]),
+ );
+
+ const response = await publish(allowAllApp());
+ const body = (await response.json()) as InvalidSnapshotBody;
+
+ expect(response.status).toBe(400);
+ const detail = body.details.find(
+ (candidate) => candidate.path.join('.') === 'nodes.1.data.properties.decisionRequest.actions.1.port',
+ );
+ expect(detail).toMatchObject({ code: 'custom', domainCode: 'port_not_allowed' });
+ expect(detail?.params).toEqual({});
+ expect(databaseMock.update).not.toHaveBeenCalled();
+ });
+
+ it('accepts a draft with a valid decision request and returns the row', async () => {
+ const published = { ...fakeWorkflow, draftJson: validDecisionSnapshot, publishedJson: validDecisionSnapshot };
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: validDecisionSnapshot }]));
+ databaseMock.update.mockReturnValue(chainResolving([published]));
+
+ const response = await publish(allowAllApp());
+
+ expect(response.status).toBe(200);
+ expect(databaseMock.update).toHaveBeenCalledTimes(1);
+ expect(await response.json()).toMatchObject({ id: 'w-1', publishedJson: validDecisionSnapshot });
+ });
+
+ it('accepts a form whose properties carry no type, as JSON Schema allows', async () => {
+ const draftJson = snapshotWithDecisionActions([approve], {
+ status: { enum: ['open', 'closed'] },
+ amount: { $ref: '#/$defs/money' },
+ nickname: { type: ['string', 'null'] },
+ });
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson }]));
+ databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, publishedJson: draftJson }]));
+
+ const response = await publish(allowAllApp());
+
+ expect(response.status).toBe(200);
+ expect(databaseMock.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('still publishes a workflow without a draft, unvalidated', async () => {
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }]));
+ databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }]));
+
+ const response = await publish(allowAllApp());
+
+ expect(response.status).toBe(200);
+ expect(databaseMock.update).toHaveBeenCalledTimes(1);
+ });
+
+ // The falsy scalars used to short-circuit execute into published_version_missing while
+ // publish went on to validate them; only null means "no version".
+ it.each([
+ { name: 'a broken decision request', draftJson: twoResumesSnapshot },
+ { name: 'a decision request without a port', draftJson: portlessResumeSnapshot },
+ { name: 'an empty string', draftJson: '' },
+ { name: 'zero', draftJson: 0 },
+ { name: 'false', draftJson: false },
+ ])('answers with the same body execute gives for $name', async ({ draftJson }) => {
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson }]));
+
+ const publishResponse = await publish(allowAllApp());
+ const publishBody = await publishResponse.json();
+ const executeResponse = await jsonRequest(allowAllApp(), '/api/workflows/w-1/execute', 'POST', {
+ sourceVersion: 'draft',
+ });
+
+ expect(executeResponse.status).toBe(400);
+ expect(await executeResponse.json()).toEqual(publishBody);
+ expect(databaseMock.insert).not.toHaveBeenCalled();
+ expect(engineMock.submit).not.toHaveBeenCalled();
+ });
+});
+
+describe('createWorkflowsRoutes - draft save never validates the snapshot', () => {
+ it('stores a draft with a broken decision request', async () => {
+ databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }]));
+
+ const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', {
+ draftJson: twoResumesSnapshot,
+ });
+
+ expect(response.status).toBe(200);
+ expect(databaseMock.update).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ---- own __proto__ keys in a stored draft -------------------------------------
+// A draft is stored as sent, so it can carry one.
+
+const poisonedDraft = JSON.parse(
+ '{"nodes":[{"id":"n1","data":{"type":"product/any","properties":' +
+ '{"__proto__":{"decisionRequest":{"version":99,"actions":[{"effect":"bogus"}],"schema":"x"}}}}}],"edges":[]}',
+);
+
+describe('createWorkflowsRoutes - own __proto__ key in the draft', () => {
+ it('publish answers 400 invalid_snapshot pointing at the key and writes nothing', async () => {
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: poisonedDraft }]));
+
+ const response = await publish(allowAllApp());
+ const body = (await response.json()) as InvalidSnapshotBody;
+
+ expect(response.status).toBe(400);
+ expect(body.code).toBe('invalid_snapshot');
+ expect(body.details.map((detail) => detail.path.join('.'))).toEqual(['nodes.0.data.properties.__proto__']);
+ expect(databaseMock.update).not.toHaveBeenCalled();
+ });
+
+ it('draft save still stores it', async () => {
+ databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: poisonedDraft }]));
+
+ const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', {
+ draftJson: poisonedDraft,
+ });
+
+ expect(response.status).toBe(200);
+ expect(databaseMock.update).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ---- deeply nested drafts -----------------------------------------------------
+// Depth is the client's to choose, and only the body limit caps it.
+
+const DEEP = 20_000;
+
+function deepDraft(leaf: string): unknown {
+ return JSON.parse(
+ '{"nodes":[{"id":"n1","data":{"type":"product/any","properties":{"deep":' +
+ '['.repeat(DEEP) +
+ leaf +
+ ']'.repeat(DEEP) +
+ '}}}],"edges":[]}',
+ );
+}
+
+describe('createWorkflowsRoutes - a draft nested deeper than a call stack', () => {
+ it('publishes a clean one', async () => {
+ databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: deepDraft('') }]));
+ databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }]));
+
+ const response = await publish(allowAllApp());
+
+ expect(response.status).toBe(200);
+ expect(databaseMock.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('refuses one hiding an own __proto__ key at the bottom, and points at it', async () => {
+ databaseMock.select.mockReturnValue(
+ chainResolving([{ ...fakeWorkflow, draftJson: deepDraft('{"__proto__":{}}') }]),
+ );
+
+ const response = await publish(allowAllApp());
+ const body = (await response.json()) as InvalidSnapshotBody;
+
+ expect(response.status).toBe(400);
+ expect(body.code).toBe('invalid_snapshot');
+ expect(body.details[0]?.path.slice(0, 5)).toEqual(['nodes', 0, 'data', 'properties', 'deep']);
+ expect(body.details[0]?.path.at(-1)).toBe('__proto__');
+ expect(databaseMock.update).not.toHaveBeenCalled();
+ });
+
+ it('answers execute the same way', async () => {
+ databaseMock.select.mockReturnValue(
+ chainResolving([{ ...fakeWorkflow, draftJson: deepDraft('{"__proto__":{}}') }]),
+ );
+
+ const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/execute', 'POST', {
+ sourceVersion: 'draft',
+ });
+
+ expect(response.status).toBe(400);
+ expect(((await response.json()) as InvalidSnapshotBody).code).toBe('invalid_snapshot');
+ expect(engineMock.submit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/backend/src/routes/workflows.ts b/apps/backend/src/routes/workflows.ts
index cb7906c85..1dcb78329 100644
--- a/apps/backend/src/routes/workflows.ts
+++ b/apps/backend/src/routes/workflows.ts
@@ -2,18 +2,20 @@ import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
-import type { AssertAuthorized, AuthVariables } from '../auth';
+import type { AssertAuthorized } from '../auth';
import { database } from '../db/client';
import { executions, workflows } from '../db/schema';
import { mapToExecutionModel } from '../domain/mapper/from-integration-data';
-import { workflowSnapshotSchema } from '../domain/mapper/snapshot-schema';
import { getWorkflowEngine } from '../engine';
import { logger as backendLogger } from '../logger';
import { guardExecution } from '../security/execution-guard';
-import type { TenantVariables } from '../tenant';
+import type { BackendEnv } from './backend-env';
+import { formatValidationDetails, parseSnapshot } from './snapshot-validation';
const logger = backendLogger.child({ component: 'workflows-route' });
+// Both write schemas store a draft as sent, so on a public deployment anyone can save one the editor
+// cannot draw. (follow-up: validate-workflow-drafts)
const createWorkflowSchema = z.object({
name: z.string().min(1).max(200),
draftJson: z.unknown().optional(),
@@ -28,18 +30,8 @@ const executeSchema = z.object({
triggerPayload: z.record(z.string(), z.unknown()).optional(),
});
-function formatValidationDetails(error: z.ZodError) {
- return error.issues.map((issue) => ({
- path: issue.path,
- message: issue.message,
- code: issue.code,
- }));
-}
-
-export function createWorkflowsRoutes(
- assertAuthorized: AssertAuthorized,
-): Hono<{ Variables: AuthVariables & TenantVariables }> {
- const routes = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+export function createWorkflowsRoutes(assertAuthorized: AssertAuthorized): Hono {
+ const routes = new Hono();
routes.post('/', async (c) => {
await assertAuthorized(c, 'workflows:create', { kind: 'workflows' });
@@ -153,6 +145,12 @@ export function createWorkflowsRoutes(
return c.json({ code: 'workflow_not_found', message: 'Workflow not found' }, 404);
}
+ // A null draft is not validated; publishing it clears `publishedJson`, as before.
+ if (existing.draftJson !== null) {
+ const parsed = parseSnapshot(c, existing.draftJson, { workflowId, sourceVersion: 'draft' });
+ if (parsed.response !== undefined) return parsed.response;
+ }
+
const [workflow] = await database
.update(workflows)
.set({
@@ -198,26 +196,12 @@ export function createWorkflowsRoutes(
const snapshotJson = body.sourceVersion === 'published' ? workflow.publishedJson : workflow.draftJson;
- if (!snapshotJson) {
+ if (snapshotJson === null) {
return c.json({ code: 'published_version_missing', message: `No ${body.sourceVersion} version available` }, 400);
}
- const snapshotParsed = z.safeParse(workflowSnapshotSchema, snapshotJson);
- if (!snapshotParsed.success) {
- logger.warn('snapshot invalid', {
- workflowId,
- sourceVersion: body.sourceVersion,
- error: { issues: formatValidationDetails(snapshotParsed.error) },
- });
- return c.json(
- {
- code: 'invalid_snapshot',
- message: 'Workflow snapshot failed validation',
- details: formatValidationDetails(snapshotParsed.error),
- },
- 400,
- );
- }
+ const snapshotParse = parseSnapshot(c, snapshotJson, { workflowId, sourceVersion: body.sourceVersion });
+ if (snapshotParse.response !== undefined) return snapshotParse.response;
// Propagate tenant identity from the HTTP boundary onto the execution row.
// The worker reads it back via subquery for event tagging (see worker
@@ -248,7 +232,7 @@ export function createWorkflowsRoutes(
sourceVersion: body.sourceVersion,
});
- const definition = mapToExecutionModel(workflowId, snapshotParsed.data);
+ const definition = mapToExecutionModel(workflowId, snapshotParse.snapshot);
await getWorkflowEngine().submit({
workflowId,
diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts
index 9a2d7773e..ec5b68ff4 100644
--- a/apps/backend/src/server.ts
+++ b/apps/backend/src/server.ts
@@ -4,22 +4,18 @@ import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { cors } from 'hono/cors';
-import {
- AllowAllAuthPort,
- AuthDeniedError,
- type AuthPort,
- type AuthVariables,
- createAuthMiddleware,
- makeAssertAuthorized,
-} from './auth';
+import { AllowAllAuthPort, AuthDeniedError, type AuthPort, createAuthMiddleware, makeAssertAuthorized } from './auth';
import { runMigrations } from './db/migrate';
import { env } from './env';
import { logger } from './logger';
+import { isListingRefused, refuseListing } from './middleware/listing-guard';
import { createRateLimitMiddleware } from './middleware/rate-limit';
+import type { BackendEnv } from './routes/backend-env';
+import { createDecisionRoutes } from './routes/decision';
import { createExecutionsRoutes } from './routes/executions';
import { createVisualizeRoutes } from './routes/visualize';
import { createWorkflowsRoutes } from './routes/workflows';
-import { NoopTenantContextPort, type TenantContextPort, type TenantVariables, createTenantMiddleware } from './tenant';
+import { NoopTenantContextPort, type TenantContextPort, createTenantMiddleware } from './tenant';
// Permissive default for local development. The constructor itself emits a
// loud startup warning and refuses to boot unless `WB_AUTH_PORT=allow-all` is
@@ -34,7 +30,7 @@ const assertAuthorized = makeAssertAuthorized(authPort);
// claim, header, …) — see `apps/backend/tenant-context-port.decision-log.md`.
const tenantPort: TenantContextPort = new NoopTenantContextPort();
-const app = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+const app = new Hono();
app.use('/*', cors());
// Reject request bodies larger than 1 MB to prevent memory exhaustion
@@ -79,8 +75,14 @@ if (env.RATE_LIMIT_EXECUTE_PER_MINUTE > 0 || env.RATE_LIMIT_EXECUTE_PER_DAY > 0)
});
}
+if (isListingRefused(authPort, env.ENABLE_WB_LISTING)) {
+ refuseListing(app);
+ logger.info('listing disabled');
+}
+
app.route('/api/workflows', createWorkflowsRoutes(assertAuthorized));
app.route('/api/executions', createExecutionsRoutes(assertAuthorized));
+app.route('/api/executions', createDecisionRoutes(assertAuthorized));
app.route('/api/visualize', createVisualizeRoutes(assertAuthorized));
// a failure (DB still starting) exits the process; the container restart policy retries
diff --git a/apps/backend/tenant-context-port.decision-log.md b/apps/backend/tenant-context-port.decision-log.md
index ef937c2d8..b8723da6a 100644
--- a/apps/backend/tenant-context-port.decision-log.md
+++ b/apps/backend/tenant-context-port.decision-log.md
@@ -2,6 +2,8 @@
### Proposed by: Kacper Cierzniewski
+### Date: 03.06.2026
+
### Proposed: 21.05.2026 — Landed: 03.06.2026 (`fa5999dd`)
> This is the **decision** (why this shape, what was rejected, what it does and does not protect). The **how-to-wire-it** lives in [`multi-tenancy.md`](./multi-tenancy.md) — that document tracks the code and is the source of truth for current signatures and per-seam status. If a snippet here ever disagrees with the code, the code wins.
@@ -28,7 +30,9 @@ The backend has two identity seams and they own different things. **Getting this
- **AuthPort owns per-resource authorization, including tenant ownership.** Every scoped route calls `assertAuthorized(c, action, resource)` before touching data. In a multi-tenant deployment "may act on this resource" _means_ "belongs to the caller's tenant". This is the single, systematic place resource scoping lives. Sprinkling `if (row.tenantId !== caller.tenantId)` into every route is the shotgun anti-pattern this design exists to avoid.
- **TenantContextPort owns identity propagation** — resolve the tenant once at the HTTP boundary, carry it onto the execution row and (denormalised) event rows, so reads, the worker, and RLS can all scope by it. **Propagation is not isolation.**
-The SSE stream cross-check (seam 4) is the **one deliberate exception** — defence-in-depth, not the general guard. `GET /:id/stream` cannot use a bearer token (EventSource sends no `Authorization` header), so its auth falls back to weaker query-param/cookie schemes; the tenant resolved independently by `TenantContextPort` gives a second check on exactly that weak path. `GET /:id` and `DELETE /:id` carry no such cross-check on purpose — they rely on `AuthPort` plus RLS.
+The SSE stream cross-check (seam 4) is a **deliberate exception** — defence-in-depth, not the general guard. `GET /:id/stream` cannot use a bearer token (EventSource sends no `Authorization` header), so its auth falls back to weaker query-param/cookie schemes; the tenant resolved independently by `TenantContextPort` gives a second check on exactly that weak path. `GET /:id` and `DELETE /:id` carry no such cross-check on purpose — they rely on `AuthPort` plus RLS.
+
+The collection route `GET /api/executions` (seam 4b) is the other exception, for a different reason: `AuthPort` answers one resource at a time and cannot filter a result set, so the list applies `WHERE tenant_id = caller OR tenant_id IS NULL` itself. Same null-tolerance, same visibility of untenanted rows, same reliance on RLS for the systematic backstop. Per-row routes stay as they are.
**Consequence a consumer must internalise:** implementing `TenantContextPort` alone does **not** isolate tenants. Isolation is `AuthPort` (app layer) plus RLS (DB layer). The seam map is the full job.
@@ -71,6 +75,7 @@ What is enforced by a test versus what is a contract only on paper. Honesty here
| 1 — middleware `resolve` / `requireTenant` | resolves once onto context; noop yields `null` | ✅ `tenant/middleware.test.ts`, `tenant/noop-tenant-context-port.test.ts` |
| 2 — execute stamps tenant on the row | `tenantId` written to the execution row, `null` in single-tenant mode, never into `variables` | ✅ `routes/workflows.test.ts` |
| 4 — SSE stream cross-check | 404-not-403 on mismatch (no existence leak); streams on match; no-op when either side `null` | ✅ `routes/executions.test.ts` |
+| 4b — list route tenant filter | WHERE scopes to the caller's tenant plus untenanted rows; no clause when the tenant is `null` | ✅ `routes/list-executions-query.test.ts` (rendered SQL), `routes/executions.test.ts` (reaches the query) |
| 3 — worker inherits `tenant_id` via subquery | event row's `tenant_id` matches its parent execution | ⚠️ **Not tested.** `execution-worker` has no test exercising `src/database.ts`. Paper contract — see follow-ups. |
| 5 — Postgres RLS | — | Not shipped; documented pattern in [`multi-tenancy.md`](./multi-tenancy.md). |
diff --git a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx
index c39c0c21f..a1750c008 100644
--- a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx
+++ b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx
@@ -21,7 +21,7 @@ import { type EdgeProps, getSmoothStepPath } from '@xyflow/react';
* "Custom edges and selection".
*
* To diverge from the built-in selection look, add a `selected` branch to the
- * `style` below, or restyle every edge globally via the `--ax-public-edge-color-select`
+ * `style` below, or restyle every edge globally via the `--wb-public-edge-color-select`
* CSS variable. See the SDK README, "Custom edges and selection".
*/
export function DashedEdge({
diff --git a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx
index 05fb08080..595d0911c 100644
--- a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx
+++ b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx
@@ -24,6 +24,7 @@ export const MultiPortNodeTemplate = defineNodeTemplate(
label,
description,
selected = false,
+ disabled = false,
data,
showHandles = true,
}: WorkflowNodeTemplateProps) => {
@@ -40,10 +41,10 @@ export const MultiPortNodeTemplate = defineNodeTemplate(
return (
-
+
-
-
+
+
diff --git a/apps/demo/src/app/data/templates/black-friday.ts b/apps/demo/src/app/data/templates/black-friday.ts
index 289414c3b..7e5d74fbc 100644
--- a/apps/demo/src/app/data/templates/black-friday.ts
+++ b/apps/demo/src/app/data/templates/black-friday.ts
@@ -21,7 +21,6 @@ const diagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '8ca95405-7bb6-496d-90ae-850b3ee88dc1',
@@ -39,7 +38,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '3817f8c6-eba8-485d-9476-cf944f64a373',
@@ -57,7 +55,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'aebc5083-5773-492d-bb49-1e928f56e331',
@@ -75,8 +72,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'd6adcc9c-3146-46fd-9b0f-af78e94ad04d',
@@ -98,7 +93,6 @@ const diagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '60ed7855-3c3a-42ca-a770-8240a3a8ee12',
@@ -120,7 +114,6 @@ const diagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '77a46d81-8b23-43a0-8c37-1e29335b67a0',
@@ -138,8 +131,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'da28f8fe-c3a0-44c0-8213-c7c88d62164e',
@@ -157,7 +148,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '455a0738-c1cc-4856-992a-07654eb29182',
@@ -175,7 +165,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'd27658ca-925d-450d-9ddd-737a5f0aeeb4',
@@ -193,8 +182,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'f1a5e5cb-58fa-4379-a45e-437553153b5f',
@@ -216,8 +203,6 @@ const diagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
],
edges: [
diff --git a/apps/demo/src/app/data/templates/call-flow.ts b/apps/demo/src/app/data/templates/call-flow.ts
index 1d46283bd..c7a2dc737 100644
--- a/apps/demo/src/app/data/templates/call-flow.ts
+++ b/apps/demo/src/app/data/templates/call-flow.ts
@@ -17,7 +17,6 @@ const diagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '6e4c57e4-4136-4aa4-a183-43679b1f7170',
@@ -35,7 +34,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'a845d12e-0852-4809-96ce-334159e100fe',
@@ -53,7 +51,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '65706043-b1d2-4600-b918-3b82ff5dfc13',
@@ -71,7 +68,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'c187ef68-0fb8-47da-9a62-310afe91bfaa',
@@ -89,8 +85,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'b2a4b7a7-1900-4218-aea4-902de2e4eb51',
@@ -108,7 +102,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '09c67367-8d0d-44ae-bf4e-66b36f5c389f',
@@ -126,7 +119,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'b01ced25-ca06-47c7-a14f-21e5a0200a61',
@@ -144,8 +136,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'f1d785cc-1715-4b27-97e4-59347f08e660',
@@ -163,7 +153,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '6c3fca17-a1d4-4008-8a72-605d91eb7fd6',
@@ -181,7 +170,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '6c0ecb78-cd09-4728-8dc7-414ab1c42ea2',
@@ -199,8 +187,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '18562830-da0b-443e-a859-79cb95473f59',
@@ -218,7 +204,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'bc34e3ae-6dd2-4543-8efe-190863df49e6',
@@ -236,8 +221,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '6ab2c9e2-7e1e-4061-83ea-83a88aa246eb',
@@ -255,8 +238,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'feb863db-de51-4aee-8746-f9e02d993cb7',
@@ -274,8 +255,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '456f755a-a1fe-4e18-a850-802cf28b4cf5',
@@ -293,7 +272,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'cd9ae822-5ae4-4fe0-b876-760d460faf0e',
@@ -311,8 +289,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '53f8322b-96ef-4775-824a-020f02cccd20',
@@ -330,8 +306,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
],
edges: [
diff --git a/apps/demo/src/app/data/templates/simple-flow.ts b/apps/demo/src/app/data/templates/simple-flow.ts
index 4746a71cd..55f39df58 100644
--- a/apps/demo/src/app/data/templates/simple-flow.ts
+++ b/apps/demo/src/app/data/templates/simple-flow.ts
@@ -23,8 +23,6 @@ const defaultDiagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'da47caa9-c695-47bb-be52-b30bb8a6be6d',
@@ -42,8 +40,6 @@ const defaultDiagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '47375954-4e4a-4567-b7d3-c70c3921e1dd',
@@ -61,8 +57,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'e7ecd597-55ca-4bba-9d32-d0c51173046d',
@@ -80,8 +74,6 @@ const defaultDiagram: DiagramModel = {
icon: 'ArrowsSplit',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '798dbba1-d356-4fcd-8ba2-90e75f4912f9',
@@ -99,7 +91,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '801b6f56-05d9-4639-b426-ef171741a408',
@@ -121,8 +112,6 @@ const defaultDiagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'fd7745bf-4562-447f-a65c-2581b6a77eac',
@@ -140,7 +129,6 @@ const defaultDiagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'c87efe62-6394-43c2-8714-96c0dc19a407',
@@ -158,7 +146,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '3bfaad20-c0b8-4a90-bf8b-04c3eec2ce31',
@@ -176,7 +163,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '8b356b4a-5959-48ff-9374-cd07dc9522f6',
@@ -194,7 +180,6 @@ const defaultDiagram: DiagramModel = {
icon: 'ArrowsSplit',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'abac90c2-af48-458e-8e0c-48a505d0826e',
@@ -216,8 +201,6 @@ const defaultDiagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '33bfbf0d-f0eb-452e-aeba-f330bb9badec',
@@ -235,7 +218,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '263e749c-dfaa-4aed-af11-c7ad161aee54',
@@ -253,7 +235,6 @@ const defaultDiagram: DiagramModel = {
icon: 'ArrowsSplit',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'caae31f8-9bf6-4488-addf-d1b80842e1f7',
@@ -271,7 +252,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PaperPlaneRight',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: 'c125ce95-d5f4-4a93-a9de-407e45016d8d',
@@ -289,8 +269,6 @@ const defaultDiagram: DiagramModel = {
icon: 'PaperPlaneRight',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
],
edges: [
diff --git a/apps/demo/src/app/data/templates/user-registration.ts b/apps/demo/src/app/data/templates/user-registration.ts
index 5c310196d..e24f6bf6d 100644
--- a/apps/demo/src/app/data/templates/user-registration.ts
+++ b/apps/demo/src/app/data/templates/user-registration.ts
@@ -17,7 +17,6 @@ const diagram: DiagramModel = {
icon: 'Lightning',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '5e11be1b-8db6-4a73-9fef-4e0bdf3f4aad',
@@ -35,7 +34,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '91c179cb-71f5-4c14-abdc-24d17480af18',
@@ -53,7 +51,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
{
id: '2b2942c5-56a2-41c6-bb3d-d653b4c314da',
@@ -71,8 +68,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'eea762a9-de40-4783-affe-aee7a0f02be6',
@@ -94,8 +89,6 @@ const diagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'c9f39f2a-5d60-409e-bf6a-c01602583c96',
@@ -113,8 +106,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '8fd0bd71-81ab-4499-8cf5-7b39967aa7f8',
@@ -132,8 +123,6 @@ const diagram: DiagramModel = {
icon: 'ListChecks',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '0ce0778e-8592-4763-81be-070797bf50c2',
@@ -151,8 +140,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '862de19e-af00-4e22-aea3-f1558b9140e7',
@@ -174,8 +161,6 @@ const diagram: DiagramModel = {
icon: 'Timer',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: 'cd262d84-8735-47be-8ccd-174296f2e21a',
@@ -193,8 +178,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
- dragging: false,
},
{
id: '7d55e5c9-6761-4940-8559-90a0d2bfc805',
@@ -212,7 +195,6 @@ const diagram: DiagramModel = {
icon: 'PlayCircle',
},
selected: false,
- measured: { width: 258, height: 64 },
},
],
edges: [
diff --git a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx
index 7472ffbca..b65347b69 100644
--- a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx
+++ b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx
@@ -12,12 +12,20 @@ export function ButtonsUndoRedo() {
return (
<>
-
-
-
-
-
-
+ }
+ />
+ }
+ />
>
);
}
diff --git a/apps/demo/src/app/plugins/undo-redo/plugin-exports.ts b/apps/demo/src/app/plugins/undo-redo/plugin-exports.ts
index 9e4e45ec7..2db7790f5 100644
--- a/apps/demo/src/app/plugins/undo-redo/plugin-exports.ts
+++ b/apps/demo/src/app/plugins/undo-redo/plugin-exports.ts
@@ -9,6 +9,7 @@ import { UndoRedoProvider } from './providers/undo-redo-provider';
export function plugin(): void {
registerComponentDecorator('OptionalHooks', {
content: UndoRedoProvider,
+ name: 'UndoRedoProvider',
});
registerComponentDecorator('OptionalAppBarTools', {
diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
index 0453e265c..9bd451f72 100644
--- a/apps/docs/astro.config.mjs
+++ b/apps/docs/astro.config.mjs
@@ -8,9 +8,11 @@ import { defineConfig, passthroughImageService } from 'astro/config';
import icon from 'astro-icon';
import rehypeExternalLinks from 'rehype-external-links';
import starlightImageZoom from 'starlight-image-zoom';
-import starlightTypeDoc from 'starlight-typedoc';
+import starlightTypeDoc, { createStarlightTypeDocPlugin } from 'starlight-typedoc';
+import uiApiCategories from './src/generated/ui-api-categories.json';
import { remarkBasePathLinks } from './src/remark-base-path-links.mjs';
+import { UI_API_REFERENCE_DIRECTORY } from './src/ui-api-reference.mjs';
// Copies the hand-written API landing page into the gitignored TypeDoc
// output directory. Two things matter:
@@ -38,6 +40,22 @@ function copyApiLanding() {
};
}
+const [starlightUiApiReference] = createStarlightTypeDocPlugin();
+
+const isAstroDevelopmentServer = process.env.NODE_ENV === 'development';
+
+// Shared by both API References; typedoc-api-reference.decision-log.md explains each option.
+const STRICT_TYPEDOC = {
+ router: 'category',
+ disableSources: true,
+ excludeInternal: true,
+ excludePrivate: true,
+ excludeProtected: true,
+ excludeNotDocumented: true,
+ treatWarningsAsErrors: true,
+ entryFileName: '_readme',
+};
+
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || '';
const BASE = '/docs';
@@ -50,7 +68,7 @@ export default defineConfig({
// Route `@workflowbuilder/sdk` through a docs-only shim that re-exports
// just the symbols demo's schema/uischema files import — without the
// SDK barrel's CSS side-effect, which would leak a full-viewport reset
- // (body overflow:hidden, global Poppins) into the docs layout.
+ // (body overflow:hidden) into the docs layout.
{
find: /^@workflowbuilder\/sdk$/,
replacement: path.resolve(import.meta.dirname, 'src/sdk-shim.ts'),
@@ -79,8 +97,6 @@ export default defineConfig({
// TypeDoc → Markdown for the SDK barrel. Runs inside `astro build` and
// `astro dev` (watch). Output goes to `src/content/docs/api/` (gitignored)
// and is wired into the sidebar via `typeDocSidebarGroup` below.
- // See `apps/docs/typedoc-api-reference.decision-log.md` for the rationale
- // behind each option.
starlightTypeDoc({
entryPoints: ['../../packages/sdk/src/index.ts'],
tsconfig: '../../packages/sdk/tsconfig.json',
@@ -90,36 +106,23 @@ export default defineConfig({
// doesn't work with `router: 'category'` — it groups by TypeDoc
// Kind ("Type Aliases" / "Functions") while the on-disk folders
// are per `@category`. Same pattern as ngDiagram.
- watch: true,
- typeDoc: {
- // Show every public export grouped by `@category` (matches the
- // sectioning already used in `packages/sdk/src/index.ts`).
- // Symbols without a category fall through to "Other".
- router: 'category',
- // Drop noise: source links (file paths inside packages/sdk),
- // private fields, anything tagged `@internal`.
- disableSources: true,
- excludeInternal: true,
- excludePrivate: true,
- excludeProtected: true,
- // Strict mode: every public symbol must have a TSDoc comment.
- // `excludeNotDocumented` hides any rogue undocumented symbol
- // from the rendered site; `treatWarningsAsErrors` makes
- // `pnpm build:docs` fail when one is found, so a missing
- // doc-comment is caught at CI time instead of shipping silently.
- excludeNotDocumented: true,
- treatWarningsAsErrors: true,
- // Hide the per-package README page that TypeDoc emits by default.
- // The category landing pages cover the same surface.
- entryFileName: '_readme',
- },
+ watch: isAstroDevelopmentServer,
+ typeDoc: STRICT_TYPEDOC,
+ }),
+ // The entry point is written by `generate:ui-api`.
+ starlightUiApiReference({
+ entryPoints: ['./src/generated/ui-types.ts'],
+ tsconfig: './tsconfig.ui-api.json',
+ output: UI_API_REFERENCE_DIRECTORY,
+ watch: isAstroDevelopmentServer,
+ typeDoc: STRICT_TYPEDOC,
}),
],
// `@workflowbuilder/ui` styles are safe to load globally: everything is
// layered (no global reset), classes are hashed or opt-in, and tokens.css
- // only defines `--ax-*` custom properties keyed on `html[data-theme]` —
- // which Starlight already toggles, so the live component showcases follow
- // the docs light/dark theme. index.css is required at document level:
+ // defines `--wb-*` custom properties on `:root` and `html[data-theme]`.
+ // Starlight toggles the latter, so the live component showcases follow the
+ // docs light/dark theme. index.css is required at document level:
// Modal/Menu/Select/Tooltip/DatePicker portal their popups to body,
// outside the shadow roots that carry the preview styles.
customCss: [
@@ -167,6 +170,7 @@ export default defineConfig({
},
{ label: 'Theming', link: '/get-started/theming/' },
{ label: 'Side effects & limitations', link: '/get-started/side-effects/' },
+ { label: 'Upgrade to 3.0', link: '/get-started/upgrade-to-3/' },
],
},
{ label: 'Guides', autogenerate: { directory: 'guides' } },
@@ -178,11 +182,21 @@ export default defineConfig({
items: [
{ label: 'Overview', link: '/ui-library/overview/' },
{ label: 'Design tokens', link: '/ui-library/design-tokens/' },
+ { label: 'Typography', link: '/ui-library/typography/' },
{ label: 'UI Components', autogenerate: { directory: 'ui-library/ui-components' } },
{ label: 'Diagram Components', autogenerate: { directory: 'ui-library/diagram-components' } },
+ {
+ label: 'UI API Reference',
+ collapsed: true,
+ items: uiApiCategories.map((category) => ({
+ label: category,
+ collapsed: true,
+ autogenerate: { directory: `${UI_API_REFERENCE_DIRECTORY}/${category}` },
+ })),
+ },
],
},
- // API Reference — pages auto-generated by `starlight-typedoc` from
+ // SDK API Reference — pages auto-generated by `starlight-typedoc` from
// packages/sdk's barrel into `src/content/docs/api//`.
// Folder names match the `@category` tag in source TSDoc verbatim.
//
@@ -197,7 +211,7 @@ export default defineConfig({
// Listeners, Forms, Integration), reference material last
// (Types, Utilities, Constants, i18n, Icons).
{
- label: 'API Reference',
+ label: 'SDK API Reference',
collapsed: true,
items: [
{ label: 'Core', collapsed: true, autogenerate: { directory: 'api/Core' } },
diff --git a/apps/docs/eslint.config.mjs b/apps/docs/eslint.config.mjs
index 0b4c51fd1..f4f739f91 100644
--- a/apps/docs/eslint.config.mjs
+++ b/apps/docs/eslint.config.mjs
@@ -1,10 +1,19 @@
-import baseConfig from '../../eslint.config.mjs';
import pluginAstro from 'eslint-plugin-astro';
+import tseslint from 'typescript-eslint';
+
+import baseConfig from '../../eslint.config.mjs';
/** @type {import('eslint').Linter.Config[]} */
export default [
...baseConfig,
...pluginAstro.configs.recommended,
+ // eslint-plugin-astro detects the TS parser from cwd, where pnpm does not hoist it, so detection
+ // depends on the NODE_PATH a bin shim sets. Pin what it would pick so every runner lints alike.
+ {
+ files: ['**/*.astro'],
+ languageOptions: { parserOptions: { parser: tseslint.parser } },
+ processor: 'astro/client-side-ts',
+ },
{ ignores: ['dist/', '.astro/'] },
{ files: ['astro.config.mjs'], languageOptions: { globals: { process: 'readonly' } } },
];
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 2e643249c..af7014728 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -4,10 +4,9 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "pnpm clean:typedoc && pnpm generate:ui-api && astro dev",
- "build": "pnpm clean:typedoc && pnpm generate:ui-api && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs && node scripts/copy-swa-config.mjs",
+ "dev": "pnpm generate:ui-api && astro dev",
+ "build": "pnpm generate:ui-api && node scripts/check-sidebar-categories.mjs && astro build && node scripts/check-ui-api-links.mjs && node scripts/touch-distribution-index.mjs && node scripts/copy-swa-config.mjs",
"generate:ui-api": "node scripts/generate-ui-api.mjs && node scripts/check-ui-component-coverage.mjs",
- "clean:typedoc": "node -e \"import('node:fs').then(fs => fs.rmSync('src/content/docs/api', { recursive: true, force: true }))\"",
"preview": "astro preview",
"typecheck": "pnpm generate:ui-api && astro check",
"format": "prettier --write --log-level silent \"**/*.astro\"",
diff --git a/apps/docs/scripts/check-sidebar-categories.mjs b/apps/docs/scripts/check-sidebar-categories.mjs
index 44720b9d5..ea47b6527 100644
--- a/apps/docs/scripts/check-sidebar-categories.mjs
+++ b/apps/docs/scripts/check-sidebar-categories.mjs
@@ -48,15 +48,15 @@ const sidebarRe = /autogenerate:\s*\{\s*directory:\s*['"]api\/([^'"\s/]+)['"]/g;
const config = readFileSync(astroConfigPath, 'utf8');
for (const match of config.matchAll(sidebarRe)) sidebarCategories.add(match[1]);
-const missing = [...sourceCategories].filter((c) => !sidebarCategories.has(c));
-const stale = [...sidebarCategories].filter((c) => !sourceCategories.has(c));
+const missing = [...sourceCategories].filter((category) => !sidebarCategories.has(category));
+const stale = [...sidebarCategories].filter((category) => !sourceCategories.has(category));
if (missing.length > 0) {
console.error('error: @category tags in packages/sdk/src have no matching sidebar entry.\n');
for (const category of missing) {
console.error(` - api/${category}`);
}
- console.error('\nAdd a matching entry under "API Reference" in apps/docs/astro.config.mjs:');
+ console.error('\nAdd a matching entry under "SDK API Reference" in apps/docs/astro.config.mjs:');
for (const category of missing) {
console.error(` { label: '${category}', collapsed: true, autogenerate: { directory: 'api/${category}' } },`);
}
diff --git a/apps/docs/scripts/check-ui-api-links.mjs b/apps/docs/scripts/check-ui-api-links.mjs
new file mode 100644
index 000000000..264205ed8
--- /dev/null
+++ b/apps/docs/scripts/check-ui-api-links.mjs
@@ -0,0 +1,33 @@
+// Post-build check: generate-ui-api.mjs only predicts UI API Reference page paths (`/`), so verify
+// that every such link on the UI Library pages reaches a built page and that no `{@link ...}` marker is left.
+import { existsSync, globSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+
+import { UI_API_REFERENCE_DIRECTORY, containsTypeLink } from '../src/ui-api-reference.mjs';
+
+const DISTRIBUTION_ROOT = path.resolve(import.meta.dirname, '../dist');
+const UI_LIBRARY_PAGES = 'docs/ui-library/**/index.html';
+const UI_API_REFERENCE_HREF_RE = new RegExp(
+ String.raw`href="(?/docs/${UI_API_REFERENCE_DIRECTORY}/[^"#]*)"`,
+ 'g',
+);
+
+const pages = globSync(UI_LIBRARY_PAGES, { cwd: DISTRIBUTION_ROOT });
+const problems = new Set(pages.length > 0 ? pages.flatMap(findProblems) : [`no page matches ${UI_LIBRARY_PAGES}`]);
+
+if (problems.size > 0) {
+ console.error(['error: broken UI API Reference links', ...problems].join('\n'));
+ process.exitCode = 1;
+} else {
+ console.log('✓ UI API Reference links ok.');
+}
+
+function findProblems(page) {
+ const html = readFileSync(path.join(DISTRIBUTION_ROOT, page), 'utf8');
+ const hrefs = [...html.matchAll(UI_API_REFERENCE_HREF_RE)].map(({ groups }) => groups.href);
+ const missingPages = hrefs.filter((href) => !existsSync(path.join(DISTRIBUTION_ROOT, href, 'index.html')));
+ const pageProblems = missingPages.map((href) => `${page}: ${href} has no page`);
+ if (containsTypeLink(html)) pageProblems.push(`${page}: unrendered {@link} marker`);
+ return pageProblems;
+}
diff --git a/apps/docs/scripts/generate-ui-api.mjs b/apps/docs/scripts/generate-ui-api.mjs
index f641b21bc..ab47b342d 100644
--- a/apps/docs/scripts/generate-ui-api.mjs
+++ b/apps/docs/scripts/generate-ui-api.mjs
@@ -1,5 +1,6 @@
/*
- * Generates `src/generated/ui-api.json` for the UI Library docs.
+ * Generates, for the UI Library docs, `src/generated/ui-api.json` (Props and CSS variables per component) and the
+ * UI API Reference inputs: `ui-types.ts` (TypeDoc entry point) and `ui-api-categories.json` (sidebar groups).
*
* Props are extracted with TypeDoc (source of truth: the component prop types
* in `@workflowbuilder/ui`); CSS variables are extracted from each component's
@@ -14,14 +15,20 @@ import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
+import { ReflectionKind } from 'typedoc';
+import { formatTypeLink, stripTypeLinks } from '../src/ui-api-reference.mjs';
import { COMPONENTS } from './ui-components.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const documentsRoot = path.resolve(here, '..');
const repoRoot = path.resolve(documentsRoot, '../..');
const uiSource = path.resolve(repoRoot, 'packages/ui/src');
-const outFile = path.resolve(documentsRoot, 'src/generated/ui-api.json');
+const componentsDataFile = path.resolve(documentsRoot, 'src/generated/ui-api.json');
+const uiApiReferenceEntryFile = path.resolve(documentsRoot, 'src/generated/ui-types.ts');
+// Resolved through the `@ui/*` path alias that tsconfig.ui-api.json inherits from the UI package.
+const UI_BARREL_IMPORT = '@ui/index';
+const uiApiReferenceCategoriesFile = path.resolve(documentsRoot, 'src/generated/ui-api-categories.json');
const tdJson = path.resolve(documentsRoot, 'node_modules/.cache/ui-typedoc.json');
// Engineering notes in the CSS, never public documentation.
@@ -56,27 +63,44 @@ async function runTypedoc() {
return JSON.parse(await readFile(tdJson, 'utf8'));
}
+const isAliasOrInterface = (node) => node?.kind === ReflectionKind.TypeAlias || node?.kind === ReflectionKind.Interface;
+
+const flattenReflections = (node) => [node, ...(node.children ?? []).flatMap(flattenReflections)];
+
function indexById(root) {
- const byId = new Map();
- (function walk(node) {
- if (node && typeof node.id === 'number') byId.set(node.id, node);
- for (const child of node.children ?? []) walk(child);
- })(root);
- return byId;
+ return new Map(
+ flattenReflections(root)
+ .filter((node) => typeof node.id === 'number')
+ .map((node) => [node.id, node]),
+ );
}
function findTypeByName(root, name, warnings) {
- const matches = [];
- (function walk(node) {
- if (node.name === name && (node.kind === 2_097_152 || node.kind === 256)) matches.push(node);
- for (const child of node.children ?? []) walk(child);
- })(root);
+ const matches = flattenReflections(root).filter((node) => node.name === name && isAliasOrInterface(node));
if (matches.length > 1 && warnings) {
- warnings.push(`type name "${name}" is ambiguous (${matches.length} declarations) - the table would document whichever TypeDoc emitted first`);
+ warnings.push(
+ `type name "${name}" is ambiguous (${matches.length} declarations) - the table would document whichever TypeDoc emitted first`,
+ );
}
return matches[0] ?? null;
}
+const TYPE_DECLARATION_KINDS = new Set([ReflectionKind.TypeAlias, ReflectionKind.Interface, ReflectionKind.Enum]);
+const isTypeDeclaration = (node) => TYPE_DECLARATION_KINDS.has(node?.kind);
+
+const linkedTypes = new Set();
+
+const CATEGORY_TAG = '@category';
+const WHITESPACE_RE = /\s/;
+
+function categoryOf(node) {
+ const categoryTag = node.comment?.blockTags?.find(({ tag }) => tag === CATEGORY_TAG);
+ const [categoryText] = categoryTag?.content ?? [];
+ return categoryText?.text.trim();
+}
+
+const pagePath = (node) => `${categoryOf(node)}/${node.name}`.toLowerCase();
+
function typeToString(t, byId, depth = 0) {
if (!t || depth > 6) return 'unknown';
switch (t.type) {
@@ -88,27 +112,30 @@ function typeToString(t, byId, depth = 0) {
}
case 'reference': {
const arguments_ = t.typeArguments?.length
- ? `<${t.typeArguments.map((a) => typeToString(a, byId, depth + 1)).join(', ')}>`
+ ? `<${t.typeArguments.map((typeArgument) => typeToString(typeArgument, byId, depth + 1)).join(', ')}>`
: '';
- return `${t.name}${arguments_}`;
+ const target = byId.get(t.target);
+ if (!isTypeDeclaration(target)) return `${t.name}${arguments_}`;
+ linkedTypes.add(target);
+ return `${formatTypeLink(pagePath(target), t.name)}${arguments_}`;
}
case 'union': {
- return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' | ');
+ return t.types.map((member) => typeToString(member, byId, depth + 1)).join(' | ');
}
case 'intersection': {
- return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' & ');
+ return t.types.map((member) => typeToString(member, byId, depth + 1)).join(' & ');
}
case 'array': {
return `${typeToString(t.elementType, byId, depth + 1)}[]`;
}
case 'tuple': {
- return `[${(t.elements ?? []).map((x) => typeToString(x, byId, depth + 1)).join(', ')}]`;
+ return `[${(t.elements ?? []).map((element) => typeToString(element, byId, depth + 1)).join(', ')}]`;
}
case 'reflection': {
const sig = t.declaration?.signatures?.[0];
if (sig) {
const params = (sig.parameters ?? [])
- .map((p) => `${p.name}: ${typeToString(p.type, byId, depth + 1)}`)
+ .map((parameter) => `${parameter.name}: ${typeToString(parameter.type, byId, depth + 1)}`)
.join(', ');
return `(${params}) => ${typeToString(sig.type, byId, depth + 1)}`;
}
@@ -118,7 +145,8 @@ function typeToString(t, byId, depth = 0) {
return `${typeToString(t.objectType, byId, depth + 1)}[${typeToString(t.indexType, byId, depth + 1)}]`;
}
case 'templateLiteral': {
- return 'string';
+ const spans = (t.tail ?? []).map(([spanType, text]) => `\${${typeToString(spanType, byId, depth + 1)}}${text}`);
+ return `\`${t.head}${spans.join('')}\``;
}
case 'query': {
return typeToString(t.queryType, byId, depth + 1);
@@ -193,7 +221,7 @@ function findNativeElement(typeNode, byId, depth = 0) {
// Own properties of a prop type, walking intersections and skipping native members.
function collectProps(typeNode, byId, accumulator = new Map(), context = null) {
if (!typeNode) return accumulator;
- if (typeNode.kind === 2_097_152 || typeNode.kind === 256) {
+ if (isAliasOrInterface(typeNode)) {
if (typeNode.children?.length) {
for (const child of typeNode.children) addProperty(child, byId, accumulator);
return accumulator;
@@ -211,27 +239,56 @@ function collectProps(typeNode, byId, accumulator = new Map(), context = null) {
if (typeNode.type === 'reference' && typeof typeNode.target === 'number') {
const target = byId.get(typeNode.target);
// Follow first-party prop types only; both declaration forms count.
- if (target && (target.kind === 2_097_152 || target.kind === 256)) {
+ if (isAliasOrInterface(target)) {
collectProps(target, byId, accumulator, context);
+ } else if (!target && context) {
+ context.warnings.push(
+ `"${context.slug}": props of ${typeNode.name} are missing from the table - export the type so TypeDoc emits it`,
+ );
}
return accumulator;
}
- // Partial / Omit would silently drop every prop of X.
- if (typeNode.type === 'reference' && typeNode.typeArguments?.length && context) {
- const firstParty = typeNode.typeArguments.find(
- (argument) => argument.type === 'reference' && typeof argument.target === 'number' && byId.get(argument.target),
- );
- if (firstParty) {
+ if (typeNode.type === 'reference' && typeNode.typeArguments?.length) {
+ const [source, keys] = typeNode.typeArguments;
+ const sourceIsFirstParty =
+ source && source.type === 'reference' && typeof source.target === 'number' && byId.get(source.target);
+ if (typeNode.name === 'Omit' || typeNode.name === 'Pick' || typeNode.name === 'Partial') {
+ const named = collectProps(source, byId, new Map(), context);
+ const listed = new Set(literalNames(keys));
+ if (keys && listed.size === 0 && context) {
+ context.warnings.push(
+ `"${context.slug}": the keys of ${typeNode.name}<...> are not string literals, so the table ${
+ typeNode.name === 'Pick' ? 'keeps nothing' : 'drops nothing'
+ } - inline the keys or extend the generator`,
+ );
+ }
+ for (const [name, property] of named) {
+ const keep = typeNode.name === 'Pick' ? listed.has(name) : !listed.has(name);
+ if (!keep) continue;
+ const optional = typeNode.name === 'Partial' ? { ...property, required: false } : property;
+ if (!accumulator.has(name)) accumulator.set(name, optional);
+ }
+ return accumulator;
+ }
+ if (sourceIsFirstParty && context) {
context.warnings.push(
- `"${context.slug}": props of ${firstParty.name} are hidden behind ${typeNode.name}<...> - unwrap the utility type or extend the generator`,
+ `"${context.slug}": props of ${source.name} are hidden behind ${typeNode.name}<...> - unwrap the utility type or extend the generator`,
);
}
}
return accumulator;
}
+// String literals a utility type was given, e.g. the 'children' in Omit.
+function literalNames(typeNode) {
+ if (!typeNode) return [];
+ if (typeNode.type === 'literal' && typeof typeNode.value === 'string') return [typeNode.value];
+ if (typeNode.type === 'union') return typeNode.types.flatMap((member) => literalNames(member));
+ return [];
+}
+
function addProperty(child, byId, accumulator) {
- if (child.kind !== 1024 || accumulator.has(child.name)) return; // 1024 = Property
+ if (child.kind !== ReflectionKind.Property || accumulator.has(child.name)) return;
accumulator.set(child.name, {
name: child.name,
type: typeToString(child.type, byId),
@@ -266,22 +323,25 @@ function collectVariantProps(propsTypeNames, project, byId, warnings, slug, cont
// `foo?: never` marks a prop forbidden in that variant.
.filter((occurrence) => occurrence.prop.type !== 'never');
if (occurrences.length === 0) continue;
- const distinctTypes = new Set(occurrences.map((o) => o.prop.type));
+ const distinctTypes = new Set(occurrences.map((occurrence) => occurrence.prop.type));
const sharedByAll = occurrences.length === perVariant.length && distinctTypes.size === 1;
// Required in every variant, else the table documents an impossible call.
const requiredEverywhere =
- occurrences.length === perVariant.length && occurrences.every((o) => o.prop.required);
- const requiredInItsVariants = !requiredEverywhere && occurrences.every((o) => o.prop.required);
+ occurrences.length === perVariant.length && occurrences.every((occurrence) => occurrence.prop.required);
+ const requiredInItsVariants = !requiredEverywhere && occurrences.every((occurrence) => occurrence.prop.required);
const base = occurrences[0].prop;
let description = base.description;
if (!sharedByAll) {
const variantLabel = (typeName) => typeName.replace(/Props$/, '');
- const variants = occurrences.map((o) => variantLabel(o.typeName)).join(', ');
+ const variants = occurrences.map((occurrence) => variantLabel(occurrence.typeName)).join(', ');
+ const typePerVariant = occurrences
+ .map((occurrence) => `${variantLabel(occurrence.typeName)}: ${stripTypeLinks(occurrence.prop.type)}`)
+ .join(', ');
const note =
distinctTypes.size > 1
- ? `Type varies by variant (${occurrences.map((o) => `${variantLabel(o.typeName)}: ${o.prop.type}`).join(', ')}).`
+ ? `Type varies by variant (${typePerVariant}).`
: requiredInItsVariants
? `Only applies to the ${variants} variant (required there).`
: `Only applies to the ${variants} variant.`;
@@ -299,7 +359,22 @@ function collectVariantProps(propsTypeNames, project, byId, warnings, slug, cont
return merged;
}
-function extractCssVariables(directory, warnings, slug) {
+// Every type the Props tables link to, plus the types those mention: rendering a type marks the types it mentions,
+// and a Set's iteration visits entries added during it.
+function collectLinkedTypes(byId, warnings) {
+ for (const node of linkedTypes) {
+ const category = categoryOf(node);
+ if (!category) warnings.push(`type "${node.name}" has no @category - the UI API Reference cannot place it`);
+ else if (WHITESPACE_RE.test(category)) {
+ warnings.push(`@category "${category}" of "${node.name}" has a space - use one word`);
+ }
+ collectProps(node, byId);
+ typeToString(node.type, byId);
+ }
+ return [...linkedTypes];
+}
+
+function extractCssVariables(directory, cssSources, warnings, slug) {
// No directory - the entry documents an API, not a styled component.
if (!directory) return [];
@@ -316,12 +391,18 @@ function extractCssVariables(directory, warnings, slug) {
const files = globSync('**/*.css', { cwd: abs })
.filter((file) => !nestedPrefixes.some((prefix) => file.startsWith(prefix)))
- .sort();
+ .sort()
+ .map((file) => path.resolve(abs, file));
+ for (const source of cssSources ?? []) {
+ const sourcePath = path.resolve(uiSource, source);
+ if (existsSync(sourcePath)) files.push(sourcePath);
+ else warnings.push(`"${slug}": CSS source ${source} does not exist`);
+ }
const seen = new Set();
const variables = [];
for (const file of files) {
- const css = readFileSync(path.resolve(abs, file), 'utf8');
- const re = /(--ax-public-[\w-]+)\s*:\s*([^;]*?)(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g;
+ const css = readFileSync(file, 'utf8');
+ const re = /(--wb-public-[\w-]+)\s*:\s*([^;]*?)(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g;
let m;
while ((m = re.exec(css))) {
if (seen.has(m[1])) continue;
@@ -349,7 +430,7 @@ function readTokenValues() {
if (!existsSync(tokenDistribution)) return values;
for (const file of globSync('*.css', { cwd: tokenDistribution })) {
const css = readFileSync(path.resolve(tokenDistribution, file), 'utf8');
- for (const [, name, value] of css.matchAll(/(--ax-[\w-]+)\s*:\s*([^;]+);/g)) {
+ for (const [, name, value] of css.matchAll(/(--wb-ds-[\w-]+)\s*:\s*([^;]+);/g)) {
if (!values.has(name)) values.set(name, value.trim());
}
}
@@ -374,9 +455,9 @@ async function main() {
let props = [];
const context = { warnings, slug: component.slug };
if (Array.isArray(component.propsType)) {
- props = [...collectVariantProps(component.propsType, project, byId, warnings, component.slug, context).values()].sort(
- (a, b) => a.name.localeCompare(b.name),
- );
+ props = [
+ ...collectVariantProps(component.propsType, project, byId, warnings, component.slug, context).values(),
+ ].sort((a, b) => a.name.localeCompare(b.name));
} else if (component.propsType) {
const typeNode = findTypeByName(project, component.propsType, warnings);
if (typeNode) {
@@ -392,12 +473,21 @@ async function main() {
name: component.name,
props,
nativeElement: context.nativeElement ?? null,
- cssVariables: extractCssVariables(component.dir, warnings, component.slug),
+ cssVariables: extractCssVariables(component.dir, component.cssSources, warnings, component.slug),
};
}
- await mkdir(path.dirname(outFile), { recursive: true });
- await writeFile(outFile, JSON.stringify(out, null, 2) + '\n');
+ const linked = collectLinkedTypes(byId, warnings);
+ const typeNames = linked.map((node) => node.name).sort();
+ const categories = [...new Set(linked.map((node) => categoryOf(node)).filter(Boolean))].sort();
+
+ await mkdir(path.dirname(componentsDataFile), { recursive: true });
+ await writeFile(componentsDataFile, JSON.stringify(out, null, 2) + '\n');
+ await writeFile(
+ uiApiReferenceEntryFile,
+ `export type {\n${typeNames.map((name) => ` ${name},\n`).join('')}} from '${UI_BARREL_IMPORT}';\n`,
+ );
+ await writeFile(uiApiReferenceCategoriesFile, JSON.stringify(categories, null, 2) + '\n');
const summary = Object.entries(out).map(
([slug, entry]) => `${slug}: ${entry.props.length} props, ${entry.cssVariables.length} vars`,
diff --git a/apps/docs/scripts/ui-components.mjs b/apps/docs/scripts/ui-components.mjs
index 03f4d47a1..7e9c65c5a 100644
--- a/apps/docs/scripts/ui-components.mjs
+++ b/apps/docs/scripts/ui-components.mjs
@@ -12,24 +12,31 @@
export const COMPONENTS = [
{ slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' },
{ slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' },
- // No single props type - one of three variants depending on `children`.
- {
- slug: 'button',
- name: 'Button',
- propsType: ['LabelButtonProps', 'IconButtonProps', 'IconLabelButtonProps'],
- dir: 'button',
- },
+ { slug: 'button', name: 'Button', propsType: ['LabelButtonProps', 'IconButtonProps'], dir: 'button' },
{ slug: 'checkbox', name: 'Checkbox', propsType: 'CheckboxProps', dir: 'checkbox' },
+ { slug: 'chip', name: 'Chip', propsType: 'ChipProps', dir: 'chip' },
{ slug: 'collapsible', name: 'Collapsible', propsType: 'CollapsibleProps', dir: 'collapsible' },
{ slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' },
{ slug: 'icon-switch', name: 'IconSwitch', propsType: 'IconSwitchProps', dir: 'switch/icon-switch' },
- { slug: 'input', name: 'Input', propsType: 'InputProps', dir: 'input' },
+ {
+ slug: 'input',
+ name: 'Input',
+ propsType: 'InputProps',
+ dir: 'input',
+ cssSources: [
+ 'shared/components/field/field.module.css',
+ 'shared/styles/field-control-height.module.css',
+ 'shared/styles/field-control-size.module.css',
+ ],
+ },
{ slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' },
+ // Documented on the Menu page; styled through the NavButton variables.
+ { slug: 'menu-trigger-button', name: 'Menu.TriggerButton', propsType: 'MenuTriggerButtonProps', dir: null },
{ slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' },
{
slug: 'nav-button',
name: 'NavButton',
- propsType: ['NavLabelButtonProps', 'NavIconButtonProps', 'NavIconLabelButtonProps'],
+ propsType: ['NavLabelButtonProps', 'NavIconButtonProps'],
dir: 'button/nav-button',
},
{ slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' },
@@ -44,7 +51,13 @@ export const COMPONENTS = [
{ slug: 'snackbar', name: 'Snackbar', propsType: 'SnackbarProps', dir: 'snackbar' },
{ slug: 'status', name: 'Status', propsType: 'StatusProps', dir: 'status' },
{ slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' },
- { slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' },
+ {
+ slug: 'text-area',
+ name: 'TextArea',
+ propsType: 'TextAreaProps',
+ dir: 'text-area',
+ cssSources: ['shared/components/field/field.module.css', 'shared/styles/field-control-size.module.css'],
+ },
{ slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' },
// Diagram components.
{ slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' },
diff --git a/apps/docs/src/components/api/props-table.astro b/apps/docs/src/components/api/props-table.astro
index 0f6d6b3c1..795dd3b31 100644
--- a/apps/docs/src/components/api/props-table.astro
+++ b/apps/docs/src/components/api/props-table.astro
@@ -1,6 +1,7 @@
---
// Props of a UI component, generated from source into ui-api.json.
import data from '../../generated/ui-api.json';
+import { splitTypeLinks, UI_API_REFERENCE_DIRECTORY } from '../../ui-api-reference.mjs';
interface PropertyRow {
name: string;
@@ -26,6 +27,9 @@ const props = [...(entry?.props ?? [])].sort(
(a, b) => Number(b.required) - Number(a.required) || a.name.localeCompare(b.name),
);
const nativeElement = entry?.nativeElement ?? null;
+const TRAILING_SLASH_RE = /\/$/;
+const base = import.meta.env.BASE_URL.replace(TRAILING_SLASH_RE, '');
+const typeLinkHref = (pagePath: string) => `${base}/${UI_API_REFERENCE_DIRECTORY}/${pagePath}/`;
---
{
@@ -50,7 +54,11 @@ const nativeElement = entry?.nativeElement ?? null;
{property.default && (
diff --git a/apps/docs/src/components/ui-examples/button.tsx b/apps/docs/src/components/ui-examples/button.tsx
index c2e5a32e9..8b2ec24cb 100644
--- a/apps/docs/src/components/ui-examples/button.tsx
+++ b/apps/docs/src/components/ui-examples/button.tsx
@@ -1,11 +1,35 @@
+import { Plus, X } from '@phosphor-icons/react';
import { Button } from '@workflowbuilder/ui';
import { ComponentPreview } from './component-preview';
+const SOLID_VARIANTS = ['primary', 'secondary', 'critical', 'success'] as const;
+const GHOST_VARIANTS = ['ghost-primary', 'ghost-secondary', 'ghost-critical', 'ghost-success'] as const;
+
export function ButtonExample() {
return (
- Button
+
+
+ {SOLID_VARIANTS.map((variant) => (
+
+ {variant}
+
+ ))}
+
+
+ {GHOST_VARIANTS.map((variant) => (
+
+ {variant}
+
+ ))}
+
+
+ } aria-label="Add" />
+ } aria-label="Close" />
+ Loading
+
+
);
}
diff --git a/apps/docs/src/components/ui-examples/chip.tsx b/apps/docs/src/components/ui-examples/chip.tsx
new file mode 100644
index 000000000..3c0f0cc2b
--- /dev/null
+++ b/apps/docs/src/components/ui-examples/chip.tsx
@@ -0,0 +1,17 @@
+import { Tag } from '@phosphor-icons/react';
+import { Chip } from '@workflowbuilder/ui';
+
+import { ComponentPreview } from './component-preview';
+
+export function ChipExample() {
+ return (
+
+
+
+
+ } />
+ {}} />
+
+
+ );
+}
diff --git a/apps/docs/src/components/ui-examples/component-preview.module.css b/apps/docs/src/components/ui-examples/component-preview.module.css
index be646fae6..65917fb34 100644
--- a/apps/docs/src/components/ui-examples/component-preview.module.css
+++ b/apps/docs/src/components/ui-examples/component-preview.module.css
@@ -3,16 +3,15 @@
width: 100%;
max-width: 50rem;
aspect-ratio: 2 / 1;
- /* The ratio is a preferred size, not a clamp: overflow:hidden zeroes the
- content-based automatic minimum, so restore it - oversized examples grow
- the stage instead of being clipped. */
- min-height: fit-content;
margin-top: 1rem;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
- overflow: hidden;
+ /* `clip`, not `hidden`: a scroll container has no content-based automatic
+ minimum, which would turn the ratio into a hard clamp and cut tall
+ examples. `clip` still hides the spotlight's negative margins. */
+ overflow: clip;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.5rem;
background-color: var(--sl-color-bg);
diff --git a/apps/docs/src/components/ui-examples/component-preview.tsx b/apps/docs/src/components/ui-examples/component-preview.tsx
index 5ca9ad289..45ccd5a35 100644
--- a/apps/docs/src/components/ui-examples/component-preview.tsx
+++ b/apps/docs/src/components/ui-examples/component-preview.tsx
@@ -1,16 +1,28 @@
-import componentCss from '@workflowbuilder/ui/index.css?raw';
-import globalCss from '@workflowbuilder/ui/styles.css?raw';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './component-preview.module.css';
+import previewCss from '../../../../../packages/ui/tmp/docs-preview.css?raw';
+
// Examples render in a shadow root so Starlight's rules cannot reach them and
// the library's cannot leak out. Inherited and custom properties still cross
// the boundary - that is how the docs theme reaches the examples. Inside a
// shadow root `:root` matches nothing, hence the retarget to `:host`.
-const shadowCss = `${`${globalCss}\n${componentCss}`.replaceAll(':root', ':host')}
-:host > :not(style) { max-width: 100%; }`;
+// The boundary also stops the docs' own stylesheets, so example layout lives
+// here as `Stack` and `Row` rather than in per-example CSS modules.
+const shadowCss = `${previewCss.replaceAll(':root', ':host')}
+:host > :not(style) { max-width: 100%; }
+.stack { display: flex; flex-direction: column; gap: var(--wb-ds-space-150); }
+.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--wb-ds-space-100); }`;
+
+function Stack({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+function Row({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
export function ComponentPreview({ children }: { children: ReactNode }) {
const hostRef = useRef
(null);
@@ -36,3 +48,6 @@ export function ComponentPreview({ children }: { children: ReactNode }) {
);
}
+
+ComponentPreview.Stack = Stack;
+ComponentPreview.Row = Row;
diff --git a/apps/docs/src/components/ui-examples/input.tsx b/apps/docs/src/components/ui-examples/input.tsx
index f1d4cc4b2..321ba3ff1 100644
--- a/apps/docs/src/components/ui-examples/input.tsx
+++ b/apps/docs/src/components/ui-examples/input.tsx
@@ -1,14 +1,48 @@
+import { MagnifyingGlass } from '@phosphor-icons/react';
import { Input } from '@workflowbuilder/ui';
import { useState } from 'react';
import { ComponentPreview } from './component-preview';
export function InputExample() {
- const [value, setValue] = useState('');
+ const [search, setSearch] = useState('');
+ const [projectName, setProjectName] = useState('');
+ const [displayName, setDisplayName] = useState('');
return (
- setValue(event.target.value)} />
+
+ }
+ placeholder="Large input"
+ value={search}
+ onChange={(event) => setSearch(event.target.value)}
+ onClear={() => setSearch('')}
+ clearLabel="Clear search"
+ />
+ setProjectName(event.target.value)}
+ />
+ setDisplayName(event.target.value)}
+ />
+
+
);
}
diff --git a/apps/docs/src/components/ui-examples/menu.tsx b/apps/docs/src/components/ui-examples/menu.tsx
index 8583d4787..4922322f7 100644
--- a/apps/docs/src/components/ui-examples/menu.tsx
+++ b/apps/docs/src/components/ui-examples/menu.tsx
@@ -1,20 +1,33 @@
+import { DotsThreeVertical } from '@phosphor-icons/react';
import { Button, Menu } from '@workflowbuilder/ui';
import { ComponentPreview } from './component-preview';
+const ITEMS = [
+ { label: 'Edit', onClick: () => {} },
+ { label: 'Duplicate', onClick: () => {} },
+ { type: 'separator' as const },
+ { label: 'Delete', tone: 'critical' as const, onClick: () => {} },
+];
+
export function MenuExample() {
return (
- {} },
- { label: 'Duplicate', onClick: () => {} },
- { type: 'separator' },
- { label: 'Delete', destructive: true, onClick: () => {} },
- ]}
- >
+
Open menu
);
}
+
+export function MenuTriggerButtonExample() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/src/components/ui-examples/nav-button.tsx b/apps/docs/src/components/ui-examples/nav-button.tsx
index edba42241..24fa6e958 100644
--- a/apps/docs/src/components/ui-examples/nav-button.tsx
+++ b/apps/docs/src/components/ui-examples/nav-button.tsx
@@ -1,11 +1,28 @@
-import { NavButton } from '@workflowbuilder/ui';
+import { ArrowRight, House, Plus } from '@phosphor-icons/react';
+import { NAV_BUTTON_SIZES, NavButton } from '@workflowbuilder/ui';
import { ComponentPreview } from './component-preview';
export function NavButtonExample() {
return (
- Nav button
+
+
+ }>Square
+ }>
+ Round
+
+ } />
+ } suffixIcon={ }>
+ Selected
+
+
+
+ {NAV_BUTTON_SIZES.map((size) => (
+ } />
+ ))}
+
+
);
}
diff --git a/apps/docs/src/components/ui-examples/status.tsx b/apps/docs/src/components/ui-examples/status.tsx
index 91f76bd50..4c3f25a11 100644
--- a/apps/docs/src/components/ui-examples/status.tsx
+++ b/apps/docs/src/components/ui-examples/status.tsx
@@ -14,7 +14,7 @@ export function StatusExample() {
display: 'inline-flex',
alignItems: 'center',
padding: '0.875rem 1.25rem',
- border: '1px solid var(--ax-ui-stroke-primary-default, #3a3a3a)',
+ border: '1px solid var(--wb-ds-ui-stroke-default)',
borderRadius: '0.5rem',
}}
>
diff --git a/apps/docs/src/components/ui-examples/text-area.tsx b/apps/docs/src/components/ui-examples/text-area.tsx
index d0d45749d..605ee0f4c 100644
--- a/apps/docs/src/components/ui-examples/text-area.tsx
+++ b/apps/docs/src/components/ui-examples/text-area.tsx
@@ -4,11 +4,33 @@ import { useState } from 'react';
import { ComponentPreview } from './component-preview';
export function TextAreaExample() {
- const [value, setValue] = useState('');
+ const [description, setDescription] = useState('');
+ const [summary, setSummary] = useState('');
return (
-
);
}
diff --git a/apps/docs/src/content/docs/faq.md b/apps/docs/src/content/docs/faq.md
index ff288b30a..a83d0dc04 100644
--- a/apps/docs/src/content/docs/faq.md
+++ b/apps/docs/src/content/docs/faq.md
@@ -92,7 +92,7 @@ description: Frequently asked questions about Workflow Builder for developers.
## Technical
11. **How do I install Workflow Builder?**
- Workflow Builder is not distributed as an npm package. You receive access to the source repository and clone it directly. See the [Quick Start](/get-started/quick-start/standalone-app/) guide.
+ The SDK is available on npm as `@workflowbuilder/sdk`; the reference apps and backend run from the repository. See the [Quick Start](/get-started/quick-start/standalone-app/) guide.
12. **What technologies and libraries are used?**
Workflow Builder is built on a modern, modular tech stack:
@@ -126,9 +126,7 @@ description: Frequently asked questions about Workflow Builder for developers.
## Updates and Versioning
18. **What happens when a new major version is released?**
- Your integration does not break when we release a new version. You own the source code - it is in your repository, not pulled from a registry. New releases are delivered as source updates that you can review, diff, and adopt at your own pace.
-
- If you have customized nodes, styles, or plugins, those changes live in your codebase and are unaffected by our releases. When you choose to upgrade, you merge our changes into your fork the same way you would handle any dependency update - with full visibility into what changed.
+ Nothing changes until you upgrade. If you install the SDK from npm, you choose when to bump the dependency; follow the [changelog](https://github.com/synergycodes/workflowbuilder/blob/main/packages/sdk/CHANGELOG.md) and the [upgrade guide](/get-started/upgrade-to-3/) for breaking changes. If you maintain a fork of the source, review and merge upstream changes at your own pace.
19. **How do I prevent users from breaking production workflows?**
Workflow Builder gives you the building blocks; safeguards are implemented on your side. Common patterns teams use:
diff --git a/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx b/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx
index 11f92a3b9..9345ac70e 100644
--- a/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx
+++ b/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx
@@ -99,7 +99,7 @@ function App() {
## TypeScript
All public types are exported from `@workflowbuilder/sdk`. The full
-[API Reference](/api/) is generated by TypeDoc directly from the SDK
+[SDK API Reference](/api/) is generated by TypeDoc directly from the SDK
source on every docs build, so it never drifts.
## Next steps
diff --git a/apps/docs/src/content/docs/get-started/side-effects.md b/apps/docs/src/content/docs/get-started/side-effects.md
index dfaa61b8b..77dfbfadd 100644
--- a/apps/docs/src/content/docs/get-started/side-effects.md
+++ b/apps/docs/src/content/docs/get-started/side-effects.md
@@ -24,4 +24,4 @@ Mount only one `` per page. Multi-instance is not supporte
### React deduplication (local-path installs only)
-When installed via `npm install `, the consumer's bundler may resolve `react` from the library's `node_modules` instead of the consumer's. Fix with `resolve.dedupe: ['react', 'react-dom', '@xyflow/react']`. Not needed once published to npm.
+When installed via `npm install `, the consumer's bundler may resolve `react` from the library's `node_modules` instead of the consumer's. Fix with `resolve.dedupe: ['react', 'react-dom', '@xyflow/react']`. Not needed when installing the SDK from npm.
diff --git a/apps/docs/src/content/docs/get-started/theming.md b/apps/docs/src/content/docs/get-started/theming.md
index 85527f326..493f3c3ec 100644
--- a/apps/docs/src/content/docs/get-started/theming.md
+++ b/apps/docs/src/content/docs/get-started/theming.md
@@ -1,26 +1,33 @@
---
title: Theming
-description: Customise the editor's visual style — fonts, background, tokens — via CSS variables on :root.
+description: Customise the editor's visual style — background, tokens, and typography — via CSS variables.
sidebar:
order: 5
---
The aggregated `style.css` ships with the SDK's default visual layer. Override CSS custom properties on `:root` (or a higher-priority selector) to customise.
+CSS variables inherit through the DOM, not the React tree. Modal, Menu, Tooltip, Select, and DatePicker surfaces mount under `document.body`, so app-shell-scoped overrides do not reach them. This applies to all theme tokens, including backgrounds and component overrides; use `:root` or apply the overrides to the portal container too. Custom layouts passed as Root children and SDK-owned body portals join the builder's font scope, but portals created by host or plugin code remain outside it.
+
## Typography
-Poppins is bundled into `style.css` as inline base64 woff2 (latin + latin-ext, weights 300–700). No external font CDN is contacted at runtime — works under strict CSP, behind GDPR-controlled consent flows, and in air-gapped deployments.
+Built-in text uses bundled type styles. `wb-text-code` uses Inter for token names and IDs, while `wb-text-code-mono` uses a fixed-width system stack. `style.css` inlines Poppins latin 400 and 600 and references the remaining Poppins and Inter faces in the adjacent `assets` directory. Preserve that `dist` layout when copying the stylesheet. If a Content Security Policy (CSP) exists, allow both `data:` and `'self'` or the origin serving those assets in `font-src`, or in `default-src` when `font-src` is absent. No external font content delivery network (CDN) is contacted at runtime, so the SDK still works behind consent controls and in air-gapped deployments.
+
+Other weights, Inter, and non-ASCII glyphs use `font-display: swap` assets. They can briefly appear in the fallback font while the matching file loads; preload the relevant `.woff2` files when that flash of unstyled text (FOUT) is unacceptable.
-Override `--wb-font-family` to use a different face:
+`--wb-public-font-family` controls the builder root and every Poppins-backed type role. The proportional `wb-text-code` role remains Inter. `--wb-public-font-family-mono` controls `wb-text-code-mono` and the syntax editor:
```css
:root {
- --wb-font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ --wb-public-font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ --wb-public-font-family-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```
-Provide the font yourself (via `@font-face`, `@fontsource/`, etc.) — the SDK only consumes the variable.
+Provide any replacement font yourself via `@font-face`, `@fontsource/`, or an equivalent local source.
## Other tokens
-The SDK exposes a small surface of `--wb-*` variables (background, scrollbar, transitions) plus the larger `--ax-*` design-token set re-exported from `@workflowbuilder/ui`. See [Design System & Customization](/overview/features/design-system-and-customization/) for the full token map.
+The supported customization contract uses `--wb-public-*` for SDK controls and component overrides. Generated design tokens from `@workflowbuilder/ui` use `--wb-ds-*`; `--wb-sdk-*` is reserved for private SDK implementation details. See [Design System & Customization](/overview/features/design-system-and-customization/) for the full token map.
+
+For the migration from `--ax-*`, see the [3.0 upgrade guide](/get-started/upgrade-to-3/).
diff --git a/apps/docs/src/content/docs/get-started/upgrade-to-3.md b/apps/docs/src/content/docs/get-started/upgrade-to-3.md
new file mode 100644
index 000000000..f058ed877
--- /dev/null
+++ b/apps/docs/src/content/docs/get-started/upgrade-to-3.md
@@ -0,0 +1,374 @@
+---
+title: Upgrade to 3.0
+description: What changed between @workflowbuilder/sdk 2.3.0 and 3.0.0, and how to migrate a themed or customised editor.
+sidebar:
+ order: 6
+---
+
+Version 3.0 replaces the component library the editor is built on and the design tokens
+that style it. The exported SDK surface is almost unchanged, so an editor that uses the
+defaults upgrades by bumping the version. An editor that overrides CSS custom properties,
+uses `@workflowbuilder/ui` components directly, or styles the built-in ones needs the steps
+below.
+
+`@workflowbuilder/ui` is published for the first time in this release, as `1.0.0`. Until
+now it reached consumers only bundled inside the SDK, so its version line starts here and
+is independent of the SDK's.
+
+## What replaced the bundled library
+
+Version 2.3.0 bundled `@synergycodes/overflow-ui@1.0.0-beta.27`, built on MUI, Mantine and
+Emotion. Version 3.0 bundles the in-repo `@workflowbuilder/ui`, rebuilt on
+[Base UI](https://base-ui.com/). `@base-ui/react` installs automatically as a regular
+dependency instead of being an inlined implementation detail.
+
+Two consequences reach consumer code:
+
+- The internal DOM structure and class names of every bundled component changed. Styles or
+ tests written against those internal class names need updating.
+- Public types that derive from the UI library (`InputControlProps`, `TextAreaControlProps`)
+ build on `@workflowbuilder/ui` shapes. The picked keys are unchanged.
+
+Modal open and close now run enter and exit fade transitions, where the dialog used to
+appear and disappear instantly.
+
+## CSS custom properties
+
+Three families replace the single `--ax-*` namespace. Only the first two are part of the
+supported surface:
+
+| Family | What it is | Override it? |
+| --------------- | -------------------------------------------------------------------- | --------------------------------------- |
+| `--wb-public-*` | Per-component overrides exposed by `@workflowbuilder/ui` and the SDK | Yes, this is the contract |
+| `--wb-ds-*` | Design tokens generated from the Figma export | Yes, to retheme wholesale |
+| `--wb-sdk-*` | Private SDK internals | No, they change without a major release |
+
+The renames:
+
+| 2.3.0 | 3.0 |
+| ------------------------------- | ------------------------------------------------------ |
+| `--ax-public-` | `--wb-public-` |
+| `--ax-` | `--wb-ds-`, where a counterpart exists |
+| `--wb-background-color` | `--wb-public-background-color` |
+| `--wb-transition` | `--wb-public-transition` |
+| `--wb-font-family` | `--wb-public-font-family` |
+| `--wb-scroll-thumb-hover-color` | no counterpart; documented but never consumed in 2.3.0 |
+| `--wb-scroll-` | `--wb-public-scroll-` |
+| every other `--wb-` | `--wb-sdk-`, now private |
+
+Renaming the prefix is not enough on its own. Button, nav button, icon-size, input,
+text area and list-item properties also rename variants, size suffixes or default states.
+Check each overridden name against this table and the Button, Input and TextArea sections
+below; properties with no direct counterpart are listed under Removed public properties.
+
+| 2.3.0 | 3.0 |
+| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `--ax-public-button-nav-background-color` | `--wb-public-nav-button-background-color-default` |
+| `--ax-public-button-nav-color` | `--wb-public-nav-button-color-default` |
+| `--ax-public-button-nav-color-` | `--wb-public-nav-button-color-` (`active`, `disabled`, `hover`) |
+| `--ax-public-button-border-radius-circle` | `--wb-public-button-border-radius-round` |
+| `--ax-public-icon-size-` | `--wb-public-icon-size-`: `extra-large`, `large`, `medium`, `small`, `extra-small` become `xl`, `l`, `m`, `s`, `xs` |
+| `--ax-public-list-item-color-destructive` | `--wb-public-list-item-color-critical`; the destructive background overrides are removed |
+
+## Design tokens
+
+The token export was rebuilt, not renamed. Of the 658 custom properties 2.3.0 published,
+150 keep their name under the new prefix, 508 have no direct counterpart, and 419 roles are
+new. The
+[design tokens page](/ui-library/design-tokens/) documents the current set.
+
+Only the primitive colour scales carry over by name. Everything else moved from a
+per-component, per-size layer to a generic scale plus semantic role sets:
+
+| 2.3.0 family | 3.0 counterpart |
+| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
+| `--ax-token-spacing-*` | `--wb-ds-space-*`, one generic scale instead of a value per component and size |
+| `--ax-token-radius-*` | `--wb-ds-radius-*`, likewise |
+| `--ax-token-shadow-*` | `--wb-ds-shadow-ui-*` and `--wb-ds-shadow-canvas-*` |
+| `--ax-primitive-even-*`, `--ax-primitive-odd-*`, `--ax-primitive-4rule-*` | `--wb-ds-space-*` and `--wb-ds-size-*` |
+| `--ax-button-*` | `--wb-ds-components-button-*` |
+| `--ax-chips-*` | `--wb-ds-components-chips-*` |
+| `--ax-nav-*` | `--wb-ds-components-nav-*` |
+| `--ax-snackbar-*` | `--wb-ds-components-snackbar-*` |
+| `--ax-dropzone-*` | `--wb-ds-components-dropzone-*` |
+| `--ax-tab-*` | `--wb-ds-components-tab-*` |
+| `--ax-avatar-*` | `--wb-ds-components-avatar-*` |
+| `--ax-datepicker-*` | `--wb-ds-components-datepicker-*` |
+| `--ax-tooltip-*` | `--wb-ds-components-tooltip-*` |
+| `--ax-txt-*` | `--wb-ds-ui-text-*` |
+| `--ax-ui-*` | `--wb-ds-ui-*` |
+| `--ax-input-*`, `--ax-label-*`, `--ax-link-*`, `--ax-dropdown-*` | `--wb-ds-ui-bg-*`, `--wb-ds-ui-stroke-*` and `--wb-ds-ui-text-*` roles |
+| `--ax-node-*` | `--wb-ds-canvas-node-*` |
+| `--ax-edge-*` | `--wb-ds-canvas-edge-*` |
+| `--ax-widget-*` | `--wb-ds-canvas-widget-*` |
+| `--ax-focus-*` | `--wb-ds-canvas-node-focus-ring-*` and `--wb-ds-ui-focus-*` |
+
+The `acc6` and `acc7` colour scales are gone, and the `--ax-colors-orange-400-{10,20,30,50}`
+alpha steps are replaced by `--wb-ds-colors-orange-500-*` on a rebuilt orange.
+
+### Primitives that kept their name and changed value
+
+These 88 tokens migrate by prefix alone, but repaint. The remaining 62 matched primitives
+keep both their name and their value.
+
+| Token | 2.x value | 3.0 value |
+| ----------------------------- | ----------- | --------------------------- |
+| `--wb-ds-colors-acc1-100` | `#e0f0fe` | `#ccd9ff` |
+| `--wb-ds-colors-acc1-200` | `#bbe2fc` | `#99b3ff` |
+| `--wb-ds-colors-acc1-300` | `#5fbefa` | `#6b90ff` |
+| `--wb-ds-colors-acc1-400` | `#3ab0f6` | `#527dff` |
+| `--wb-ds-colors-acc1-50` | `#f0f8ff` | `#edf2ff` |
+| `--wb-ds-colors-acc1-500` | `#1096e7` | `#3969ff` |
+| `--wb-ds-colors-acc1-500-10` | `#1096e71a` | `rgba(57, 105, 255, 0.1)` |
+| `--wb-ds-colors-acc1-500-20` | `#1096e733` | `rgba(57, 105, 255, 0.2)` |
+| `--wb-ds-colors-acc1-500-30` | `#1096e74d` | `rgba(57, 105, 255, 0.3)` |
+| `--wb-ds-colors-acc1-500-40` | `#1096e766` | `rgba(57, 105, 255, 0.4)` |
+| `--wb-ds-colors-acc1-500-50` | `#1096e780` | `rgba(57, 105, 255, 0.5)` |
+| `--wb-ds-colors-acc1-600` | `#0477c5` | `#144cf5` |
+| `--wb-ds-colors-acc1-700` | `#045fa0` | `#0f3fcc` |
+| `--wb-ds-colors-acc1-800` | `#085184` | `#11349c` |
+| `--wb-ds-colors-acc1-900` | `#0d446d` | `#132c76` |
+| `--wb-ds-colors-acc1-950` | `#092b48` | `#111f4b` |
+| `--wb-ds-colors-acc1-950-10` | `#092b481a` | `rgba(17, 31, 75, 0.1)` |
+| `--wb-ds-colors-acc1-950-20` | `#092b4833` | `rgba(17, 31, 75, 0.2)` |
+| `--wb-ds-colors-acc1-950-30` | `#092b484d` | `rgba(17, 31, 75, 0.3)` |
+| `--wb-ds-colors-acc1-950-40` | `#092b4866` | `rgba(17, 31, 75, 0.4)` |
+| `--wb-ds-colors-acc1-950-50` | `#092b4880` | `rgba(17, 31, 75, 0.5)` |
+| `--wb-ds-colors-acc2-500-10` | `#ed4c461a` | `rgba(237, 76, 70, 0.1)` |
+| `--wb-ds-colors-acc2-500-20` | `#ed4c4633` | `rgba(237, 76, 70, 0.2)` |
+| `--wb-ds-colors-acc2-500-30` | `#ed4c464d` | `rgba(237, 76, 70, 0.3)` |
+| `--wb-ds-colors-acc2-500-40` | `#ed4c4666` | `rgba(237, 76, 70, 0.4)` |
+| `--wb-ds-colors-acc2-500-50` | `#ed4c4680` | `rgba(237, 76, 70, 0.5)` |
+| `--wb-ds-colors-acc2-950-10` | `#440d0b1a` | `rgba(68, 13, 11, 0.1)` |
+| `--wb-ds-colors-acc2-950-20` | `#440d0b33` | `rgba(68, 13, 11, 0.2)` |
+| `--wb-ds-colors-acc2-950-30` | `#440d0b4d` | `rgba(68, 13, 11, 0.3)` |
+| `--wb-ds-colors-acc2-950-40` | `#440d0b66` | `rgba(68, 13, 11, 0.4)` |
+| `--wb-ds-colors-acc2-950-50` | `#440d0b80` | `rgba(68, 13, 11, 0.5)` |
+| `--wb-ds-colors-acc3-500-10` | `#0bc1751a` | `rgba(11, 193, 117, 0.1)` |
+| `--wb-ds-colors-acc3-500-20` | `#0bc17533` | `rgba(11, 193, 117, 0.2)` |
+| `--wb-ds-colors-acc3-500-30` | `#0bc1754d` | `rgba(11, 193, 117, 0.3)` |
+| `--wb-ds-colors-acc3-500-40` | `#0bc17566` | `rgba(11, 193, 117, 0.4)` |
+| `--wb-ds-colors-acc3-500-50` | `#0bc17580` | `rgba(11, 193, 117, 0.5)` |
+| `--wb-ds-colors-acc4-500-10` | `#ba5af21a` | `rgba(186, 90, 242, 0.1)` |
+| `--wb-ds-colors-acc4-500-20` | `#ba5af233` | `rgba(186, 90, 242, 0.2)` |
+| `--wb-ds-colors-acc4-500-30` | `#ba5af24d` | `rgba(186, 90, 242, 0.3)` |
+| `--wb-ds-colors-acc4-500-40` | `#ba5af266` | `rgba(186, 90, 242, 0.4)` |
+| `--wb-ds-colors-acc4-500-50` | `#ba5af280` | `rgba(186, 90, 242, 0.5)` |
+| `--wb-ds-colors-acc5-500-10` | `#f4841b1a` | `rgba(244, 132, 27, 0.1)` |
+| `--wb-ds-colors-acc5-500-20` | `#f4841b33` | `rgba(244, 132, 27, 0.2)` |
+| `--wb-ds-colors-acc5-500-30` | `#f4841b4d` | `rgba(244, 132, 27, 0.3)` |
+| `--wb-ds-colors-acc5-500-40` | `#f4841b66` | `rgba(244, 132, 27, 0.4)` |
+| `--wb-ds-colors-acc5-500-50` | `#f4841b80` | `rgba(244, 132, 27, 0.5)` |
+| `--wb-ds-colors-blue-400-10` | `#336dff1a` | `rgba(51, 109, 255, 0.1)` |
+| `--wb-ds-colors-blue-400-20` | `#336dff33` | `rgba(51, 109, 255, 0.2)` |
+| `--wb-ds-colors-blue-400-30` | `#336dff4d` | `rgba(51, 109, 255, 0.3)` |
+| `--wb-ds-colors-blue-400-50` | `#336dff80` | `rgba(51, 109, 255, 0.5)` |
+| `--wb-ds-colors-gray-100-10` | `#ffffff1a` | `rgba(255, 255, 255, 0.1)` |
+| `--wb-ds-colors-gray-100-20` | `#ffffff33` | `rgba(255, 255, 255, 0.2)` |
+| `--wb-ds-colors-gray-100-30` | `#ffffff4d` | `rgba(255, 255, 255, 0.3)` |
+| `--wb-ds-colors-gray-100-5` | `#ffffff0d` | `rgba(255, 255, 255, 0.05)` |
+| `--wb-ds-colors-gray-100-50` | `#ffffff80` | `rgba(255, 255, 255, 0.5)` |
+| `--wb-ds-colors-gray-100-75` | `#ffffffbf` | `rgba(255, 255, 255, 0.75)` |
+| `--wb-ds-colors-gray-900-10` | `#0707081a` | `rgba(7, 7, 8, 0.1)` |
+| `--wb-ds-colors-gray-900-20` | `#07070833` | `rgba(7, 7, 8, 0.2)` |
+| `--wb-ds-colors-gray-900-30` | `#0707084d` | `rgba(7, 7, 8, 0.3)` |
+| `--wb-ds-colors-gray-900-5` | `#0707080d` | `rgba(7, 7, 8, 0.05)` |
+| `--wb-ds-colors-gray-900-50` | `#07070880` | `rgba(7, 7, 8, 0.5)` |
+| `--wb-ds-colors-gray-900-75` | `#07070880` | `rgba(7, 7, 8, 0.75)` |
+| `--wb-ds-colors-green-100` | `#e9f7ee` | `#dcfce7` |
+| `--wb-ds-colors-green-200` | `#c2edd1` | `#bbf7d0` |
+| `--wb-ds-colors-green-300` | `#29974e` | `#4ade80` |
+| `--wb-ds-colors-green-400` | `#007c29` | `#16a34a` |
+| `--wb-ds-colors-green-400-10` | `#007c291a` | `rgba(22, 163, 74, 0.1)` |
+| `--wb-ds-colors-green-400-20` | `#007c2933` | `rgba(22, 163, 74, 0.2)` |
+| `--wb-ds-colors-green-400-30` | `#007c294d` | `rgba(22, 163, 74, 0.3)` |
+| `--wb-ds-colors-green-400-50` | `#007c2980` | `rgba(22, 163, 74, 0.5)` |
+| `--wb-ds-colors-orange-100` | `#f7f2e9` | `#fff2e1` |
+| `--wb-ds-colors-orange-200` | `#eddfc2` | `#fee3c0` |
+| `--wb-ds-colors-orange-300` | `#ffaf10` | `#ffd195` |
+| `--wb-ds-colors-orange-400` | `#e59800` | `#ffc26e` |
+| `--wb-ds-colors-red-100` | `#f7e9e9` | `#fee2e2` |
+| `--wb-ds-colors-red-100-10` | `#f7e9e91a` | `rgba(254, 226, 226, 0.1)` |
+| `--wb-ds-colors-red-100-20` | `#f7e9e933` | `rgba(254, 226, 226, 0.2)` |
+| `--wb-ds-colors-red-100-30` | `#f7e9e94d` | `rgba(254, 226, 226, 0.3)` |
+| `--wb-ds-colors-red-100-50` | `#f7e9e980` | `rgba(254, 226, 226, 0.5)` |
+| `--wb-ds-colors-red-200` | `#edc2c2` | `#fca5a5` |
+| `--wb-ds-colors-red-300` | `#deadad` | `#f87171` |
+| `--wb-ds-colors-red-400` | `#962929` | `#e02020` |
+| `--wb-ds-colors-red-400-10` | `#9629291a` | `rgba(224, 32, 32, 0.1)` |
+| `--wb-ds-colors-red-400-20` | `#96292933` | `rgba(224, 32, 32, 0.2)` |
+| `--wb-ds-colors-red-400-30` | `#9629294d` | `rgba(224, 32, 32, 0.3)` |
+| `--wb-ds-colors-red-400-50` | `#96292980` | `rgba(224, 32, 32, 0.5)` |
+| `--wb-ds-colors-red-500` | `#7d0000` | `#c41a1a` |
+| `--wb-ds-colors-red-600` | `#670000` | `#a51515` |
+
+## Components
+
+### Button
+
+`Button` composes its content from `prefixIcon`, `children` and `suffixIcon` instead of
+inferring a subtype from the children structure.
+
+| 2.3.0 `variant` | 3.0 `variant` |
+| --------------------- | ------------------------------------------------------------------- |
+| `primary` | `primary` |
+| `gray` | `secondary`, now a solid grey |
+| `secondary`, outlined | `ghost-secondary` |
+| `error` | `critical` |
+| `warning` | `critical` for destructive actions, `secondary` for cautionary ones |
+| `success` | `success` |
+| `ghost-destructive` | `ghost-critical` |
+
+Sizes `extra-large`, `large`, `medium`, `small` and `extra-small` become `xl`, `l`, `m`, `s`
+and `xs`. The `xx-small` and `xxx-small` steps are gone. On `Button`, `shape="circle"`
+becomes `shape="round"`, alongside the new `shape="square"`. `SegmentPicker` keeps
+`shape="circle"`; its default shape is now spelled `'default'` instead of the empty string,
+and the shape union is exported as `SegmentPickerShape`.
+
+`Variant` is renamed to `ButtonVariant`. `BaseRegularButtonProps` and the label, icon and
+icon-with-label component subtypes are removed; `LabelButtonProps` and `IconButtonProps` are
+redefined for the new API.
+
+Public button variables follow the same migration: the `gray`, `error`, `warning` and
+`ghost-destructive` families no longer exist, and size suffixes follow the letter scale. The
+`secondary` family now describes the solid grey variant, so an override written for the old
+outlined `secondary` belongs on `ghost-secondary`.
+
+For sizes `extra-large` through `extra-small`, the suffixes in
+`--ax-public-button-border-radius-*`, `--ax-public-button-gap-*` and
+`--ax-public-button-icon-padding-*` become `xl`, `l`, `m`, `s` and `xs` under
+`--wb-public-`. The `-gray-background*` properties become `-secondary-background*`,
+`-error-background*` become `-critical-background*`, and `-ghost-destructive-*` become
+`-ghost-critical-*`. Retarget the old `-secondary-border-color*`, `-secondary-color` and
+`-secondary-color-disabled` to `-ghost-secondary-*` to keep the outlined treatment.
+The `-warning-background*` overrides belong on `-critical-background*` for destructive
+actions or `-secondary-background*` for cautionary ones. Keep any `-active`, `-focus`,
+`-hover` or `-disabled` suffix that exists on the replacement; the old secondary active
+text colour and the smallest size overrides are listed under Removed public properties.
+
+### Input and TextArea
+
+| 2.3.0 | 3.0 |
+| ------------------------------------- | ------------------------------------------- |
+| `error={true}` | `state="critical"` |
+| `startAdornment` | `prefixIcon` |
+| `endAdornment` | `suffixIcon` |
+| `size="large" \| "medium" \| "small"` | `size="l" \| "m" \| "s"`, plus the new `xs` |
+
+`state` also accepts `success` and `read-only`. Both controls render inside a shared `Field`
+that supplies an associated label, helper text and a required marker, so `label`,
+`helperText` and `isRequired` replace hand-wired markup.
+
+The public variables follow: Input's `-error` properties and TextArea's background and
+border `-error` properties become `-critical`; TextArea's error text-colour override is
+removed, and Select keeps `-error`. The size suffixes in
+`--ax-public-input-padding-medium`, `--ax-public-input-gap-medium` and
+`--ax-public-input-border-radius-medium` become `-m` under `--wb-public-`, with `-l`, `-s`
+and `-xs` alongside.
+
+### Select and DatePicker
+
+Both keep their existing props and gain `label`, `helperText`, `state`, `isRequired` and
+`id` from the same field composition. `Select`'s `size` and `DatePicker`'s `inputSize` keep
+the word-based scale and map to the letter scale internally. Both paint a disabled
+background they previously left transparent.
+
+### NavButton and SegmentPicker
+
+`NavButton` takes `size`, `variant` (`square`, `round`, `plain`), `prefixIcon`, `suffixIcon`
+and `children`. An icon passed as `children` is now rendered as label content, so move it to
+`prefixIcon` or `suffixIcon`. Sizes follow the letter scale. The selected state no longer
+shares a treatment with the pointer-down state.
+
+`SegmentPicker` keeps its API but adopts the new slots, so an icon passed as
+`SegmentPicker.Item` children must move to an explicit icon slot. `Menu.TriggerButton` is new: an icon-only trigger that shows the pressed state while its `Menu` is open.
+
+### Custom node templates
+
+`WorkflowNodeTemplateProps` gains `disabled?: boolean`. Forward `disabled` to
+`NodePanel.Root`, `NodeIcon` and `NodeDescription` so palette entries look disabled when
+they cannot be added; the palette wrapper no longer fades them. See
+[Add a custom node](/guides/add-a-custom-node/#7-optional-custom-node-template).
+
+### Properties panel
+
+`PropertiesBarProps.onDeleteClick` is now optional. A decorator on the `'PropertiesBar'`
+slot that calls it must use `onDeleteClick?.()`; omitting it hides the Delete button.
+
+### Typography classes
+
+| 2.3.0 | 3.0 |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `ax-public-h1` … `ax-public-h12` | no one-to-one replacement; pick the `wb-text-{family}-{size}[-emphasized]` role that matches the semantic use |
+| `ax-public-p1` … `ax-public-p12` | likewise |
+| `ax-public-button-large` | `wb-text-label-xl-emphasized` |
+| `ax-public-button-medium` | `wb-text-label-l-emphasized` |
+| `ax-public-button-small` | `wb-text-label-m-emphasized` |
+| `ax-public-button-extra-small` | `wb-text-label-s-emphasized` |
+| `ax-public-edge-label-medium` | `wb-text-label-m` |
+| `ax-public-edge-label-small` | `wb-text-label-m` |
+| `ax-public-edge-label-extra-small` | `wb-text-label-s` |
+
+### Removed public properties
+
+Families that changed variant name or size suffix are listed with their replacements
+above. These 31 old properties, grouped by family below, have no direct counterpart; do not
+just change their prefix:
+
+| Removed | What to do |
+| --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--ax-public-button-border-radius-`, `--ax-public-button-gap-`, `--ax-public-button-icon-padding-` for `xx-small` and `xxx-small` | Button no longer has these sizes; use a supported size and its `--wb-public-button-border-radius-*`, `--wb-public-button-gap-*` or `--wb-public-button-icon-padding-*` override. |
+| `--ax-public-label-button-padding-`, `--ax-public-icon-label-button-padding-` | The label and icon-with-label subtypes no longer have their own padding. Use the shared `--wb-public-button-padding-` on the letter scale (`extra-large` to `extra-small` become `xl` to `xs`); `xx-small` and `xxx-small` have no counterpart. |
+| `--ax-public-icon-size-xx-small`, `--ax-public-icon-size-xxx-small` | Choose a supported icon size; NavButton has its own `--wb-public-nav-button-icon-size-xxs` and `--wb-public-nav-button-icon-size-xxxs`. |
+| `--ax-public-list-item-background-color-destructive`, `--ax-public-list-item-background-color-hover-destructive` | Critical items share the default item background, including `--wb-public-list-item-background-color` on hover, with separate critical text and icon colours. |
+| `--ax-public-button-secondary-color-active` | The outlined treatment uses `--wb-public-button-ghost-secondary-color`, with no separate active text-colour override. |
+| `--ax-public-date-picker-border-size` | The dropdown border width comes from the design tokens; keep colour overrides on `--wb-public-date-picker-dropdown-border-color`. |
+| `--ax-public-icon-switch-thumb-bg` | Use the variant-specific `--wb-public-icon-switch-thumb-bg-primary` or `--wb-public-icon-switch-thumb-bg-secondary`. |
+| `--ax-public-segment-picker-padding` | `SegmentPicker` no longer pads its container, matching every variant of the design master. Space between segments comes from `--wb-public-segment-picker-gap`. |
+| `--ax-public-modal-close-button-color` | The close control is a `NavButton` now and takes its colour from the button's own properties. |
+| `--ax-public-textarea-root-color` | Split by state: `--wb-public-textarea-color` for the value, `--wb-public-textarea-placeholder-color` for the placeholder and `--wb-public-textarea-color-disabled` for a disabled field. |
+| `--ax-public-textarea-root-color-error` | Critical fields use the normal value and placeholder colours; the critical background and border have separate overrides. |
+
+## Fonts
+
+Poppins latin 400 and 600 are inlined in the stylesheet; every other weight, Inter and the
+non-ASCII glyphs load from `.woff2` files in the `assets` directory next to it, together
+with the SIL Open Font License texts. Preserve that `dist` layout when copying the
+stylesheet somewhere else.
+
+If a Content Security Policy exists, allow `data:` and `'self'` or the serving origin in
+`font-src`, or in `default-src` when `font-src` is absent. No font CDN is contacted at
+runtime.
+
+## Behaviour
+
+- **The SDK no longer resets the font of the whole document.** A host page that relied on
+ the SDK stylesheet setting its font must set it itself. Use `--wb-public-font-family` to
+ retheme the builder instead of overriding font declarations on SDK elements.
+- **Saved diagrams no longer carry runtime sizes.** `getStoreDataForIntegration` and the
+ localStorage, REST and callback integrations drop `measured` and `dragging`, so stored
+ data cannot go stale. A diagram saved by 2.x opens once at a slightly different zoom,
+ because the nodes are measured again before the view is fitted. Saving it again clears the
+ old values.
+- **Self-connecting edges loop 48px above the node's top edge**, for any node height, where
+ 2.3.0 drew them a flat 100px above the source port. `SelfConnectingEdge` no longer takes
+ `nodeHeight`; `SELF_CONNECTING_EDGE_LABEL_OFFSET` is now `48`, measured from the node's
+ top edge. The component reads the node position from the React Flow store, and
+ `useSelfLoopApexY` is exported for custom edges that draw their own loop.
+- **Canvas nodes use the design geometry.** The node shell is 241px wide and no longer
+ scales with the root font size. Node titles, subtitles and row labels truncate to one line
+ and expose the full text through the browser's native tooltip.
+- **Menus mark the current choice.** A menu with a selection renders its entries as a radio
+ group (`menuitemradio` with `aria-checked`).
+- **SDK snackbars of the same variant can show together.** A second, different message of
+ the same variant now appears next to the first instead of being dropped.
+- **The single top-level cascade layer.** The SDK stylesheet declares
+ `@layer ui.base, ui.component;` and moved the XYFlow stylesheet and its own resets into
+ `ui.base`. If you targeted the removed `reset` or `ext-lib` layer names, plain unlayered
+ CSS now wins over every library layer.
+
+## Where to go next
+
+- [Theming](/get-started/theming/) for the current override surface.
+- [Design tokens](/ui-library/design-tokens/) for the generated token set.
+- [UI Library](/ui-library/overview/) for each component's props and variables.
diff --git a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx
index a7ebb5537..419620631 100644
--- a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx
+++ b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx
@@ -224,19 +224,19 @@ import { Handle, Position } from '@xyflow/react';
import { memo, useMemo } from 'react';
export const MyNodeTemplate = memo(
- ({ id, icon, label, description, selected = false, showHandles = true }: WorkflowNodeTemplateProps) => {
+ ({ icon, label, description, selected = false, disabled = false, showHandles = true }: WorkflowNodeTemplateProps) => {
const iconElement = useMemo(() =>