diff --git a/.changeset/config.json b/.changeset/config.json index 0b6c61ea6..dbdf4a627 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,15 +7,8 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [ - "@workflow-builder/demo", - "@workflow-builder/ai-studio", - "@workflow-builder/backend", - "@workflow-builder/execution-worker", - "@workflow-builder/docs", - "@workflow-builder/execution-core", - "@workflow-builder/icons", - "@workflow-builder/types", - "@workflow-builder/tools" - ] + "privatePackages": { + "version": false, + "tag": false + } } diff --git a/.changeset/move-ui-library-in-repo.md b/.changeset/move-ui-library-in-repo.md new file mode 100644 index 000000000..b8dd6b002 --- /dev/null +++ b/.changeset/move-ui-library-in-repo.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 000000000..8d95131a5 --- /dev/null +++ b/.changeset/sdk-date-picker-trigger-height.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..5ce8e71f4 --- /dev/null +++ b/.changeset/sdk-is-start-node-flag.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..21f883139 --- /dev/null +++ b/.changeset/sdk-single-top-layer.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..1dd301c26 --- /dev/null +++ b/.changeset/sdk-ssr-modal-portal.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..9f26fb365 --- /dev/null +++ b/.changeset/ui-base-ui-1-7.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..383d256fe --- /dev/null +++ b/.changeset/ui-export-prop-types.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..31e85cb50 --- /dev/null +++ b/.changeset/ui-export-use-edge-style-params.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..e51b1f16b --- /dev/null +++ b/.changeset/ui-layer-root-defaults.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 000000000..908ca2778 --- /dev/null +++ b/.changeset/ui-react-18-peer.md @@ -0,0 +1,5 @@ +--- +'@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/.dockerignore b/.dockerignore index e0fcd955c..19d349853 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,7 +24,22 @@ tools/ !tools/deployment/nginx .gitignore +# examples +examples/ + # env files hold secrets — never in a build context **/.env **/.env.* !**/.env.example + +# certificate material is mounted at runtime — never built into an image. The +# Dockerfile needs one file from deploy/, so the rest stays out of the context: +# a TEMPORAL_TLS_DIR under deploy/ cannot reach COPY . . whatever it is named. +deploy/ +!deploy/ai-studio/nginx +**/*.pem +**/*.key +**/*.crt +**/*.cer +**/*.p12 +**/*.pfx diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml index 0a1850c10..9adba0961 100644 --- a/.github/workflows/deploy-ai-studio.yml +++ b/.github/workflows/deploy-ai-studio.yml @@ -77,6 +77,9 @@ jobs: needs: build-and-push steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Log in to Azure uses: azure/login@v2 with: @@ -84,18 +87,48 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + # 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. + # + # 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. - name: Refresh docker compose on Azure VM + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.APP }}:${{ needs.build-and-push.outputs.image_tag }} run: | + 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 + az acr login --name synergycodes + docker compose pull + docker compose up -d --no-build --force-recreate --remove-orphans + echo DEPLOY_SCRIPT_SUCCEEDED + EOF + ) OUTPUT=$(az vm run-command invoke \ --name ${{ vars.AI_STUDIO_VM_NAME }} \ --resource-group ${{ vars.AI_STUDIO_VM_RG }} \ --command-id RunShellScript \ - --scripts ' - set -e - az acr login --name synergycodes - docker compose -f /app/ai-studio/docker-compose.yml pull - docker compose -f /app/ai-studio/docker-compose.yml up -d --no-build --force-recreate - echo DEPLOY_SCRIPT_SUCCEEDED - ') + --scripts "$SCRIPT") echo "$OUTPUT" echo "$OUTPUT" | grep -q DEPLOY_SCRIPT_SUCCEEDED diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 7d55e66bc..354c760ad 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,7 +1,11 @@ name: Deploy docs -# Builds the docs site and deploys to Azure Static Web Apps on every push -# to the `release` branch. +# Builds the docs site and deploys it to Azure Static Web Apps. Runs only when a +# maintainer starts it from the Actions tab (workflow_dispatch): pick the `release` +# branch, then "Run workflow". Nothing deploys on push. The site documents what is +# on npm, and `main` runs ahead of npm between releases (unreleased SDK API, packages +# not published yet), so the production deploy refuses every branch but `release`. +# A preview environment (SWA_ENV set) may deploy from any branch. # # Required repo secrets: # SWA_TOKEN Azure Static Web Apps deployment token @@ -11,8 +15,6 @@ name: Deploy docs # SWA_ENV SWA environment name (leave unset for production) on: - push: - branches: ["release"] workflow_dispatch: permissions: @@ -23,6 +25,13 @@ jobs: name: Build and deploy docs runs-on: ubuntu-latest steps: + - name: Refuse a production deploy from any branch but release + # An unset SWA_ENV means the production environment. + if: github.ref_name != 'release' && !vars.SWA_ENV + run: | + echo "::error::Production docs deploy requested from '${GITHUB_REF_NAME}'. The site has to match npm, so it is built from 'release' only. Select the release branch, or set the SWA_ENV variable to deploy a preview." + exit 1 + - name: Checkout code uses: actions/checkout@v4 with: @@ -42,6 +51,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # UI must build first: docs imports @workflowbuilder/ui/{styles,tokens}.css + # and its components, both resolved from packages/ui/dist. + - name: Build UI + run: pnpm build:ui + # SDK must build first so TypeDoc can read its compiled types. - name: Build SDK run: pnpm --filter @workflowbuilder/sdk build:lib diff --git a/.github/workflows/pr-check-docs.yml b/.github/workflows/pr-check-docs.yml new file mode 100644 index 000000000..84d61443d --- /dev/null +++ b/.github/workflows/pr-check-docs.yml @@ -0,0 +1,58 @@ +name: PR Check (docs) + +# The docs site is generated from packages/ui source (ui-api tables, live +# examples) and consumes its built dist, so a PR touching either can break +# the docs build - which otherwise first runs at release-time deploy. +# Full `astro build` is the gate: it runs the ui-api generator, the +# component-coverage guard, and page rendering (loud failures included). +# Typecheck is deliberately absent: apps/docs tolerates known starlight +# virtual-module type errors. + +on: + pull_request: + branches: + - main + - release # merging into release is what deploys the docs site + paths: + - 'apps/docs/**' + - 'packages/ui/**' + - 'packages/tokens/**' + # starlight-typedoc generates the /api reference from SDK sources. + - 'packages/sdk/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/pr-check-docs.yml' + +permissions: + contents: read + +jobs: + docs: + name: Docs build (ui-api generator + coverage + astro) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build UI library + run: pnpm build:ui + + - name: Build docs + run: pnpm build:docs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 3c5815076..9ff75f7d5 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,15 +1,21 @@ name: PR Check # Runs on every PR to give reviewers fast feedback on whether the change -# is safe to merge. Focus on @workflowbuilder/sdk (what we publish to -# npm) + global format consistency. Other workspaces (apps/docs, demo, -# ai-studio, …) are not checked here — they're internal and have their -# own broken-state tolerances (e.g. starlight virtual-module type errors). +# is safe to merge. Two things are gated here: the published packages +# @workflowbuilder/sdk, @workflowbuilder/ui (plus its private +# @workflowbuilder/ui-tokens build) and @workflowbuilder/temporal, and the +# execution pipeline (execution-core, backend, execution-worker) — whose +# 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. on: pull_request: branches: - main + - release # the release PR is the last stop before a tag, so it gets the same checks permissions: contents: read @@ -41,6 +47,17 @@ jobs: - name: Prettier --check run: pnpm exec prettier --check "**/*.+(css|ts|tsx|json|md|mdx|astro)" --log-level=warn + # tools/ sits outside the pnpm workspace, so `pnpm -r lint` and `pnpm -r test` never reach + # it, and the release scripts live there. + - name: Lint tools + run: pnpm exec eslint tools --max-warnings=0 + + - name: Prettier --check tools + run: pnpm exec prettier --check "tools/**/*.mjs" --log-level=warn + + - name: Test tools + run: pnpm test:tools + sdk: name: SDK lint + typecheck + test + build runs-on: ubuntu-latest @@ -75,3 +92,209 @@ jobs: - name: Build run: pnpm --filter @workflowbuilder/sdk build:lib + + starter: + name: Starter build + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/workflow-builder-starter + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: examples/workflow-builder-starter/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + temporal: + name: Temporal plugin lint + typecheck + test + build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 # the changeset guard below diffs against the base branch + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm --filter @workflowbuilder/temporal lint + + - name: Typecheck + run: pnpm --filter @workflowbuilder/temporal typecheck + + - name: Test + run: pnpm --filter @workflowbuilder/temporal test + + - name: Build + run: pnpm --filter @workflowbuilder/temporal build + + - name: Verify the published shape + # Three subpaths with their own types — exactly where an exports map breaks + # quietly, and consumers would be the ones to find out. Versions pinned so a + # tool release cannot redden PRs on its own; the esm-only attw profile matches + # the package (ESM without CJS is a finding under stricter profiles). + working-directory: packages/temporal + run: | + pnpm dlx publint@0.3.24 + pnpm dlx @arethetypeswrong/cli@0.18.5 --pack --profile esm-only + + - name: Changeset guards for bundled packages + # execution-core and types are private and source-only: they are bundled into + # @workflowbuilder/temporal's dist, so a behaviour change in either ships to + # consumers only when that package is released. The SDK bundles @workflowbuilder/ui + # the same way. Warnings, not failures: a pure refactor legitimately needs no + # changeset. Only changesets this PR adds count, so a file that has been sitting in + # .changeset/ for weeks cannot vouch for today's change. + run: | + BASE="origin/${{ github.base_ref }}" + CHANGED=$(git diff --name-only "$BASE...HEAD") + ADDED_CHANGESETS=$(git diff --name-only --diff-filter=A "$BASE...HEAD" -- '.changeset/*.md') + # Only the front matter (between the two `---` lines) names packages; a package mentioned in + # the summary text does not count. + added_changeset_for() { + for file in $ADDED_CHANGESETS; do + awk 'NR==1 && /^---/ {f=1; next} f && /^---/ {exit} f' "$file" | grep -q -- "$1" && return 0 + done + return 1 + } + if echo "$CHANGED" | grep -qE '^packages/(execution-core|types)/' && ! added_changeset_for '@workflowbuilder/temporal'; then + echo "::warning title=Missing changeset::packages/execution-core or packages/types changed with no new changeset for @workflowbuilder/temporal. If the change affects execution behaviour or the published types, add one (/wb.changeset)." + fi + if echo "$CHANGED" | grep -qE '^packages/ui/' && ! added_changeset_for '@workflowbuilder/sdk'; then + echo "::warning title=Missing changeset::packages/ui changed with no new changeset for @workflowbuilder/sdk. The SDK build inlines @workflowbuilder/ui, so anything SDK consumers can see needs a changeset for the SDK as well." + fi + + deploy-compose: + name: Deploy compose files parse + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Parse both compose modes on the runner's Compose and on the oldest supported one + # These files reach the demo VM on every deploy, so a parse error is only + # discovered there, with the stack already down. 2.21 is the floor: it + # interpolates a nested `${A:+${B:?}}` default eagerly where newer + # compose-go is lazy, so a file that parses on the runner can still fail + # on a VM. Both modes are covered because COMPOSE_FILE in the VM's .env + # decides whether the override file is applied at all. + working-directory: deploy/ai-studio + run: | + cp .env.example .env + # An empty COMPOSE_FILE is not the same as an unset one — compose then + # reads the working directory as a file — so the default mode runs with + # the variable absent and `-e` forwards it only once it is exported. + parse() { + docker compose config --quiet + docker run --rm -v "$PWD:/w" -w /w -e COMPOSE_FILE docker:24.0.5-cli docker compose config --quiet + } + parse + export COMPOSE_FILE=docker-compose.yml + parse + + ui: + name: UI + UI tokens lint + typecheck + test + build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint tokens + run: pnpm --filter @workflowbuilder/ui-tokens lint + + - name: Typecheck tokens + run: pnpm --filter @workflowbuilder/ui-tokens typecheck + + - name: Test tokens + run: pnpm --filter @workflowbuilder/ui-tokens test + + - name: Lint + run: pnpm --filter @workflowbuilder/ui lint + + - name: Typecheck + run: pnpm --filter @workflowbuilder/ui typecheck + + - name: Test + run: pnpm --filter @workflowbuilder/ui test + + - name: Build + # build:ui builds @workflowbuilder/ui-tokens first, then ui, then runs + # the check:built-css guard. + run: pnpm build:ui + + execution: + name: Execution pipeline lint + typecheck + test + runs-on: ubuntu-latest + # No `services:` block: the suites run against in-memory fakes — no Postgres, + # no API keys. The one exception is temporal-connection's TLS test, which + # starts Temporal's dev server itself (@temporalio/testing downloads the CLI + # on first run). If a suite here ever needs infra it cannot start itself, + # give it its own job rather than adding services to this one. + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint + + - name: Typecheck + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck + + - name: Test + run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 4c4858410..247cde356 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -1,13 +1,15 @@ name: Release SDK -# Triggers on tags `vX.Y.Z` matching the version in packages/sdk/package.json. -# Maintainer pushes the tag after merging the version-bump PR (which ran -# `pnpm changeset version`). See packages/sdk/RELEASE.md. +# Triggers on scoped tags `@workflowbuilder/sdk@X.Y.Z` matching the version in +# packages/sdk/package.json. Scoped tags let this repo publish more than one +# package (see release-ui.yml for @workflowbuilder/ui). Maintainer pushes the +# tag after merging the version-bump PR (which ran `pnpm release:version `). +# See packages/RELEASE.md. on: push: tags: - - "v*" + - "@workflowbuilder/sdk@*" workflow_dispatch: # manual fire from GitHub UI — for testing or rerunning a failed publish permissions: @@ -62,10 +64,12 @@ jobs: run: pnpm --filter @workflowbuilder/sdk build:lib - name: Verify version matches tag - # Tag refs/tags/vX.Y.Z must match packages/sdk/package.json version. - # Catches push of wrong tag (typo, pushed before version-bump PR merged). + # Tag `@workflowbuilder/sdk@X.Y.Z` must match packages/sdk/package.json + # version. ${GITHUB_REF_NAME##*@} strips everything up to the last `@`, + # leaving X.Y.Z. Catches push of wrong tag (typo, pushed before the + # version-bump PR merged). run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" + TAG_VERSION="${GITHUB_REF_NAME##*@}" PKG_VERSION=$(node -p "require('./packages/sdk/package.json').version") if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)." @@ -80,13 +84,30 @@ jobs: id: check-version run: | PKG_VERSION=$(node -p "require('./packages/sdk/package.json').version") - if npm view "@workflowbuilder/sdk@$PKG_VERSION" version 2>/dev/null; then + # E404 is npm's only answer that means "not there". Any other failure means npm could not be + # asked, and guessing "not published" would send the job into a doomed publish. + if OUT=$(npm view "@workflowbuilder/sdk@$PKG_VERSION" version 2>&1); then echo "already_published=true" >> "$GITHUB_OUTPUT" echo "::notice::@workflowbuilder/sdk@$PKG_VERSION already on npm — skipping publish step." - else + elif printf '%s' "$OUT" | grep -q 'E404'; then echo "already_published=false" >> "$GITHUB_OUTPUT" + else + echo "::error::npm could not be queried for @workflowbuilder/sdk@$PKG_VERSION: $OUT" + exit 1 fi + - name: Extract release notes from CHANGELOG + id: notes + # Before the publish on purpose: a missing or empty section fails here, while nothing has + # reached npm yet. release:tag applies the same rule (tools/release-shared.mjs). + run: | + NOTES=$(pnpm -s release:notes sdk) + { + echo "notes<> "$GITHUB_OUTPUT" + - name: Publish to npm # Authenticates via npm Trusted Publisher (OIDC). No NPM_TOKEN. # Requires the package to have GitHub Actions trusted publishing @@ -98,26 +119,11 @@ jobs: if: steps.check-version.outputs.already_published == 'false' run: pnpm --filter @workflowbuilder/sdk publish --no-git-checks --access public --provenance - - name: Extract release notes from CHANGELOG - id: notes - # Pulls the section for the current version out of CHANGELOG.md - # so the GitHub Release body matches what consumers see on npm. - run: | - VERSION=$(node -p "require('./packages/sdk/package.json').version") - # Matches either a bracketed Keep-a-Changelog heading - # (`## [X.Y.Z] - 2026-06-16`) or a bare Changesets heading (`## X.Y.Z`). - NOTES=$(awk -v v="$VERSION" '$0 ~ ("^## \\[?" v "\\]?([ -]|$)"){flag=1;next}/^## /{flag=0}flag' packages/sdk/CHANGELOG.md) - { - echo "notes<> "$GITHUB_OUTPUT" - - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - name: "@workflowbuilder/sdk ${{ github.ref_name }}" + name: ${{ github.ref_name }} body: ${{ steps.notes.outputs.notes }} draft: false prerelease: false diff --git a/.github/workflows/release-temporal.yml b/.github/workflows/release-temporal.yml new file mode 100644 index 000000000..b6ae32da5 --- /dev/null +++ b/.github/workflows/release-temporal.yml @@ -0,0 +1,135 @@ +name: Release Temporal + +# Triggers on scoped tags `@workflowbuilder/temporal@X.Y.Z` matching the version in +# packages/temporal/package.json. Mirrors release-ui.yml. Maintainer pushes the tag +# 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". + +on: + push: + tags: + - "@workflowbuilder/temporal@*" + workflow_dispatch: # manual fire from GitHub UI — for testing or rerunning a failed publish + +permissions: + contents: write # create GitHub Release + id-token: write # OIDC for npm Trusted Publisher (and provenance attestation) + +jobs: + publish: + name: Build and publish to npm + runs-on: ubuntu-latest + steps: + - name: Checkout code (at tag) + uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 # full history so changesets can read CHANGELOG context + + - name: Set up Node.js + # No `registry-url:` here on purpose — see the note in release-sdk.yml. + # setup-node's registry-url breaks OIDC by injecting a sentinel token. + # + # Node 24, not 22: the Trusted Publisher OIDC exchange is performed by the + # bundled npm, and only npm 11.x (Node 24) does it natively when no token is + # configured. release-sdk.yml was bumped for exactly this reason after the + # publish failed on authorization; do not lower it. + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Defensive layer — fail before npm, not on consumers. + - name: Lint + run: pnpm --filter @workflowbuilder/temporal lint + + - name: Typecheck + run: pnpm --filter @workflowbuilder/temporal typecheck + + - name: Test + run: pnpm --filter @workflowbuilder/temporal test + + - name: Build + run: pnpm --filter @workflowbuilder/temporal build + + - name: Verify the published shape + # Three subpaths with their own types — exactly where an exports map breaks + # quietly. publint checks the packaging, attw checks that each subpath's types + # actually resolve. Versions pinned so a tool release cannot block a publish; + # the esm-only attw profile matches the package (ESM without CJS is a finding + # under stricter profiles). + working-directory: packages/temporal + run: | + pnpm dlx publint@0.3.24 + pnpm dlx @arethetypeswrong/cli@0.18.5 --pack --profile esm-only + + - name: Verify version matches tag + # Tag `@workflowbuilder/temporal@X.Y.Z` must match packages/temporal/package.json + # version. ${GITHUB_REF_NAME##*@} strips everything up to the last `@`. + run: | + TAG_VERSION="${GITHUB_REF_NAME##*@}" + PKG_VERSION=$(node -p "require('./packages/temporal/package.json').version") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)." + echo "Did you forget to merge the version-bump PR before pushing the tag?" + exit 1 + fi + echo "Publishing @workflowbuilder/temporal@$PKG_VERSION" + + - name: Check if version already published + # Idempotency — re-pushing a tag should not re-publish. + id: check-version + run: | + PKG_VERSION=$(node -p "require('./packages/temporal/package.json').version") + # E404 is npm's only answer that means "not there". Any other failure means npm could not be + # asked, and guessing "not published" would send the job into a doomed publish. + if OUT=$(npm view "@workflowbuilder/temporal@$PKG_VERSION" version 2>&1); then + echo "already_published=true" >> "$GITHUB_OUTPUT" + echo "::notice::@workflowbuilder/temporal@$PKG_VERSION already on npm — skipping publish step." + elif printf '%s' "$OUT" | grep -q 'E404'; then + echo "already_published=false" >> "$GITHUB_OUTPUT" + else + echo "::error::npm could not be queried for @workflowbuilder/temporal@$PKG_VERSION: $OUT" + exit 1 + fi + + - name: Extract release notes from CHANGELOG + id: notes + # Before the publish on purpose: a missing or empty section fails here, while nothing has + # reached npm yet. release:tag applies the same rule (tools/release-shared.mjs). + run: | + NOTES=$(pnpm -s release:notes temporal) + { + echo "notes<> "$GITHUB_OUTPUT" + + - name: Publish to npm + # Authenticates via npm Trusted Publisher (OIDC). No NPM_TOKEN. + if: steps.check-version.outputs.already_published == 'false' + run: pnpm --filter @workflowbuilder/temporal publish --no-git-checks --access public --provenance + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body: ${{ steps.notes.outputs.notes }} + draft: false + prerelease: false diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml new file mode 100644 index 000000000..4e1cdb78c --- /dev/null +++ b/.github/workflows/release-ui.yml @@ -0,0 +1,127 @@ +name: Release UI + +# Triggers on scoped tags `@workflowbuilder/ui@X.Y.Z` matching the version in +# packages/ui/package.json. Mirrors release-sdk.yml. Maintainer pushes the tag +# 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/ui needs its own GitHub Actions trusted +# publisher registered on npmjs.com pointing at THIS workflow file +# (.github/workflows/release-ui.yml). It is separate from the sdk one, and npm only +# offers it on a package that already exists: packages/RELEASE.md § "First +# release of a new package". + +on: + push: + tags: + - "@workflowbuilder/ui@*" + workflow_dispatch: # manual fire from GitHub UI — for testing or rerunning a failed publish + +permissions: + contents: write # create GitHub Release + id-token: write # OIDC for npm Trusted Publisher (and provenance attestation) + +jobs: + publish: + name: Build and publish to npm + runs-on: ubuntu-latest + steps: + - name: Checkout code (at tag) + uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 # full history so changesets can read CHANGELOG context + + - name: Set up Node.js + # No `registry-url:` here on purpose — see the note in release-sdk.yml. + # setup-node's registry-url breaks OIDC by injecting a sentinel token. + # + # Node 24, not 22: the Trusted Publisher OIDC exchange is performed by the + # bundled npm, and only npm 11.x (Node 24) does it natively when no token is + # configured. release-sdk.yml was bumped for exactly this reason after the + # publish failed on authorization; do not lower it. + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Defensive layer — fail before npm, not on consumers. + - name: Lint + run: pnpm --filter @workflowbuilder/ui lint + + - name: Typecheck + run: pnpm --filter @workflowbuilder/ui typecheck + + - name: Test + run: pnpm --filter @workflowbuilder/ui test + + - name: Build UI + # build:ui builds @workflowbuilder/ui-tokens first (ui consumes its + # built tokens.css) and runs the check:built-css guard afterwards. + run: pnpm build:ui + + - name: Verify version matches tag + # Tag `@workflowbuilder/ui@X.Y.Z` must match packages/ui/package.json + # version. ${GITHUB_REF_NAME##*@} strips everything up to the last `@`. + run: | + TAG_VERSION="${GITHUB_REF_NAME##*@}" + PKG_VERSION=$(node -p "require('./packages/ui/package.json').version") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)." + echo "Did you forget to merge the version-bump PR before pushing the tag?" + exit 1 + fi + echo "Publishing @workflowbuilder/ui@$PKG_VERSION" + + - name: Check if version already published + # Idempotency — re-pushing a tag should not re-publish. + id: check-version + run: | + PKG_VERSION=$(node -p "require('./packages/ui/package.json').version") + # E404 is npm's only answer that means "not there". Any other failure means npm could not be + # asked, and guessing "not published" would send the job into a doomed publish. + if OUT=$(npm view "@workflowbuilder/ui@$PKG_VERSION" version 2>&1); then + echo "already_published=true" >> "$GITHUB_OUTPUT" + echo "::notice::@workflowbuilder/ui@$PKG_VERSION already on npm — skipping publish step." + elif printf '%s' "$OUT" | grep -q 'E404'; then + echo "already_published=false" >> "$GITHUB_OUTPUT" + else + echo "::error::npm could not be queried for @workflowbuilder/ui@$PKG_VERSION: $OUT" + exit 1 + fi + + - name: Extract release notes from CHANGELOG + id: notes + # Before the publish on purpose: a missing or empty section fails here, while nothing has + # reached npm yet. release:tag applies the same rule (tools/release-shared.mjs). + run: | + NOTES=$(pnpm -s release:notes ui) + { + echo "notes<> "$GITHUB_OUTPUT" + + - name: Publish to npm + # Authenticates via npm Trusted Publisher (OIDC). No NPM_TOKEN. + # Requires @workflowbuilder/ui to have trusted publishing configured on + # npmjs.com pointing at this workflow file. + if: steps.check-version.outputs.already_published == 'false' + run: pnpm --filter @workflowbuilder/ui publish --no-git-checks --access public --provenance + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body: ${{ steps.notes.outputs.notes }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore index 3c2d2a3a0..f0626771d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,8 @@ CLAUDE.local.md # and is copied into this dir by a tiny Astro integration after typedoc # 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. +apps/docs/src/generated/ diff --git a/.prettierignore b/.prettierignore index 40c7a9838..ebef0901d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,6 @@ apps/icons/src/utils/icons.gen.ts # Astro auto-generated content collections + types (gitignored, regenerated on build/dev) **/.astro/ +# 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/CLAUDE.md b/CLAUDE.md index b7e76c160..ae2f7ded3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,24 +6,31 @@ Visual workflow editor SDK (React) with a reference backend and Temporal-based e Three onboarding paths (A installs from npm; B, C run the repo locally). README "Get started" covers all three. Path A ("Embed the SDK") installs `@workflowbuilder/sdk` from npm; the README has install + a minimal snippet, and the full guide lives in the [docs site](https://www.workflowbuilder.io/docs/get-started/quick-start/wb-as-react-component/). -| Command | Path | What it does | -| ---------------------------- | ---- | --------------------------------------------------------------------------- | -| `pnpm preflight` | B/C | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents | -| `pnpm dev` / `pnpm dev:demo` | B | Demo (UI only, port 4200). No backend, no Docker | -| `pnpm infra:up` | C | Start Postgres + Temporal in Docker. Required before backend/worker | -| `pnpm -F backend db:migrate` | C | Apply Drizzle migrations out-of-band (backend also auto-migrates on boot) | -| `pnpm dev:ai-studio` | C | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) | -| `pnpm dev:backend` | C | Backend only (debug). Needs infra up | -| `pnpm dev:worker` | C | Execution worker only (debug). Needs infra up | -| `pnpm infra:down` | C | Stop the Docker stack | -| `pnpm dev:docs` | - | Docs site (Astro + Starlight) | -| `pnpm build:lib` | - | Build the SDK package (`packages/sdk`) | -| `pnpm build` | - | Build the demo app | -| `pnpm test` | - | Run tests in `packages/sdk` and `packages/execution-core` | -| `pnpm check` | - | Lint + typecheck + format + knip | +| Command | Path | What it does | +| ---------------------------- | ---- | -------------------------------------------------------------------------------------------------------- | +| `pnpm preflight` | B/C | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents | +| `pnpm dev` / `pnpm dev:demo` | B | Demo (UI only, port 4200). No backend, no Docker | +| `pnpm infra:up` | C | Start Postgres + Temporal in Docker. Required before backend/worker | +| `pnpm -F backend db:migrate` | C | Apply Drizzle migrations out-of-band (backend also auto-migrates on boot) | +| `pnpm dev:ai-studio` | C | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) | +| `pnpm dev:backend` | C | Backend only (debug). Needs infra up | +| `pnpm dev:worker` | C | Execution worker only (debug). Needs infra up | +| `pnpm infra:down` | C | Stop the Docker stack | +| `pnpm dev:docs` | - | Docs site (Astro + Starlight) | +| `pnpm build:lib` | - | Build the publishable chain: `ui-tokens` -> `ui` -> SDK (`build:ui` does the first two) | +| `pnpm build:temporal` | - | Build `@workflowbuilder/temporal` (also built on install via its `prepare`) | +| `pnpm build` | - | Build the demo app | +| `pnpm test` | - | Run tests in every workspace that defines a `test` script (`pnpm -r test`) | +| `pnpm check` | - | Lint + typecheck + format + knip | +| `pnpm release:version ` | - | Maintainer: consume one published package's changesets (computes `changeset version --ignore …`) | +| `pnpm release:tag ` | - | Maintainer: create and push `@workflowbuilder/@X.Y.Z` from the tip of `release`. The push publishes | Path B is UI-only and does not need Docker. Path C requires `pnpm infra:up` before backend/worker can start; the backend applies pending migrations automatically at boot. +App builds and dev servers consume the prebuilt `packages/ui/dist` (created on install). After editing `packages/ui/src`, run `pnpm build:ui` (or keep `pnpm --filter @workflowbuilder/ui dev` watching) - app commands do not rebuild it. + +The same applies to `@workflowbuilder/temporal`: the worker and the backend import its built `dist`, so after editing `packages/temporal/src` run `pnpm build:temporal` (or keep `pnpm --filter @workflowbuilder/temporal dev` watching) before `pnpm dev:worker` picks the change up. + ### Agent signals Long-running processes already emit stable log lines that scripts and agents can grep for: @@ -31,54 +38,68 @@ Long-running processes already emit stable log lines that scripts and agents can | Process | Ready signal | | -------------------- | -------------------------------------------------------------------------------- | | `pnpm dev:demo` | `VITE vX.Y.Z ready in NNN ms` and `Local: http://localhost:4200/` | -| `pnpm dev:backend` | `Backend running on http://127.0.0.1:3001` | -| `pnpm dev:worker` | `Execution worker started on task queue: workflow-execution` | +| `pnpm dev:backend` | `backend listening` (structured: `{"component":"backend","url":"..."}`) | +| `pnpm dev:worker` | `execution worker started` (structured: `{"taskQueue":"workflow-execution"}`) | | `pnpm dev:ai-studio` | All three above interleaved with `[backend]`, `[worker]`, `[ai-studio]` prefixes | | `pnpm infra:wait` | `Temporal ready` | +Grep for the message text only, not for a rendered sentence: the backend and worker log through a structured logger, so the fields arrive as JSON after the message and their order is not guaranteed. Both lines are lowercase. + `pnpm preflight --json` returns `{ ok: boolean, checks: [{ name: string, status: 'pass' | 'warn' | 'fail', detail: string }] }` for programmatic inspection. Top-level `ok` is `true` when no check has `status: 'fail'`. ## Monorepo Structure ``` tools/ - Root dev scripts: preflight, setup:env, infra wait - deployment/ - Swarm/Ansible deploy path mirroring the workflow-builder repo (ACR, Traefik) deploy/ ai-studio/ - Production deployment: Dockerfile (runtime/web), compose, nginx, README apps/ demo/ - Reference app consuming the SDK (React + Vite, port 4200) ai-studio/ - Reference AI workflow product (React + Vite, port 4201) backend/ - Hono REST + SSE server, Drizzle/Postgres, Temporal client (port 3001) - execution-worker/ - Temporal worker hosting execution-core (task queue: workflow-execution) + execution-worker/ - Temporal worker built on @workflowbuilder/temporal: node executors + DB store (task queue: workflow-execution) docs/ - Astro + Starlight documentation site icons/ - Icon generation pipeline tools/ - @workflow-builder/tools workspace (decision-log collector, lint-staged config) packages/ + ai-config/ - Private, source-only: the AI_API_KEY / AI_BASE_URL / AI_MODEL contract, one copy shared by backend and worker sdk/ - @workflowbuilder/sdk public package (WorkflowBuilder compound component, plugin API, components) + ui/ - @workflowbuilder/ui published component library (Base UI), consumed by sdk/demo/ai-studio + tokens/ - @workflowbuilder/ui-tokens private design-token build (style-dictionary), feeds packages/ui execution-core/ - Pure topological graph runner + node executor registry + temporal/ - @workflowbuilder/temporal published Temporal Plugin (activities + workflow runner); bundles execution-core + types into its dist + temporal-connection/ - Private, source-only: TEMPORAL_* env -> validated connection options + namespace, one copy shared by backend and worker types/ - Shared TypeScript types ``` -Where to put a new script: root `tools/` for pure-Node bootstrap (runs before any workspace is built); `apps/tools/` for tooling that needs TypeScript or workspace deps. +Where to put a new script: root `tools/` for plain Node scripts that run with `node` and no build step (bootstrap checks and the maintainer release commands; root devDependencies such as Changesets are fine); `apps/tools/` for tooling that needs TypeScript or workspace packages. `tools/` is outside the pnpm workspace, so `pr-check.yml` lints, formats and tests it with dedicated steps (`pnpm test:tools`). ## Per-workspace docs Each workspace has its own context. Read the relevant file before extending a workspace. -| Workspace | Authoritative docs | -| ------------------------- | ----------------------------------- | -| `packages/sdk` | `packages/sdk/README.md` | -| `packages/execution-core` | `packages/execution-core/README.md` | -| `apps/demo` | `apps/demo/CLAUDE.md` | -| `apps/ai-studio` | `apps/ai-studio/README.md` | -| `apps/backend` | `apps/backend/README.md` | -| `apps/execution-worker` | `apps/execution-worker/README.md` | +| Workspace | Authoritative docs | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `packages/sdk` | `packages/sdk/README.md` | +| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) | +| `packages/tokens` | `packages/tokens/README.md` | +| `packages/ai-config` | `packages/ai-config/README.md` | +| `packages/execution-core` | `packages/execution-core/README.md` | +| `packages/temporal` | `packages/temporal/README.md` (+ `packages/temporal/activity-profiles.md`, `packages/temporal/event-history-labels.md`) | +| `packages/temporal-connection` | `packages/temporal-connection/README.md` | +| `apps/demo` | `apps/demo/CLAUDE.md` | +| `apps/ai-studio` | `apps/ai-studio/README.md` | +| `apps/backend` | `apps/backend/README.md` | +| `apps/execution-worker` | `apps/execution-worker/README.md` | ## Types & Aliases Shared types: `packages/types/` (imported as `@workflow-builder/types/*`). +AI configuration contract: `packages/ai-config/` (imported as `@workflow-builder/ai-config`; `aiConfig()` tells backend and worker whether the LLM is configured and what is missing). +Temporal connection config: `packages/temporal-connection/` (imported as `@workflow-builder/temporal-connection`; `temporalConfig()` gives backend and worker their connect options and namespace). Icons: `apps/icons/` (imported as `@workflow-builder/icons`). SDK: `packages/sdk/` (imported as `@workflowbuilder/sdk`). +UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuilder/ui/styles.css`, `/index.css`, `/tokens.css`). ## Local Infrastructure @@ -89,18 +110,38 @@ SDK: `packages/sdk/` (imported as `@workflowbuilder/sdk`). - Temporal server on `7233` (gRPC) - Temporal UI on http://localhost:8233 -Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the box. `pnpm infra:down` stops everything. +Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the box. Pointing either app at a secured cluster or Temporal Cloud is env-only (`TEMPORAL_NAMESPACE`, `TEMPORAL_TLS`, `TEMPORAL_API_KEY`, `TEMPORAL_TLS_*_PATH`) - see `apps/backend/README.md` "Connecting to a secured Temporal cluster". `pnpm infra:down` stops everything. ## Code Quality -| Tool | Command | Notes | -| ---------- | ----------------------------- | --------------------------------------------------------- | -| ESLint | `pnpm lint` / `pnpm lint:fix` | Per-workspace configs | -| Prettier | `pnpm format` | Sorts imports via `@trivago/prettier-plugin-sort-imports` | -| TypeScript | `pnpm typecheck` | Per-workspace `tsconfig.json` | -| Knip | Part of `pnpm check` | Detects unused exports/dependencies | -| Vitest | `pnpm test` | Runs in `packages/sdk` and `packages/execution-core` | -| Full check | `pnpm check` | Run before PR | +| Tool | Command | Notes | +| ---------- | ----------------------------- | ------------------------------------------------------------------------------------------------------- | +| ESLint | `pnpm lint` / `pnpm lint:fix` | Per-workspace configs | +| Prettier | `pnpm format` | Sorts imports via `@trivago/prettier-plugin-sort-imports` | +| TypeScript | `pnpm typecheck` | Per-workspace `tsconfig.json` | +| Knip | `pnpm knip` | Detects unused exports/dependencies. Run by hand: not part of `pnpm check` or CI | +| Vitest | `pnpm test` | Runs in every workspace with a `test` script — recursive, so a new workspace is picked up automatically | +| Full check | `pnpm check` | Run before PR | + +### Code comments + +Two things earn a comment. Nothing else does. + +1. **What the code cannot show.** External behaviour (a zero timeout is treated as unset by the Temporal server), a constraint that lives elsewhere (this runs in the V8 sandbox, so no Node built-ins), a hazard with no visible trace (a node type named `constructor` resolves off `Object.prototype`). +2. **A decision other developers need, so it does not get lost.** Why the plugin accepts a value it never reads. Why this warns instead of throwing. Why a field is lifted out of `config`. + +Even then, keep it to the fewest words that carry the fact. One line is the default, three the hard ceiling. If a comment would run longer, the explanation belongs in the workspace README or a `*.decision-log.md` next to the code, with the comment reduced to a pointer. + +Do not comment what the next line does, that a change is correct, where a value came from, or how to use an API the README already shows. A test's name replaces its comment: write the non-obvious context only, never a restatement of the assertion. When a file header and an inline comment would say the same thing, keep the inline one. + +### Referencing future work in code + +This repo is public, but ticket IDs (`WB-123`) point to a private ClickUp — for external readers they are dead links. Never put ticket IDs in code comments, READMEs, or anything else that ships. Instead: + +- 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. ## Getting Started @@ -119,25 +160,31 @@ If you're new to this repo and want to build your own consumer app or POC, follo ## Common Slash Commands -| Command | What it does | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `/wb.create-app ` | Scaffold a new SDK-consuming frontend app under `apps//` — interactive (port, plugins/nodes/templates to seed from demo) | -| `/wb.create-node ` | Scaffold a new UI node type — asks for target app (default `demo`) | -| `/wb.create-plugin ` | Scaffold a new SDK plugin — asks for target app (default `demo`) | -| `/wb.create-template ` | Scaffold a new diagram template — asks for target app (default `demo`) | -| `/wb.add-execution-handler ` | Wire a node type into execution-core + worker registry (global pipeline, no target) | -| `/wb.run-locally` | Bring up the stack — Path B (`pnpm dev:demo`) or Path C (infra + backend + worker + AI Studio frontend) | -| `/wb.task` | Fetch assigned ClickUp tasks via MCP and recommend one to pick up | -| `/wb.task WB-42` | Pick up a specific task with an inline plan | -| `/wb.changeset ""` | Add a changeset for SDK changes (`patch` / `minor` / `major`) — required before merging consumer-visible changes to `packages/sdk/**` | +| Command | What it does | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/wb.create-app ` | Scaffold a new SDK-consuming frontend app under `apps//` — interactive (port, plugins/nodes/templates to seed from demo) | +| `/wb.create-node ` | Scaffold a new UI node type — asks for target app (default `demo`) | +| `/wb.create-plugin ` | Scaffold a new SDK plugin — asks for target app (default `demo`) | +| `/wb.create-template ` | Scaffold a new diagram template — asks for target app (default `demo`) | +| `/wb.add-execution-handler ` | Wire a node type into execution-core + worker registry (global pipeline, no target) | +| `/wb.run-locally` | Bring up the stack — Path B (`pnpm dev:demo`) or Path C (infra + backend + worker + AI Studio frontend) | +| `/wb.task` | Fetch assigned ClickUp tasks via MCP and recommend one to pick up | +| `/wb.task WB-42` | Pick up a specific task with an inline plan | +| `/wb.changeset ""` | Add a changeset for a published package (`patch` / `minor` / `major`) — required before merging consumer-visible changes to `packages/sdk/**`, `packages/ui/**` or `packages/temporal/**` | + +### Releasing the `@workflowbuilder/*` packages -### Releasing `@workflowbuilder/sdk` +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`. -The SDK is the only npm-published workspace; everything else under `apps/` and `packages/` is private (and listed under `ignore` in `.changeset/config.json`). +**`@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. **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. -**Daily SDK change:** +**Daily change to a published package** (the SDK below; `ui` and `temporal` follow the same steps with their own paths): 1. Edit `packages/sdk/**`, run tests/typecheck locally. 2. **Add a changeset** with `/wb.changeset ""`. Required for any consumer-visible change. Skip only for changes that don't ship in `dist/` (e.g. `eslint.config.mjs`, internal tests, source-only comments). Keep it to 1-2 plain sentences: the consumer-visible change plus any migration note - changesets become the public CHANGELOG, so no rationale, investigation history, or noise (that belongs in the commit message and PR). @@ -152,15 +199,17 @@ The SDK is the only npm-published workspace; everything else under `apps/` and ` **Release moment** (maintainer, not Claude): -1. Open PR `release/vX.Y.Z` → `release`. In the branch, run `pnpm changeset version` — bumps `packages/sdk/package.json`, regenerates `packages/sdk/CHANGELOG.md` (then reformat it into Keep a Changelog style before committing, see [`packages/sdk/RELEASE.md`](packages/sdk/RELEASE.md) § "Reformat the generated CHANGELOG section"), deletes consumed `.changeset/*.md`. -2. Review the diff, merge the PR into `release`. -3. Tag the merge commit on `release`: `git tag vX.Y.Z && git push origin vX.Y.Z`. +1. On `main`, `pnpm release:version --dry-run` shows the version the pending changesets add up to. Cut `release--X.Y.Z` and run `pnpm release:version `. It bumps only `packages//package.json`, regenerates its `CHANGELOG.md` (reformat it into Keep a Changelog style before committing, see [`packages/RELEASE.md`](packages/RELEASE.md) § "Reformat the generated CHANGELOG section"), deletes only the changesets that named ``. +2. Open PR `release--X.Y.Z` → `release`, review the diff, merge. +3. On the tip of `release`: `pnpm release:tag `. It checks the state, asks, then creates and pushes `@workflowbuilder/@X.Y.Z`. 4. GitHub Action triggered by the tag runs lint + typecheck + test + `pnpm publish --provenance` (authenticated via npm Trusted Publisher / OIDC, no `NPM_TOKEN` stored anywhere) + creates a GitHub Release. 5. Sync back: `git checkout main && git merge release && git push` so main picks up the bumped version + clean `.changeset/`. -Tag format follows the ng-diagram convention (single-package monorepo, `v*` regex). If we ever publish a second package we'll migrate to scoped (`@workflowbuilder/sdk@X.Y.Z`) — ~1h of work, see `packages/sdk/RELEASE.md` § "Why these decisions". +Tags are scoped per package (`@workflowbuilder/sdk@X.Y.Z`, `@workflowbuilder/ui@X.Y.Z`, `@workflowbuilder/temporal@X.Y.Z`); each package has its own tag-triggered workflow (`release-sdk.yml`, `release-ui.yml`, `release-temporal.yml`). The earlier single-package `v*` scheme was retired when `@workflowbuilder/ui` became publishable. See `packages/RELEASE.md` § "Why these decisions". + +`@workflowbuilder/temporal` additionally carries a replay contract: a workflow can wait in Event History for days, so a patch or minor release must still replay a history recorded by an older version. Breaking that is a major, with a note to drain in-flight runs. See `packages/temporal/README.md` § "Versioning and replay". Each release of it records the replay histories under the new version inside the release PR (`packages/RELEASE.md` § "Release procedure"). -Canonical procedure with edge cases and rollback: [`packages/sdk/RELEASE.md`](packages/sdk/RELEASE.md). +Canonical procedure with edge cases and rollback: [`packages/RELEASE.md`](packages/RELEASE.md). ### Maintaining `/wb.*` skills diff --git a/DECISION-LOGS.md b/DECISION-LOGS.md index 65078abca..c2df150c7 100644 --- a/DECISION-LOGS.md +++ b/DECISION-LOGS.md @@ -9,10 +9,11 @@ - _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](./apps/execution-worker/src/engines/temporal/workflows/cancellation-handling.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) - _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) +- _24.08.2026_: [`incomplete` as a third terminal state, distinct from `failed` and from a stall](./packages/execution-core/terminal-states.decision-log.md) diff --git a/README.md b/README.md index c8aa662ff..5303c445f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Drag-and-drop workflow builder UI with a reference back-end and an execution eng [Try it live ->](https://app.workflowbuilder.io/) +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/fork/github/synergycodes/workflowbuilder/tree/main/examples/workflow-builder-starter?title=Workflow%20Builder%20Starter%20Example&file=src%2Fapp.tsx) + Used in production by teams including [Vercom](https://www.workflowbuilder.io/case-study/vercom), [Athena Intelligence](https://www.workflowbuilder.io/case-study/athena-intelligence), [Plura AI](https://www.workflowbuilder.io/case-study/plura-ai), and others. @@ -42,7 +44,9 @@ Three onboarding paths. Pick one based on what you want to evaluate. | See the editor running in your browser | [B. Try the demo](#path-b-try-the-demo) | ~2 min | no | | Run the full reference stack (editor + execution + AI) | [C. Run the full stack demo](#path-c-run-the-full-stack-demo) | ~10 min | yes | -Don't want to install or clone anything yet? [Try the live demo](https://app.workflowbuilder.io) in your browser first. +Don't want to install or clone anything yet? [Try the live demo](https://app.workflowbuilder.io) in your browser, or open the runnable starter below: + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/fork/github/synergycodes/workflowbuilder/tree/main/examples/workflow-builder-starter?title=Workflow%20Builder%20Starter%20Example&file=src%2Fapp.tsx) ### Path A. Embed the SDK @@ -193,30 +197,31 @@ Temporal ready [ai-studio] ➜ Local: http://127.0.0.1:4201/ ``` -Open `http://localhost:4201`. Pick the "Sales Inquiry" template, click Play. The Temporal UI at `http://localhost:8233` shows the running execution. +Open `http://localhost:4201`. Every bundled template contains AI Agent nodes, so either connect an LLM first (next section) or expect the run to stop at its first AI Agent node with `ai_not_configured` while the Trigger, Decision and Visualize nodes before it run. Pick a template, click Play. The Temporal UI at `http://localhost:8233` shows the running execution. To stop: `Ctrl+C`, then `pnpm infra:down`. #### Connect a real LLM (optional) -AI Studio works with stub responses out of the box. To use a real model, add to both `apps/backend/.env` and `apps/execution-worker/.env`: +The stack starts without an LLM: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. AI nodes need three variables in both `apps/backend/.env` and `apps/execution-worker/.env`. The files `pnpm setup:env` created already carry an endpoint and a model for [OpenRouter](https://openrouter.ai), so only the key is missing: ```env -OPENROUTER_API_KEY=sk-or-v1-... -AI_MODEL=anthropic/claude-3.5-haiku +AI_API_KEY=sk-or-v1-... +AI_BASE_URL=https://openrouter.ai/api/v1 +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct ``` -If the key is missing the worker fails to start with `OPENROUTER_API_KEY is required`. If the model id is wrong the first AI node fails at runtime and the error surfaces in the UI log panel. +None of the three has a built-in default. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and model requests stay inside it. That covers the model only: the optional web-search tool calls Tavily's API when `TAVILY_API_KEY` is set, so leave it unset if nothing may call out. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel. ### Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port | -| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | -| Worker exits with `OPENROUTER_API_KEY is required` | Real LLM env var missing | Set it in `apps/execution-worker/.env`. Optional unless you want a real LLM call | -| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily | -| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun | +| Symptom | Cause | Fix | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port | +| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` | +| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env` | +| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily | +| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun | For the full command reference, see the table in [`CLAUDE.md`](./CLAUDE.md) or the documentation site. @@ -262,6 +267,8 @@ Using `pnpm workspaces`, Workflow Builder is split into runnable apps under `app - [`packages/sdk`](./packages/sdk/README.md) - `@workflowbuilder/sdk`, the embeddable React library (public API, types, build) - [`packages/types`](./packages/types) - `@workflow-builder/types`, shared TypeScript types used by the SDK and the bundled backend/worker - [`packages/execution-core`](./packages/execution-core/README.md) - Pure domain layer (ports, graph runner, node executors) shared by the bundled backend and worker +- [`packages/ui`](./packages/ui/README.md) - `@workflowbuilder/ui`, the component library (Base UI) the SDK is built on, published on its own +- [`packages/temporal`](./packages/temporal/README.md) - `@workflowbuilder/temporal`, the Temporal Plugin that runs diagrams as durable Workflow Executions (bundles `execution-core` and `types`) - [`apps/demo`](./apps/demo/README.md) - Reference SPA that consumes the SDK with the full plugin set (also the source of truth for example node types, templates, and plugins) - [`apps/docs`](./apps/docs/README.md) - Documentation site - [`apps/icons`](./apps/icons/README.md) - Lazy-loadable, extensible icons consumed by the SDK diff --git a/apps/ai-studio/package.json b/apps/ai-studio/package.json index 747ed5079..ed9adb2bb 100644 --- a/apps/ai-studio/package.json +++ b/apps/ai-studio/package.json @@ -13,15 +13,19 @@ "test:watch": "vitest --passWithNoTests" }, "dependencies": { + "@base-ui/react": "catalog:", + "@jsonforms/core": "^3.4.1", + "@jsonforms/react": "^3.4.1", "@phosphor-icons/react": "^2.1.7", - "@synergycodes/overflow-ui": "1.0.0-beta.27", "@workflow-builder/types": "workspace:*", + "@workflowbuilder/ui": "workspace:*", "@xyflow/react": "catalog:", "clsx": "^2.1.1", "html-to-image": "1.11.11", "immer": "^10.1.1", "mermaid": "^11.15.0", - "react": "^19.1.0", + "react": "catalog:", + "react-is": "catalog:", "react-dom": "catalog:", "react-i18next": "^15.4.1", "react-markdown": "^10.1.0", diff --git a/apps/ai-studio/src/adapters/execution-stream-adapter.ts b/apps/ai-studio/src/adapters/execution-stream-adapter.ts index 70e293271..6cd307646 100644 --- a/apps/ai-studio/src/adapters/execution-stream-adapter.ts +++ b/apps/ai-studio/src/adapters/execution-stream-adapter.ts @@ -1,10 +1,17 @@ -import type { ExecutionEvent, ExecutionSnapshot } from '@workflow-builder/types/workflow-execution/execution-events'; +import { + type ExecutionEvent, + type ExecutionEventType, + type ExecutionSnapshot, + type ExecutionStatus, + TERMINAL_EXECUTION_EVENT_TYPES, + TERMINAL_EXECUTION_STATUSES, +} from '@workflow-builder/types/workflow-execution/execution-events'; import { BACKEND_URL } from '../config'; import { applyConnectionLost, applyEvent, applySnapshot } from '../stores/use-execution-store'; -const TERMINAL_TYPES = new Set(['execution_completed', 'execution_failed', 'execution_cancelled']); -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); +const TERMINAL_TYPES: ReadonlySet = new Set(TERMINAL_EXECUTION_EVENT_TYPES); +const TERMINAL_STATUSES: ReadonlySet = new Set(TERMINAL_EXECUTION_STATUSES); const MAX_RETRIES = 5; export function connectExecutionStream(executionId: string, streamUrl: string): () => void { diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx index 7b21ee11f..df5548fbf 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -1,4 +1,4 @@ -import { WorkflowBuilder } from '@workflowbuilder/sdk'; +import { WorkflowBuilder, type WorkflowBuilderIsValidConnection } from '@workflowbuilder/sdk'; import './node-overrides.css'; import '@workflowbuilder/sdk/style.css'; @@ -17,6 +17,9 @@ import { plugin as undoRedoPlugin } from '../plugins/undo-redo/plugin-exports'; const flagship = supportTriageFlow.value; +// 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() { return ( diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx index 2e93bae8e..45133ba32 100644 --- a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx +++ b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx @@ -1,23 +1,23 @@ -import { NavButton } from '@synergycodes/overflow-ui'; import { Icon, getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk'; +import { NavButton } from '@workflowbuilder/ui'; import clsx from 'clsx'; import { useCallback } from 'react'; import styles from './ai-studio-controls.module.css'; import { useBackendExecution } from '../../hooks/use-backend-execution'; -import { useHasNodeTypeInDiagram } from '../../hooks/use-has-node-type-in-diagram'; +import { useHasStartNode } from '../../hooks/use-has-start-node'; export function AiStudioControls() { const { executeFromCanvas, cancel, reset, status } = useBackendExecution(); - const shouldShowControls = useHasNodeTypeInDiagram('ai-studio/trigger'); + const shouldShowControls = useHasStartNode(); const handleExecute = useCallback(async () => { const nodes = getStoreNodes(); const edges = getStoreEdges(); - const triggerNode = nodes.find((n) => n.data.type === 'ai-studio/trigger'); - const inputPrompt = (triggerNode?.data.properties as { inputPrompt?: string })?.inputPrompt ?? ''; + const startNode = nodes.find((n) => n.data.isStartNode); + const inputPrompt = (startNode?.data.properties as { inputPrompt?: string })?.inputPrompt ?? ''; const triggerPayload = inputPrompt ? { input: inputPrompt } : {}; try { @@ -28,7 +28,7 @@ export function AiStudioControls() { }, [executeFromCanvas]); const isRunning = status === 'pending' || status === 'running'; - const isDone = status === 'completed' || status === 'failed' || status === 'cancelled'; + const isDone = status === 'completed' || status === 'incomplete' || status === 'failed' || status === 'cancelled'; return (

