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 ( + + onChange?.(changeEvent, changeEvent.target.value)} + > + {items.map((item) => + item.type === 'separator' ? null : ( + + ), + )} + + ); + } + 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 ( +