- The workflows here run for real: every AI step calls a live model through OpenRouter. + The workflows here run for real: every AI step calls a live model.

It is not a place to test or benchmark AI models. The model is just the engine — the point diff --git a/apps/ai-studio/src/components/execution/highlighting.css b/apps/ai-studio/src/components/execution/highlighting.css index fef4548f1..f485e2779 100644 --- a/apps/ai-studio/src/components/execution/highlighting.css +++ b/apps/ai-studio/src/components/execution/highlighting.css @@ -1,16 +1,19 @@ html[data-theme='light'] { --ai-studio-edge-color--active: var(--ax-colors-green-400); - --ai-studio-status-color--completed: var(--ax-colors-green-400); + --ai-studio-status-color--incomplete: var(--ax-txt-warning-default); } html[data-theme='dark'] { --ai-studio-edge-color--active: var(--ax-colors-green-200); - --ai-studio-status-color--completed: var(--ax-colors-green-200); + /* No dark warning-accent token exists (--ax-txt-warning-default is orange-100 body-text cream). */ + --ai-studio-status-color--incomplete: var(--ax-colors-orange-300); } :root { + --ai-studio-status-color--completed: var(--ax-txt-success-default); --ai-studio-status-color--failed: var(--ax-txt-error-default); --ai-studio-status-bg--completed: color-mix(in srgb, var(--ai-studio-status-color--completed), transparent 85%); + --ai-studio-status-bg--incomplete: color-mix(in srgb, var(--ai-studio-status-color--incomplete), transparent 85%); --ai-studio-status-bg--failed: color-mix(in srgb, var(--ai-studio-status-color--failed), transparent 85%); --ai-studio-node-shadow-color--active: var(--ai-studio-edge-color--active); diff --git a/apps/ai-studio/src/components/execution/highlighting.tsx b/apps/ai-studio/src/components/execution/highlighting.tsx index 79051a9a9..b28db5ec8 100644 --- a/apps/ai-studio/src/components/execution/highlighting.tsx +++ b/apps/ai-studio/src/components/execution/highlighting.tsx @@ -33,7 +33,7 @@ export function ExecutionHighlighting() { const nonIdleNodes = new Set(); for (const [nodeId, state] of Object.entries(nodeStates) as [string, NodeExecutionState][]) { - if (state.status !== 'idle') { + if (state.status !== 'idle' && state.status !== 'skipped') { nonIdleNodes.add(nodeId); } diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index ec687cd99..22b063107 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -89,8 +89,13 @@ color: var(--ai-studio-status-color--completed); } -.badge--node_started { - color: var(--ax-txt-secondary, #888); +.badge--node_started, +.badge--node_skipped { + color: var(--ax-txt-secondary-default, #888); +} + +.badge--execution_incomplete { + color: var(--ai-studio-status-color--incomplete); } .badge--node_failed, @@ -99,7 +104,7 @@ } .badge--execution_cancelled { - color: var(--ax-txt-secondary, #888); + color: var(--ax-txt-secondary-default, #888); } .node-id { @@ -108,6 +113,15 @@ opacity: 0.5; } +.reason { + font-size: 0.65rem; + font-style: italic; + opacity: 0.55; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .time { margin-left: auto; font-size: 0.65rem; @@ -149,6 +163,11 @@ background: var(--ai-studio-status-bg--completed); } +.status--incomplete { + color: var(--ai-studio-status-color--incomplete); + background: var(--ai-studio-status-bg--incomplete); +} + .status--failed { color: var(--ai-studio-status-color--failed); background: var(--ai-studio-status-bg--failed); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 31bf79880..37682d779 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -2,7 +2,7 @@ import { useSingleSelectedElement } from '@workflowbuilder/sdk'; import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; -import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events'; +import type { ExecutionEvent, NodeSkipReason } from '@workflow-builder/types/workflow-execution/execution-events'; import styles from './log-panel.module.css'; @@ -10,6 +10,12 @@ import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor'; import { toggleLog, useExecutionStore } from '../../stores/use-execution-store'; import { extractOutputText } from '../../utils/extract-output-text'; +const SKIP_REASON_LABEL: Record = { + branch_not_taken: 'branch not taken', + upstream_skipped: 'upstream skipped', + error_route_not_taken: 'error branch not taken', +}; + const DETAIL_PREVIEW_CHARS = 120; const NODE_ID_PREVIEW_CHARS = 8; const AT_BOTTOM_TOLERANCE_PX = 4; @@ -25,6 +31,7 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo const isNode = typeof nodeId === 'string' && nodeId.length > 0; const isHighlighted = isNode && nodeId === selectedNodeId; const label = event.type.replaceAll('_', ' '); + const skipReason = event.type === 'node_skipped' ? SKIP_REASON_LABEL[event.payload.reason] : undefined; let detail: string | undefined; switch (event.type) { @@ -43,6 +50,13 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo break; } + case 'execution_incomplete': { + detail = event.payload.deadEnds + .map(({ nodeId, port }) => `${nodeId} routed to "${port}" — nothing connected to that handle`) + .join('\n'); + + break; + } // No default } @@ -71,6 +85,7 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo

{label} {isNode && {nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}} + {skipReason && {skipReason}} {formatTime(event.timestamp)} {hasDetail && {isExpanded ? '▲' : '▼'}}
diff --git a/apps/ai-studio/src/components/execution/node-markers.module.css b/apps/ai-studio/src/components/execution/node-markers.module.css index 158f88ef2..2d2711449 100644 --- a/apps/ai-studio/src/components/execution/node-markers.module.css +++ b/apps/ai-studio/src/components/execution/node-markers.module.css @@ -41,6 +41,11 @@ color: var(--ai-studio-status-color--failed); } +.icon--skipped { + color: var(--ax-txt-secondary-default, #888); + opacity: 0.7; +} + .icon--running { color: var(--ai-studio-status-color--completed); animation: spin 1.6s linear infinite; diff --git a/apps/ai-studio/src/components/execution/node-markers.tsx b/apps/ai-studio/src/components/execution/node-markers.tsx index d5a130ce4..d760aa9b6 100644 --- a/apps/ai-studio/src/components/execution/node-markers.tsx +++ b/apps/ai-studio/src/components/execution/node-markers.tsx @@ -17,7 +17,8 @@ export function ExecutionNodeMarkers({ props }: Props) { if (!nodeState || nodeState.status === 'idle') return null; - const isClickable = nodeState.status === 'completed' || nodeState.status === 'failed'; + const isClickable = + nodeState.status === 'completed' || nodeState.status === 'failed' || nodeState.status === 'skipped'; // The click also selects the node on the canvas, which drives the log highlight. return ( @@ -40,6 +41,11 @@ export function ExecutionNodeMarkers({ props }: Props) { )} + {nodeState.status === 'skipped' && ( + + + + )}
); } diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index 96b909bfd..0eb055280 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -23,7 +23,7 @@ type Props = { type VisualizeMode = VisualizeRenderer | 'auto'; const VALID_MODES = new Set(VISUALIZE_MODES); -function EmptyState({ running }: { running: boolean }) { +function EmptyState({ running, skipped }: { running: boolean; skipped: boolean }) { if (running) { return (
@@ -36,6 +36,14 @@ function EmptyState({ running }: { running: boolean }) {
); } + if (skipped) { + return ( +
+ +

This node was skipped — its branch was not taken during the last run.

+
+ ); + } return (
@@ -124,7 +132,7 @@ export function VisualizeCard({ props }: Props) {
) : ( - + )} {isExpanded && ( { if (!executionId) return; - disconnectRef.current?.(); - + // The stream stays open on purpose. Cancelling is asynchronous: the backend only + // asks Temporal to cancel, and the worker emits `execution_cancelled` a moment + // later. That event is what moves the UI to 'cancelled', and the adapter closes + // the EventSource once it sees it. Disconnecting here dropped the event, so the + // panel stayed in 'running' forever with no terminal log line. await fetch(`${BACKEND_URL}/api/executions/${executionId}`, { method: 'DELETE', }); diff --git a/apps/ai-studio/src/hooks/use-has-node-type-in-diagram.ts b/apps/ai-studio/src/hooks/use-has-start-node.ts similarity index 58% rename from apps/ai-studio/src/hooks/use-has-node-type-in-diagram.ts rename to apps/ai-studio/src/hooks/use-has-start-node.ts index 9c658e8c7..73586398d 100644 --- a/apps/ai-studio/src/hooks/use-has-node-type-in-diagram.ts +++ b/apps/ai-studio/src/hooks/use-has-start-node.ts @@ -1,5 +1,5 @@ import { useStore } from '@workflowbuilder/sdk'; -export function useHasNodeTypeInDiagram(nodeType: string) { - return useStore((store) => store.nodes.some((node) => node.data.type === nodeType)); +export function useHasStartNode() { + return useStore((store) => store.nodes.some((node) => node.data.isStartNode === true)); } diff --git a/apps/ai-studio/src/nodes/trigger/index.ts b/apps/ai-studio/src/nodes/trigger/index.ts index 117f0b7d5..de7f3d5b2 100644 --- a/apps/ai-studio/src/nodes/trigger/index.ts +++ b/apps/ai-studio/src/nodes/trigger/index.ts @@ -10,6 +10,7 @@ export const triggerPaletteItem: PaletteItem = { type: 'ai-studio/trigger', icon: 'Lightning', templateType: NodeType.StartNode, + isStartNode: true, defaultPropertiesData, schema, uischema, diff --git a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx index c12f452fc..7472ffbca 100644 --- a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx +++ b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx @@ -1,5 +1,5 @@ -import { NavButton } from '@synergycodes/overflow-ui'; import { Icon, useStore } from '@workflowbuilder/sdk'; +import { NavButton } from '@workflowbuilder/ui'; import { useTranslation } from 'react-i18next'; import { redo, undo, useUndoRedoStore } from '../../stores/use-undo-redo-store'; diff --git a/apps/ai-studio/src/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts index effb405b6..aaa571136 100644 --- a/apps/ai-studio/src/stores/use-execution-store.ts +++ b/apps/ai-studio/src/stores/use-execution-store.ts @@ -7,7 +7,7 @@ import type { ExecutionStatus, } from '@workflow-builder/types/workflow-execution/execution-events'; -type NodeExecutionStatus = 'idle' | 'running' | 'completed' | 'failed'; +type NodeExecutionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'skipped'; export type NodeExecutionState = { status: NodeExecutionStatus; @@ -107,6 +107,10 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record { - const shouldUseLocalOverflowUI = process.env.LOCAL_OVERFLOW_UI === 'true'; - const sdkDirectory = path.resolve(import.meta.dirname, '../../packages/sdk'); return { plugins: [svgr(), react()], resolve: { alias: [ - ...(shouldUseLocalOverflowUI - ? Object.entries(getLocalOverflowUIAliases()).map(([find, replacement]) => ({ - find, - replacement, - })) - : []), { find: /^@workflowbuilder\/sdk\/style\.css$/, replacement: path.resolve(sdkDirectory, 'src/index.css'), @@ -27,14 +19,6 @@ export default defineConfig(() => { find: /^@workflowbuilder\/sdk$/, replacement: path.resolve(sdkDirectory, 'src/index.ts'), }, - // overflow-ui doesn't expose ./dist/index.css via package.json exports - { - find: 'overflow-ui-css', - replacement: path.resolve( - sdkDirectory, - 'node_modules/@synergycodes/overflow-ui/dist/index.css', - ), - }, // SDK source files use @/ to refer to their own root (packages/sdk/src/...). // AI Studio files do not use @/ (they import via @workflowbuilder/sdk subpaths), // so it's safe to point @/ at the SDK root for cross-package alias parity. @@ -56,12 +40,3 @@ export default defineConfig(() => { }, }; }); - -function getLocalOverflowUIAliases(): Record { - const distribution = path.resolve(import.meta.dirname, '../../../overflow-ui/packages/ui/dist'); - - return { - '@synergycodes/overflow-ui/tokens.css': path.join(distribution, 'tokens.css'), - '@synergycodes/overflow-ui': path.join(distribution, 'overflow-ui.js'), - }; -} diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d639840f1..ab1034d78 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,5 +1,26 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 +# Must match the worker's namespace. Leave as `default` for the bundled dev cluster. +TEMPORAL_NAMESPACE=default +# Connection security. All optional, and all default to a plaintext connection — +# which is what the bundled dev cluster expects. +# +# TEMPORAL_TLS: leave empty to infer (setting any credential below turns TLS on), +# `true` to require TLS with the OS trust store, `false` to assert plaintext. +TEMPORAL_TLS= +# API key auth, as used by Temporal Cloud. Implies TLS. +TEMPORAL_API_KEY= +# Paths to PEM files, read when the connection opens. CA for a private issuer; +# the cert/key pair for mTLS (set both or neither, and not alongside an API key). +TEMPORAL_TLS_CA_PATH= +TEMPORAL_TLS_CERT_PATH= +TEMPORAL_TLS_KEY_PATH= +# +# Temporal Cloud looks like this: +# TEMPORAL_ADDRESS=..tmprl.cloud:7233 +# TEMPORAL_NAMESPACE=. +# TEMPORAL_API_KEY= + PORT=3001 # Hostname to bind. Default 127.0.0.1 (loopback only - single-tenant local dev). # Change ONLY if you understand: this server has no auth, anyone reachable on @@ -15,8 +36,14 @@ WB_AUTH_PORT=allow-all # verification (local dev). When set, POST /api/workflows/:id/execute requires a # valid Turnstile token sent by the frontend as the cf-turnstile-token header. TURNSTILE_SECRET_KEY= -# OpenRouter key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). -# Optional: leave empty to disable AI adapt (the endpoint returns 501). The -# execution worker keeps its own key for running workflows. -OPENROUTER_API_KEY= +# API key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt). +# Optional: leave empty to disable AI adapt (the endpoint returns 501). The three +# AI_* variables are all-or-nothing (see packages/ai-config/README.md). The +# execution worker reads its own copy of them. OpenRouter keys look like sk-or-v1-... +AI_API_KEY= +# Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own +# network. Must be the base URL, without a trailing /chat/completions. +# Pre-filled with OpenRouter's URL; there is no built-in default. +AI_BASE_URL=https://openrouter.ai/api/v1 +# Model id as the endpoint above understands it. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/backend/README.md b/apps/backend/README.md index 018180252..936bbdadf 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -7,7 +7,7 @@ > **Note:** setup is in [root README "Path C. Run the full stack demo"](../../README.md#path-c-run-the-full-stack-demo). This file documents the backend's internals, not how to start it. -Backend execution layer for Workflow Builder AI Studio plugin. Runs AI workflows defined on the canvas via Temporal + OpenRouter. +Backend execution layer for Workflow Builder AI Studio plugin. Runs AI workflows defined on the canvas via Temporal and an OpenAI-compatible LLM endpoint (`AI_BASE_URL`). ## Architecture @@ -19,12 +19,12 @@ Frontend (React) ▼ │ Backend (Hono) ──▶ WorkflowEnginePort ──▶ Temporal ──▶ Worker ──┼── emit event → Postgres ▲ │ │ │ - │ └─ impl: TemporalEngine │ └── update status → Postgres + │ └─ impl: TemporalWorkflowEngine │ └── update status → Postgres └── SSE stream (Postgres LISTEN/NOTIFY) ◀────────────┘ ``` - **Backend** (`apps/backend`) — Hono HTTP server, workflow CRUD, SSE streaming via Postgres LISTEN/NOTIFY. Submits executions through `WorkflowEnginePort`. -- **Engine adapter** (`apps/backend/src/engine/temporal-engine.ts`) — implements `WorkflowEnginePort` against Temporal. Swap this file to switch engines. +- **Engine adapter** (`apps/backend/src/engine/index.ts`) — wires `TemporalWorkflowEngine` from [`@workflowbuilder/temporal/client`](../../packages/temporal/README.md), which implements `WorkflowEnginePort` against Temporal. Swap what this file constructs to switch engines. - **Worker** (`apps/execution-worker`) — Temporal worker. Activities delegate node execution to `execution-core`. See the [worker README](../execution-worker/README.md). - **Domain** (`packages/execution-core`) — pure graph runner + ports + node executors. No Temporal, no HTTP. See the [execution-core README](../../packages/execution-core/README.md). - **Frontend** (`apps/ai-studio`) — full AI workflow product. Composes `@workflowbuilder/sdk` directly via JSX, with a slim plugin only for per-node execution markers. Owns Play/Stop controls, log panel, node detail, and execution highlighting. @@ -56,7 +56,36 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 ``` -Worker additionally needs `OPENROUTER_API_KEY` and optionally `AI_MODEL`. See [`apps/execution-worker/README.md`](../execution-worker/README.md). +Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all three or none, through +[`@workflow-builder/ai-config`](../../packages/ai-config/README.md), which is the canonical description +of that contract. Each side degrades on its own when they are missing: the backend's AI adapt endpoint +returns 501, and the worker runs everything except AI Agent nodes. See +[`apps/execution-worker/README.md`](../execution-worker/README.md). + +### Connecting to a secured Temporal cluster + +The defaults above open a plaintext connection to the bundled dev cluster. Everything about the +connection is env-driven, so a hardened cluster or Temporal Cloud needs no code change. The +variables are read and validated by [`@workflow-builder/temporal-connection`](../../packages/temporal-connection/README.md), +the same code the worker uses: + +| Var | Purpose | Default | +| ------------------------ | ------------------------------------------------------------ | ------------- | +| `TEMPORAL_NAMESPACE` | Namespace to use. Must match the worker's | `default` | +| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty (infer) | +| `TEMPORAL_API_KEY` | API key auth (Temporal Cloud). Implies TLS | — | +| `TEMPORAL_TLS_CA_PATH` | PEM for a private certificate authority | — | +| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set with the key | — | +| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set with the certificate | — | + +Any credential turns TLS on by itself, so `TEMPORAL_TLS` only has to be set to force TLS with no +credentials, or to assert plaintext. Contradictory combinations — half an mTLS pair, an API key +together with a client certificate, or credentials alongside `TEMPORAL_TLS=false` — are rejected +with an explanatory error at startup, rather than being silently ignored. The connection itself is +opened on the first run, so booting does not require Temporal to be reachable. + +For Temporal Cloud, set `TEMPORAL_ADDRESS` to `..tmprl.cloud:7233`, +`TEMPORAL_NAMESPACE` to `.`, and `TEMPORAL_API_KEY` to your key. ## Scripts @@ -79,7 +108,6 @@ All scripts run from the monorepo root. Grouped by purpose: | `dev:backend` | Backend only (Hono server with `tsx watch`) | | `dev:worker` | Execution worker only (Temporal worker with `tsx watch`) | | `dev:docs` | Docs site (Astro) | -| `dev:local` | Demo frontend with `LOCAL_OVERFLOW_UI=true` (linked local overflow-ui) | ### Infra (Docker lifecycle) diff --git a/apps/backend/package.json b/apps/backend/package.json index 045a7d4db..3fbcf3545 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,16 +17,20 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@ai-sdk/openai-compatible": "catalog:", "@hono/node-server": "^1.14.0", - "@openrouter/ai-sdk-provider": "^2.8.0", - "@temporalio/client": "^1.11.0", + "@temporalio/client": "catalog:", + "@workflow-builder/ai-config": "workspace:*", "@workflow-builder/execution-core": "workspace:*", + "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", - "ai": "^6.0.168", + "@workflowbuilder/temporal": "workspace:*", + "ai": "catalog:", "dotenv": "^17.4.2", "drizzle-orm": "^0.44.0", "hono": "^4.7.0", "postgres": "^3.4.5", + "remeda": "^2.19.2", "tsx": "^4.19.3", "zod": "^4.3.6" }, diff --git a/apps/backend/src/domain/mapper/from-integration-data.ts b/apps/backend/src/domain/mapper/from-integration-data.ts index a289647e5..803ff5de5 100644 --- a/apps/backend/src/domain/mapper/from-integration-data.ts +++ b/apps/backend/src/domain/mapper/from-integration-data.ts @@ -1,7 +1,10 @@ +import { isDefined, pickBy } from 'remeda'; + import { type BaseNode, NODE_ERROR_POLICIES, type NodeErrorPolicy, + type NodeRole, type WorkflowDefinition, type WorkflowEdgeDefinition, } from '@workflow-builder/types/workflow-execution/execution-model'; @@ -25,21 +28,26 @@ export function mapToExecutionModel(workflowId: string, data: WorkflowSnapshot): return { workflowId, nodes, edges }; } -// `errorPolicy` is authored in the UI as a regular JSONForms property -// (via `sharedProperties` in the SDK), so it arrives nested in -// `data.properties`. The runner expects it at the top level of `BaseNode`, -// so we lift it here and keep `config` free of runner-only fields. +// Lifts the three fields an engine reads out of `data.properties`, where the SDK's +// `sharedProperties` put them. `role` comes from `data.isStartNode` instead, which sits +// beside the properties. `description` stays in `config`: no engine reads it. function mapNode(node: FrontendNode): BaseNode { - const { errorPolicy: rawErrorPolicy, ...config } = node.data.properties ?? {}; + const { errorPolicy: rawErrorPolicy, label: rawLabel, ...config } = node.data.properties ?? {}; const errorPolicy = isErrorPolicy(rawErrorPolicy) ? rawErrorPolicy : undefined; + const label = isNonEmptyString(rawLabel) ? rawLabel.trim() : undefined; + const role: NodeRole | undefined = node.data.isStartNode === true ? 'start' : undefined; return { id: node.id, type: node.data.type, config, - ...(errorPolicy === undefined ? {} : { errorPolicy }), + ...pickBy({ label, errorPolicy, role }, isDefined), }; } +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + function isErrorPolicy(value: unknown): value is NodeErrorPolicy { return typeof value === 'string' && ERROR_POLICIES.has(value as NodeErrorPolicy); } diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index 1e890f2ac..e0841f3c5 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -16,6 +16,26 @@ describe('workflowSnapshotSchema', () => { expect(workflowSnapshotSchema.safeParse(snapshot).success).toBe(true); }); + it('keeps `data.isStartNode` so entrypoints stay identifiable', () => { + const snapshot = { + nodes: [{ id: 'n1', data: { type: 'my-product/trigger', isStartNode: true } }], + edges: [], + }; + + const result = workflowSnapshotSchema.parse(snapshot); + expect(result.nodes[0]!.data.isStartNode).toBe(true); + }); + + it("strips the editor's node kind — it is a rendering detail, not an entrypoint marker", () => { + const snapshot = { + nodes: [{ id: 'n1', type: 'start-node', data: { type: 'my-product/trigger' } }], + edges: [], + }; + + const result = workflowSnapshotSchema.parse(snapshot); + expect(result.nodes[0]).not.toHaveProperty('type'); + }); + it('rejects a node missing `id`', () => { const snapshot = { nodes: [{ data: { type: 'x/y' } }], @@ -96,14 +116,14 @@ describe('workflowSnapshotSchema', () => { }); describe('mapToExecutionModel', () => { - it('maps `data.properties` to `config` verbatim — no per-type extraction', () => { + it('copies every property the runner does not lift into `config`', () => { const snapshot = workflowSnapshotSchema.parse({ nodes: [ { id: 'n1', data: { type: 'product/foo', - properties: { foo: 1, bar: 'two', extra: { ui: 'metadata' } }, + properties: { foo: 1, bar: 'two', extra: { ui: 'metadata' }, label: 'Lifted', errorPolicy: 'continue' }, }, }, ], @@ -117,6 +137,8 @@ describe('mapToExecutionModel', () => { id: 'n1', type: 'product/foo', config: { foo: 1, bar: 'two', extra: { ui: 'metadata' } }, + label: 'Lifted', + errorPolicy: 'continue', }, ]); }); @@ -151,6 +173,117 @@ describe('mapToExecutionModel', () => { ]); }); + it("lifts `data.isStartNode` to role 'start'", () => { + const result = mapToExecutionModel('wf-1', { + nodes: [ + // Any node type can be an entrypoint — the flag is what counts, not the + // product type or the visual template it renders with. + { id: 'n1', data: { type: 'my-product/action', isStartNode: true } }, + { id: 'n2', data: { type: 'my-product/action', isStartNode: false } }, + { id: 'n3', data: { type: 'my-product/action' } }, + ], + edges: [], + }); + + // Only the flagged node carries a role; the runner reads it to pick the + // entrypoint instead of inferring one from in-degree. + expect(result.nodes[0]!.role).toBe('start'); + expect(result.nodes[1]).not.toHaveProperty('role'); + expect(result.nodes[2]).not.toHaveProperty('role'); + }); + + it('gives a legacy start-node kind no role — the flag is the only marker', () => { + // Diagrams saved before the flag existed marked their entrypoint with the + // editor's node kind alone. Those no longer resolve, and the runner rejects + // them with "Workflow has no start node" until the node is re-created. + const snapshot = workflowSnapshotSchema.parse({ + nodes: [{ id: 'n1', type: 'start-node', data: { type: 'my-product/trigger' } }], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]).not.toHaveProperty('role'); + }); + + it('keeps the entrypoint flag out of `config`', () => { + const snapshot = workflowSnapshotSchema.parse({ + nodes: [{ id: 'n1', data: { type: 'my-product/trigger', isStartNode: true, properties: { foo: 1 } } }], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]!.config).toEqual({ foo: 1 }); + }); + + it('lifts the authored label out of `config`', () => { + const snapshot = workflowSnapshotSchema.parse({ + nodes: [ + { + id: 'n1', + data: { type: 'my-product/action', properties: { label: 'Fetch order', description: 'why', foo: 1 } }, + }, + ], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]!.label).toBe('Fetch order'); + expect(result.nodes[0]!.config).toEqual({ description: 'why', foo: 1 }); + }); + + it('trims the label and drops a blank one', () => { + // An empty Summary renders as nothing; no Summary falls back to the activity type. + const snapshot = workflowSnapshotSchema.parse({ + nodes: [ + { id: 'n1', data: { type: 'my-product/action', properties: { label: ' Approve ' } } }, + { id: 'n2', data: { type: 'my-product/action', properties: { label: ' ' } } }, + { id: 'n3', data: { type: 'my-product/action', properties: { label: '' } } }, + { id: 'n4', data: { type: 'my-product/action', properties: {} } }, + ], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]!.label).toBe('Approve'); + expect(result.nodes[1]).not.toHaveProperty('label'); + expect(result.nodes[2]).not.toHaveProperty('label'); + expect(result.nodes[3]).not.toHaveProperty('label'); + }); + + it('ignores a non-string label rather than forwarding it', () => { + // `properties` is `Record` at the boundary. + const snapshot = workflowSnapshotSchema.parse({ + nodes: [{ id: 'n1', data: { type: 'my-product/action', properties: { label: { nested: true } } } }], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]).not.toHaveProperty('label'); + }); + + it('drops an errorPolicy the runner does not know instead of leaving it in `config`', () => { + // Destructured out of `properties` before it is validated, so an unrecognised + // value vanishes rather than reaching an executor as ordinary config. + const snapshot = workflowSnapshotSchema.parse({ + nodes: [ + { id: 'n1', data: { type: 'my-product/action', properties: { errorPolicy: 'retryForever', foo: 1 } } }, + { id: 'n2', data: { type: 'my-product/action', properties: { foo: 1 } } }, + ], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]).not.toHaveProperty('errorPolicy'); + expect(result.nodes[1]).not.toHaveProperty('errorPolicy'); + expect(result.nodes[0]!.config).toEqual({ foo: 1 }); + }); + it('passes unknown node types through unchanged — backend does not know any vocabulary', () => { // The whole point of the structural mapper: a type the backend has never // heard of reaches the worker, where the registry-miss becomes a diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index f3c428c25..a7acf610b 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -10,6 +10,11 @@ const frontendNodeSchema = z.object({ id: z.string(), data: z.object({ type: z.string(), + // The editor's entrypoint flag. Declared here because zod strips whatever + // it is not told about, and the runner needs it to pick the node a run + // starts from. The editor's node kind (`start-node`, `node`, ...) is a + // rendering detail and deliberately not read here. + isStartNode: z.boolean().optional(), properties: z.record(z.string(), z.unknown()).optional(), }), }); diff --git a/apps/backend/src/engine/index.test.ts b/apps/backend/src/engine/index.test.ts new file mode 100644 index 000000000..69b280f28 --- /dev/null +++ b/apps/backend/src/engine/index.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The engine module reads TEMPORAL_* when it is imported, so each case needs a fresh +// module and an environment free of whatever the runner's shell carries. +const TEMPORAL_NAMES = [ + 'TEMPORAL_ADDRESS', + 'TEMPORAL_NAMESPACE', + 'TEMPORAL_TLS', + 'TEMPORAL_API_KEY', + 'TEMPORAL_TLS_CA_PATH', + 'TEMPORAL_TLS_CERT_PATH', + 'TEMPORAL_TLS_KEY_PATH', +]; + +async function loadEngine(values: Record = {}) { + vi.resetModules(); + for (const name of TEMPORAL_NAMES) { + // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable + // eslint-disable-next-line unicorn/no-useless-undefined + vi.stubEnv(name, undefined); + } + for (const [name, value] of Object.entries(values)) { + vi.stubEnv(name, value); + } + return import('./index'); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getWorkflowEngine', () => { + it('rejects a contradictory TEMPORAL_* combination at import, not on the first submit', async () => { + await expect(loadEngine({ TEMPORAL_TLS_CERT_PATH: '/tls/client.pem' })).rejects.toThrow(/TEMPORAL_TLS_CERT_PATH/); + }); + + it('builds the engine without reaching Temporal, so the backend boots while the cluster is down', async () => { + const { getWorkflowEngine } = await loadEngine({ TEMPORAL_ADDRESS: '203.0.113.1:7233' }); + + expect(getWorkflowEngine()).toBe(getWorkflowEngine()); + }); +}); diff --git a/apps/backend/src/engine/index.ts b/apps/backend/src/engine/index.ts index 1b9430fed..87bf268ac 100644 --- a/apps/backend/src/engine/index.ts +++ b/apps/backend/src/engine/index.ts @@ -1,13 +1,27 @@ +import { Client, Connection } from '@temporalio/client'; +import { TemporalWorkflowEngine } from '@workflowbuilder/temporal/client'; + import type { WorkflowEnginePort } from '@workflow-builder/execution-core/workflow'; +import { temporalConfig } from '@workflow-builder/temporal-connection'; import type { BaseNode } from '@workflow-builder/types/workflow-execution/execution-model'; -import { TemporalEngine } from './temporal-engine'; +// Read here rather than inside the factory below, so a contradictory combination or +// an unreadable certificate stops the backend at boot, as it stops the worker. +// Neither parsing nor reading the PEM files needs Temporal to be reachable. +const temporal = temporalConfig(); let engine: WorkflowEnginePort | undefined; export function getWorkflowEngine(): WorkflowEnginePort { if (!engine) { - engine = new TemporalEngine(); + engine = new TemporalWorkflowEngine({ + // A factory rather than a ready client: the connection is opened on the first + // submit, so booting the backend does not require Temporal to be reachable. + client: async () => { + const connection = await Connection.connect(temporal.connection); + return new Client({ connection, namespace: temporal.namespace }); + }, + }); } return engine; } diff --git a/apps/backend/src/engine/temporal-engine.ts b/apps/backend/src/engine/temporal-engine.ts deleted file mode 100644 index e6ecad4b7..000000000 --- a/apps/backend/src/engine/temporal-engine.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Client, Connection, WorkflowNotFoundError } from '@temporalio/client'; - -import type { WorkflowEnginePort, WorkflowExecutionInput } from '@workflow-builder/execution-core/workflow'; -import type { BaseNode } from '@workflow-builder/types/workflow-execution/execution-model'; - -import { env } from '../env'; - -const TASK_QUEUE = 'workflow-execution'; - -export class TemporalEngine implements WorkflowEnginePort { - private clientPromise: Promise | undefined; - - async submit(input: WorkflowExecutionInput): Promise { - const client = await this.client(); - await client.workflow.start('runWorkflow', { - taskQueue: TASK_QUEUE, - workflowId: `execution-${input.executionId}`, - args: [input], - }); - } - - async cancel(executionId: string): Promise { - const client = await this.client(); - const handle = client.workflow.getHandle(`execution-${executionId}`); - try { - await handle.cancel(); - } catch (error) { - // Cancel is idempotent from the caller's perspective — "not found" means already gone - if (error instanceof WorkflowNotFoundError) return; - throw error; - } - } - - private client(): Promise { - if (!this.clientPromise) { - this.clientPromise = Connection.connect({ address: env.TEMPORAL_ADDRESS }).then( - (connection) => new Client({ connection }), - ); - } - return this.clientPromise; - } -} diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts new file mode 100644 index 000000000..8b0272b45 --- /dev/null +++ b/apps/backend/src/env.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { env as shape } from './env'; + +// The keys of `env` are the variable names, so a variable added to env.ts is +// cleared here without anyone remembering to list it. +const ENV_NAMES = Object.keys(shape); + +// env.ts reads process.env once at module load, so every case needs a fresh module +// and a clean environment: whatever the runner's shell carries is unset first. +async function loadEnv(values: Record) { + vi.resetModules(); + for (const name of ENV_NAMES) { + // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable + // eslint-disable-next-line unicorn/no-useless-undefined + vi.stubEnv(name, undefined); + } + for (const [name, value] of Object.entries(values)) { + vi.stubEnv(name, value); + } + const module = await import('./env'); + return module.env; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('loadEnv', () => { + it('ignores variables inherited from the runner', async () => { + vi.stubEnv('TURNSTILE_SECRET_KEY', 'ambient-secret'); + + const env = await loadEnv({}); + + expect(env.TURNSTILE_SECRET_KEY).toBeNull(); + }); +}); + +describe('local defaults', () => { + // Not `localhost`: on some Windows / Node configs it resolves to ::1 first, which the + // IPv4-only docker mapping rejects. See local-dev-binding.decision-log.md. + it.each(['HOST', 'DATABASE_URL'] as const)('spells the loopback address of %s as 127.0.0.1', async (name) => { + const env = await loadEnv({}); + + expect(env[name]).toContain('127.0.0.1'); + }); + + it('serves port 3001', async () => { + const env = await loadEnv({}); + + expect(env.PORT).toBe(3001); + }); + + it('reads a port that is set', async () => { + const env = await loadEnv({ PORT: '8080' }); + + expect(env.PORT).toBe(8080); + }); + + it('reads a database url that is set', async () => { + const url = 'postgresql://wb:wb@app-db:5432/workflow_builder'; + const env = await loadEnv({ DATABASE_URL: url }); + + expect(env.DATABASE_URL).toBe(url); + }); +}); + +describe('TRUST_PROXY', () => { + // Decides whether X-Forwarded-For is believed, so only the exact string opts in: + // anything else must leave the rate limiter keying on the socket address. + it.each(['true'])('trusts the proxy on %s', async (value) => { + const env = await loadEnv({ TRUST_PROXY: value }); + + expect(env.TRUST_PROXY).toBe(true); + }); + + it.each(['TRUE', 'True', '1', 'yes', ''])('does not trust the proxy on %s', async (value) => { + const env = await loadEnv({ TRUST_PROXY: value }); + + expect(env.TRUST_PROXY).toBe(false); + }); + + it('does not trust the proxy when unset', async () => { + const env = await loadEnv({}); + + expect(env.TRUST_PROXY).toBe(false); + }); +}); + +describe('execute rate limits', () => { + // server.ts mounts the limiter only when one of them is above zero, so the default + // has to be the number 0 rather than NaN — `Number('')` and `Number(undefined)` differ. + it.each(['RATE_LIMIT_EXECUTE_PER_MINUTE', 'RATE_LIMIT_EXECUTE_PER_DAY'] as const)( + 'leaves %s disabled by default', + async (name) => { + const env = await loadEnv({}); + + expect(env[name]).toBe(0); + }, + ); + + it('reads both limits when they are set', async () => { + const env = await loadEnv({ RATE_LIMIT_EXECUTE_PER_MINUTE: '10', RATE_LIMIT_EXECUTE_PER_DAY: '50' }); + + expect(env).toMatchObject({ RATE_LIMIT_EXECUTE_PER_MINUTE: 10, RATE_LIMIT_EXECUTE_PER_DAY: 50 }); + }); +}); + +describe('TURNSTILE_SECRET_KEY', () => { + it('reads a secret that is set', async () => { + const env = await loadEnv({ TURNSTILE_SECRET_KEY: 'secret' }); + + expect(env.TURNSTILE_SECRET_KEY).toBe('secret'); + }); +}); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 278e18aa8..b36d8f5ce 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -11,14 +11,12 @@ export const env = { PORT: Number(envOr('PORT', '3001')), HOST: envOr('HOST', '127.0.0.1'), DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'), - TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), + // TEMPORAL_*: read at connect time by @workflow-builder/temporal-connection. + // AI_API_KEY / AI_BASE_URL / AI_MODEL: read per request by @workflow-builder/ai-config. // 0 disables (dev default); the deploy compose sets both RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')), RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')), TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true', // Null = Turnstile verification disabled (local dev runs unprotected). TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null, - // Null = the "AI adapt" endpoint is disabled (returns 501). The worker keeps its own key. - OPENROUTER_API_KEY: process.env['OPENROUTER_API_KEY'] ?? null, - AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), }; diff --git a/apps/backend/src/events/drain-events.test.ts b/apps/backend/src/events/drain-events.test.ts index b365c7fd7..770e44390 100644 --- a/apps/backend/src/events/drain-events.test.ts +++ b/apps/backend/src/events/drain-events.test.ts @@ -78,6 +78,15 @@ describe('drainEventsSince', () => { expect(result.lastSequence).toBe(2); }); + it('flags terminal when the last fetched event is execution_incomplete', async () => { + const fetch = fixedFetcher([makeEvent('exec-1', 1), makeEvent('exec-1', 2, 'execution_incomplete')]); + + const result = await drainEventsSince('exec-1', 0, fetch, async () => {}); + + expect(result.reachedTerminal).toBe(true); + expect(result.lastSequence).toBe(2); + }); + it('write failure stops the drain and pins the cursor at the last successful write', async () => { const fetch = fixedFetcher([makeEvent('exec-1', 1), makeEvent('exec-1', 2), makeEvent('exec-1', 3)]); let calls = 0; diff --git a/apps/backend/src/events/drain-events.ts b/apps/backend/src/events/drain-events.ts index c7e16f558..eb1f4a1dd 100644 --- a/apps/backend/src/events/drain-events.ts +++ b/apps/backend/src/events/drain-events.ts @@ -1,9 +1,12 @@ +import { TERMINAL_EXECUTION_EVENT_TYPES } from '@workflow-builder/types/workflow-execution/execution-events'; + import type { ExecutionEventRow } from './fetch-events-after'; export type EventFetcher = (executionId: string, afterSequence: number) => Promise; type EventWriter = (event: ExecutionEventRow) => Promise; -const TERMINAL_EVENT_TYPES = new Set(['execution_completed', 'execution_failed', 'execution_cancelled']); +// Set because ExecutionEventRow.type is a plain string from $inferSelect. +const TERMINAL_EVENT_TYPES = new Set(TERMINAL_EXECUTION_EVENT_TYPES); export type DrainResult = { lastSequence: number; @@ -15,6 +18,11 @@ export type DrainResult = { writeFailed: boolean; }; +// Requires rows to become visible in ascending `sequence` order: the cursor only moves +// forward, so a row committing below it is never selected again and its event is lost. +// The worker upholds that by serializing its event writes — see +// `packages/temporal/src/workflow/sequenced-event-emitter.ts`. +// Gaps in the numbering are fine; only reordering is not. See drain-order.test.ts. export async function drainEventsSince( executionId: string, afterSequence: number, diff --git a/apps/backend/src/events/drain-order.test.ts b/apps/backend/src/events/drain-order.test.ts new file mode 100644 index 000000000..6031359e5 --- /dev/null +++ b/apps/backend/src/events/drain-order.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { drainEventsSince } from './drain-events'; +import type { ExecutionEventRow } from './fetch-events-after'; +import { createSerializedDrainer } from './serialized-drainer'; + +// The cursor contract, from the reading side. +// +// The drain advances a monotonic cursor and queries `sequence > cursor`, so it can +// only deliver events that become visible in Postgres in ascending `sequence` order. +// These tests state that requirement as executable documentation: the middle one +// asserts the loss, not because losing events is desirable, but so that anyone who +// makes the writer emit concurrently again can see what it costs. The writer upholds +// the ordering in `sequenced-event-emitter.ts`. +// +// Rows are made visible one at a time via `visible`, modelling per-row commit moments; +// the fetcher sees only visible rows, exactly as the real query would. + +const EXECUTION = 'exec-1'; + +function row(sequence: number, type: string, nodeId?: string): ExecutionEventRow { + const stamp = new Date(`2026-05-19T00:00:${String(sequence).padStart(2, '0')}Z`); + return { + id: `e-${sequence}`, + executionId: EXECUTION, + sequence, + timestamp: stamp, + type, + nodeId: nodeId ?? null, + pathId: null, + payloadJson: null, + tenantId: null, + createdAt: stamp, + }; +} + +function makeTable() { + const visible: ExecutionEventRow[] = []; + const delivered: number[] = []; + + const fetch = (executionId: string, afterSequence: number) => + Promise.resolve( + visible + .filter((r) => r.executionId === executionId && r.sequence > afterSequence) + .sort((a, b) => a.sequence - b.sequence), + ); + + const write = async (event: ExecutionEventRow) => { + delivered.push(event.sequence); + }; + + // Commit a row and wake the drainer, as the worker's INSERT + pg_notify pair does. + return { visible, delivered, fetch, write }; +} + +function drainerOver(table: ReturnType, initialCursor: number) { + return createSerializedDrainer(initialCursor, (cursor) => + drainEventsSince(EXECUTION, cursor, table.fetch, table.write), + ); +} + +describe('drain cursor — commit-order contract', () => { + it('delivers every event when rows commit in ascending sequence order', async () => { + const table = makeTable(); + const drainer = drainerOver(table, 0); + + for (const sequence of [1, 2, 3, 4]) { + table.visible.push(row(sequence, 'node_started', `n-${sequence}`)); + await drainer.notify(); + } + + expect(table.delivered).toEqual([1, 2, 3, 4]); + }); + + it('drops a row that commits after the cursor has passed it', async () => { + // Why the writer must serialize: node C's insert lands before node B's, the + // cursor moves to 3, and B's event can never be selected again. + const table = makeTable(); + const drainer = drainerOver(table, 1); + + table.visible.push(row(3, 'node_completed', 'C')); + await drainer.notify(); + + table.visible.push(row(2, 'node_completed', 'B')); + await drainer.notify(); + + expect(table.delivered).toEqual([3]); + expect(table.delivered).not.toContain(2); + expect(drainer.cursor).toBe(3); + }); + + it('steps over a permanent gap without stalling', async () => { + // Gaps are the tolerable failure: a sequence number consumed by an emit that never + // commits (cancelled run, exhausted retries) costs nothing, because the cursor only + // ever needs rows to arrive in ascending order — not to be contiguous. + const table = makeTable(); + const drainer = drainerOver(table, 0); + + table.visible.push(row(1, 'node_started', 'B')); + await drainer.notify(); + // 2 is never written. + table.visible.push(row(3, 'node_completed', 'B')); + await drainer.notify(); + table.visible.push(row(4, 'execution_completed')); + await drainer.notify(); + + expect(table.delivered).toEqual([1, 3, 4]); + expect(drainer.done).toBe(true); + }); +}); diff --git a/apps/backend/src/routes/executions.test.ts b/apps/backend/src/routes/executions.test.ts index 0c702df36..fbdffcaf8 100644 --- a/apps/backend/src/routes/executions.test.ts +++ b/apps/backend/src/routes/executions.test.ts @@ -144,7 +144,7 @@ describe('createExecutionsRoutes - authorize is called with the right shape per const app = buildApp(port); databaseMock.select.mockReturnValue(chainResolving([execution])); - databaseMock.update.mockReturnValue(chainResolving([])); + databaseMock.update.mockReturnValue(chainResolving([{ id: 'e-1' }])); await app.request(path, { method }); @@ -261,3 +261,134 @@ describe('createExecutionsRoutes - stream tenant cross-check', () => { expect(response.status).toBe(200); }); }); + +// ---- snapshot-window race ---------------------------------------------------- +// +// The worker commits the terminal event and the terminal status as two separate +// activities, so the route's executions-row read can lag its events read. The +// stream must trust the last event, not the stale row: otherwise the snapshot +// says "running", the drainer is seeded past the terminal row, no NOTIFY is +// coming (updateStatus does not notify), and the connection heartbeats forever. + +function makeEventRow(sequence: number, type: string) { + return { + id: `ev-${sequence}`, + executionId: 'e-1', + sequence, + timestamp: new Date(0), + type, + nodeId: null, + pathId: null, + payloadJson: null, + tenantId: null, + createdAt: new Date(0), + }; +} + +function snapshotFrom(body: string): { status: string; lastSequence: number } { + const dataLine = body.split('\n').find((line) => line.startsWith('data:')); + return JSON.parse(dataLine!.slice('data:'.length)) as { status: string; lastSequence: number }; +} + +describe('createExecutionsRoutes - stream snapshot-window race', () => { + it('stale pending row with a committed terminal event - snapshot carries the derived status and the stream closes', async () => { + const app = buildApp(allowStream()); + // Row read returns the stale pre-terminal status; the events read already + // contains the terminal event the worker committed in between. + databaseMock.select.mockReturnValueOnce(chainResolving([pendingExecution])); + databaseMock.select.mockReturnValue( + chainResolving([makeEventRow(1, 'execution_started'), makeEventRow(2, 'execution_completed')]), + ); + + const response = await app.request('/api/executions/e-1/stream'); + // Pre-fix this text() never resolves: the handler holds the stream open on + // heartbeats forever, so a test timeout here is the regression signal. + const body = await response.text(); + + const snapshot = snapshotFrom(body); + expect(snapshot.status).toBe('completed'); + expect(snapshot.lastSequence).toBe(2); + expect(subscribeMock).not.toHaveBeenCalled(); + }); + + it.each([ + { type: 'execution_completed', status: 'completed' }, + { type: 'execution_incomplete', status: 'incomplete' }, + { type: 'execution_failed', status: 'failed' }, + { type: 'execution_cancelled', status: 'cancelled' }, + ])('a trailing $type derives snapshot status $status', async ({ type, status }) => { + const app = buildApp(allowStream()); + databaseMock.select.mockReturnValueOnce(chainResolving([pendingExecution])); + databaseMock.select.mockReturnValue(chainResolving([makeEventRow(1, type)])); + + const response = await app.request('/api/executions/e-1/stream'); + const body = await response.text(); + + expect(snapshotFrom(body).status).toBe(status); + expect(subscribeMock).not.toHaveBeenCalled(); + }); + + it('a non-terminal last event does not flip the status - the live path still drains to terminal', async () => { + const app = buildApp(allowStream()); + subscribeMock.mockResolvedValue(() => {}); + // Call order: executions row, snapshot events fetch, catch-up drain fetch. + databaseMock.select.mockReturnValueOnce(chainResolving([pendingExecution])); + databaseMock.select.mockReturnValueOnce(chainResolving([makeEventRow(1, 'execution_started')])); + databaseMock.select.mockReturnValue(chainResolving([makeEventRow(2, 'execution_completed')])); + + const response = await app.request('/api/executions/e-1/stream'); + const body = await response.text(); + + // Negative half: 'execution_started' must not read as terminal. + expect(snapshotFrom(body).status).toBe('pending'); + // The catch-up drain still delivered the post-snapshot terminal event and + // ended the stream (drainer.done resolves the hold-open promise). + expect(body).toContain('execution_completed'); + expect(subscribeMock).toHaveBeenCalledTimes(1); + }); +}); + +// ---- cancel race ------------------------------------------------------------- +// +// The pre-check 409 reads the row, but the enforcement is the UPDATE's WHERE: +// a worker committing a terminal status between the two must not be overwritten +// to 'cancelling' - nothing ever writes a run out of that status again. + +describe('createExecutionsRoutes - cancel enforcement in the UPDATE', () => { + it('DELETE races a terminal write - guarded UPDATE matches 0 rows, 409, engine untouched', async () => { + const app = buildApp(allowStream()); + databaseMock.select.mockReturnValue(chainResolving([pendingExecution])); + databaseMock.update.mockReturnValue(chainResolving([])); + + const response = await app.request('/api/executions/e-1', { method: 'DELETE' }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: 'execution_not_cancellable', + message: 'Execution already finished', + }); + expect(engineMock.cancel).not.toHaveBeenCalled(); + }); + + it('DELETE wins the race - 200 cancelling and engine.cancel fires', async () => { + const app = buildApp(allowStream()); + databaseMock.select.mockReturnValue(chainResolving([pendingExecution])); + databaseMock.update.mockReturnValue(chainResolving([{ id: 'e-1' }])); + + const response = await app.request('/api/executions/e-1', { method: 'DELETE' }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: 'e-1', status: 'cancelling' }); + expect(engineMock.cancel).toHaveBeenCalledWith('e-1'); + }); + + it('DELETE on a terminal row - 409 from the pre-check, no UPDATE issued', async () => { + const app = buildApp(allowStream()); + databaseMock.select.mockReturnValue(chainResolving([terminalExecution])); + + const response = await app.request('/api/executions/e-1', { method: 'DELETE' }); + + expect(response.status).toBe(409); + expect(databaseMock.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/routes/executions.ts b/apps/backend/src/routes/executions.ts index 57580ba27..d502c14a5 100644 --- a/apps/backend/src/routes/executions.ts +++ b/apps/backend/src/routes/executions.ts @@ -1,7 +1,13 @@ -import { eq } from 'drizzle-orm'; +import { and, eq, notInArray } from 'drizzle-orm'; import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; +import { + TERMINAL_EVENT_TO_STATUS, + TERMINAL_EXECUTION_STATUSES, + type TerminalExecutionEventType, +} from '@workflow-builder/types/workflow-execution/execution-events'; + import type { AssertAuthorized, AuthVariables } from '../auth'; import { database } from '../db/client'; import { executions } from '../db/schema'; @@ -15,7 +21,7 @@ import type { TenantVariables } from '../tenant'; const logger = backendLogger.child({ component: 'executions-route' }); -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); +const TERMINAL_STATUSES = new Set(TERMINAL_EXECUTION_STATUSES); export function createExecutionsRoutes( assertAuthorized: AssertAuthorized, @@ -98,17 +104,29 @@ export function createExecutionsRoutes( const existingEvents = await fetchEventsAfter(executionId, 0); const lastSequence = existingEvents.length > 0 ? Number(existingEvents.at(-1)!.sequence) : 0; + // The worker commits the terminal event and the terminal status in two separate + // activities, so the row read before this stream opened can lag the events read + // here. Trusting the stale row would send a live-looking snapshot (the client + // closes only on a terminal snapshot status, so it would reconnect forever) and + // seed the drainer past the terminal row: the catch-up drain returns empty, + // `updateStatus` fires no NOTIFY, and the stream heartbeats until the client + // gives up. The last event is the authority on "over" — derive the status from it. + const lastEventType = existingEvents.at(-1)?.type; + const effectiveStatus = isTerminalEventType(lastEventType) + ? TERMINAL_EVENT_TO_STATUS[lastEventType] + : execution.status; + await stream.writeSSE({ data: JSON.stringify({ type: 'execution_snapshot', executionId, - status: execution.status, + status: effectiveStatus, lastSequence, events: existingEvents.map(formatEvent), }), }); - if (TERMINAL_STATUSES.has(execution.status)) { + if (TERMINAL_STATUSES.has(effectiveStatus)) { return; } @@ -187,10 +205,21 @@ export function createExecutionsRoutes( return c.json({ code: 'execution_not_cancellable', message: 'Execution already finished' }, 409); } - await database + // The check above is a courtesy read; this WHERE is the enforcement. The worker + // can commit a terminal status between the two, and an unguarded UPDATE would + // resurrect the finished run as 'cancelling' — a status nothing ever writes it + // out of. 'cancelling' itself stays cancellable on purpose: a repeat cancel is + // an idempotent no-op write and a second engine cancel is harmless, which is + // friendlier to a retrying client than a 409. + const [updated] = await database .update(executions) .set({ status: 'cancelling', updatedAt: new Date() }) - .where(eq(executions.id, executionId)); + .where(and(eq(executions.id, executionId), notInArray(executions.status, [...TERMINAL_EXECUTION_STATUSES]))) + .returning({ id: executions.id }); + + if (!updated) { + return c.json({ code: 'execution_not_cancellable', message: 'Execution already finished' }, 409); + } logger.info('cancel requested', { executionId: execution.id, workflowId: execution.workflowId }); await getWorkflowEngine().cancel(execution.id); @@ -201,6 +230,10 @@ export function createExecutionsRoutes( return routes; } +function isTerminalEventType(type: string | undefined): type is TerminalExecutionEventType { + return type !== undefined && type in TERMINAL_EVENT_TO_STATUS; +} + function formatEvent(event: ExecutionEventRow) { return { executionId: event.executionId, diff --git a/apps/backend/src/routes/visualize.test.ts b/apps/backend/src/routes/visualize.test.ts new file mode 100644 index 000000000..6743c73cb --- /dev/null +++ b/apps/backend/src/routes/visualize.test.ts @@ -0,0 +1,63 @@ +import { Hono } from 'hono'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AssertAuthorized, AuthVariables } from '../auth'; +import type { TenantVariables } from '../tenant'; + +const { warn } = vi.hoisted(() => ({ warn: vi.fn() })); + +vi.mock('../logger', () => ({ + logger: { child: () => ({ warn, error: vi.fn(), info: vi.fn(), debug: vi.fn() }) }, +})); + +const { createVisualizeRoutes } = await import('./visualize'); + +const AI_NAMES = ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL', 'OPENROUTER_API_KEY']; + +function adapt(env: Record = {}) { + for (const name of AI_NAMES) { + // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable + // eslint-disable-next-line unicorn/no-useless-undefined + vi.stubEnv(name, undefined); + } + for (const [name, value] of Object.entries(env)) { + vi.stubEnv(name, value); + } + + const app = new Hono<{ Variables: AuthVariables & TenantVariables }>(); + app.route('/api/visualize', createVisualizeRoutes((async () => {}) as unknown as AssertAuthorized)); + + return app.request('/api/visualize/adapt', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content: 'anything', format: 'text' }), + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); + warn.mockClear(); +}); + +describe('POST /api/visualize/adapt without an LLM', () => { + it('answers 501', async () => { + const response = await adapt(); + + expect(response.status).toBe(501); + }); + + it('names a retired variable that is set, so an ignored key is not a silent 501', async () => { + await adapt({ OPENROUTER_API_KEY: 'old-key' }); + + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ missing: ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'], retired: ['OPENROUTER_API_KEY'] }), + ); + }); + + it('reports no retired key when none is set', async () => { + await adapt(); + + expect(warn).toHaveBeenCalledWith(expect.any(String), expect.not.objectContaining({ retired: expect.anything() })); + }); +}); diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts index ea1039894..f59e967dc 100644 --- a/apps/backend/src/routes/visualize.ts +++ b/apps/backend/src/routes/visualize.ts @@ -1,10 +1,11 @@ -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { generateText } from 'ai'; import { Hono } from 'hono'; import { z } from 'zod'; +import { aiConfig, retiredAiVariables } from '@workflow-builder/ai-config'; + import type { AssertAuthorized, AuthVariables } from '../auth'; -import { env } from '../env'; import { logger as backendLogger } from '../logger'; import { guardExecution } from '../security/execution-guard'; import type { TenantVariables } from '../tenant'; @@ -50,9 +51,19 @@ export function createVisualizeRoutes( return blocked; } - if (!env.OPENROUTER_API_KEY) { + // After authorization and the guard on purpose: an unconfigured server still gates the call. + const ai = aiConfig(); + if (!ai.available) { + // `retired` names a variable that is set and no longer read — the reason a key + // that used to work now yields a 501. Only the name is logged, never the value. + const retired = retiredAiVariables(); + logger.warn('adapt requested while AI is not configured', { + missing: ai.missing, + ...(retired.length > 0 ? { retired } : {}), + }); return c.json({ code: 'adapt_disabled', message: 'AI adapt is not configured on this server.' }, 501); } + const { apiKey, baseURL, modelId } = ai.config; const parsed = z.safeParse(adaptSchema, await c.req.json()); if (!parsed.success) { @@ -61,9 +72,9 @@ export function createVisualizeRoutes( const { content, format } = parsed.data; try { - const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); + const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey }); const result = await generateText({ - model: openrouter.chat(env.AI_MODEL), + model: provider.chatModel(modelId), system: FORMAT_PROMPTS[format], // Low temperature for stable structured output. temperature: 0.2, diff --git a/apps/demo/package.json b/apps/demo/package.json index 4869d045b..bec9280f8 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -12,13 +12,16 @@ "typecheck:watch": "tsc --noEmit --watch --pretty", "lint": "eslint", "lint:fix": "eslint --fix", - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest" }, "dependencies": { + "@base-ui/react": "catalog:", + "@jsonforms/core": "^3.4.1", + "@jsonforms/react": "^3.4.1", "@microsoft/clarity": "^1.0.0", "@phosphor-icons/react": "^2.1.7", - "@synergycodes/overflow-ui": "1.0.0-beta.27", + "@workflowbuilder/ui": "workspace:*", "@xyflow/react": "catalog:", "ajv": "catalog:", "clsx": "^2.1.1", @@ -28,7 +31,7 @@ "immer": "^10.1.1", "jspdf": "^3.0.1", "libavoid-js": "0.4.0-beta.1", - "react": "^19.1.0", + "react": "catalog:", "react-dom": "catalog:", "react-i18next": "^15.4.1", "remeda": "^2.19.2", diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 79d479f09..d79b0a96f 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -35,8 +35,8 @@ const edgeTemplates = { dashed: DashedEdge, } satisfies WorkflowBuilderEdgeTemplates; -// A trigger is a workflow entry point, so it can never be a connection target. -const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => targetNode.data.type !== 'trigger'; +// A start node is where the run begins, so it can never be a connection target. +const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode; // Advanced escape hatch: forward extra ReactFlow props (SDK-owned props can't be set here). const reactFlowProps = { diff --git a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx index 96b090494..05fb08080 100644 --- a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx +++ b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx @@ -1,6 +1,6 @@ -import { NodeDescription, NodeIcon, NodePanel } from '@synergycodes/overflow-ui'; import { Icon, defineNodeTemplate, getHandleId, statusOptions } from '@workflowbuilder/sdk'; import type { NodeDataProperties, WorkflowNodeTemplateProps } from '@workflowbuilder/sdk'; +import { NodeDescription, NodeIcon, NodePanel } from '@workflowbuilder/ui'; import { Handle, Position } from '@xyflow/react'; import clsx from 'clsx'; import { memo, useMemo } from 'react'; diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 62291e86c..dc2dcaacd 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -9,6 +9,7 @@ export const triggerNode: PaletteItem = { description: 'node.trigger.description', type: 'trigger', icon: 'Lightning', + isStartNode: true, defaultPropertiesData, schema, uischema, diff --git a/apps/demo/src/app/data/templates/black-friday.ts b/apps/demo/src/app/data/templates/black-friday.ts index b79e3e3eb..289414c3b 100644 --- a/apps/demo/src/app/data/templates/black-friday.ts +++ b/apps/demo/src/app/data/templates/black-friday.ts @@ -17,6 +17,7 @@ const diagram: DiagramModel = { dataSource: 'Time-based Trigger', }, type: 'trigger', + isStartNode: true, icon: 'Lightning', }, selected: false, diff --git a/apps/demo/src/app/data/templates/call-flow.ts b/apps/demo/src/app/data/templates/call-flow.ts index ed6205ec6..1d46283bd 100644 --- a/apps/demo/src/app/data/templates/call-flow.ts +++ b/apps/demo/src/app/data/templates/call-flow.ts @@ -13,6 +13,7 @@ const diagram: DiagramModel = { segments: [], properties: { label: 'Incoming Call', description: 'Trigger', dataSource: 'Time-based Trigger' }, type: 'trigger', + isStartNode: true, icon: 'Lightning', }, selected: false, diff --git a/apps/demo/src/app/data/templates/simple-flow.ts b/apps/demo/src/app/data/templates/simple-flow.ts index 4c5d4a687..4746a71cd 100644 --- a/apps/demo/src/app/data/templates/simple-flow.ts +++ b/apps/demo/src/app/data/templates/simple-flow.ts @@ -19,6 +19,7 @@ const defaultDiagram: DiagramModel = { dataSource: 'Time-based Trigger', }, type: 'trigger', + isStartNode: true, icon: 'Lightning', }, selected: false, diff --git a/apps/demo/src/app/data/templates/user-registration.ts b/apps/demo/src/app/data/templates/user-registration.ts index bf82affa2..5c310196d 100644 --- a/apps/demo/src/app/data/templates/user-registration.ts +++ b/apps/demo/src/app/data/templates/user-registration.ts @@ -13,6 +13,7 @@ const diagram: DiagramModel = { segments: [], properties: { label: 'New User Created', description: 'Trigger', dataSource: 'Time-based Trigger' }, type: 'trigger', + isStartNode: true, icon: 'Lightning', }, selected: false, diff --git a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx index c12f452fc..7472ffbca 100644 --- a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx +++ b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx @@ -1,5 +1,5 @@ -import { NavButton } from '@synergycodes/overflow-ui'; import { Icon, useStore } from '@workflowbuilder/sdk'; +import { NavButton } from '@workflowbuilder/ui'; import { useTranslation } from 'react-i18next'; import { redo, undo, useUndoRedoStore } from '../../stores/use-undo-redo-store'; diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json index 2fa5614b6..c45765729 100644 --- a/apps/demo/tsconfig.json +++ b/apps/demo/tsconfig.json @@ -7,7 +7,7 @@ "@workflowbuilder/sdk": ["../../packages/sdk/src/index.ts"] }, "verbatimModuleSyntax": true, - "types": ["node", "vite/client", "vite-plugin-svgr/client"] + "types": ["node", "vite/client", "vite-plugin-svgr/client", "vitest/globals"] }, - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx", "dist"] + "exclude": ["dist"] } diff --git a/apps/demo/vite.config.mts b/apps/demo/vite.config.mts index f7a6a5b55..2f1aa453a 100644 --- a/apps/demo/vite.config.mts +++ b/apps/demo/vite.config.mts @@ -16,7 +16,6 @@ import { export default defineConfig(({ mode }) => { const isProduction = mode === 'production'; const isAnalyze = process.env.ANALYZE === 'true'; - const shouldUseLocalOverflowUI = process.env.LOCAL_OVERFLOW_UI === 'true'; const plugins: PluginOption[] = []; @@ -52,12 +51,6 @@ export default defineConfig(({ mode }) => { plugins, resolve: { alias: [ - ...(shouldUseLocalOverflowUI - ? Object.entries(getLocalOverflowUIAliases()).map(([find, replacement]) => ({ - find, - replacement, - })) - : []), { find: /^@workflowbuilder\/sdk\/style\.css$/, replacement: path.resolve(sdkDirectory, 'src/index.css'), @@ -66,14 +59,6 @@ export default defineConfig(({ mode }) => { find: /^@workflowbuilder\/sdk$/, replacement: path.resolve(sdkDirectory, 'src/index.ts'), }, - // overflow-ui doesn't expose ./dist/index.css via package.json exports - { - find: 'overflow-ui-css', - replacement: path.resolve( - sdkDirectory, - 'node_modules/@synergycodes/overflow-ui/dist/index.css', - ), - }, // SDK source files use @/ to refer to their own root (packages/sdk/src/...). // Demo files do not use @/ (they import via @workflowbuilder/sdk subpaths), // so it's safe to point @/ at the SDK root for cross-package alias parity with sdk's vite.config. @@ -96,15 +81,6 @@ export default defineConfig(({ mode }) => { }; }); -function getLocalOverflowUIAliases(): Record { - const distribution = path.resolve(import.meta.dirname, '../../../overflow-ui/packages/ui/dist'); - - return { - '@synergycodes/overflow-ui/tokens.css': path.join(distribution, 'tokens.css'), - '@synergycodes/overflow-ui': path.join(distribution, 'overflow-ui.js'), - }; -} - type EnvMode = 'demo' | 'production' | 'development' | 'staging'; const fileReplacementsMap: Record = { demo: [], diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 599ee9638..0453e265c 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -1,6 +1,7 @@ import { copyFileSync, mkdirSync } from 'node:fs'; import path from 'node:path'; +import react from '@astrojs/react'; import starlight from '@astrojs/starlight'; import umami from '@yeskunall/astro-umami'; import { defineConfig, passthroughImageService } from 'astro/config'; @@ -70,6 +71,7 @@ export default defineConfig({ }, integrations: [ icon(), + react(), umami({ id: UMAMI_WEBSITE_ID }), starlight({ plugins: [ @@ -113,7 +115,19 @@ export default defineConfig({ }, }), ], - customCss: ['./src/styles/custom.css'], + // `@workflowbuilder/ui` styles are safe to load globally: everything is + // layered (no global reset), classes are hashed or opt-in, and tokens.css + // only defines `--ax-*` custom properties keyed on `html[data-theme]` — + // which Starlight already toggles, so the live component showcases follow + // the docs light/dark theme. index.css is required at document level: + // Modal/Menu/Select/Tooltip/DatePicker portal their popups to body, + // outside the shadow roots that carry the preview styles. + customCss: [ + './src/styles/custom.css', + '@workflowbuilder/ui/styles.css', + '@workflowbuilder/ui/tokens.css', + '@workflowbuilder/ui/index.css', + ], components: { Head: './src/components/head.astro', Search: './src/components/search.astro', @@ -159,6 +173,15 @@ export default defineConfig({ { label: 'Node Schemas', autogenerate: { directory: 'node-schemas' } }, { label: 'Built-in Nodes', autogenerate: { directory: 'nodes' } }, { label: 'Plugins', autogenerate: { directory: 'plugins' } }, + { + label: 'UI Library', + items: [ + { label: 'Overview', link: '/ui-library/overview/' }, + { label: 'Design tokens', link: '/ui-library/design-tokens/' }, + { label: 'UI Components', autogenerate: { directory: 'ui-library/ui-components' } }, + { label: 'Diagram Components', autogenerate: { directory: 'ui-library/diagram-components' } }, + ], + }, // API Reference — pages auto-generated by `starlight-typedoc` from // packages/sdk's barrel into `src/content/docs/api//`. // Folder names match the `@category` tag in source TSDoc verbatim. diff --git a/apps/docs/package.json b/apps/docs/package.json index 9fe55bfe4..2e643249c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,28 +4,37 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm clean:typedoc && astro dev", - "build": "pnpm clean:typedoc && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs", + "dev": "pnpm clean:typedoc && pnpm generate:ui-api && astro dev", + "build": "pnpm clean:typedoc && pnpm generate:ui-api && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs && node scripts/copy-swa-config.mjs", + "generate:ui-api": "node scripts/generate-ui-api.mjs && node scripts/check-ui-component-coverage.mjs", "clean:typedoc": "node -e \"import('node:fs').then(fs => fs.rmSync('src/content/docs/api', { recursive: true, force: true }))\"", "preview": "astro preview", - "typecheck": "astro check", + "typecheck": "pnpm generate:ui-api && astro check", "format": "prettier --write --log-level silent \"**/*.astro\"", "lint": "eslint .", "lint:fix": "eslint . --fix" }, "dependencies": { + "@astrojs/react": "^4.4.0", "@astrojs/starlight": "^0.37.6", + "@base-ui/react": "catalog:", "@iconify-json/ph": "^1.2.2", + "@phosphor-icons/react": "catalog:", "@workflowbuilder/sdk": "workspace:*", + "@workflowbuilder/ui": "workspace:*", "@yeskunall/astro-umami": "^0.0.7", "astro": "^5.18.0", "astro-icon": "^1.1.5", "marked": "^15.0.0", + "react": "catalog:", + "react-dom": "catalog:", "rehype-external-links": "^3.0.0", "starlight-image-zoom": "^0.13.2" }, "devDependencies": { "@astrojs/check": "^0.9.6", + "@types/react": "catalog:", + "@types/react-dom": "^19.1.0", "eslint-plugin-astro": "^1.3.1", "starlight-typedoc": "^0.21.3", "typedoc": "^0.28.9", diff --git a/apps/docs/scripts/check-ui-component-coverage.mjs b/apps/docs/scripts/check-ui-component-coverage.mjs new file mode 100644 index 000000000..6665c6b88 --- /dev/null +++ b/apps/docs/scripts/check-ui-component-coverage.mjs @@ -0,0 +1,77 @@ +// Cross-checks the documented component surface: every publishable subpath in +// packages/ui/vite.config.mts has a COMPONENTS entry, and every COMPONENTS +// entry is rendered by an MDX page. Without it a component ships to npm with +// no docs page and nothing in the build complains. Runs as part of +// `generate:ui-api`. + +import { globSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { COMPONENTS } from './ui-components.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const documentsRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(documentsRoot, '../..'); +const viteConfigPath = path.resolve(repoRoot, 'packages/ui/vite.config.mts'); + +const NARRATIVE_ONLY = new Set([]); + +// Read as text, not imported - it is TypeScript. Empty means the shape changed. +function extractComponentEntries(source) { + const match = /const componentEntries = \[([\s\S]*?)] as const;/.exec(source); + if (!match) throw new Error('Could not find `componentEntries` in packages/ui/vite.config.mts'); + const entries = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); + if (entries.length === 0) { + throw new Error('Extracted zero entries from `componentEntries` in packages/ui/vite.config.mts'); + } + return entries; +} + +const componentEntries = extractComponentEntries(readFileSync(viteConfigPath, 'utf8')); +const componentDirectories = COMPONENTS.map((component) => component.dir).filter(Boolean); +const componentSlugs = COMPONENTS.map((component) => component.slug); + +const missing = componentEntries.filter((entry) => { + if (NARRATIVE_ONLY.has(entry)) return false; + return !componentDirectories.some((directory) => directory === entry || directory.startsWith(`${entry}/`)); +}); + +const contentRoot = path.resolve(documentsRoot, 'src/content/docs'); +const pageSources = globSync('**/*.mdx', { cwd: contentRoot }).map((file) => + readFileSync(path.resolve(contentRoot, file), 'utf8'), +); +const unrendered = componentSlugs.filter( + (slug) => !pageSources.some((source) => source.includes(`slug="${slug}"`)), +); + +if (missing.length > 0) { + console.error('error: componentEntries in packages/ui/vite.config.mts have no matching COMPONENTS entry.\n'); + for (const entry of missing) { + console.error(` - ${entry}`); + } + console.error( + '\nAdd a COMPONENTS entry in apps/docs/scripts/generate-ui-api.mjs with a matching `dir`, ' + + 'or add the entry to NARRATIVE_ONLY in this script if it is deliberately prose-only.', + ); + process.exitCode = 1; +} + +if (unrendered.length > 0) { + console.error('error: COMPONENTS entries in generate-ui-api.mjs are rendered by no MDX page.\n'); + for (const slug of unrendered) { + console.error(` - ${slug}`); + } + console.error( + '\nAdd a docs page that uses / , ' + + 'or remove the generator entry.', + ); + process.exitCode = 1; +} + +if (missing.length === 0 && unrendered.length === 0) { + console.log( + `✓ component coverage ok — ${componentEntries.length} vite entries and ${componentSlugs.length} docs slugs cross-checked.`, + ); +} diff --git a/apps/docs/scripts/copy-swa-config.mjs b/apps/docs/scripts/copy-swa-config.mjs new file mode 100644 index 000000000..d355d25a8 --- /dev/null +++ b/apps/docs/scripts/copy-swa-config.mjs @@ -0,0 +1,10 @@ +// Azure Static Web Apps reads staticwebapp.config.json from the artifact root, +// which is `dist` — the site sits one level down in `dist/docs`. Astro's +// `public/` copies into `dist/docs`, one level too deep, so copy it here. +import { copyFileSync, mkdirSync } from 'node:fs'; + +const source = './staticwebapp.config.json'; +const target = './dist/staticwebapp.config.json'; + +mkdirSync('./dist', { recursive: true }); +copyFileSync(source, target); diff --git a/apps/docs/scripts/generate-ui-api.mjs b/apps/docs/scripts/generate-ui-api.mjs new file mode 100644 index 000000000..f641b21bc --- /dev/null +++ b/apps/docs/scripts/generate-ui-api.mjs @@ -0,0 +1,419 @@ +/* + * Generates `src/generated/ui-api.json` for the UI Library docs. + * + * Props are extracted with TypeDoc (source of truth: the component prop types + * in `@workflowbuilder/ui`); CSS variables are extracted from each component's + * stylesheets. The per-component docs pages render this JSON, so the Props and + * CSS variables tables never drift from source. Run by `pnpm generate:ui-api` + * and as a prebuild step in `dev` / `build`. + */ +import { execFile } from 'node:child_process'; +import { existsSync, globSync, readFileSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { COMPONENTS } from './ui-components.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const documentsRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(documentsRoot, '../..'); +const uiSource = path.resolve(repoRoot, 'packages/ui/src'); +const outFile = path.resolve(documentsRoot, 'src/generated/ui-api.json'); +const tdJson = path.resolve(documentsRoot, 'node_modules/.cache/ui-typedoc.json'); + +// Engineering notes in the CSS, never public documentation. +const INTERNAL_NOTE_RE = /missing token/i; + +async function runTypedoc() { + await mkdir(path.dirname(tdJson), { recursive: true }); + const bin = path.resolve(documentsRoot, 'node_modules/.bin/typedoc'); + await promisify(execFile)( + bin, + [ + '--json', + tdJson, + // Whole tree, not the barrel: variant prop types are not re-exported. + '--entryPoints', + path.resolve(uiSource, 'components'), + // A type outside the entry tree gets no reflection and vanishes from the tables. + '--entryPoints', + path.resolve(uiSource, 'shared'), + '--entryPointStrategy', + 'expand', + '--tsconfig', + path.resolve(repoRoot, 'packages/ui/tsconfig.json'), + '--excludeExternals', + '--excludePrivate', + '--skipErrorChecking', + '--logLevel', + 'Error', + ], + { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 }, + ); + return JSON.parse(await readFile(tdJson, 'utf8')); +} + +function indexById(root) { + const byId = new Map(); + (function walk(node) { + if (node && typeof node.id === 'number') byId.set(node.id, node); + for (const child of node.children ?? []) walk(child); + })(root); + return byId; +} + +function findTypeByName(root, name, warnings) { + const matches = []; + (function walk(node) { + if (node.name === name && (node.kind === 2_097_152 || node.kind === 256)) matches.push(node); + for (const child of node.children ?? []) walk(child); + })(root); + if (matches.length > 1 && warnings) { + warnings.push(`type name "${name}" is ambiguous (${matches.length} declarations) - the table would document whichever TypeDoc emitted first`); + } + return matches[0] ?? null; +} + +function typeToString(t, byId, depth = 0) { + if (!t || depth > 6) return 'unknown'; + switch (t.type) { + case 'intrinsic': { + return t.name; + } + case 'literal': { + return typeof t.value === 'string' ? `'${t.value}'` : String(t.value); + } + case 'reference': { + const arguments_ = t.typeArguments?.length + ? `<${t.typeArguments.map((a) => typeToString(a, byId, depth + 1)).join(', ')}>` + : ''; + return `${t.name}${arguments_}`; + } + case 'union': { + return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' | '); + } + case 'intersection': { + return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' & '); + } + case 'array': { + return `${typeToString(t.elementType, byId, depth + 1)}[]`; + } + case 'tuple': { + return `[${(t.elements ?? []).map((x) => typeToString(x, byId, depth + 1)).join(', ')}]`; + } + case 'reflection': { + const sig = t.declaration?.signatures?.[0]; + if (sig) { + const params = (sig.parameters ?? []) + .map((p) => `${p.name}: ${typeToString(p.type, byId, depth + 1)}`) + .join(', '); + return `(${params}) => ${typeToString(sig.type, byId, depth + 1)}`; + } + return '{ … }'; + } + case 'indexedAccess': { + return `${typeToString(t.objectType, byId, depth + 1)}[${typeToString(t.indexType, byId, depth + 1)}]`; + } + case 'templateLiteral': { + return 'string'; + } + case 'query': { + return typeToString(t.queryType, byId, depth + 1); + } + case 'predicate': { + return 'boolean'; + } + case 'typeOperator': { + return `${t.operator} ${typeToString(t.target, byId, depth + 1)}`; + } + default: { + return t.name ?? 'unknown'; + } + } +} + +function summaryText(comment) { + if (!comment?.summary) return ''; + return comment.summary + .map((s) => s.text) + .join('') + .trim(); +} + +function defaultTag(comment) { + const tag = (comment?.blockTags ?? []).find((b) => b.tag === '@default' || b.tag === '@defaultValue'); + if (!tag) return null; + let value = tag.content + .map((c) => c.text) + .join('') + .trim(); + value = value + .replace(/^```[a-z]*\s*/i, '') + .replace(/\s*```$/, '') + .trim(); // strip ```ts … ``` fences + value = value.replaceAll(/^`+|`+$/g, '').trim(); // strip inline backticks + return value || null; +} + +// Which native element a component forwards its remaining props to; listing +// ~280 DOM attributes in the table would drown the props that are ours. +const NATIVE_ATTRIBUTE_TYPES = new Map([ + ['InputHTMLAttributes', 'input'], + ['ButtonHTMLAttributes', 'button'], + ['TextareaHTMLAttributes', 'textarea'], + ['SelectHTMLAttributes', 'select'], + ['AnchorHTMLAttributes', 'a'], + ['HTMLAttributes', 'element'], +]); + +function findNativeElement(typeNode, byId, depth = 0) { + if (!typeNode || depth > 8) return null; + if (typeNode.type === 'reference') { + const element = NATIVE_ATTRIBUTE_TYPES.get(typeNode.name.replace(/^React\./, '')); + if (element === 'element') { + const tag = /^HTML(\w*?)Element$/.exec(typeNode.typeArguments?.[0]?.name ?? '')?.[1]; + return tag ? tag.toLowerCase() || 'element' : 'element'; + } + if (element) return element; + if (typeof typeNode.target === 'number') { + const found = findNativeElement(byId.get(typeNode.target)?.type, byId, depth + 1); + if (found) return found; + } + } + for (const nested of [...(typeNode.types ?? []), ...(typeNode.typeArguments ?? [])]) { + const found = findNativeElement(nested, byId, depth + 1); + if (found) return found; + } + return null; +} + +// Own properties of a prop type, walking intersections and skipping native members. +function collectProps(typeNode, byId, accumulator = new Map(), context = null) { + if (!typeNode) return accumulator; + if (typeNode.kind === 2_097_152 || typeNode.kind === 256) { + if (typeNode.children?.length) { + for (const child of typeNode.children) addProperty(child, byId, accumulator); + return accumulator; + } + return collectProps(typeNode.type, byId, accumulator, context); + } + if (typeNode.type === 'intersection' || typeNode.type === 'union') { + for (const member of typeNode.types) collectProps(member, byId, accumulator, context); + return accumulator; + } + if (typeNode.type === 'reflection' && typeNode.declaration?.children) { + for (const child of typeNode.declaration.children) addProperty(child, byId, accumulator); + return accumulator; + } + if (typeNode.type === 'reference' && typeof typeNode.target === 'number') { + const target = byId.get(typeNode.target); + // Follow first-party prop types only; both declaration forms count. + if (target && (target.kind === 2_097_152 || target.kind === 256)) { + collectProps(target, byId, accumulator, context); + } + return accumulator; + } + // Partial / Omit would silently drop every prop of X. + if (typeNode.type === 'reference' && typeNode.typeArguments?.length && context) { + const firstParty = typeNode.typeArguments.find( + (argument) => argument.type === 'reference' && typeof argument.target === 'number' && byId.get(argument.target), + ); + if (firstParty) { + context.warnings.push( + `"${context.slug}": props of ${firstParty.name} are hidden behind ${typeNode.name}<...> - unwrap the utility type or extend the generator`, + ); + } + } + return accumulator; +} + +function addProperty(child, byId, accumulator) { + if (child.kind !== 1024 || accumulator.has(child.name)) return; // 1024 = Property + accumulator.set(child.name, { + name: child.name, + type: typeToString(child.type, byId), + required: !child.flags?.isOptional, + default: defaultTag(child.comment), + description: summaryText(child.comment), + }); +} + +// Merges the variants of a union/overload component into one flat table, +// noting in the description where a prop applies to some variants only. +function collectVariantProps(propsTypeNames, project, byId, warnings, slug, context) { + const perVariant = []; + for (const typeName of propsTypeNames) { + const typeNode = findTypeByName(project, typeName, warnings); + if (!typeNode) { + warnings.push(`props type "${typeName}" not found for "${slug}"`); + continue; + } + context.nativeElement ??= findNativeElement(typeNode.type, byId); + perVariant.push({ typeName, props: collectProps(typeNode, byId, new Map(), context) }); + } + + const propertyNames = new Set(); + for (const variant of perVariant) for (const name of variant.props.keys()) propertyNames.add(name); + + const merged = new Map(); + for (const propertyName of propertyNames) { + const occurrences = perVariant + .filter((variant) => variant.props.has(propertyName)) + .map((variant) => ({ typeName: variant.typeName, prop: variant.props.get(propertyName) })) + // `foo?: never` marks a prop forbidden in that variant. + .filter((occurrence) => occurrence.prop.type !== 'never'); + if (occurrences.length === 0) continue; + const distinctTypes = new Set(occurrences.map((o) => o.prop.type)); + const sharedByAll = occurrences.length === perVariant.length && distinctTypes.size === 1; + + // Required in every variant, else the table documents an impossible call. + const requiredEverywhere = + occurrences.length === perVariant.length && occurrences.every((o) => o.prop.required); + const requiredInItsVariants = !requiredEverywhere && occurrences.every((o) => o.prop.required); + + const base = occurrences[0].prop; + let description = base.description; + if (!sharedByAll) { + const variantLabel = (typeName) => typeName.replace(/Props$/, ''); + const variants = occurrences.map((o) => variantLabel(o.typeName)).join(', '); + const note = + distinctTypes.size > 1 + ? `Type varies by variant (${occurrences.map((o) => `${variantLabel(o.typeName)}: ${o.prop.type}`).join(', ')}).` + : requiredInItsVariants + ? `Only applies to the ${variants} variant (required there).` + : `Only applies to the ${variants} variant.`; + description = description ? `${description} ${note}` : note; + } + + merged.set(propertyName, { + name: propertyName, + type: sharedByAll ? base.type : [...distinctTypes].join(' | '), + required: requiredEverywhere, + default: base.default, + description, + }); + } + return merged; +} + +function extractCssVariables(directory, warnings, slug) { + // No directory - the entry documents an API, not a styled component. + if (!directory) return []; + + const abs = path.resolve(uiSource, 'components', directory); + if (!existsSync(abs)) { + warnings.push(`"${slug}": component directory ${directory} does not exist`); + return []; + } + // Subcomponents with their own page document their own variables; an + // override offered on the parent page would do nothing. + const nestedPrefixes = COMPONENTS.map((component) => component.dir) + .filter((nested) => nested?.startsWith(`${directory}/`)) + .map((nested) => `${nested.slice(directory.length + 1)}/`); + + const files = globSync('**/*.css', { cwd: abs }) + .filter((file) => !nestedPrefixes.some((prefix) => file.startsWith(prefix))) + .sort(); + const seen = new Set(); + const variables = []; + for (const file of files) { + const css = readFileSync(path.resolve(abs, file), 'utf8'); + const re = /(--ax-public-[\w-]+)\s*:\s*([^;]*?)(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g; + let m; + while ((m = re.exec(css))) { + if (seen.has(m[1])) continue; + seen.add(m[1]); + const comment = (m[3] ?? '').trim(); + variables.push({ + name: m[1], + kind: valueKind(m[2].trim()), + comment: INTERNAL_NOTE_RE.test(comment) ? '' : comment, + }); + } + } + return variables; +} + +// Groups by what the value resolves to - the name misleads (`edge-stroke-width` +// is a length, `snackbar-success-border` is a color), so follow it to the literal. +const LITERAL_COLOR_RE = /^(#|rgb|hsl|oklch|color-mix|linear-gradient|radial-gradient|transparent\b|currentColor\b)/i; + +const tokenValues = readTokenValues(); + +function readTokenValues() { + const values = new Map(); + const tokenDistribution = path.resolve(repoRoot, 'packages/tokens/dist'); + if (!existsSync(tokenDistribution)) return values; + for (const file of globSync('*.css', { cwd: tokenDistribution })) { + const css = readFileSync(path.resolve(tokenDistribution, file), 'utf8'); + for (const [, name, value] of css.matchAll(/(--ax-[\w-]+)\s*:\s*([^;]+);/g)) { + if (!values.has(name)) values.set(name, value.trim()); + } + } + return values; +} + +function valueKind(value, depth = 0) { + if (LITERAL_COLOR_RE.test(value)) return 'color'; + const referenced = /var\(\s*(--[\w-]+)/.exec(value); + if (!referenced || depth > 8) return 'size'; + const resolved = tokenValues.get(referenced[1]); + return resolved ? valueKind(resolved, depth + 1) : 'size'; +} + +async function main() { + const project = await runTypedoc(); + const byId = indexById(project); + const out = {}; + const warnings = []; + + for (const component of COMPONENTS) { + let props = []; + const context = { warnings, slug: component.slug }; + if (Array.isArray(component.propsType)) { + props = [...collectVariantProps(component.propsType, project, byId, warnings, component.slug, context).values()].sort( + (a, b) => a.name.localeCompare(b.name), + ); + } else if (component.propsType) { + const typeNode = findTypeByName(project, component.propsType, warnings); + if (typeNode) { + context.nativeElement = findNativeElement(typeNode.type, byId); + props = [...collectProps(typeNode, byId, new Map(), context).values()].sort((a, b) => + a.name.localeCompare(b.name), + ); + } else { + warnings.push(`props type "${component.propsType}" not found for "${component.slug}"`); + } + } + out[component.slug] = { + name: component.name, + props, + nativeElement: context.nativeElement ?? null, + cssVariables: extractCssVariables(component.dir, warnings, component.slug), + }; + } + + await mkdir(path.dirname(outFile), { recursive: true }); + await writeFile(outFile, JSON.stringify(out, null, 2) + '\n'); + + const summary = Object.entries(out).map( + ([slug, entry]) => `${slug}: ${entry.props.length} props, ${entry.cssVariables.length} vars`, + ); + console.log('✔ ui-api.json generated\n ' + summary.join('\n ')); + + if (warnings.length > 0) { + // An unresolved type would silently ship a "no configurable props" page. + console.error('✗ ' + warnings.join('\n✗ ')); + process.exitCode = 1; + } +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/apps/docs/scripts/ui-components.mjs b/apps/docs/scripts/ui-components.mjs new file mode 100644 index 000000000..03f4d47a1 --- /dev/null +++ b/apps/docs/scripts/ui-components.mjs @@ -0,0 +1,68 @@ +/** + * The documented component surface: which UI components get generated Props / + * CSS variables tables, which prop type each one exposes, and where its source + * lives under packages/ui/src/components. + * + * Imported by both the generator and the coverage guard, so the guard can + * never disagree with the generator about what is documented. + */ +// `propsType`: the exported prop type, a list of variant types, or null when +// the parts are described in prose. `dir`: the folder under +// packages/ui/src/components, or null for an entry that owns no stylesheet. +export const COMPONENTS = [ + { slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' }, + { slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' }, + // No single props type - one of three variants depending on `children`. + { + slug: 'button', + name: 'Button', + propsType: ['LabelButtonProps', 'IconButtonProps', 'IconLabelButtonProps'], + dir: 'button', + }, + { slug: 'checkbox', name: 'Checkbox', propsType: 'CheckboxProps', dir: 'checkbox' }, + { slug: 'collapsible', name: 'Collapsible', propsType: 'CollapsibleProps', dir: 'collapsible' }, + { slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' }, + { slug: 'icon-switch', name: 'IconSwitch', propsType: 'IconSwitchProps', dir: 'switch/icon-switch' }, + { slug: 'input', name: 'Input', propsType: 'InputProps', dir: 'input' }, + { slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' }, + { slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' }, + { + slug: 'nav-button', + name: 'NavButton', + propsType: ['NavLabelButtonProps', 'NavIconButtonProps', 'NavIconLabelButtonProps'], + dir: 'button/nav-button', + }, + { slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' }, + { + slug: 'segment-picker', + name: 'SegmentPicker', + propsType: ['ControlledSegmentPickerProps', 'UncontrolledSegmentPickerProps'], + dir: 'segment-picker', + }, + { slug: 'select', name: 'Select', propsType: 'SelectBaseProps', dir: 'select' }, + { slug: 'separator', name: 'Separator', propsType: null, dir: 'separator' }, + { slug: 'snackbar', name: 'Snackbar', propsType: 'SnackbarProps', dir: 'snackbar' }, + { slug: 'status', name: 'Status', propsType: 'StatusProps', dir: 'status' }, + { slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' }, + { slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' }, + { slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' }, + // Diagram components. + { slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' }, + { + slug: 'node-description', + name: 'NodeDescription', + propsType: 'NodeDescriptionProps', + dir: 'node/node-description', + }, + { + slug: 'node-as-port-wrapper', + name: 'NodeAsPortWrapper', + propsType: 'NodeAsPortWrapperProps', + dir: 'node/node-as-port-wrapper', + }, + { slug: 'edge', name: 'EdgeLabel', propsType: 'EdgeLabelProps', dir: 'edge' }, + // Documented on the Edge page, which already renders the edge variables. + { slug: 'use-edge-style', name: 'useEdgeStyle', propsType: 'UseEdgeStyleParams', dir: null }, + // Compound component - parts described in prose, variables generated. + { slug: 'node-panel', name: 'NodePanel', propsType: null, dir: 'node/node-panel' }, +]; diff --git a/apps/docs/src/components/api/css-variables-table.astro b/apps/docs/src/components/api/css-variables-table.astro new file mode 100644 index 000000000..01c8cde06 --- /dev/null +++ b/apps/docs/src/components/api/css-variables-table.astro @@ -0,0 +1,79 @@ +--- +// CSS custom properties of a UI component, generated from source into ui-api.json. +import data from '../../generated/ui-api.json'; + +interface CssVariable { + name: string; + kind: 'color' | 'size'; + comment: string; +} + +const { slug } = Astro.props as { slug: string }; +const entry = (data as Record)[slug]; +if (!entry) { + throw new Error( + `ui-api.json has no entry for slug "${slug}" - check the slug against COMPONENTS in generate-ui-api.mjs`, + ); +} +const cssVariables = entry?.cssVariables ?? []; + +const groups = [ + { label: 'Color', vars: cssVariables.filter((v) => v.kind === 'color') }, + { label: 'Size', vars: cssVariables.filter((v) => v.kind !== 'color') }, +].filter((group) => group.vars.length > 0); +--- + +{ + cssVariables.length === 0 ? ( +

This component does not expose its own CSS variables.

+ ) : ( +
+ {groups.map((group) => ( +
+

{group.label}

+ {group.vars.map((cssVariable) => ( +
+ {cssVariable.name} + {cssVariable.comment && {cssVariable.comment}} +
+ ))} +
+ ))} +
+ ) +} + + diff --git a/apps/docs/src/components/api/props-table.astro b/apps/docs/src/components/api/props-table.astro new file mode 100644 index 000000000..0f6d6b3c1 --- /dev/null +++ b/apps/docs/src/components/api/props-table.astro @@ -0,0 +1,128 @@ +--- +// Props of a UI component, generated from source into ui-api.json. +import data from '../../generated/ui-api.json'; + +interface PropertyRow { + name: string; + type: string; + required: boolean; + default: string | null; + description: string; +} + +interface Entry { + props: PropertyRow[]; + nativeElement: string | null; +} + +const { slug } = Astro.props as { slug: string }; +const entry = (data as Record)[slug]; +if (!entry) { + throw new Error( + `ui-api.json has no entry for slug "${slug}" - check the slug against COMPONENTS in generate-ui-api.mjs`, + ); +} +const props = [...(entry?.props ?? [])].sort( + (a, b) => Number(b.required) - Number(a.required) || a.name.localeCompare(b.name), +); +const nativeElement = entry?.nativeElement ?? null; +--- + +{ + nativeElement && ( +

+ Also accepts every standard <{nativeElement}> attribute (onChange,{' '} + placeholder, aria-*, …); they are forwarded to the underlying element. +

+ ) +} + +{ + props.length === 0 ? ( +

This component has no configurable props.

+ ) : ( +
+ {props.map((property) => ( +
+
+ {property.name} + {property.required && required} +
+
+ Type + {property.type} +
+ {property.default && ( +
+ Default + {property.default} +
+ )} + {property.description &&

{property.description.replaceAll('\n', ' ')}

} +
+ ))} +
+ ) +} + + diff --git a/apps/docs/src/components/stackblitz-embed.astro b/apps/docs/src/components/stackblitz-embed.astro new file mode 100644 index 000000000..74241ca54 --- /dev/null +++ b/apps/docs/src/components/stackblitz-embed.astro @@ -0,0 +1,60 @@ +--- +import { STARTER_FILE, STARTER_FORK_URL, STARTER_PROJECT, STARTER_TITLE } from '../config'; + +// `corp=1` makes StackBlitz send its own isolation headers. That works only on a +// page which is itself cross-origin isolated. `staticwebapp.config.json` grants +// isolation to one route, so render this component only there. Anywhere else the +// embed shows "Unable to run Embedded Project". +const embedParams = new URLSearchParams({ + ctl: '1', + embed: '1', + corp: '1', + view: 'preview', + terminalHeight: '20', + startScript: 'dev', + title: STARTER_TITLE, + file: STARTER_FILE, +}); + +// The frame uses `/github/`, not `/fork/github/`, so it does not make a project +// per visitor. The link below the frame forks instead. +const embedUrl = `https://stackblitz.com/github/${STARTER_PROJECT}?${embedParams}`; +--- + +
+ + { + /* Full-size link, and the way out when the frame cannot start. StackBlitz + supports embeds in Chromium browsers only, and the failure is silent, so + there is nothing to detect and nothing to fall back to automatically. */ + } +

+ Open on StackBlitz +

+
+ + diff --git a/apps/docs/src/components/ui-examples/accordion.tsx b/apps/docs/src/components/ui-examples/accordion.tsx new file mode 100644 index 000000000..f873f4260 --- /dev/null +++ b/apps/docs/src/components/ui-examples/accordion.tsx @@ -0,0 +1,13 @@ +import { Accordion } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function AccordionExample() { + return ( + + + Our component library, built on Base UI. + + + ); +} diff --git a/apps/docs/src/components/ui-examples/avatar.tsx b/apps/docs/src/components/ui-examples/avatar.tsx new file mode 100644 index 000000000..02cd08366 --- /dev/null +++ b/apps/docs/src/components/ui-examples/avatar.tsx @@ -0,0 +1,17 @@ +import { Avatar } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +const AVATAR = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + "AL", + ); + +export function AvatarExample() { + return ( + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/button.tsx b/apps/docs/src/components/ui-examples/button.tsx new file mode 100644 index 000000000..c2e5a32e9 --- /dev/null +++ b/apps/docs/src/components/ui-examples/button.tsx @@ -0,0 +1,11 @@ +import { Button } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function ButtonExample() { + return ( + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/checkbox.tsx b/apps/docs/src/components/ui-examples/checkbox.tsx new file mode 100644 index 000000000..5c7176382 --- /dev/null +++ b/apps/docs/src/components/ui-examples/checkbox.tsx @@ -0,0 +1,14 @@ +import { Checkbox } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function CheckboxExample() { + const [checked, setChecked] = useState(true); + + return ( + + setChecked(event.target.checked)} /> + + ); +} diff --git a/apps/docs/src/components/ui-examples/collapsible.tsx b/apps/docs/src/components/ui-examples/collapsible.tsx new file mode 100644 index 000000000..3abaf5645 --- /dev/null +++ b/apps/docs/src/components/ui-examples/collapsible.tsx @@ -0,0 +1,14 @@ +import { Collapsible } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function CollapsibleExample() { + return ( + + + + Additional details go here. + + + ); +} diff --git a/apps/docs/src/components/ui-examples/component-preview.module.css b/apps/docs/src/components/ui-examples/component-preview.module.css new file mode 100644 index 000000000..be646fae6 --- /dev/null +++ b/apps/docs/src/components/ui-examples/component-preview.module.css @@ -0,0 +1,42 @@ +.stage { + position: relative; + width: 100%; + max-width: 50rem; + aspect-ratio: 2 / 1; + /* The ratio is a preferred size, not a clamp: overflow:hidden zeroes the + content-based automatic minimum, so restore it - oversized examples grow + the stage instead of being clipped. */ + min-height: fit-content; + margin-top: 1rem; + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + background-color: var(--sl-color-bg); + /* dot grid */ + background-image: radial-gradient(circle at center, var(--sl-color-gray-5) 1.5px, transparent 1.5px); + background-size: 1.5rem 1.5rem; +} + +/* A soft radial of the page background behind the component so it reads cleanly + over the dot grid (mirrors the reference preview spotlight). */ +.spotlight { + display: flex; + align-items: center; + justify-content: center; + padding: 6rem 10rem; + margin: -6rem -10rem; + background: radial-gradient(closest-side, var(--sl-color-bg) 45%, transparent 100%); + max-width: calc(100% + 20rem); +} + +.host { + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + min-width: 0; +} diff --git a/apps/docs/src/components/ui-examples/component-preview.tsx b/apps/docs/src/components/ui-examples/component-preview.tsx new file mode 100644 index 000000000..5ca9ad289 --- /dev/null +++ b/apps/docs/src/components/ui-examples/component-preview.tsx @@ -0,0 +1,38 @@ +import componentCss from '@workflowbuilder/ui/index.css?raw'; +import globalCss from '@workflowbuilder/ui/styles.css?raw'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import styles from './component-preview.module.css'; + +// Examples render in a shadow root so Starlight's rules cannot reach them and +// the library's cannot leak out. Inherited and custom properties still cross +// the boundary - that is how the docs theme reaches the examples. Inside a +// shadow root `:root` matches nothing, hence the retarget to `:host`. +const shadowCss = `${`${globalCss}\n${componentCss}`.replaceAll(':root', ':host')} +:host > :not(style) { max-width: 100%; }`; + +export function ComponentPreview({ children }: { children: ReactNode }) { + const hostRef = useRef(null); + const [shadow, setShadow] = useState(null); + + useEffect(() => { + const host = hostRef.current; + if (!host || shadow) return; + const root = host.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = shadowCss; + root.append(style); + setShadow(root); + }, [shadow]); + + return ( +
+
+
+ {shadow ? createPortal(children, shadow) : null} +
+
+
+ ); +} diff --git a/apps/docs/src/components/ui-examples/date-picker.tsx b/apps/docs/src/components/ui-examples/date-picker.tsx new file mode 100644 index 000000000..bc5575afd --- /dev/null +++ b/apps/docs/src/components/ui-examples/date-picker.tsx @@ -0,0 +1,19 @@ +import { DatePicker } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function DatePickerExample() { + const [date, setDate] = useState(null); + + return ( + + setDate((next as Date | null) ?? null)} + /> + + ); +} diff --git a/apps/docs/src/components/ui-examples/edge.tsx b/apps/docs/src/components/ui-examples/edge.tsx new file mode 100644 index 000000000..cec6f5def --- /dev/null +++ b/apps/docs/src/components/ui-examples/edge.tsx @@ -0,0 +1,31 @@ +import { DiamondsFour } from '@phosphor-icons/react'; +import { EdgeLabel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +// EdgeLabel defaults to `position: absolute` for placement on a diagram edge. +// Outside a canvas we override it to `relative` so the variants lay out inline. +export function EdgeExample() { + return ( + +
+ Edge Label + + + Edge Label + + + + Edge Label + + + + Edge Label + + + + +
+
+ ); +} diff --git a/apps/docs/src/components/ui-examples/input.tsx b/apps/docs/src/components/ui-examples/input.tsx new file mode 100644 index 000000000..f1d4cc4b2 --- /dev/null +++ b/apps/docs/src/components/ui-examples/input.tsx @@ -0,0 +1,14 @@ +import { Input } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function InputExample() { + const [value, setValue] = useState(''); + + return ( + + setValue(event.target.value)} /> + + ); +} diff --git a/apps/docs/src/components/ui-examples/menu.tsx b/apps/docs/src/components/ui-examples/menu.tsx new file mode 100644 index 000000000..8583d4787 --- /dev/null +++ b/apps/docs/src/components/ui-examples/menu.tsx @@ -0,0 +1,20 @@ +import { Button, Menu } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function MenuExample() { + return ( + + {} }, + { label: 'Duplicate', onClick: () => {} }, + { type: 'separator' }, + { label: 'Delete', destructive: true, onClick: () => {} }, + ]} + > + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/modal.tsx b/apps/docs/src/components/ui-examples/modal.tsx new file mode 100644 index 000000000..07b4de026 --- /dev/null +++ b/apps/docs/src/components/ui-examples/modal.tsx @@ -0,0 +1,29 @@ +import { Button, Modal } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function ModalExample() { + const [open, setOpen] = useState(false); + + return ( + + + setOpen(false)} + title="Example modal" + subtitle="Rendered live from @workflowbuilder/ui" + footer={ + + } + > + The backdrop and popup fade in and out via the Base UI transition lifecycle. + + + ); +} diff --git a/apps/docs/src/components/ui-examples/nav-button.tsx b/apps/docs/src/components/ui-examples/nav-button.tsx new file mode 100644 index 000000000..edba42241 --- /dev/null +++ b/apps/docs/src/components/ui-examples/nav-button.tsx @@ -0,0 +1,11 @@ +import { NavButton } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NavButtonExample() { + return ( + + Nav button + + ); +} diff --git a/apps/docs/src/components/ui-examples/node-description.tsx b/apps/docs/src/components/ui-examples/node-description.tsx new file mode 100644 index 000000000..bb552affb --- /dev/null +++ b/apps/docs/src/components/ui-examples/node-description.tsx @@ -0,0 +1,15 @@ +import { NodeDescription, NodePanel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NodeDescriptionExample() { + return ( + + + + + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/node-icon.tsx b/apps/docs/src/components/ui-examples/node-icon.tsx new file mode 100644 index 000000000..b527c3349 --- /dev/null +++ b/apps/docs/src/components/ui-examples/node-icon.tsx @@ -0,0 +1,17 @@ +import { User } from '@phosphor-icons/react'; +import { NodeIcon, NodePanel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NodeIconExample() { + return ( + + + + } /> + Node with Icon + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/node-panel.tsx b/apps/docs/src/components/ui-examples/node-panel.tsx new file mode 100644 index 000000000..ff561ca49 --- /dev/null +++ b/apps/docs/src/components/ui-examples/node-panel.tsx @@ -0,0 +1,17 @@ +import { User } from '@phosphor-icons/react'; +import { NodeDescription, NodeIcon, NodePanel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NodePanelExample() { + return ( + + + + } /> + + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/radio.tsx b/apps/docs/src/components/ui-examples/radio.tsx new file mode 100644 index 000000000..e50209f0e --- /dev/null +++ b/apps/docs/src/components/ui-examples/radio.tsx @@ -0,0 +1,18 @@ +import { Radio } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function RadioExample() { + const [value, setValue] = useState('daily'); + + return ( + + + {['daily', 'weekly', 'monthly'].map((v) => ( + setValue(v)} /> + ))} + + + ); +} diff --git a/apps/docs/src/components/ui-examples/segment-picker.tsx b/apps/docs/src/components/ui-examples/segment-picker.tsx new file mode 100644 index 000000000..023fcc8ff --- /dev/null +++ b/apps/docs/src/components/ui-examples/segment-picker.tsx @@ -0,0 +1,18 @@ +import { SegmentPicker } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function SegmentPickerExample() { + const [view, setView] = useState('list'); + + return ( + + setView(next)}> + List + Grid + Board + + + ); +} diff --git a/apps/docs/src/components/ui-examples/select.tsx b/apps/docs/src/components/ui-examples/select.tsx new file mode 100644 index 000000000..9b9831a76 --- /dev/null +++ b/apps/docs/src/components/ui-examples/select.tsx @@ -0,0 +1,20 @@ +import { Select, type SelectItem } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +const ITEMS: SelectItem[] = [ + { value: 'opus', label: 'Claude Opus' }, + { value: 'sonnet', label: 'Claude Sonnet' }, + { value: 'haiku', label: 'Claude Haiku' }, +]; + +export function SelectExample() { + const [model, setModel] = useState('opus'); + + return ( + +