diff --git a/.dockerignore b/.dockerignore index 8ec9bf851..3258e7c39 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,7 +9,7 @@ node_modules **/node_modules # docker files -docker-compose*.yml +**/docker-compose*.yml **/Dockerfile* # build artifacts @@ -21,7 +21,6 @@ coverage/ # not needed files README.md tools/ -!tools/deployment/nginx .gitignore # examples @@ -31,3 +30,23 @@ examples/ **/.env **/.env.* !**/.env.example + +# registry auth must not end up in a layer +**/.npmrc +**/.yarnrc* + +# 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 + +# air-gap bundle and image tarballs — a build must never copy them into an image +**/ai-studio-offline +**/*.tar 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/pr-check.yml b/.github/workflows/pr-check.yml index 9e01a1c89..ff064787b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -6,10 +6,12 @@ name: PR Check # @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 -# 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. +# regress silently. Plus the deploy compose files, which ship to the demo VM on +# every deploy, the deploy Dockerfile's air-gap boundary (every RUN after +# `pnpm fetch` must be --network=none), 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: @@ -46,6 +48,9 @@ jobs: - name: Prettier --check run: pnpm exec prettier --check "**/*.+(css|ts|tsx|json|md|mdx|astro)" --log-level=warn + - name: Air-gap boundary of the deploy Dockerfile + run: pnpm check:offline-build + sdk: name: SDK lint + typecheck + test + build runs-on: ubuntu-latest @@ -170,6 +175,34 @@ jobs: fi 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 @@ -219,10 +252,11 @@ jobs: execution: name: Execution pipeline lint + typecheck + test runs-on: ubuntu-latest - # No `services:` block: all three suites are pure unit tests against - # in-memory fakes — no Postgres, no Temporal, no API keys. If a suite here - # ever needs real infra, give it its own job rather than adding services - # to this one. + # 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 @@ -244,10 +278,10 @@ jobs: run: pnpm install --frozen-lockfile - name: Lint - run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker 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/backend --filter @workflow-builder/execution-worker 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/backend --filter @workflow-builder/execution-worker 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/.gitignore b/.gitignore index f0626771d..ab963d620 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ tmp # pnpm pack / npm pack artefacts (local tarballs for smoke tests + publish dry-runs) *.tgz +# air-gap bundle from deploy/ai-studio/pack-offline.sh, if written into the checkout +ai-studio-offline/ + # Emitted declarations from `pnpm --filter @workflow-builder/icons build` # (prepare hook). Root + src/ locations are outside `dist/` so need explicit # patterns. `global.d.ts` is a source file — don't ignore. diff --git a/CLAUDE.md b/CLAUDE.md index ae6a3d247..751632165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Three onboarding paths (A installs from npm; B, C run the repo locally). README | `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 check` | - | Lint + typecheck + format + deploy Dockerfile air-gap guard | 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. @@ -60,11 +60,13 @@ apps/ 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 ``` @@ -74,21 +76,25 @@ Where to put a new script: root `tools/` for pure-Node bootstrap (runs before an Each workspace has its own context. Read the relevant file before extending a workspace. -| 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/execution-core` | `packages/execution-core/README.md` | -| `packages/temporal` | `packages/temporal/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-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`). @@ -102,7 +108,11 @@ UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuil - 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. + +### Migrating a local `.env` after pulling + +`OPENROUTER_API_KEY` was renamed to `AI_API_KEY`, and `AI_BASE_URL` is now required alongside `AI_MODEL` for AI Agent nodes (September 2026; no alias, no built-in default). A stale `apps/backend/.env` or `apps/execution-worker/.env` does not fail at boot: the apps start with AI silently off and AI nodes fail with `ai_not_configured`. `pnpm preflight` warns on both files, so run it after pulling and before `pnpm dev:backend`, `pnpm dev:worker` or `pnpm dev:ai-studio`. When it warns, offer the user this migration and apply it only with their go-ahead, since the files hold their key: rename the `OPENROUTER_API_KEY` line to `AI_API_KEY` keeping the value, add `AI_BASE_URL=https://openrouter.ai/api/v1` (or their own OpenAI-compatible endpoint), leave `AI_MODEL` as is. Never print the key value. The deploy stack has its own guard: the deploy workflow refuses to run while `OPENROUTER_API_KEY` is set in the VM's `.env`, before it writes anything. ## Code Quality @@ -111,7 +121,7 @@ Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the bo | 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 | +| Knip | `pnpm exec knip` | Detects unused exports/dependencies (not part of `pnpm check`) | | 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 | diff --git a/README.md b/README.md index 4bec5f419..2922fbf08 100644 --- a/README.md +++ b/README.md @@ -197,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. diff --git a/apps/ai-studio/index.html b/apps/ai-studio/index.html index 64a1b1375..279dbf6c8 100644 --- a/apps/ai-studio/index.html +++ b/apps/ai-studio/index.html @@ -7,12 +7,6 @@ - - -
diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx index 01961e551..4c7855f57 100644 --- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx @@ -68,7 +68,7 @@ export function DisclaimerModal() { workflow editors.

- 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/index-html.test.ts b/apps/ai-studio/src/index-html.test.ts new file mode 100644 index 000000000..abe8dd11f --- /dev/null +++ b/apps/ai-studio/src/index-html.test.ts @@ -0,0 +1,12 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +// Poppins ships inside @workflowbuilder/sdk/style.css, so index.html needs no CDN. +// Anything external here would be browser egress the air-gapped deployment cannot make. +describe('index.html', () => { + it('references no external resources', () => { + const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + expect(html.match(/\b(?:href|src)="https?:\/\/[^"]*"/g) ?? []).toEqual([]); + }); +}); 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 fdd487c41..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 @@ -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 diff --git a/apps/backend/package.json b/apps/backend/package.json index 85f2e79b7..3fbcf3545 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,13 +17,15 @@ "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": "catalog:", + "@workflow-builder/ai-config": "workspace:*", "@workflow-builder/execution-core": "workspace:*", + "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", "@workflowbuilder/temporal": "workspace:*", - "ai": "^6.0.168", + "ai": "catalog:", "dotenv": "^17.4.2", "drizzle-orm": "^0.44.0", "hono": "^4.7.0", 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 179170e7d..87bf268ac 100644 --- a/apps/backend/src/engine/index.ts +++ b/apps/backend/src/engine/index.ts @@ -2,9 +2,13 @@ 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 { env } from '../env'; +// 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; @@ -13,7 +17,10 @@ export function getWorkflowEngine(): WorkflowEnginePort { 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 () => new Client({ connection: await Connection.connect({ address: env.TEMPORAL_ADDRESS }) }), + client: async () => { + const connection = await Connection.connect(temporal.connection); + return new Client({ connection, namespace: temporal.namespace }); + }, }); } return engine; 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/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 8ebc5066c..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,11 +72,9 @@ export function createVisualizeRoutes( const { content, format } = parsed.data; try { - const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); - // Unlike the worker's AI agent activity, this route has no outer retry - // policy, so the SDK's default retries stay on. + 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/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx index 97877fc9d..ef537ec21 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx @@ -154,30 +154,68 @@ Temporal ready [ai-studio] ➜ Local: http://127.0.0.1:4201/ ``` -Open [http://localhost:4201](http://localhost:4201). Pick the "Sales Inquiry" template, click Play. The Temporal UI at [http://localhost:8233](http://localhost:8233) shows the running execution. +Open [http://localhost:4201](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](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 +```dotenv +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. + +### Connect a secured or external Temporal (optional) + +`pnpm infra:up` runs a plaintext dev cluster on `localhost:7233`. The connection is entirely env-driven, so an operated cluster or Temporal Cloud needs no code change. Set the same values in both `apps/backend/.env` and `apps/execution-worker/.env` — the two must agree on the namespace, or the worker polls a queue nobody submits to. + +| Variable | Purpose | Default | +| ------------------------ | -------------------------------------------------------------- | ---------------- | +| `TEMPORAL_ADDRESS` | `host:port` of the cluster | `127.0.0.1:7233` | +| `TEMPORAL_NAMESPACE` | Namespace to use | `default` | +| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty (infer) | +| `TEMPORAL_API_KEY` | API-key authentication (Temporal Cloud). Implies TLS | — | +| `TEMPORAL_TLS_CA_PATH` | PEM of a private certificate authority | — | +| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set together with the key | — | +| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set together with the certificate | — | + +Any credential turns TLS on by itself, so `TEMPORAL_TLS` is only needed to force TLS without credentials or to assert plaintext. Contradictions — half an mTLS pair, an API key together with a client certificate, or credentials alongside `TEMPORAL_TLS=false` — are rejected with an explanatory error when the connection opens. + +Temporal Cloud: + +```dotenv +TEMPORAL_ADDRESS=..tmprl.cloud:7233 +TEMPORAL_NAMESPACE=. +TEMPORAL_API_KEY= +``` + +A self-hosted cluster behind mTLS with a private CA: + +```dotenv +TEMPORAL_ADDRESS=temporal.internal:7233 +TEMPORAL_NAMESPACE=workflows +TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem +TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem +TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem +``` + +The Docker Compose deployment under `deploy/ai-studio/` reads the same variables and additionally lets you retire its bundled cluster; see its README for the `COMPOSE_FILE` switch and the `tls/` mount for certificate files. ## 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`. | +| Backend or worker exits at boot with a `TEMPORAL_TLS` or `TEMPORAL_TLS_*_PATH` error | Contradictory Temporal settings (half an mTLS pair, API key plus client cert, credentials with `TEMPORAL_TLS=false`) | Remove one side, as the message says. Both `.env` files must carry the same values. | +| `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. | ## See also diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 94e2ecd4c..afb9418dc 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -1,8 +1,36 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder TEMPORAL_ADDRESS=127.0.0.1:7233 +# Must match the backend'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= -# OpenRouter — any model -OPENROUTER_API_KEY=sk-or-... +# LLM for AI Agent nodes. Optional: leave empty and the worker still starts and +# runs every other node type — AI Agent nodes then fail with `ai_not_configured`. +# The three AI_* variables are all-or-nothing (see packages/ai-config/README.md). +# 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 # Tavily web search (optional). Enables the AI Agent's "Web search" tool. Get a diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index a9aa5d107..b2140ba7e 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -26,21 +26,44 @@ Requires Postgres + Temporal running. Start them with `pnpm infra:up`. ## Environment -See `.env.example`. Required: - -| Var | Purpose | Default | -| -------------------- | ---------------------------------- | ---------------------------------------------------- | -| `OPENROUTER_API_KEY` | AI agent activities (**required**) | — | -| `DATABASE_URL` | Execution events + status | `postgresql://wb:wb@127.0.0.1:5432/workflow_builder` | -| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | -| `AI_MODEL` | OpenRouter model ID | `anthropic/claude-3.5-haiku` | +See `.env.example`. Everything the bundled dev stack needs has a working default; the `AI_*` trio is optional: + +| Var | Purpose | Default | +| -------------------- | ------------------------------------- | ---------------------------------------------------- | +| `DATABASE_URL` | Execution events + status | `postgresql://wb:wb@127.0.0.1:5432/workflow_builder` | +| `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | +| `TEMPORAL_NAMESPACE` | Namespace. Must match the backend's | `default` | +| `AI_API_KEY` | LLM for AI Agent nodes (optional) | — (AI Agent nodes fail) | +| `AI_BASE_URL` | Any OpenAI-compatible endpoint | — (AI Agent nodes fail) | +| `AI_MODEL` | Model id, as the endpoint spells it | — (AI Agent nodes fail) | +| `TAVILY_API_KEY` | AI Agent's web-search tool (optional) | — (tool disabled) | + +The three `AI_*` variables are optional by design, but all-or-nothing — the contract is described +once in [`@workflow-builder/ai-config`](../../packages/ai-config/README.md), which both apps read +through. The worker boots without them and runs every non-AI node, and an AI Agent node that is +reached fails with the `ai_not_configured` code rather than taking the whole worker down. +`AI_API_KEY` was previously called `OPENROUTER_API_KEY`; the old name is no longer read. + +Point `AI_BASE_URL` at any OpenAI-compatible server — a gateway, or a model hosted inside your +own network — and model requests stay inside it. That covers the model only: the optional +web-search tool calls Tavily's API whenever `TAVILY_API_KEY` is set, a node enables web search and +the model invokes the tool, so leave the key unset if nothing may call out; Temporal and the +database go wherever `TEMPORAL_ADDRESS` and `DATABASE_URL` point. There is no built-in endpoint or model: +`.env.example` pre-fills the OpenRouter values the worker used before they became configurable. + +The connection to Temporal is env-driven too: `TEMPORAL_TLS`, `TEMPORAL_API_KEY` and the +`TEMPORAL_TLS_CA_PATH` / `TEMPORAL_TLS_CERT_PATH` / `TEMPORAL_TLS_KEY_PATH` trio cover a hardened +cluster or Temporal Cloud. Both apps read them through +[`@workflow-builder/temporal-connection`](../../packages/temporal-connection/README.md), so the rules +cannot drift, but each environment must still agree on the namespace — the full table is in +[`apps/backend/README.md`](../backend/README.md#connecting-to-a-secured-temporal-cluster). ## Structure ``` src/ ├── database.ts # Raw SQL for exec events + status updates (no Drizzle — avoids backend schema coupling) -├── env.ts # Centralized env validation — fail fast at module load +├── env.ts # Env reading with the defaults documented above (TEMPORAL_* come from @workflow-builder/temporal-connection) └── engines/ └── temporal/ ├── worker.ts # Worker bootstrap: executors + store, handed to WorkflowBuilderPlugin @@ -54,9 +77,10 @@ own: one executor per node type and the database as the store port. ## Temporal specifics - **Task queue:** `workflow-execution`, read from `plugin.taskQueue` so the backend and the worker cannot drift apart. Both default to the same constant in the package. +- **Namespace:** `TEMPORAL_NAMESPACE`, default `default`. Unlike the task queue this is _not_ shared through the plugin: both apps read it through `@workflow-builder/temporal-connection`, but each environment has to set the same value — a mismatch is silent, the worker simply never sees the backend's submissions. - **Workflow ID:** `execution-` — deterministic, lets the backend cancel by execution ID. Also owned by the package. - **Activity timeouts:** DB activities get 30s / 5 retries; node activities (may call LLMs) get 10m / 2 retries. Exported as `DEFAULT_DATABASE_ACTIVITY_PROFILE` and `DEFAULT_NODE_ACTIVITY_PROFILE`. -- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior — the reference executors have not been classified yet. +- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior. Of the reference executors, only the AI Agent's `ai_not_configured` is classified (permanent) so far; the rest are still unclassified. - **Sandbox constraint:** `workflows.ts` is bundled into V8 with no Web APIs. It may only re-export from `@workflowbuilder/temporal/workflow`, never from the package root. - **Editing the package:** the worker imports its built `dist`, so run `pnpm build:temporal` after changing `packages/temporal/src`. - **Deploys that change the emitted event set:** drain in-flight runs first. Replaying an old run's history against a new emit sequence diverges — see [`replay-audit.md`](../../packages/execution-core/replay-audit.md) rule 9. diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index b0ee15a9b..d1c51274d 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -14,13 +14,15 @@ "test:watch": "vitest" }, "dependencies": { - "@openrouter/ai-sdk-provider": "^2.5.0", + "@ai-sdk/openai-compatible": "catalog:", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", + "@workflow-builder/ai-config": "workspace:*", "@workflow-builder/execution-core": "workspace:*", + "@workflow-builder/temporal-connection": "workspace:*", "@workflow-builder/types": "workspace:*", "@workflowbuilder/temporal": "workspace:*", - "ai": "^6.0.0", + "ai": "catalog:", "dotenv": "^17.4.2", "postgres": "^3.4.5", "tsx": "^4.19.3" diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 60732cc47..db0ce4831 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -3,22 +3,35 @@ import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal'; import 'dotenv/config'; import { fileURLToPath } from 'node:url'; -import { executeAiAgent } from '../../activities/ai-agent'; +import { aiConfig, retiredAiVariables } from '@workflow-builder/ai-config'; +import { temporalConfig } from '@workflow-builder/temporal-connection'; + import { database } from '../../database'; import type { AiStudioNode } from '../../domain/ai-studio-nodes'; import { env } from '../../env'; +import { createAiAgentExecutor } from '../../executors/ai-agent'; import { executeDecision } from '../../executors/decision'; import { executeTrigger } from '../../executors/trigger'; import { executeVisualize } from '../../executors/visualize'; import { logger } from '../../logger'; import { withPayloadSizeWarning } from '../../store-payload-warning'; -const { createOpenRouter } = await import('@openrouter/ai-sdk-provider'); - -const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); -const model = openrouter.chat(env.AI_MODEL); - -const aiAgentLogger = logger.child({ component: 'ai-agent' }); +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 is now ignored. Only the name is logged, never the value. + const retired = retiredAiVariables(); + logger.warn('AI not configured — AI Agent nodes will fail; every other node type runs as usual', { + missing: ai.missing, + ...(retired.length > 0 ? { retired } : {}), + }); +} + +const executeAIAgent = createAiAgentExecutor({ + ai, + logger: logger.child({ component: 'ai-agent' }), + tavilyApiKey: env.TAVILY_API_KEY, +}); // The plugin contributes the three activities that execute a graph. What each node // type actually does stays here, and so does where events are persisted. @@ -26,22 +39,24 @@ const plugin = new WorkflowBuilderPlugin({ executors: { 'ai-studio/trigger': executeTrigger, 'ai-studio/decision': executeDecision, - 'ai-studio/ai-agent': (node, context) => - executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }), + 'ai-studio/ai-agent': executeAIAgent, 'ai-studio/visualize': executeVisualize, }, store: withPayloadSizeWarning(database, logger), }); -// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS -const connection = await NativeConnection.connect({ address: env.TEMPORAL_ADDRESS }); +// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS. +// Contradictory TEMPORAL_* values throw here, before the worker starts polling. +const temporal = temporalConfig(); +const connection = await NativeConnection.connect(temporal.connection); const worker = await Worker.create({ connection, + namespace: temporal.namespace, taskQueue: plugin.taskQueue, workflowsPath: fileURLToPath(new URL('workflows.ts', import.meta.url)), plugins: [plugin], }); -logger.info('execution worker started', { taskQueue: plugin.taskQueue }); +logger.info('execution worker started', { taskQueue: plugin.taskQueue, namespace: temporal.namespace }); await worker.run(); diff --git a/apps/execution-worker/src/env.test.ts b/apps/execution-worker/src/env.test.ts new file mode 100644 index 000000000..790976481 --- /dev/null +++ b/apps/execution-worker/src/env.test.ts @@ -0,0 +1,52 @@ +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('TAVILY_API_KEY', 'ambient-key'); + + const env = await loadEnv({}); + + expect(env.TAVILY_API_KEY).toBeUndefined(); + }); +}); + +describe('TAVILY_API_KEY', () => { + // compose passes it through as `${TAVILY_API_KEY:-}`, so '' must disable the tool like unset does + it('reads an empty value as unset', async () => { + const env = await loadEnv({ TAVILY_API_KEY: '' }); + + expect(env.TAVILY_API_KEY).toBeUndefined(); + }); + + it('reads a key', async () => { + const env = await loadEnv({ TAVILY_API_KEY: 'tvly-key' }); + + expect(env.TAVILY_API_KEY).toBe('tvly-key'); + }); +}); diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index 5bbd6a61a..7e5d5b83a 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -1,12 +1,3 @@ -// Centralized env — fail fast at module load with a readable message. -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is required — see apps/execution-worker/.env.example`); - } - return value; -} - function envOr(name: string, defaultValue: string): string { return process.env[name] ?? defaultValue; } @@ -15,10 +6,9 @@ function envOr(name: string, defaultValue: string): string { // bindings; see apps/backend/src/env.ts for the full reason. export const env = { 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'), - OPENROUTER_API_KEY: requireEnv('OPENROUTER_API_KEY'), - // Cheap, fast default for the public demo; quality-per-cost over frontier capability. - AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), + // TEMPORAL_*: read at startup by @workflow-builder/temporal-connection. + // AI_API_KEY / AI_BASE_URL / AI_MODEL: read at startup by @workflow-builder/ai-config. // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. - TAVILY_API_KEY: process.env['TAVILY_API_KEY'], + // Empty counts as unset: compose passes it through as `${TAVILY_API_KEY:-}`. + TAVILY_API_KEY: process.env['TAVILY_API_KEY'] || undefined, }; diff --git a/apps/execution-worker/src/executors/ai-agent.test.ts b/apps/execution-worker/src/executors/ai-agent.test.ts new file mode 100644 index 000000000..087229b5c --- /dev/null +++ b/apps/execution-worker/src/executors/ai-agent.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { aiConfig } from '@workflow-builder/ai-config'; +import { + type ExecutionContext, + NodeExecutionError, + PermanentNodeExecutionError, + classifyNodeError, +} from '@workflow-builder/execution-core'; + +import type { AiAgentNode } from '../domain/ai-studio-nodes'; +import { createAiAgentExecutor } from './ai-agent'; + +function context(): ExecutionContext { + return { + workflowId: 'wf', + executionId: 'exec', + triggerPayload: {}, + nodeOutputs: {}, + variables: {}, + global: {}, + }; +} + +const node: AiAgentNode = { + id: 'a1', + type: 'ai-studio/ai-agent', + config: { systemPrompt: 'Summarise the input.' }, +}; + +const endpoint = { AI_BASE_URL: 'https://openrouter.ai/api/v1', AI_MODEL: 'some/model' }; + +describe('createAiAgentExecutor without a key', () => { + const executor = createAiAgentExecutor({ ai: aiConfig(endpoint) }); + + it('fails the node instead of the worker boot', () => { + // The factory itself must not throw — that is what lets the worker start and + // keep serving Trigger/Decision/Visualize nodes. + expect(() => executor(node, context())).toThrow(NodeExecutionError); + }); + + it('reports a code the UI can key off, and names the variable to set', () => { + try { + executor(node, context()); + expect.unreachable('executor should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NodeExecutionError); + expect((error as NodeExecutionError).code).toBe('ai_not_configured'); + expect((error as NodeExecutionError).message).toContain('AI_API_KEY'); + } + }); + + it('is permanent, so the engine adapter stops after one attempt', () => { + try { + executor(node, context()); + expect.unreachable('executor should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(PermanentNodeExecutionError); + expect(classifyNodeError(error)).toBe('permanent'); + } + }); +}); + +describe('createAiAgentExecutor with a key', () => { + it('builds the executor without calling the endpoint', () => { + // Construction is eager (the model is built once per worker), so it has to + // stay free of network I/O — the endpoint may not even be reachable at boot. + const executor = createAiAgentExecutor({ ai: aiConfig({ ...endpoint, AI_API_KEY: 'test-key' }) }); + + expect(executor).toBeTypeOf('function'); + }); +}); + +describe('createAiAgentExecutor with a key but no endpoint or model', () => { + // Neither has a built-in default, so they gate the node exactly like the key does. + it('fails the node with the same code and names only the missing variables', () => { + const executor = createAiAgentExecutor({ ai: aiConfig({ AI_API_KEY: 'key' }) }); + + try { + executor(node, context()); + expect.unreachable('executor should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NodeExecutionError); + expect((error as NodeExecutionError).code).toBe('ai_not_configured'); + expect((error as NodeExecutionError).message).toContain('AI_BASE_URL'); + expect((error as NodeExecutionError).message).toContain('AI_MODEL'); + expect((error as NodeExecutionError).message).not.toContain('AI_API_KEY'); + } + }); +}); diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts new file mode 100644 index 000000000..25c801a93 --- /dev/null +++ b/apps/execution-worker/src/executors/ai-agent.ts @@ -0,0 +1,38 @@ +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; + +import type { AiConfigResult } from '@workflow-builder/ai-config'; +import { type LoggerPort, type NodeExecutor, PermanentNodeExecutionError } from '@workflow-builder/execution-core'; + +import { executeAiAgent } from '../activities/ai-agent'; +import type { AiAgentNode } from '../domain/ai-studio-nodes'; + +type AiAgentExecutorOptions = { + // Unavailable is allowed: the worker still boots; only this node type is + // unavailable, so a graph of Trigger/Decision/Visualize nodes runs fine. + ai: AiConfigResult; + logger?: LoggerPort; + tavilyApiKey?: string; +}; + +export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExecutor { + const { ai, logger, tavilyApiKey } = options; + + if (!ai.available) { + const missing = ai.missing.join(', '); + // Thrown when the node is reached rather than at boot, so missing config + // costs one failed node instead of the whole worker. Permanent: a retry + // cannot find configuration that is not there. + return () => { + throw new PermanentNodeExecutionError( + 'ai_not_configured', + `AI is not configured on this worker — set ${missing} (see apps/execution-worker/.env.example).`, + ); + }; + } + + const { apiKey, baseURL, modelId } = ai.config; + const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey }); + const model = provider.chatModel(modelId); + + return (node, context) => executeAiAgent(node, context, { model, logger, tavilyApiKey }); +} diff --git a/apps/icons/package.json b/apps/icons/package.json index 56e58a9cf..d51b79cae 100644 --- a/apps/icons/package.json +++ b/apps/icons/package.json @@ -12,7 +12,7 @@ }, "devDependencies": { "@phosphor-icons/core": "catalog:", - "@svgr/core": "^8.1.0", + "@svgr/cli": "^8.1.0", "@types/react": "catalog:", "react": "catalog:" }, diff --git a/apps/icons/src/generate-icons.ts b/apps/icons/src/generate-icons.ts index 8158f937b..54ab953a6 100644 --- a/apps/icons/src/generate-icons.ts +++ b/apps/icons/src/generate-icons.ts @@ -12,7 +12,9 @@ export function generateIcons() { setupOutputDirectory(); for (const path of sources) { - execSync(`npx @svgr/cli --no-index --typescript --out-dir ${outputDirectory} -- ${path}`); + // `pnpm exec`, not `npx`: npx would fetch @svgr/cli from the registry on every + // build, which breaks an offline install and an air-gapped image build. + execSync(`pnpm exec svgr --no-index --typescript --out-dir ${outputDirectory} -- ${path}`); } const keys = generateKeys(); diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 6d250ca1d..30a119d91 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -1,21 +1,35 @@ -# Copy to .env next to docker-compose.yml and fill in. Everything except -# OPENROUTER_API_KEY has a working default. +# Copy to .env next to docker-compose.yml and fill in. The stack comes up with +# none of these set. AI Agent nodes need AI_API_KEY, AI_BASE_URL and AI_MODEL; +# without them the stack runs and every other node type works, while AI Agent +# nodes fail with `ai_not_configured`. -# --- required --------------------------------------------------------------- +# --- LLM -------------------------------------------------------------------- -# Server-side only; never reaches the browser. Pair it with an OpenRouter -# account Guardrail (hard $/day ceiling) — see README "Spend safety". -OPENROUTER_API_KEY= +# Server-side only; never reaches the browser. Pair it with a provider-side +# spend cap (hard $/day ceiling) — see README "Spend safety". Empty keeps AI +# Agent nodes off; OpenRouter keys look like sk-or-v1-... +# Renamed from OPENROUTER_API_KEY, which is no longer read: rename it here rather +# than adding this one next to it. The deploy workflow refuses to run while a +# deployed .env still carries the old name. +AI_API_KEY= -# --- LLM -------------------------------------------------------------------- +# Any OpenAI-compatible endpoint. There is no built-in default: the value below +# is the OpenRouter setup the stack used before the endpoint became configurable. +# Point it at a gateway or a model inside your own network and model requests +# stay inside it (the web search below is separate: leave TAVILY_API_KEY empty +# if nothing may call out). +AI_BASE_URL=https://openrouter.ai/api/v1 -# Demo model. Cheap, EU-hosted, solid tool calling. +# Model id as the endpoint above understands it. This one +# is the demo pick: cheap, EU-hosted, solid tool calling. # ~$0.075/M input + $0.20/M output => ~$0.0004 per 3-call template run. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct # Tavily web search (optional). Enables the AI Agent's "Web search" tool - # free key at https://tavily.com (~1000 searches/month). Leave empty to # disable: agents with web search toggled on still run, just without the tool. +# A key opens egress to api.tavily.com regardless of AI_BASE_URL - keep it +# empty inside an air gap (README "What still needs egress"). TAVILY_API_KEY= # --- abuse gate (per-IP, execute route) --------------------------------------- @@ -35,6 +49,61 @@ WEB_PORT=8080 # served from a different host than the backend. VITE_BACKEND_URL= +# --- temporal ----------------------------------------------------------------- + +# Leave these alone to use the bundled dev-grade cluster (see README "Known +# limitations"). To run against an operated cluster or Temporal Cloud instead, +# point them at it — the backend and the worker read the same values and must +# agree on the namespace — and set COMPOSE_FILE so the bundled cluster is not +# started at all (it lives in docker-compose.override.yml, which compose applies +# by default; the apps then depend only on app-db): +# +# COMPOSE_FILE=docker-compose.yml +# TEMPORAL_ADDRESS=..tmprl.cloud:7233 +# TEMPORAL_NAMESPACE=. +# TEMPORAL_API_KEY= +# +# Run `docker compose down --remove-orphans` once when switching, so the retired +# temporal containers from the bundled setup are removed. +# +# TEMPORAL_TLS: empty infers (any credential turns TLS on by itself), `true` requires +# TLS with the OS trust store, `false` asserts plaintext. +TEMPORAL_ADDRESS=temporal:7233 +TEMPORAL_NAMESPACE=default +TEMPORAL_TLS= +TEMPORAL_API_KEY= + +# Private CA or mTLS. Drop the PEM files into ./tls, or point TEMPORAL_TLS_DIR at a +# directory OUTSIDE the checkout (an absolute path such as /etc/wb-tls). Both +# containers see it read-only at /etc/workflowbuilder/tls, so the three paths below +# are container paths: +# +# TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem +# TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem +# TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem +# +# CA alone covers a private issuer without client auth. Cert and key go together, +# and a client certificate excludes TEMPORAL_API_KEY. +# +# Never use any other directory inside the repository. A local `docker compose +# up --build` sends the whole checkout as the build context, and the Dockerfile +# copies it into the runtime image; the read-only mount does not remove that +# second copy. Only ./tls (and deploy/ as a whole, plus *.pem/*.key/*.crt/*.p12/ +# *.pfx anywhere) is excluded by .dockerignore, so a key parked elsewhere in the +# checkout ships to everyone who can pull the image. +TEMPORAL_TLS_DIR=./tls +TEMPORAL_TLS_CA_PATH= +TEMPORAL_TLS_CERT_PATH= +TEMPORAL_TLS_KEY_PATH= + +# --- images ------------------------------------------------------------------- + +# Prebuilt runtime and web images from a registry. Leave empty to build locally +# (`docker compose up --build`). On the deploy VM the workflow rewrites these two +# lines with the exact tags it pushed, so later compose commands keep using them. +RUNTIME_IMAGE= +WEB_IMAGE= + # --- databases (internal network only, not published) ------------------------- APP_DB_PASSWORD=wb diff --git a/deploy/ai-studio/Dockerfile b/deploy/ai-studio/Dockerfile index 1f3af0b03..e54879fcf 100644 --- a/deploy/ai-studio/Dockerfile +++ b/deploy/ai-studio/Dockerfile @@ -1,12 +1,17 @@ -# syntax=docker/dockerfile:1 - # Targets: runtime (backend + worker, command chosen per compose service), # web (nginx, SPA + /api proxy). Build context must be the repo root — # workspace packages are linked via pnpm `workspace:*`. # +# No `# syntax=` line on purpose: it would pull the build frontend from Docker Hub +# as an unpinned fourth download. Needs Docker Engine 23+ (built-in BuildKit frontend). +# # Exact Node pin: engineStrict rejects any other version. pnpm via npm, not # corepack — this Node's corepack cannot load pnpm 10 # (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). Keep in sync with `packageManager`. +# +# The cache mount only spares the npm download on rebuilds. This step and `pnpm fetch` +# are the two that need the registry, which is why an air-gapped host loads prebuilt +# images instead of building (README "Air-gapped / offline install"). FROM node:22.12.0-bookworm-slim AS base ENV PNPM_HOME=/pnpm \ PATH="/pnpm:$PATH" \ @@ -14,13 +19,19 @@ ENV PNPM_HOME=/pnpm \ HUSKY=0 \ npm_config_store_dir=/pnpm/store \ CI=true -RUN npm install -g pnpm@10.17.0 +RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ + npm install -g pnpm@10.17.0 --prefer-offline WORKDIR /app +# `useNodeVersion` would make pnpm download Node from nodejs.org for lifecycle scripts: +# fatal with no network, redundant on this base image (engineStrict still pins it). +# Stripped in the image only, twice because `COPY . .` restores the file. FROM base AS source COPY pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --network=none sed -i '/^useNodeVersion:/d' pnpm-workspace.yaml RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch COPY . . +RUN --network=none sed -i '/^useNodeVersion:/d' pnpm-workspace.yaml # @workflowbuilder/temporal is the one workspace dependency of backend/worker that # ships built output: its `exports` point at ./dist, and .dockerignore keeps dist out @@ -28,31 +39,37 @@ COPY . . # because tsup is a devDependency and that install is --prod. So it is built here, # with dev dependencies present, and only the result is carried over. FROM source AS package-build -RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm install --frozen-lockfile --prefer-offline --filter @workflowbuilder/temporal... -RUN pnpm build:temporal +RUN --network=none --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --offline --filter @workflowbuilder/temporal... +RUN --network=none pnpm build:temporal # tsx runs TS directly — required anyway for the worker, whose workflow # sandbox bundles from TS source on disk at runtime. -# --prefer-offline (not --offline): offline mode leaks into lifecycle -# scripts and breaks the icons build, which shells out to npx. +# +# Every RUN after `pnpm fetch` is --network=none: `--offline` stops only pnpm's +# resolver, not lifecycle scripts or builds. tools/check-offline-build.mjs +# enforces it from `pnpm check`. +# +# Inherits the whole-lockfile virtual store that `pnpm fetch` materialised (~1.5 GB, +# docs and release toolchains included). A clean stage fed by `pnpm deploy --prod` +# would ship only backend + worker (follow-up: slim-runtime-image). FROM source AS runtime COPY --from=package-build /app/packages/temporal/dist ./packages/temporal/dist -RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ +RUN --network=none --mount=type=cache,id=pnpm-store,target=/pnpm/store \ # `prepare` runs husky at the root, and `tsup` in packages/temporal — neither is # available in a --prod install, and the dist copied above is what it would produce npm pkg delete scripts.prepare && \ (cd packages/temporal && npm pkg delete scripts.prepare) && \ - pnpm install --frozen-lockfile --prefer-offline --prod \ + pnpm install --frozen-lockfile --offline --prod \ --filter backend... --filter execution-worker... # VITE_BACKEND_URL is baked at build time; empty = same-origin /api, # proxied by the web target's nginx. FROM source AS frontend-build ARG VITE_BACKEND_URL= -RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm install --frozen-lockfile --prefer-offline --filter @workflow-builder/ai-studio... -RUN VITE_BACKEND_URL=$VITE_BACKEND_URL pnpm build:ai-studio +RUN --network=none --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --offline --filter @workflow-builder/ai-studio... +RUN --network=none VITE_BACKEND_URL=$VITE_BACKEND_URL pnpm build:ai-studio FROM nginx:1.31-alpine AS web COPY deploy/ai-studio/nginx/default.conf /etc/nginx/conf.d/default.conf diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 530155ac3..7824241b7 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -1,19 +1,25 @@ # Deploying AI Studio -Self-contained, portable deployment of the AI Studio stack (WB-229). Runs on +Self-contained, portable deployment of the AI Studio stack. Runs on any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue. ## What runs -| Service | Image | Role | Exposed | -| ------------- | ------------------------------ | ----------------------------------------------- | ------------------------ | -| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | -| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | -| `worker` | `ai-studio-runtime` | Temporal worker, makes the OpenRouter LLM calls | internal | -| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | -| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | -| `temporal-db` | `postgres:16` | Temporal's own state store | internal | -| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | +| Service | Image | Role | Exposed | +| ------------- | ------------------------------ | ---------------------------------------------------------------------- | ------------------------ | +| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | +| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream; calls the LLM for `/api/visualize/adapt` | internal | +| `worker` | `ai-studio-runtime` | Temporal worker, runs the nodes; AI Agent nodes call the LLM | internal | +| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | +| `app-db` | `postgres:16.15` | Workflow snapshots + execution events | internal | +| `temporal-db` | `postgres:16.15` | Temporal's own state store | internal | +| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | + +The three Temporal rows come from +[`docker-compose.override.yml`](docker-compose.override.yml), which compose +applies on top of [`docker-compose.yml`](docker-compose.yml) by default. The base +file alone has no cluster: the apps connect to whatever `TEMPORAL_ADDRESS` names +and depend only on `app-db` — see "Pointing at a different Temporal". Both images build from one Dockerfile (`deploy/ai-studio/Dockerfile`) with the repo root as context. Backend and worker share a single image and differ only @@ -25,7 +31,7 @@ service or step. ```bash cd deploy/ai-studio -cp .env.example .env # set OPENROUTER_API_KEY +cp .env.example .env # set AI_API_KEY to enable AI Agent nodes docker compose up -d --build ``` @@ -40,6 +46,142 @@ curl -s http://localhost:8080/api/health # {"status":"ok"} # open http://localhost:8080, run the "Sales Inquiry Pipeline" template ``` +## Air-gapped / offline install + +The host that runs the stack needs no internet access — but the machine that +builds the images does. The Dockerfile reaches the network in exactly three +places: pulling base images, installing pnpm itself, and `pnpm fetch` of the +package store. Every `RUN` after that fetch is `--network=none`, so BuildKit +cuts egress for the whole step — installs, lifecycle scripts and build +commands included (`--offline` alone would only stop pnpm's own resolver). A +step that needs the network fails every ordinary build, and +`pnpm check:offline-build` fails if a post-fetch step ever loses the flag or +adds a download of its own (`ADD `, `COPY --from=`). +That per-step guarantee is the enforceable one: a whole-build +`docker build --network none` cannot pass, because the pnpm bootstrap and +`pnpm fetch` need the registry by design. The Dockerfile also carries no +`# syntax=` directive, which would pull the build frontend from Docker Hub as +an unpinned fourth download; the guard rejects one, and the packing machine +needs Docker Engine 23 or later for the built-in frontend instead. So don't +build on the air-gapped host — build on a connected machine and ship the +images. + +Shipping prebuilt images is the one supported air-gapped model. Building +inside the gap from a customer-side registry mirror is not: there is no +`.npmrc` to point at a mirror and no mirror procedure, and none is planned as +long as the image route covers the need. The trade-off is that the bundle is +platform-specific: the base images are, and so are the native packages pnpm +selects for the build platform during the image's install (esbuild behind +`tsx`, swc). The Temporal worker's Rust core is the exception — one package +carries the binary for every supported platform and picks at runtime — but the +rest means the images must be built for the destination platform; see the +platform note under step 1. + +The air-gapped host needs exactly one thing preinstalled: Docker Engine with +the Compose v2 plugin (plus ~3 GB of disk for the loaded images). Compose v1 +cannot parse the nested `${A:+${B:?}}` interpolation that the retired-key +guard at the top of `docker-compose.yml` relies on. + +### 1. Build and pack on a connected machine + +```bash +cd deploy/ai-studio +./pack-offline.sh ~/ai-studio-offline # any directory outside the checkout +``` + +The script builds both images, pulls the infra images and writes one directory +to ship. It refuses a directory inside the checkout: the repo root is the image +build context, so a bundle left there would be copied into the next build. + +- `ai-studio-images.tar` (~1 GB): `ai-studio-runtime`, `ai-studio-web`, + Postgres, Temporal and the Temporal UI (drop `--profile debug` from the + script to leave the UI out and save ~100 MB) +- `ai-studio-images.tar.sha256`: checksum to verify on the host +- `ai-studio-images.manifest.txt`: `docker image inspect` of every image in + the tarball — tags, registry digests, image IDs, platform. The compose files + pin tags, not digests, so this file is the record of exactly which builds + shipped; keep it with the bundle. +- `docker-compose.yml`, `docker-compose.override.yml`, `.env.example` and an + empty `tls/` (the nginx config is already baked into the `web` image) + +Build for the destination platform, not the packing machine's. On an ARM Mac +packing for an x86 host, put `DOCKER_DEFAULT_PLATFORM=linux/amd64` in front of +the script: `docker save` ships exactly what you built (slower under emulation, +but correct), and an image built for the wrong platform fails at container +start, not at load. The manifest's last column shows the platform of every +image in the tarball — check it before shipping. + +### 2. Ship to the host + +Move the bundle directory across the gap (USB drive, scp over the internal +network — whatever your process allows). + +### 3. Verify, load and start on the host + +```bash +cd ai-studio-offline # wherever you copied the bundle to +shasum -a 256 -c ai-studio-images.tar.sha256 # or: sha256sum -c ai-studio-images.tar.sha256 +docker load -i ai-studio-images.tar +cp .env.example .env # set AI_API_KEY, AI_BASE_URL, AI_MODEL — see "What still needs egress" +docker compose up -d --no-build # --no-build: use the loaded images, never rebuild here +``` + +First boot behaves exactly as in Quick start: the backend applies migrations +before serving, and the worker crash-loops for ~30s until Temporal finishes +auto-setup. + +### 4. Connect + +Only the `web` container publishes a port. The backend, Temporal, and both +databases stay on the internal Docker network — you reach the API through the +nginx inside `web`, on the same port as the SPA: + +```bash +# on the host itself +curl http://localhost:8080/api/health # {"status":"ok"} +``` + +From another machine on the same network, open `http://:8080` in a +browser (the SPA calls `/api` on its own origin — there is no separate +backend address to configure). If the host answers locally but not from +outside, it's the host firewall: allow `WEB_PORT` (default 8080) in. The +default `WEB_BIND=0.0.0.0` already listens on all interfaces; set +`WEB_BIND=127.0.0.1` only when a host-level reverse proxy should be the sole +way in (see "TLS / going public"). + +### Image versions + +Every image this stack does not build itself is pinned to a version tag: +`node` and `nginx` in [Dockerfile](Dockerfile), Postgres in both compose files, +Temporal and its UI in +[docker-compose.override.yml](docker-compose.override.yml). All are exact +releases except `nginx`, pinned to its 1.31 minor line. Tags are not +digests: a base image can be rebuilt under the same tag, which is how it +receives OS security patches, so two packs made months apart can differ in +those layers. The manifest the script writes records the digests that actually +shipped, so a bundle is always traceable to its exact contents. Bump a tag in +the file that holds it; the Postgres tag appears in both compose files. + +### What still needs egress + +"Air-gapped" covers the install — nothing above pulls from a registry at +deploy time. At runtime the stack has three optional egress paths, each behind +one setting. Zero egress means all three are closed: + +| Path | Destination | Closed when | +| ----------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| LLM calls (backend + worker) | whatever `AI_BASE_URL` names | `AI_BASE_URL` points at an OpenAI-compatible endpoint inside your network ("Pointing at a different LLM" under Configuration) | +| AI Agent web-search tool (worker) | `api.tavily.com`, not configurable | `TAVILY_API_KEY` is empty — agents with web search toggled on still run, just without the tool | +| Turnstile bot check (SPA + backend) | `challenges.cloudflare.com` | Always, in this deployment: the Dockerfile has no `VITE_TURNSTILE_SITE_KEY` build arg and compose passes no `TURNSTILE_SECRET_KEY`, so neither side ever contacts Cloudflare | + +The SPA itself loads nothing external: Poppins is bundled with the build and +served as `/assets/*.woff2` by the `web` container, so the browser talks only +to that container. +With the pre-filled `AI_BASE_URL` (OpenRouter) the one required destination is +`openrouter.ai:443`; without it the stack runs and every ordinary node works, +while AI Agent nodes and the visualize route fail. On a restricted network, +allow-list that host. + ## Spend safety (do not skip) Two independent controls; both must be in place before the URL goes public: @@ -77,9 +219,59 @@ this compose never publishes them; don't undo that. ## Configuration See [.env.example](.env.example) — every variable is documented there. -Swapping the LLM is a one-liner: change `AI_MODEL` to any -[OpenRouter model id](https://openrouter.ai/models) and -`docker compose up -d worker`. +Swapping the model is a one-liner: change `AI_MODEL` to any id the endpoint +understands (for OpenRouter, an [OpenRouter model id](https://openrouter.ai/models)) +and `docker compose up -d worker`. + +**Pointing at a different LLM.** `AI_BASE_URL` takes any OpenAI-compatible +endpoint, so a gateway or a model hosted inside your own network works without +a code change — set it alongside `AI_API_KEY` and `AI_MODEL`. None of the three +has a built-in default; `.env.example` pre-fills the OpenRouter values the stack +used before the endpoint became configurable. Leave any of them empty and the +stack still comes up: every node type runs except AI Agent nodes, which fail +with `ai_not_configured`. + + + +**Before deploying this version.** The key is now `AI_API_KEY`, and the endpoint +and model are no longer built in, so a `.env` written for an earlier version +needs three lines before this one is deployed: + +```bash +AI_API_KEY= +AI_BASE_URL=https://openrouter.ai/api/v1 +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct +``` + +Renaming only the key is not enough: the stack comes up with every AI Agent +node failing `ai_not_configured`, because the endpoint and the model have no +built-in defaults any more. The deploy workflow refuses to run while +`OPENROUTER_API_KEY` is still set, before it writes anything to the VM, so a +stale `.env` stops the deploy instead of coming up with AI silently off. An +operator deploying by hand can run the same check: + +```bash +grep -E '^OPENROUTER_API_KEY=.+' .env # a hit means .env still needs the rename +``` + +**Pointing at a different Temporal.** Every `TEMPORAL_*` variable reaches the +backend and the worker from one shared block in the compose file, so the two +cannot disagree. `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and +`TEMPORAL_API_KEY` are all an operated cluster or Temporal Cloud needs. Add +`COMPOSE_FILE=docker-compose.yml` to `.env` at the same time: it leaves the +override file out, so the bundled cluster is not started and cannot block the +apps, and `backend` / `worker` depend only on `app-db`. Run +`docker compose down --remove-orphans` once when switching. A contradictory +`TEMPORAL_*` combination stops both apps at boot with an explanatory error +(`docker compose logs backend worker`). The bundled debug +UI (`--profile debug`) is part of the override and only ever shows the bundled +cluster — an external cluster has its own UI. For a private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted +read-only into both containers at `/etc/workflowbuilder/tls`) and set +`TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH` to those container paths — +see [.env.example](.env.example) for the exact lines. `TEMPORAL_TLS_DIR` may +point at `./tls` or at a directory outside the checkout, nothing else: the +whole repository is the image build context, so a key placed in any other +in-repo directory is copied into the runtime image by a local build. ## Operations @@ -91,6 +283,19 @@ docker compose down # stop (volumes survive) docker exec ai-studio-app-db-1 pg_dump -U wb workflow_builder > backup.sql ``` +The public demo is deployed by the `Deploy AI Studio` GitHub Actions workflow: +it builds and pushes both images to the registry, copies `docker-compose.yml` +and `docker-compose.override.yml` from the repo to the VM, writes the tags it +just pushed into the VM's `.env` as `RUNTIME_IMAGE` / `WEB_IMAGE`, and runs +compose there. A first deploy of this version onto a VM whose `.env` still +carries `OPENROUTER_API_KEY` stops before writing anything — see [Before +deploying this version](#before-deploying-this-version). +Because the tags live in `.env`, every later compose command on +the VM (`docker compose up -d worker` after a model change, `--profile debug`) +resolves the deployed images, not the local `ai-studio-*` build names. The VM's +compose files are that copy — change them in the repo, never on the VM. Only +`.env` lives on the VM alone; the deploy replaces just its two image lines. + Workflow data is treated as ephemeral for the public demo — losing the volumes is acceptable; there is nothing precious in them. @@ -98,8 +303,8 @@ volumes is acceptable; there is nothing precious in them. emitted**, let in-flight executions finish. Temporal replays a running workflow's history against the deployed code, so a run started on the old emit sequence diverges when replayed on the new one. Check for active runs in -the Temporal UI (`--profile debug`), or accept that any still running will -fail. Deploys that leave the emit sequence alone are unaffected. See +the Temporal UI (`--profile debug` for the bundled cluster, your cluster's own UI +otherwise), or accept that any still running will fail. Deploys that leave the emit sequence alone are unaffected. See [`replay-audit.md`](../../packages/execution-core/replay-audit.md) rule 9. ## Known limitations (accepted for the lean MVP) @@ -110,6 +315,7 @@ fail. Deploys that leave the emit sequence alone are unaffected. See - **Single backend replica.** The rate limiter is process-local. Scaling out needs a shared store (Redis) — deferred to the scale-ready task. - **`temporalio/auto-setup` is dev-grade.** Fine for a demo; move to Temporal - Cloud or an operated cluster for sustained load. + Cloud or an operated cluster for sustained load. That move is configuration + only — see "Pointing at a different Temporal" above. - **Anyone-can-edit demo content.** Visitors share one workspace; data is wiped whenever you decide to recreate the volumes. diff --git a/deploy/ai-studio/docker-compose.override.yml b/deploy/ai-studio/docker-compose.override.yml new file mode 100644 index 000000000..1c88ec0cc --- /dev/null +++ b/deploy/ai-studio/docker-compose.override.yml @@ -0,0 +1,62 @@ +# The bundled dev-grade Temporal cluster, plus the start-order edges that make the +# apps wait for it. Compose merges this over docker-compose.yml automatically, so +# a plain `docker compose up` runs everything locally. To use an operated cluster +# or Temporal Cloud instead, set COMPOSE_FILE=docker-compose.yml in .env: this +# file is then skipped, nothing here starts, and the apps depend only on app-db. + +services: + temporal-db: + # same tag as app-db in docker-compose.yml — bump both + image: postgres:16.15 + environment: + POSTGRES_DB: temporal + POSTGRES_USER: temporal + POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal} + volumes: + - temporal-db-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal'] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + # auto-setup is dev-grade; sustained load should move to Temporal Cloud or an + # operated cluster — the apps only consume TEMPORAL_ADDRESS + temporal: + image: temporalio/auto-setup:1.29.6.1 + depends_on: + temporal-db: + condition: service_healthy + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: temporal + POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal} + POSTGRES_SEEDS: temporal-db + restart: unless-stopped + + # Inspects this bundled cluster only. An external cluster comes with its own UI. + temporal-ui: + image: temporalio/ui:2.51.0 + profiles: [debug] + depends_on: + - temporal + environment: + TEMPORAL_ADDRESS: temporal:7233 + ports: + - '127.0.0.1:8233:8080' + restart: unless-stopped + + backend: + depends_on: + temporal: + condition: service_started + + worker: + depends_on: + temporal: + condition: service_started + +volumes: + temporal-db-data: diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 5eed67bf7..0941578f7 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -1,6 +1,15 @@ -# AI Studio production stack (WB-229). Usage: cp .env.example .env, set -# OPENROUTER_API_KEY, then `docker compose up -d --build`. Only `web` +# AI Studio production stack. Usage: cp .env.example .env, set +# AI_API_KEY, then `docker compose up -d --build`. Only `web` # publishes a port. +# +# This file has no Temporal cluster of its own: the apps connect to whatever +# TEMPORAL_ADDRESS names. The bundled dev-grade cluster lives in +# docker-compose.override.yml, which compose applies on top of this file by +# default; COMPOSE_FILE=docker-compose.yml in .env leaves it out. +# +# RUNTIME_IMAGE / WEB_IMAGE name prebuilt images from a registry; unset, the +# services build locally under the default names. The deploy workflow writes the +# tags it just pushed into the VM's .env, so this file is the one the demo VM runs too. name: ai-studio @@ -9,9 +18,28 @@ x-runtime-build: &runtime-build dockerfile: deploy/ai-studio/Dockerfile target: runtime +# Shared by backend and worker via YAML merge, so the two can never drift apart: +# the namespace must match or the worker polls a queue nobody submits to. +x-temporal-env: &temporal-env + TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233} + TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default} + TEMPORAL_TLS: ${TEMPORAL_TLS:-} + TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-} + # container paths under the mount below — see .env.example + TEMPORAL_TLS_CA_PATH: ${TEMPORAL_TLS_CA_PATH:-} + TEMPORAL_TLS_CERT_PATH: ${TEMPORAL_TLS_CERT_PATH:-} + TEMPORAL_TLS_KEY_PATH: ${TEMPORAL_TLS_KEY_PATH:-} + +# PEM files for a private CA or mTLS. ./tls ships empty (and git-ignored) so the +# mount always resolves; plaintext deployments never touch it. TEMPORAL_TLS_DIR +# must stay ./tls or leave the checkout — anything else in-repo is build context. +x-temporal-tls-volumes: &temporal-tls-volumes + - ${TEMPORAL_TLS_DIR:-./tls}:/etc/workflowbuilder/tls:ro + services: app-db: - image: postgres:16 + # same tag as temporal-db in docker-compose.override.yml — bump both + image: postgres:16.15 environment: POSTGRES_DB: workflow_builder POSTGRES_USER: wb @@ -25,57 +53,16 @@ services: retries: 12 restart: unless-stopped - temporal-db: - image: postgres:16 - environment: - POSTGRES_DB: temporal - POSTGRES_USER: temporal - POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal} - volumes: - - temporal-db-data:/var/lib/postgresql/data - healthcheck: - test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal'] - interval: 5s - timeout: 3s - retries: 12 - restart: unless-stopped - - # auto-setup is dev-grade; sustained load should move to Temporal Cloud - # or an operated cluster — the apps only consume TEMPORAL_ADDRESS - temporal: - image: temporalio/auto-setup:1.29.6.1 - depends_on: - temporal-db: - condition: service_healthy - environment: - DB: postgres12 - DB_PORT: 5432 - POSTGRES_USER: temporal - POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal} - POSTGRES_SEEDS: temporal-db - restart: unless-stopped - - temporal-ui: - image: temporalio/ui:2.51.0 - profiles: [debug] - depends_on: - - temporal - environment: - TEMPORAL_ADDRESS: temporal:7233 - ports: - - '127.0.0.1:8233:8080' - restart: unless-stopped - # applies migrations at boot; on failure exits and `restart` retries backend: - image: ai-studio-runtime + image: ${RUNTIME_IMAGE:-ai-studio-runtime} build: *runtime-build command: ['pnpm', '--filter', 'backend', 'start:prod'] environment: + <<: *temporal-env HOST: 0.0.0.0 PORT: 3001 DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder - TEMPORAL_ADDRESS: temporal:7233 # explicit opt-in — a forgotten env var fails loudly instead of exposing the API WB_AUTH_PORT: allow-all # only nginx can reach the backend, so X-Forwarded-For is trustworthy @@ -83,13 +70,13 @@ services: RATE_LIMIT_EXECUTE_PER_MINUTE: ${RATE_LIMIT_EXECUTE_PER_MINUTE:-10} RATE_LIMIT_EXECUTE_PER_DAY: ${RATE_LIMIT_EXECUTE_PER_DAY:-50} # the backend calls the LLM itself for /api/visualize/adapt - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} - AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + AI_API_KEY: ${AI_API_KEY:-} + AI_BASE_URL: ${AI_BASE_URL:-} + AI_MODEL: ${AI_MODEL:-} + volumes: *temporal-tls-volumes depends_on: app-db: condition: service_healthy - temporal: - condition: service_started healthcheck: test: [ @@ -106,28 +93,29 @@ services: # crash-loops until Temporal answers (no usable healthcheck); restart converges it worker: - image: ai-studio-runtime + image: ${RUNTIME_IMAGE:-ai-studio-runtime} build: *runtime-build command: ['pnpm', '--filter', 'execution-worker', 'start:prod'] environment: + <<: *temporal-env DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder - TEMPORAL_ADDRESS: temporal:7233 - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} - AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + # empty is allowed: the worker starts and runs every node except AI Agent ones + AI_API_KEY: ${AI_API_KEY:-} + AI_BASE_URL: ${AI_BASE_URL:-} + AI_MODEL: ${AI_MODEL:-} # optional - empty disables the AI Agent's web search tool TAVILY_API_KEY: ${TAVILY_API_KEY:-} + volumes: *temporal-tls-volumes depends_on: app-db: condition: service_healthy # backend healthy = migrations applied backend: condition: service_healthy - temporal: - condition: service_started restart: unless-stopped web: - image: ai-studio-web + image: ${WEB_IMAGE:-ai-studio-web} build: context: ../.. dockerfile: deploy/ai-studio/Dockerfile @@ -143,4 +131,3 @@ services: volumes: app-db-data: - temporal-db-data: diff --git a/deploy/ai-studio/pack-offline.sh b/deploy/ai-studio/pack-offline.sh new file mode 100755 index 000000000..301236479 --- /dev/null +++ b/deploy/ai-studio/pack-offline.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Builds the images and packs the air-gap bundle into one directory: the image +# tarball, its sha256, an inspect manifest, and the files the host needs to run +# compose. Run on a connected machine — README "Air-gapped / offline install". +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) + +# Resolved before the cd below, so a relative path means relative to the caller. +# The checkout is the image build context: a bundle inside it would be copied +# into the next build, so it is refused. +out=${1:-$HOME/ai-studio-offline} +mkdir -p "$out/tls" +out=$(cd "$out" && pwd) +case "$out/" in + "$repo"/*) + rmdir "$out/tls" "$out" 2>/dev/null || true + echo "pack-offline: $out is inside the checkout ($repo); choose a directory outside it" >&2 + exit 1 + ;; +esac + +cd "$here" + +# A developer's ./.env or exported shell variables (Temporal Cloud, registry image +# names, COMPOSE_FILE) would shape the bundle; the host gets .env.example defaults, +# so build from those: skip the file and drop every variable the compose files read. +unset COMPOSE_FILE COMPOSE_PROFILES COMPOSE_PATH_SEPARATOR +for name in $(grep -oh '\${[A-Za-z_][A-Za-z0-9_]*' docker-compose.yml docker-compose.override.yml | tr -d '${' | sort -u); do + unset "$name" +done +compose() { docker compose --env-file /dev/null "$@"; } +[ -f .env ] && echo "pack-offline: ignoring ./.env — the bundle is built from .env.example defaults" >&2 + +compose build --pull +compose --profile debug pull --ignore-buildable + +images=() +while IFS= read -r ref; do images+=("$ref"); done < <(compose --profile debug config --images | sort -u) + +docker save -o "$out/ai-studio-images.tar" "${images[@]}" +(cd "$out" && shasum -a 256 ai-studio-images.tar > ai-studio-images.tar.sha256) +docker image inspect "${images[@]}" \ + --format '{{join .RepoTags " "}} {{join .RepoDigests " "}} {{.Id}} {{.Os}}/{{.Architecture}}' \ + > "$out/ai-studio-images.manifest.txt" +cp .env.example docker-compose.yml docker-compose.override.yml "$out/" + +echo "Bundle written to $out:" +ls -la "$out" diff --git a/deploy/ai-studio/tls/.gitignore b/deploy/ai-studio/tls/.gitignore new file mode 100644 index 000000000..75f8fade2 --- /dev/null +++ b/deploy/ai-studio/tls/.gitignore @@ -0,0 +1,4 @@ +# Mounted read-only into the backend and worker as /etc/workflowbuilder/tls. +# Certificates and keys dropped here must never reach git. +* +!.gitignore diff --git a/knip.config.js b/knip.config.js index e5b8cc0bc..923b76397 100644 --- a/knip.config.js +++ b/knip.config.js @@ -26,7 +26,10 @@ export default { 'apps/icons': { entry: ['index.ts', 'src/generate-icons.ts'], project: '**/*.{ts,tsx}', - ignoreDependencies: ['@phosphor-icons/core', '@svgr/core'], + // svgr is run as a binary from generate-icons.ts, never imported, so knip + // cannot see it. Removing it sends the icons build back to fetching svgr + // over the network on every run. + ignoreDependencies: ['@phosphor-icons/core', '@svgr/cli'], }, 'apps/tools': { entry: ['src/scripts/*.ts'], @@ -48,6 +51,17 @@ export default { 'packages/execution-core': { entry: ['src/index.ts'], }, + 'packages/ai-config': { + entry: ['src/index.ts'], + }, + 'packages/temporal-connection': { + // test/fixtures/tls-probe-workflow.ts is handed to Temporal's bundler by path, so nothing imports it + entry: ['src/index.ts', 'test/fixtures/tls-probe-workflow.ts'], + project: ['src/**/*.ts', 'test/**/*.ts'], + // Never imported here, but Temporal's workflow bundler resolves it from this + // workspace while compiling the test fixture. + ignoreDependencies: ['@temporalio/workflow'], + }, 'apps/execution-worker': { entry: ['src/engines/temporal/worker.ts', 'src/engines/temporal/workflows.ts'], // @temporalio/workflow is never imported by this app's code, but Temporal's diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 11d85dcf1..7c3b0aa0d 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -10,4 +10,5 @@ export default { '*.{ts,tsx,js,json,css,astro,md,mdx}': (files) => `prettier --write --ignore-path "${prettierIgnore}" --log-level=silent ${files.join(' ')}`, '*.{ts,tsx}': [(files) => `eslint --max-warnings=0 --fix ${files.join(' ')}`, () => `tsc --noEmit`], + 'deploy/ai-studio/Dockerfile': () => 'node tools/check-offline-build.mjs', }; diff --git a/package.json b/package.json index cd845c4b8..33a4ee43d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "pnpm dev:demo", "preflight": "node tools/preflight.mjs", + "check:offline-build": "node tools/check-offline-build.mjs", "setup:env": "node tools/setup-env.mjs", "dev:demo": "pnpm --filter @workflow-builder/demo dev", "dev:ai-studio": "pnpm preflight && pnpm infra:up && pnpm infra:wait && concurrently --kill-others-on-fail -n backend,worker,ai-studio -c blue,magenta,green \"pnpm dev:backend\" \"pnpm dev:worker\" \"pnpm --filter @workflow-builder/ai-studio dev\"", @@ -28,7 +29,7 @@ "format": "prettier --write --log-level silent \"**/*.+(css|ts|tsx|json|md|mdx|astro)\"", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", - "check": "pnpm lint && pnpm typecheck && pnpm format", + "check": "pnpm lint && pnpm typecheck && pnpm format && pnpm check:offline-build", "pre-commit": "lint-staged", "pre-push": "pnpm format", "prepare": "husky" diff --git a/packages/ai-config/README.md b/packages/ai-config/README.md new file mode 100644 index 000000000..56dd5c956 --- /dev/null +++ b/packages/ai-config/README.md @@ -0,0 +1,40 @@ +# @workflow-builder/ai-config + +Private, source-only. The one place that says what "AI is configured" means for the reference backend and execution worker. + +## The contract + +Three variables, all or nothing: + +| Variable | Meaning | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `AI_API_KEY` | Key for the endpoint. OpenRouter keys look like `sk-or-v1-...` | +| `AI_BASE_URL` | Any OpenAI-compatible base URL (a hosted gateway or a model inside your own network), without a trailing `/chat/completions` | +| `AI_MODEL` | Model id as that endpoint spells it | + +- None has a built-in default: with the three unset there is no model endpoint to call. Both `.env.example` files pre-fill the OpenRouter values the stack used before the endpoint became configurable. +- An empty value counts as unset (compose passes absent optionals through as `${VAR:-}`). +- `OPENROUTER_API_KEY`, the old name of the key, is not read. `retiredAiVariables()` reports whether it is still set, so an app can say why a key that used to work is ignored; the value is never read. + +```ts +import { aiConfig } from '@workflow-builder/ai-config'; + +const ai = aiConfig(); // reads process.env when called; never throws +// { available: true, config: { apiKey, baseURL, modelId } } +// { available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] } + +retiredAiVariables(); // ['OPENROUTER_API_KEY'] while the old name is still set, else [] +``` + +`TAVILY_API_KEY` is not part of this contract. It is a worker-only, independently optional key that enables the AI Agent's web-search tool on nodes that ask for it, and the one other outbound call an AI Agent node can make — an internal `AI_BASE_URL` keeps model requests in your network, but only an unset Tavily key keeps the search from calling out — see [`apps/execution-worker/README.md`](../../apps/execution-worker/README.md). + +## What happens when it is unavailable + +Deliberately not decided here. Each app reacts in its own way so that a missing model never blocks graphs without AI: + +- **Backend** — `POST /api/visualize/adapt` answers `501 adapt_disabled`, after authorization and the execution guard have run (`apps/backend/src/routes/visualize.ts`). +- **Worker** — boots, logs a warning naming the missing variables, and runs every node type. An AI Agent node that a run reaches fails with the permanent `ai_not_configured` code and the same names (`apps/execution-worker/src/executors/ai-agent.ts`). + +Both warnings also name any retired variable still present, which is what tells an operator that yesterday's key is being ignored rather than misread. + +Logging, provider lifetime and retries also stay in the apps. Sharing the parser keeps the two readings of the rule identical; it cannot make two independently configured processes agree on the values — set the variables in both `.env` files. diff --git a/packages/ai-config/eslint.config.mjs b/packages/ai-config/eslint.config.mjs new file mode 100644 index 000000000..eee9610de --- /dev/null +++ b/packages/ai-config/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from '../../eslint.config.mjs'; diff --git a/packages/ai-config/lint-staged.config.mjs b/packages/ai-config/lint-staged.config.mjs new file mode 100644 index 000000000..63809e0a3 --- /dev/null +++ b/packages/ai-config/lint-staged.config.mjs @@ -0,0 +1 @@ +export { default } from '../../lint-staged.config.mjs'; diff --git a/packages/ai-config/package.json b/packages/ai-config/package.json new file mode 100644 index 000000000..305aedc96 --- /dev/null +++ b/packages/ai-config/package.json @@ -0,0 +1,20 @@ +{ + "name": "@workflow-builder/ai-config", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "vitest": "^3.0.4" + } +} diff --git a/packages/ai-config/src/index.test.ts b/packages/ai-config/src/index.test.ts new file mode 100644 index 000000000..ae742784a --- /dev/null +++ b/packages/ai-config/src/index.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { aiConfig, retiredAiVariables } from './index'; + +const complete = { + AI_API_KEY: 'sk-or-v1-key', + AI_BASE_URL: 'http://vllm.internal:8000/v1', + AI_MODEL: 'some/model', +}; + +describe('aiConfig', () => { + it('is available only when all three variables are set', () => { + expect(aiConfig(complete)).toEqual({ + available: true, + config: { apiKey: 'sk-or-v1-key', baseURL: 'http://vllm.internal:8000/v1', modelId: 'some/model' }, + }); + }); + + // Booting without an LLM is the point: a deployment that runs no AI nodes should + // not need an LLM account, so this never throws. + it('names every variable when nothing is set', () => { + expect(aiConfig({})).toEqual({ available: false, missing: ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] }); + }); + + it.each([ + ['AI_API_KEY', ['AI_API_KEY']], + ['AI_BASE_URL', ['AI_BASE_URL']], + ['AI_MODEL', ['AI_MODEL']], + ] as const)('names only the missing variable when %s is absent', (absent, missing) => { + const env: NodeJS.ProcessEnv = { ...complete }; + delete env[absent]; + + expect(aiConfig(env)).toEqual({ available: false, missing }); + }); + + it('names two missing variables in declaration order', () => { + expect(aiConfig({ AI_API_KEY: 'key' })).toEqual({ available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] }); + }); + + // compose passes absent optionals through as `${VAR:-}`, so '' must not count as configured + it('treats an empty string like an unset variable', () => { + expect(aiConfig({ ...complete, AI_MODEL: '' })).toEqual({ available: false, missing: ['AI_MODEL'] }); + }); + + // The alias was dropped rather than scoped: a provider-named key that silently + // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are + // no external deployments to keep working. Rename the variable in .env instead. + it('does not read the retired OPENROUTER_API_KEY name', () => { + expect(aiConfig({ ...complete, AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' })).toEqual({ + available: false, + missing: ['AI_API_KEY'], + }); + }); + + it('reads process.env when no environment is given', () => { + vi.stubEnv('AI_API_KEY', 'from-process-env'); + vi.stubEnv('AI_BASE_URL', complete.AI_BASE_URL); + vi.stubEnv('AI_MODEL', complete.AI_MODEL); + try { + expect(aiConfig()).toMatchObject({ available: true, config: { apiKey: 'from-process-env' } }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('retiredAiVariables', () => { + it('names a retired variable that is still set', () => { + expect(retiredAiVariables({ OPENROUTER_API_KEY: 'old-key' })).toEqual(['OPENROUTER_API_KEY']); + }); + + it('is empty when no retired variable is set', () => { + expect(retiredAiVariables(complete)).toEqual([]); + }); + + // same rule as the contract's own variables: compose writes an absent one as '' + it('treats an empty string like an unset variable', () => { + expect(retiredAiVariables({ OPENROUTER_API_KEY: '' })).toEqual([]); + }); + + it('reads process.env when no environment is given', () => { + vi.stubEnv('OPENROUTER_API_KEY', 'old-key'); + try { + expect(retiredAiVariables()).toEqual(['OPENROUTER_API_KEY']); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts new file mode 100644 index 000000000..01753d725 --- /dev/null +++ b/packages/ai-config/src/index.ts @@ -0,0 +1,36 @@ +const AI_VARIABLES = ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const; + +// Names this contract dropped. Still set somewhere, they are dead weight the +// operator cannot see: the app reads none of them. +const RETIRED_AI_VARIABLES = ['OPENROUTER_API_KEY'] as const; + +export type AiVariable = (typeof AI_VARIABLES)[number]; + +export type RetiredAiVariable = (typeof RETIRED_AI_VARIABLES)[number]; + +export type AiConfig = { apiKey: string; baseURL: string; modelId: string }; + +// Either everything an OpenAI-compatible client needs, or which variables are missing. +// What to do about `available: false` is each app's call: the backend answers 501, the +// worker boots and fails an AI Agent node only when a run reaches one. +export type AiConfigResult = { available: true; config: AiConfig } | { available: false; missing: AiVariable[] }; + +export function aiConfig(env: NodeJS.ProcessEnv = process.env): AiConfigResult { + // Empty string counts as unset: compose passes absent optionals through as + // `${VAR:-}`, and a bare `?? null` would read '' as a configured value. + const value = (name: AiVariable) => env[name] || null; + const apiKey = value('AI_API_KEY'); + const baseURL = value('AI_BASE_URL'); + const modelId = value('AI_MODEL'); + + // No built-in endpoint or model: unset means there is no model endpoint to call. + return apiKey && baseURL && modelId + ? { available: true, config: { apiKey, baseURL, modelId } } + : { available: false, missing: AI_VARIABLES.filter((name) => !value(name)) }; +} + +// Which retired names an environment still carries, so an app can say why a key that +// used to work is ignored. The value is never read, only whether one is present. +export function retiredAiVariables(env: NodeJS.ProcessEnv = process.env): RetiredAiVariable[] { + return RETIRED_AI_VARIABLES.filter((name) => Boolean(env[name])); +} diff --git a/packages/ai-config/tsconfig.json b/packages/ai-config/tsconfig.json new file mode 100644 index 000000000..08eedd0d1 --- /dev/null +++ b/packages/ai-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022"], + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/temporal-connection/README.md b/packages/temporal-connection/README.md new file mode 100644 index 000000000..34b33af3a --- /dev/null +++ b/packages/temporal-connection/README.md @@ -0,0 +1,20 @@ +# @workflow-builder/temporal-connection + +Private, source-only. Turns the `TEMPORAL_*` environment variables into everything the apps need to reach Temporal — connection options and the namespace — and holds the one copy of the rules: the defaults, which combinations are contradictory, when TLS is inferred, and how certificate files are read. + +Two consumers hand the result straight to their SDK: `apps/backend/src/engine/index.ts` (`@temporalio/client`) and `apps/execution-worker/src/engines/temporal/worker.ts` (`@temporalio/worker`). Change a rule here and both apps follow; a rule that only one of them should have does not belong here. + +Nothing is validated when this package is imported. `temporalConfig` reads `process.env` (or the environment it is given) when called and throws on a bad combination. Both apps call it as they start, before serving or polling, so a bad combination stops the process instead of surfacing on the first run. Reading it costs nothing at run time: the certificate files are read with it, and Temporal does not have to be reachable. + +```ts +import { temporalConfig } from '@workflow-builder/temporal-connection'; + +const { connection, namespace } = temporalConfig(); +// connection: { address } for plaintext, { address, tls: true } for the OS trust store, +// { address, tls: { serverRootCACertificate, clientCertPair? }, apiKey? } otherwise +// namespace: TEMPORAL_NAMESPACE, 'default' when unset +``` + +Tests: `src/index.test.ts` is the validation matrix. `test/tls.test.ts` drives the built options through a real TLS handshake on both SDK transports (grpc-js and the worker's native core) against a Temporal dev server behind a TLS-terminating proxy (`test/harness/`), with certificates minted per run — private CA, mutual TLS, untrusted server CA, wrong client certificate, an API key inside the TLS session, and work in a non-default namespace. The plaintext default is covered against the dev server directly, with no proxy, alongside the same server refusing a client that demands TLS. Handing the connection options to both SDKs' connect calls there is the compile-time proof that the contract fits both. + +This module is engine plumbing, not part of the execution model, so it is neither in `execution-core` nor in the published `@workflowbuilder/temporal` API. diff --git a/packages/temporal-connection/eslint.config.mjs b/packages/temporal-connection/eslint.config.mjs new file mode 100644 index 000000000..eee9610de --- /dev/null +++ b/packages/temporal-connection/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from '../../eslint.config.mjs'; diff --git a/packages/temporal-connection/lint-staged.config.mjs b/packages/temporal-connection/lint-staged.config.mjs new file mode 100644 index 000000000..63809e0a3 --- /dev/null +++ b/packages/temporal-connection/lint-staged.config.mjs @@ -0,0 +1 @@ +export { default } from '../../lint-staged.config.mjs'; diff --git a/packages/temporal-connection/package.json b/packages/temporal-connection/package.json new file mode 100644 index 000000000..b057bafe5 --- /dev/null +++ b/packages/temporal-connection/package.json @@ -0,0 +1,26 @@ +{ + "name": "@workflow-builder/temporal-connection", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@temporalio/client": "catalog:", + "@temporalio/testing": "catalog:", + "@temporalio/worker": "catalog:", + "@temporalio/workflow": "catalog:", + "@types/node": "^22.12.0", + "@types/node-forge": "^1.3.14", + "node-forge": "^1.4.0", + "vitest": "^3.0.4" + } +} diff --git a/packages/temporal-connection/src/index.test.ts b/packages/temporal-connection/src/index.test.ts new file mode 100644 index 000000000..d35c9a7c7 --- /dev/null +++ b/packages/temporal-connection/src/index.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { temporalConfig } from './index'; + +// Keyed by path so a test can tell the CA apart from the client cert. +function fakeReader() { + return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`)); +} + +function bytes(path: string) { + return new TextEncoder().encode(`contents-of:${path}`); +} + +const ADDRESS = 'temporal.example:7233'; + +// TLS / API-key cases are about everything but the address, so they pin it. +function tlsOptions(env: NodeJS.ProcessEnv, readFile = fakeReader()) { + return temporalConfig({ TEMPORAL_ADDRESS: ADDRESS, ...env }, readFile).connection; +} + +describe('temporalConfig', () => { + it('defaults to the local docker stack on the default namespace', () => { + expect(temporalConfig({}, fakeReader())).toEqual({ + connection: { address: '127.0.0.1:7233' }, + namespace: 'default', + }); + }); + + it('reads the address and namespace', () => { + const env = { TEMPORAL_ADDRESS: 'ns.acct.tmprl.cloud:7233', TEMPORAL_NAMESPACE: 'ns.acct' }; + + expect(temporalConfig(env, fakeReader())).toEqual({ + connection: { address: 'ns.acct.tmprl.cloud:7233' }, + namespace: 'ns.acct', + }); + }); + + it('reads process.env when no environment is given', () => { + vi.stubEnv('TEMPORAL_NAMESPACE', 'from-process-env'); + try { + expect(temporalConfig().namespace).toBe('from-process-env'); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('temporalConfig().connection TLS', () => { + it('stays plaintext when nothing is configured — the local-dev default', () => { + expect(tlsOptions({})).toEqual({ address: ADDRESS }); + }); + + // compose passes absent optionals through as `${VAR:-}`, so '' must not count as configured + it('treats an empty string like an unset variable', () => { + const env = { TEMPORAL_TLS: '', TEMPORAL_API_KEY: '', TEMPORAL_TLS_CA_PATH: '' }; + + expect(tlsOptions(env)).toEqual({ address: ADDRESS }); + }); + + it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => { + expect(tlsOptions({ TEMPORAL_TLS: 'true' })).toEqual({ address: ADDRESS, tls: true }); + }); + + it('stays plaintext on an explicit TEMPORAL_TLS=false', () => { + expect(tlsOptions({ TEMPORAL_TLS: 'false' })).toEqual({ address: ADDRESS }); + }); + + // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an + // apiKey is present. Temporal Cloud rejects an API key sent in the clear. + it('infers TLS from an API key alone', () => { + expect(tlsOptions({ TEMPORAL_API_KEY: 'tmprl-key' })).toEqual({ + address: ADDRESS, + tls: true, + apiKey: 'tmprl-key', + }); + }); + + it('loads a private CA certificate', () => { + const read = fakeReader(); + + expect(tlsOptions({ TEMPORAL_TLS_CA_PATH: '/certs/ca.pem' }, read)).toEqual({ + address: ADDRESS, + tls: { serverRootCACertificate: bytes('/certs/ca.pem') }, + }); + expect(read).toHaveBeenCalledWith('/certs/ca.pem'); + }); + + it('loads a full mTLS pair alongside the CA', () => { + const env = { + TEMPORAL_TLS_CA_PATH: '/certs/ca.pem', + TEMPORAL_TLS_CERT_PATH: '/certs/client.pem', + TEMPORAL_TLS_KEY_PATH: '/certs/client.key', + }; + + expect(tlsOptions(env)).toEqual({ + address: ADDRESS, + tls: { + serverRootCACertificate: bytes('/certs/ca.pem'), + clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') }, + }, + }); + }); +}); + +describe('temporalConfig rejects contradictory TLS config at connect time', () => { + it('refuses half an mTLS pair', () => { + expect(() => tlsOptions({ TEMPORAL_TLS_CERT_PATH: '/certs/client.pem' })).toThrow(/must be set together/); + expect(() => tlsOptions({ TEMPORAL_TLS_KEY_PATH: '/certs/client.key' })).toThrow(/must be set together/); + }); + + it('refuses an API key and a client certificate together', () => { + const both = { + TEMPORAL_API_KEY: 'k', + TEMPORAL_TLS_CERT_PATH: '/certs/client.pem', + TEMPORAL_TLS_KEY_PATH: '/certs/client.key', + }; + + expect(() => tlsOptions(both)).toThrow(/not both/); + }); + + it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => { + const contradiction = { TEMPORAL_TLS: 'false', TEMPORAL_API_KEY: 'k' }; + + expect(() => tlsOptions(contradiction)).toThrow(/contradicts/); + }); + + it('refuses a TEMPORAL_TLS value that is neither true nor false', () => { + expect(() => tlsOptions({ TEMPORAL_TLS: 'yes' })).toThrow(/must be 'true'/); + }); + + it('names the variable and the path when a certificate cannot be read', () => { + const explode = vi.fn(() => { + throw new Error('ENOENT'); + }); + + expect(() => tlsOptions({ TEMPORAL_TLS_CA_PATH: '/nope.pem' }, explode)).toThrow( + /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/, + ); + }); +}); diff --git a/packages/temporal-connection/src/index.ts b/packages/temporal-connection/src/index.ts new file mode 100644 index 000000000..100f4928e --- /dev/null +++ b/packages/temporal-connection/src/index.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; + +export type TemporalTlsOptions = { + serverRootCACertificate?: Uint8Array; + clientCertPair?: { crt: Uint8Array; key: Uint8Array }; +}; + +// The subset both SDKs accept as-is: the client also takes an apiKey function and +// tls: false | null, neither of which this module ever produces. +export type TemporalConnectionOptions = { + address: string; + tls?: true | TemporalTlsOptions; + apiKey?: string; +}; + +export type TemporalConfig = { + connection: TemporalConnectionOptions; + // Not a connection option — it goes to the Client and the Worker — but it must + // match between the two, so it is read here alongside the rest. + namespace: string; +}; + +// 127.0.0.1, not `localhost`: the local docker stack binds loopback IPv4 only, and +// some Node setups resolve `localhost` to ::1 first (see apps/backend/src/env.ts). +const DEFAULT_ADDRESS = '127.0.0.1:7233'; +// Temporal Cloud spells it `.`. +const DEFAULT_NAMESPACE = 'default'; + +type Config = { + // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest", + // which is not the same as an explicit 'false'. + tls: string | null; + apiKey: string | null; + caPath: string | null; + certPath: string | null; + keyPath: string | null; +}; + +export function temporalConfig( + env: NodeJS.ProcessEnv = process.env, + readFile: (path: string) => Uint8Array = readFileSync, +): TemporalConfig { + return { + connection: { address: env['TEMPORAL_ADDRESS'] || DEFAULT_ADDRESS, ...connectionOptions(env, readFile) }, + namespace: env['TEMPORAL_NAMESPACE'] || DEFAULT_NAMESPACE, + }; +} + +function connectionOptions( + env: NodeJS.ProcessEnv, + readFile: (path: string) => Uint8Array, +): Omit { + const { tls, apiKey, caPath, certPath, keyPath } = read(env); + + if (tls !== null && tls !== 'true' && tls !== 'false') { + throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`); + } + if (Boolean(certPath) !== Boolean(keyPath)) { + throw new Error( + 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.', + ); + } + if (apiKey && certPath) { + throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.'); + } + + const hasTlsMaterial = Boolean(apiKey || caPath || certPath); + if (tls === 'false' && hasTlsMaterial) { + throw new Error( + 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.', + ); + } + + // Material implies TLS, matching what the SDKs already do for apiKey. Being + // explicit here keeps the client and the worker in step and makes it testable. + if (tls !== 'true' && !hasTlsMaterial) { + // Plaintext — the local-dev default. + return {}; + } + + const certificates: TemporalTlsOptions = { + ...(caPath ? { serverRootCACertificate: readPemFile(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}), + ...(certPath && keyPath + ? { + clientCertPair: { + crt: readPemFile(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'), + key: readPemFile(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'), + }, + } + : {}), + }; + + return { + // `true` means TLS with the OS trust store — enough for Temporal Cloud. + tls: Object.keys(certificates).length > 0 ? certificates : true, + ...(apiKey ? { apiKey } : {}), + }; +} + +function read(env: NodeJS.ProcessEnv): Config { + // Empty string counts as unset: compose passes absent optionals through as + // `${VAR:-}`, and a bare `?? null` would read '' as a configured value. + const optional = (name: string) => env[name] || null; + return { + tls: optional('TEMPORAL_TLS'), + apiKey: optional('TEMPORAL_API_KEY'), + caPath: optional('TEMPORAL_TLS_CA_PATH'), + certPath: optional('TEMPORAL_TLS_CERT_PATH'), + keyPath: optional('TEMPORAL_TLS_KEY_PATH'), + }; +} + +function readPemFile(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array { + try { + return readFile(path); + } catch (error) { + throw new Error(`Could not read ${variable} (${path}).`, { cause: error }); + } +} diff --git a/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts b/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts new file mode 100644 index 000000000..451f4e2ea --- /dev/null +++ b/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts @@ -0,0 +1,5 @@ +// Handed to Temporal's bundler by path from tls.test.ts. All the test needs is proof +// that a task round-trips through the worker's TLS connection. +export async function tlsProbe(): Promise { + return 'pong'; +} diff --git a/packages/temporal-connection/test/harness/authorization-sink.ts b/packages/temporal-connection/test/harness/authorization-sink.ts new file mode 100644 index 000000000..902058c61 --- /dev/null +++ b/packages/temporal-connection/test/harness/authorization-sink.ts @@ -0,0 +1,47 @@ +import { type Http2SecureServer, type Http2Session, createSecureServer } from 'node:http2'; +import type { AddressInfo } from 'node:net'; + +import type { PemPair } from './certificates'; + +type AuthorizationSink = { + address: string; + /** The `authorization` header of every gRPC call received, in order. */ + authorizations: string[]; + close: () => Promise; +}; + +/** + * A TLS endpoint that records the `authorization` header of each gRPC request and + * answers UNAUTHENTICATED, so a client's connect attempt fails fast instead of hanging. + * The header travels inside the encrypted HTTP/2 stream, so a TCP-level proxy cannot see it. + */ +export async function startAuthorizationSink(server: PemPair): Promise { + const authorizations: string[] = []; + const sessions = new Set(); + + const http2Server: Http2SecureServer = createSecureServer({ cert: server.cert, key: server.key, allowHTTP1: false }); + http2Server.on('session', (session) => { + sessions.add(session); + session.on('close', () => sessions.delete(session)); + }); + http2Server.on('stream', (stream, headers) => { + authorizations.push(String(headers.authorization ?? '')); + stream.respond( + { ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '16', 'grpc-message': 'authorization sink' }, + { endStream: true }, + ); + }); + + await new Promise((resolve) => http2Server.listen(0, resolve)); + const { port } = http2Server.address() as AddressInfo; + + return { + address: `localhost:${port}`, + authorizations, + close: () => + new Promise((resolve) => { + for (const session of sessions) session.destroy(); + http2Server.close(() => resolve()); + }), + }; +} diff --git a/packages/temporal-connection/test/harness/certificates.ts b/packages/temporal-connection/test/harness/certificates.ts new file mode 100644 index 000000000..5206774ba --- /dev/null +++ b/packages/temporal-connection/test/harness/certificates.ts @@ -0,0 +1,78 @@ +import forge from 'node-forge'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +export type PemPair = { cert: string; key: string }; + +/** A throwaway CA with one server leaf (SAN localhost / 127.0.0.1 / ::1) and one client leaf. */ +export type TestPki = { ca: PemPair; server: PemPair; client: PemPair }; + +/** The PEM files a TEMPORAL_TLS_*_PATH-style config can point at; `directory` holds them all, for cleanup. */ +export type TestPkiFiles = { directory: string; ca: string; clientCert: string; clientKey: string }; + +type Issued = { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey; pem: PemPair }; + +let nextSerial = 1; + +function issue(commonName: string, issuer: Issued | null, extensions: object[]): Issued { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = (nextSerial++).toString(16).padStart(2, '0'); + cert.validity.notBefore = new Date(Date.now() - 60 * 60 * 1000); + cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000); + const subject = [{ name: 'commonName', value: commonName }]; + cert.setSubject(subject); + cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject); + cert.setExtensions(extensions); + // rustls, the worker's native transport, rejects forge's default SHA-1 signature. + cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create()); + return { + cert, + key: keys.privateKey, + pem: { cert: forge.pki.certificateToPem(cert), key: forge.pki.privateKeyToPem(keys.privateKey) }, + }; +} + +export function createTestPki(name: string): TestPki { + const ca = issue(`${name} test CA`, null, [ + { name: 'basicConstraints', cA: true, critical: true }, + { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true }, + { name: 'subjectKeyIdentifier' }, + ]); + const server = issue(`${name} server`, ca, [ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true }, + { name: 'extKeyUsage', serverAuth: true }, + { + name: 'subjectAltName', + altNames: [ + { type: 2, value: 'localhost' }, + { type: 7, ip: '127.0.0.1' }, + { type: 7, ip: '::1' }, + ], + }, + ]); + const client = issue(`${name} client`, ca, [ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'keyUsage', digitalSignature: true, critical: true }, + { name: 'extKeyUsage', clientAuth: true }, + ]); + return { ca: ca.pem, server: server.pem, client: client.pem }; +} + +/** Writes the CA and client PEMs to a fresh temp directory, so config paths resolve like in production. */ +export function writeTestPki(pki: TestPki, name: string): TestPkiFiles { + const directory = mkdtempSync(path.join(tmpdir(), `wb-tls-${name}-`)); + const files = { + directory, + ca: path.join(directory, 'ca.pem'), + clientCert: path.join(directory, 'client.pem'), + clientKey: path.join(directory, 'client-key.pem'), + }; + writeFileSync(files.ca, pki.ca.cert); + writeFileSync(files.clientCert, pki.client.cert); + writeFileSync(files.clientKey, pki.client.key); + return files; +} diff --git a/packages/temporal-connection/test/harness/index.ts b/packages/temporal-connection/test/harness/index.ts new file mode 100644 index 000000000..5ed146f47 --- /dev/null +++ b/packages/temporal-connection/test/harness/index.ts @@ -0,0 +1,5 @@ +// Harness for tls.test.ts: throwaway certificates, a TLS-terminating proxy in front +// of a plaintext dev server, and an endpoint that records bearer tokens. +export { type TestPki, type TestPkiFiles, createTestPki, writeTestPki } from './certificates'; +export { startAuthorizationSink } from './authorization-sink'; +export { startTlsProxy } from './tls-proxy'; diff --git a/packages/temporal-connection/test/harness/tls-proxy.ts b/packages/temporal-connection/test/harness/tls-proxy.ts new file mode 100644 index 000000000..6343f54e3 --- /dev/null +++ b/packages/temporal-connection/test/harness/tls-proxy.ts @@ -0,0 +1,73 @@ +import { type AddressInfo, type Socket, connect } from 'node:net'; +import { type TlsOptions, createServer } from 'node:tls'; + +import type { PemPair } from './certificates'; + +type TlsProxy = { + /** host:port a Temporal client can dial; the hostname is covered by the server certificate's SAN. */ + address: string; + /** One entry per failed handshake, whichever side aborted it. */ + handshakeErrors: string[]; + close: () => Promise; +}; + +type TlsProxyOptions = { + /** host:port of the plaintext Temporal server behind the proxy. */ + upstream: string; + server: PemPair; + /** When set, a client certificate signed by this CA is required. */ + clientCa?: string; +}; + +/** + * Terminates TLS in front of a plaintext Temporal server and forwards the bytes as-is. + * gRPC frames pass through untouched, so what is exercised is the client's transport: + * server-certificate trust, hostname check, ALPN and, with `clientCa`, mutual TLS. + */ +export async function startTlsProxy({ upstream, server, clientCa }: TlsProxyOptions): Promise { + const [upstreamHost, upstreamPort] = splitAddress(upstream); + const handshakeErrors: string[] = []; + const sockets = new Set(); + + const options: TlsOptions = { + cert: server.cert, + key: server.key, + // gRPC clients hang up on a server that does not select h2 + ALPNProtocols: ['h2'], + ...(clientCa ? { ca: clientCa, requestCert: true, rejectUnauthorized: true } : {}), + }; + + const tlsServer = createServer(options, (downstream) => { + const upstreamSocket = connect({ host: upstreamHost, port: upstreamPort }); + sockets.add(downstream); + sockets.add(upstreamSocket); + downstream.pipe(upstreamSocket).pipe(downstream); + const drop = () => { + downstream.destroy(); + upstreamSocket.destroy(); + }; + downstream.on('error', drop); + upstreamSocket.on('error', drop); + downstream.on('close', drop); + upstreamSocket.on('close', drop); + }); + tlsServer.on('tlsClientError', (error) => handshakeErrors.push(error.message)); + + await new Promise((resolve) => tlsServer.listen(0, resolve)); + const { port } = tlsServer.address() as AddressInfo; + + return { + address: `localhost:${port}`, + handshakeErrors, + close: () => + new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + tlsServer.close(() => resolve()); + }), + }; +} + +function splitAddress(address: string): [string, number] { + const separator = address.lastIndexOf(':'); + return [address.slice(0, separator), Number(address.slice(separator + 1))]; +} diff --git a/packages/temporal-connection/test/tls.test.ts b/packages/temporal-connection/test/tls.test.ts new file mode 100644 index 000000000..764c04476 --- /dev/null +++ b/packages/temporal-connection/test/tls.test.ts @@ -0,0 +1,232 @@ +// Drives the options this package builds through a real TLS handshake on both SDK +// transports: grpc-js in @temporalio/client and the Rust core in @temporalio/worker. +// A Temporal dev server sits behind a TLS-terminating proxy; certificates are minted +// per run. The unit tests prove the shape of the options; this file proves they connect. +import { Client, Connection } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import { rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { temporalConfig } from '../src/index'; +import { + type TestPki, + type TestPkiFiles, + createTestPki, + startAuthorizationSink, + startTlsProxy, + writeTestPki, +} from './harness'; + +const NAMESPACE = 'tls-test'; +const TASK_QUEUE = 'tls-probe'; + +type Pki = { pki: TestPki; files: TestPkiFiles }; + +function mint(name: string): Pki { + const pki = createTestPki(name); + return { pki, files: writeTestPki(pki, name) }; +} + +type Transport = { + name: string; + connect: (address: string, env: NodeJS.ProcessEnv) => Promise<{ close(): Promise }>; +}; + +// Handing the built options to each SDK's own connect call is also the compile-time +// proof that the shared contract is assignable to both option types without a cast. +function connectClient(address: string, env: NodeJS.ProcessEnv) { + const { connection } = temporalConfig({ TEMPORAL_ADDRESS: address, ...env }); + return Connection.connect({ connectTimeout: '3s', ...connection }); +} + +function connectWorker(address: string, env: NodeJS.ProcessEnv) { + return NativeConnection.connect(temporalConfig({ TEMPORAL_ADDRESS: address, ...env }).connection); +} + +const transports: Transport[] = [ + { name: '@temporalio/client (grpc-js)', connect: connectClient }, + { name: '@temporalio/worker (native core)', connect: connectWorker }, +]; + +let env: TestWorkflowEnvironment; +// `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA. +let trusted: Pki; +let stranger: Pki; + +beforeAll(async () => { + [env, trusted, stranger] = await Promise.all([ + TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }), + mint('trusted'), + mint('stranger'), + ]); +}, 300_000); + +afterAll(async () => { + await env?.teardown(); + for (const minted of [trusted, stranger]) { + if (minted) rmSync(minted.files.directory, { recursive: true, force: true }); + } +}); + +describe.each(transports)('$name over TLS', ({ connect }) => { + it('connects through a private CA', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const connection = await connect(proxy.address, { TEMPORAL_TLS_CA_PATH: trusted.files.ca }); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('authenticates with a client certificate when the server requires one', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + const connection = await connect(proxy.address, { + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + TEMPORAL_TLS_CERT_PATH: trusted.files.clientCert, + TEMPORAL_TLS_KEY_PATH: trusted.files.clientKey, + }); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('refuses a server certificate from a CA it does not trust', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + await expect(connect(proxy.address, { TEMPORAL_TLS_CA_PATH: stranger.files.ca })).rejects.toThrow(); + // the proxy records the failed handshake asynchronously, after the client has given up + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('is refused when its client certificate comes from the wrong CA', async () => { + const proxy = await startTlsProxy({ + upstream: env.address, + server: trusted.pki.server, + clientCa: trusted.pki.ca.cert, + }); + try { + await expect( + connect(proxy.address, { + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + TEMPORAL_TLS_CERT_PATH: stranger.files.clientCert, + TEMPORAL_TLS_KEY_PATH: stranger.files.clientKey, + }), + ).rejects.toThrow(); + await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0); + } finally { + await proxy.close(); + } + }, 60_000); + + it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => { + const sink = await startAuthorizationSink(trusted.pki.server); + try { + // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion + await expect( + connect(sink.address, { TEMPORAL_API_KEY: 'synthetic-key', TEMPORAL_TLS_CA_PATH: trusted.files.ca }), + ).rejects.toThrow(); + expect(sink.authorizations).toContain('Bearer synthetic-key'); + } finally { + await sink.close(); + } + }, 60_000); +}); + +// The local-dev default, which no proxy is involved in: straight to the plaintext dev +// server. The unit tests pin the option shape for an unconfigured environment; these pin +// that the shape actually connects, and that demanding TLS from the same server does not. +describe.each(transports)('$name without TLS', ({ connect }) => { + it.each([ + ['nothing is configured', {}], + ['TEMPORAL_TLS=false asserts plaintext', { TEMPORAL_TLS: 'false' }], + ])( + 'connects to a plaintext server when %s', + async (_label, variables) => { + const connection = await connect(env.address, variables); + + await connection.close(); + }, + 60_000, + ); + + it('is refused by that same server once TEMPORAL_TLS demands TLS', async () => { + await expect(connect(env.address, { TEMPORAL_TLS: 'true' })).rejects.toThrow(); + }, 60_000); +}); + +describe('work in a non-default namespace over a private CA', () => { + it('a client starts a workflow that lands in the configured namespace', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const config = temporalConfig({ + TEMPORAL_ADDRESS: proxy.address, + TEMPORAL_NAMESPACE: NAMESPACE, + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + }); + const connection = await Connection.connect(config.connection); + const client = new Client({ connection, namespace: config.namespace }); + const handle = await client.workflow.start('tls-probe', { + taskQueue: TASK_QUEUE, + workflowId: `tls-probe-client-${Date.now()}`, + }); + + // Read back over the dev server's own plaintext connection, so the assertion + // does not depend on the connection under test. + const inNamespace = new Client({ connection: env.connection, namespace: NAMESPACE }); + await expect(inNamespace.workflow.getHandle(handle.workflowId).describe()).resolves.toMatchObject({ + status: { name: 'RUNNING' }, + }); + await expect(env.client.workflow.getHandle(handle.workflowId).describe()).rejects.toThrow(); + + await handle.terminate('tls test done'); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 60_000); + + it('a worker polls the configured namespace and completes a workflow', async () => { + const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server }); + try { + const config = temporalConfig({ + TEMPORAL_ADDRESS: proxy.address, + TEMPORAL_NAMESPACE: NAMESPACE, + TEMPORAL_TLS_CA_PATH: trusted.files.ca, + }); + const connection = await NativeConnection.connect(config.connection); + const worker = await Worker.create({ + connection, + namespace: config.namespace, + taskQueue: TASK_QUEUE, + workflowsPath: fileURLToPath(new URL('fixtures/tls-probe-workflow.ts', import.meta.url)), + }); + // The client submits over the dev server's own plaintext connection; only the + // worker's polling and completion travel through TLS. + const client = new Client({ connection: env.connection, namespace: NAMESPACE }); + const result = await worker.runUntil( + client.workflow.execute('tlsProbe', { taskQueue: TASK_QUEUE, workflowId: `tls-probe-worker-${Date.now()}` }), + ); + + expect(result).toBe('pong'); + await connection.close(); + expect(proxy.handshakeErrors).toEqual([]); + } finally { + await proxy.close(); + } + }, 120_000); +}); diff --git a/packages/temporal-connection/tsconfig.json b/packages/temporal-connection/tsconfig.json new file mode 100644 index 000000000..c93019dec --- /dev/null +++ b/packages/temporal-connection/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022"], + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/temporal/test/error-boundary.test.ts b/packages/temporal/test/error-boundary.test.ts new file mode 100644 index 000000000..ce08fea5e --- /dev/null +++ b/packages/temporal/test/error-boundary.test.ts @@ -0,0 +1,129 @@ +// Runs graphs through a real Temporal dev server, so every assertion crosses the actual +// activity → workflow boundary where a thrown error is serialized and its class is lost. +import { WorkflowFailedError } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + type BaseNode, + DEFAULT_NODE_ACTIVITY_PROFILE, + NodeExecutionError, + type NodeExecutorRegistry, + PermanentNodeExecutionError, + RUN_WORKFLOW_NAME, + WorkflowBuilderPlugin, + type WorkflowDefinition, + type WorkflowExecutionInput, + executionWorkflowId, +} from '../src/index'; +import { type RecordingStore, createRecordingStore } from './fixtures/graph'; + +type BoundaryNode = (BaseNode & { type: 'test/step' }) | (BaseNode & { type: 'test/fail' }); + +const TASK_QUEUE = 'error-boundary-test'; + +function graph(workflowId: string): WorkflowDefinition { + return { + workflowId, + nodes: [ + { id: 'start', type: 'test/step', role: 'start', config: {} }, + { id: 'fail', type: 'test/fail', config: {} }, + ], + edges: [{ id: 'e-start-fail', sourceNodeId: 'start', targetNodeId: 'fail' }], + }; +} + +type Run = { store: RecordingStore; attempts: number; failure: unknown }; + +function nodeFailedPayload(store: RecordingStore): unknown { + return store.events.find((event) => event.type === 'node_failed' && event.nodeId === 'fail')?.payload; +} + +describe('error classification across the activity boundary', () => { + let env: TestWorkflowEnvironment; + let workflowBundle: { code: string }; + + beforeAll(async () => { + [workflowBundle, env] = await Promise.all([ + bundleWorkflowCode({ workflowsPath: fileURLToPath(new URL('fixtures/workflows.ts', import.meta.url)) }), + TestWorkflowEnvironment.createLocal(), + ]); + }, 300_000); + + afterAll(async () => { + await env?.teardown(); + }); + + async function run(executionId: string, thrown: () => Error): Promise { + const store = createRecordingStore(); + let attempts = 0; + + const executors: NodeExecutorRegistry = { + 'test/step': () => ({ output: null }), + 'test/fail': () => { + attempts += 1; + throw thrown(); + }, + }; + + const plugin = new WorkflowBuilderPlugin({ store, executors, taskQueue: TASK_QUEUE }); + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: plugin.taskQueue, + workflowBundle, + plugins: [plugin], + }); + + const workflowId = `wf-${executionId}`; + const input: WorkflowExecutionInput = { + workflowId, + executionId, + definition: graph(workflowId), + triggerPayload: {}, + variables: {}, + global: {}, + }; + + const failure = await worker.runUntil( + env.client.workflow + .execute(RUN_WORKFLOW_NAME, { + taskQueue: plugin.taskQueue, + workflowId: executionWorkflowId(executionId), + args: [input], + }) + .catch((error: unknown) => error), + ); + + return { store, attempts, failure }; + } + + it('a permanent throw stops on its first attempt and reaches node_failed with its code', async () => { + const { store, attempts, failure } = await run( + 'permanent', + () => new PermanentNodeExecutionError('ai_not_configured', 'AI is not configured on this worker'), + ); + + expect(attempts).toBe(1); + expect(nodeFailedPayload(store)).toEqual({ + error: { message: 'AI is not configured on this worker', code: 'ai_not_configured', attempt: 1 }, + }); + expect(store.statuses.at(-1)).toMatchObject({ status: 'failed' }); + + // The code also names the workflow's terminal failure type. + expect(failure).toBeInstanceOf(WorkflowFailedError); + expect((failure as WorkflowFailedError).cause).toMatchObject({ type: 'ai_not_configured' }); + }, 60_000); + + it('an unclassified throw retries per the profile and is reported exactly as before', async () => { + const { store, attempts } = await run( + 'unclassified', + () => new NodeExecutionError('no_branch_matched', 'No branch matched'), + ); + + expect(attempts).toBe(DEFAULT_NODE_ACTIVITY_PROFILE.retry.maximumAttempts); + expect(nodeFailedPayload(store)).toEqual({ error: { message: 'No branch matched' } }); + }, 60_000); +}); diff --git a/packages/temporal/test/fixtures/graph.ts b/packages/temporal/test/fixtures/graph.ts index 215bf2429..fd73b1755 100644 --- a/packages/temporal/test/fixtures/graph.ts +++ b/packages/temporal/test/fixtures/graph.ts @@ -33,7 +33,7 @@ export const replayTestExecutors: NodeExecutorRegistry = { }; export type RecordingStore = ExecutionStore & { - events: { sequence: number; type: string; nodeId?: string }[]; + events: { sequence: number; type: string; nodeId?: string; payload?: unknown }[]; statuses: { status: string; errorMessage?: string }[]; }; @@ -44,8 +44,8 @@ export function createRecordingStore(): RecordingStore { return { events, statuses, - async emitExecutionEvent(_executionId, sequence, type, _payload, nodeId) { - events.push({ sequence, type, nodeId }); + async emitExecutionEvent(_executionId, sequence, type, payload, nodeId) { + events.push({ sequence, type, nodeId, payload }); }, async updateExecutionStatus(_executionId, status, errorMessage) { statuses.push({ status, errorMessage }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adecd8493..31f490dc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@ai-sdk/openai-compatible': + specifier: ^2.0.74 + version: 2.0.74 '@base-ui/react': specifier: 1.7.0 version: 1.7.0 @@ -42,6 +45,9 @@ catalogs: '@xyflow/react': specifier: 12.10.0 version: 12.10.0 + ai: + specifier: ^6.0.168 + version: 6.0.168 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -204,18 +210,24 @@ importers: apps/backend: dependencies: + '@ai-sdk/openai-compatible': + specifier: 'catalog:' + version: 2.0.74(zod@4.3.6) '@hono/node-server': specifier: ^1.14.0 version: 1.19.14(hono@4.12.14) - '@openrouter/ai-sdk-provider': - specifier: ^2.8.0 - version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6) '@temporalio/client': specifier: 'catalog:' version: 1.23.0 + '@workflow-builder/ai-config': + specifier: workspace:* + version: link:../../packages/ai-config '@workflow-builder/execution-core': specifier: workspace:* version: link:../../packages/execution-core + '@workflow-builder/temporal-connection': + specifier: workspace:* + version: link:../../packages/temporal-connection '@workflow-builder/types': specifier: workspace:* version: link:../../packages/types @@ -223,7 +235,7 @@ importers: specifier: workspace:* version: link:../../packages/temporal ai: - specifier: ^6.0.168 + specifier: 'catalog:' version: 6.0.168(zod@4.3.6) dotenv: specifier: ^17.4.2 @@ -438,18 +450,24 @@ importers: apps/execution-worker: dependencies: - '@openrouter/ai-sdk-provider': - specifier: ^2.5.0 - version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6) + '@ai-sdk/openai-compatible': + specifier: 'catalog:' + version: 2.0.74(zod@4.3.6) '@temporalio/worker': specifier: 'catalog:' version: 1.23.0(tslib@2.8.1) '@temporalio/workflow': specifier: 'catalog:' version: 1.23.0 + '@workflow-builder/ai-config': + specifier: workspace:* + version: link:../../packages/ai-config '@workflow-builder/execution-core': specifier: workspace:* version: link:../../packages/execution-core + '@workflow-builder/temporal-connection': + specifier: workspace:* + version: link:../../packages/temporal-connection '@workflow-builder/types': specifier: workspace:* version: link:../../packages/types @@ -457,7 +475,7 @@ importers: specifier: workspace:* version: link:../../packages/temporal ai: - specifier: ^6.0.0 + specifier: 'catalog:' version: 6.0.168(zod@4.3.6) dotenv: specifier: ^17.4.2 @@ -488,7 +506,7 @@ importers: '@phosphor-icons/core': specifier: 'catalog:' version: 2.1.1 - '@svgr/core': + '@svgr/cli': specifier: ^8.1.0 version: 8.1.0(typescript@5.9.3) '@types/react': @@ -510,6 +528,15 @@ importers: specifier: ^2.19.2 version: 2.20.0 + packages/ai-config: + devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.12.0 + vitest: + specifier: ^3.0.4 + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + packages/execution-core: dependencies: '@workflow-builder/types': @@ -560,7 +587,7 @@ importers: version: 4.1.0 i18next: specifier: ^24.0.0 - version: 24.2.3(typescript@5.6.3) + version: 24.2.3(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.0.0 version: 8.0.5 @@ -581,7 +608,7 @@ importers: version: 19.1.0(react@19.1.0) react-i18next: specifier: ^15.0.0 - version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-mentions-ts: specifier: ^5.4.7 version: 5.4.7(class-variance-authority@0.7.1)(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwind-merge@3.5.0) @@ -621,10 +648,10 @@ importers: version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) @@ -675,6 +702,33 @@ importers: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + packages/temporal-connection: + devDependencies: + '@temporalio/client': + specifier: 'catalog:' + version: 1.23.0 + '@temporalio/testing': + specifier: 'catalog:' + version: 1.23.0(tslib@2.8.1) + '@temporalio/worker': + specifier: 'catalog:' + version: 1.23.0(tslib@2.8.1) + '@temporalio/workflow': + specifier: 'catalog:' + version: 1.23.0 + '@types/node': + specifier: ^22.12.0 + version: 22.12.0 + '@types/node-forge': + specifier: ^1.3.14 + version: 1.3.14 + node-forge: + specifier: ^1.4.0 + version: 1.4.0 + vitest: + specifier: ^3.0.4 + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + packages/tokens: devDependencies: '@tokens-studio/sd-transforms': @@ -773,12 +827,28 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.74': + resolution: {integrity: sha512-HdYUgacC08HjHyzL8Y59bjeOJTcsZWpHYZS0K8T4ChV/zYGktqbktzT0nJuhn3r9lVoMzA9Miw7abGQcdhI2yw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.23': resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.50': + resolution: {integrity: sha512-YAcB+7M1JhAYsHorTrWyldCyZihjCKr/QRXH2vFrara/+lwqNE7q5KzoucKLZ7ktFiUonhnhFhRoiymsq/2K2Q==} + engines: {node: '>=18.17'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.15': + resolution: {integrity: sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} @@ -2306,13 +2376,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openrouter/ai-sdk-provider@2.8.0': - resolution: {integrity: sha512-oDDW/0KMqz4suHVloB9sNv0YyKLGNYf1FTevXH6adDkid5dsmbbcYuiEsbIhpZSZtHa6o5AVjK1jEAfePOLxww==} - engines: {node: '>=18'} - peerDependencies: - ai: ^6.0.0 - zod: ^3.25.0 || ^4.0.0 - '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -2745,6 +2808,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@svgr/cli@8.1.0': + resolution: {integrity: sha512-SnlaLspB610XFXvs3PmhzViHErsXp0yIy4ERyZlHDlO1ro2iYtHMWYk2mztdLD/lBjiA4ZXe4RePON3qU/Tc4A==} + engines: {node: '>=14'} + hasBin: true + '@svgr/core@8.1.0': resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} engines: {node: '>=14'} @@ -2759,6 +2827,18 @@ packages: peerDependencies: '@svgr/core': '*' + '@svgr/plugin-prettier@8.1.0': + resolution: {integrity: sha512-o4/uFI8G64tAjBZ4E7gJfH+VP7Qi3T0+M4WnIsP91iFnGPqs5WvPDkpZALXPiyWEtzfYs1Rmwy1Zdfu8qoZuKw==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/plugin-svgo@8.1.0': + resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + '@swc/core-darwin-arm64@1.15.26': resolution: {integrity: sha512-OmcP96CFsNOwa65tamQayRcfqhNlcQ3YCWOq+0Wb+CAM4uB7kOMrXY41Gj4atthxrGhLQ9pg7Vk26iApb88idA==} engines: {node: '>=10'} @@ -3108,6 +3188,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node-forge@1.3.14': + resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -3943,6 +4026,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -4258,6 +4345,10 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + dashify@2.0.0: + resolution: {integrity: sha512-hpA5C/YrPjucXypHPPc0oJ1l9Hf6wWbiOL7Ik42cxnsUOhWiCB/fylKbKqqJalW9FgkNQCw16YO8uW9Hs0Iy1A==} + engines: {node: '>=4'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -4836,8 +4927,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} expect-type@1.1.0: @@ -5000,6 +5091,9 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5078,6 +5172,11 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} @@ -5333,6 +5432,10 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.3: resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} @@ -6120,6 +6223,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -6247,6 +6354,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} @@ -7392,10 +7503,6 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} - tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -7648,6 +7755,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -8310,13 +8421,31 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.3.6 + '@ai-sdk/openai-compatible@2.0.74(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.15 + '@ai-sdk/provider-utils': 4.0.50(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.1 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.50(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.15 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.1 + undici: 6.28.0 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.15': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -10002,11 +10131,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.18.0 - '@openrouter/ai-sdk-provider@2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6)': - dependencies: - ai: 6.0.168(zod@4.3.6) - zod: 4.3.6 - '@opentelemetry/api@1.9.0': {} '@oslojs/encoding@1.1.0': {} @@ -10348,12 +10472,17 @@ snapshots: '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.7) '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.7) - '@svgr/core@8.1.0(typescript@5.6.3)': + '@svgr/cli@8.1.0(typescript@5.9.3)': dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-prettier': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.6.3) + chalk: 4.1.2 + commander: 9.5.0 + dashify: 2.0.0 + glob: 8.1.0 snake-case: 3.0.4 transitivePeerDependencies: - supports-color @@ -10375,25 +10504,30 @@ snapshots: '@babel/types': 7.29.0 entities: 4.5.0 - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.6.3))': + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': dependencies: '@babel/core': 7.26.7 '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) - '@svgr/core': 8.1.0(typescript@5.6.3) + '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/hast-util-to-babel-ast': 8.0.0 svg-parser: 2.0.4 transitivePeerDependencies: - supports-color - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + '@svgr/plugin-prettier@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) '@svgr/core': 8.1.0(typescript@5.9.3) - '@svgr/hast-util-to-babel-ast': 8.0.0 - svg-parser: 2.0.4 + deepmerge: 4.3.1 + prettier: 2.8.8 + + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + svgo: 3.3.3 transitivePeerDependencies: - - supports-color + - typescript '@swc/core-darwin-arm64@1.15.26': optional: true @@ -10525,6 +10659,31 @@ snapshots: - uglify-js - webpack-cli + '@temporalio/testing@1.23.0(tslib@2.8.1)': + dependencies: + '@temporalio/activity': 1.23.0 + '@temporalio/client': 1.23.0 + '@temporalio/common': 1.23.0 + '@temporalio/core-bridge': 1.23.0 + '@temporalio/proto': 1.23.0 + '@temporalio/worker': 1.23.0(tslib@2.8.1) + '@temporalio/workflow': 1.23.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - tslib + - uglify-js + - webpack-cli + '@temporalio/worker@1.23.0(esbuild@0.27.3)(postcss@8.5.6)(tslib@2.8.1)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -10863,6 +11022,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node-forge@1.3.14': + dependencies: + '@types/node': 22.12.0 + '@types/node@12.20.55': {} '@types/node@17.0.45': {} @@ -11153,6 +11316,19 @@ snapshots: optionalDependencies: typescript: 5.6.3 + '@vue/language-core@2.2.0(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + '@vue/shared@3.5.33': {} '@webassemblyjs/ast@1.14.1': @@ -11936,6 +12112,8 @@ snapshots: commander@8.3.0: {} + commander@9.5.0: {} + common-ancestor-path@1.0.1: {} compare-func@2.0.0: @@ -12016,15 +12194,6 @@ snapshots: jiti: 2.6.1 typescript: 5.6.3 - cosmiconfig@8.3.6(typescript@5.6.3): - dependencies: - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - path-type: 4.0.0 - optionalDependencies: - typescript: 5.6.3 - cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.0 @@ -12284,6 +12453,8 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.21 + dashify@2.0.0: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -12951,7 +13122,7 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.1.1: {} expect-type@1.1.0: {} @@ -13159,6 +13330,8 @@ snapshots: fs-monkey@1.1.0: {} + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true @@ -13257,6 +13430,14 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + global-directory@5.0.0: dependencies: ini: 6.0.0 @@ -13591,12 +13772,6 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 - i18next@24.2.3(typescript@5.6.3): - dependencies: - '@babel/runtime': 7.27.0 - optionalDependencies: - typescript: 5.6.3 - i18next@24.2.3(typescript@5.9.3): dependencies: '@babel/runtime': 7.27.0 @@ -13632,6 +13807,11 @@ snapshots: indent-string@5.0.0: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.3: {} inherits@2.0.4: {} @@ -14744,6 +14924,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.1 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -14836,6 +15020,8 @@ snapshots: node-fetch-native@1.6.7: {} + node-forge@1.4.0: {} + node-mock-http@1.0.4: {} node-releases@2.0.37: {} @@ -15351,15 +15537,6 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@babel/runtime': 7.27.0 - html-parse-stringify: 3.0.1 - i18next: 24.2.3(typescript@5.6.3) - react: 19.1.0 - optionalDependencies: - react-dom: 19.1.0(react@19.1.0) - react-i18next@15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.0 @@ -16266,8 +16443,6 @@ snapshots: tailwind-merge@3.5.0: {} - tapable@2.3.2: {} - tapable@2.3.3: {} tar@7.5.11: @@ -16511,6 +16686,8 @@ snapshots: undici-types@6.20.0: {} + undici@6.28.0: {} + undici@7.24.4: {} unified@11.0.5: @@ -16733,6 +16910,25 @@ snapshots: - rollup - supports-color + vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): + dependencies: + '@microsoft/api-extractor': 7.58.7(@types/node@22.12.0) + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.9.3 + optionalDependencies: + vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-lib-inject-css@2.2.2(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@ast-grep/napi': 0.36.3 @@ -16749,17 +16945,6 @@ snapshots: picocolors: 1.1.1 vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) - vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@svgr/core': 8.1.0(typescript@5.6.3) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.6.3)) - vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) - transitivePeerDependencies: - - rollup - - supports-color - - typescript - vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.57.1) @@ -16966,7 +17151,7 @@ snapshots: minimizer-webpack-plugin: 5.8.0(@swc/core@1.15.26)(webpack@5.110.1(@swc/core@1.15.26)) neo-async: 2.6.2 schema-utils: 4.3.3 - tapable: 2.3.2 + tapable: 2.3.3 watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: @@ -17001,7 +17186,7 @@ snapshots: minimizer-webpack-plugin: 5.8.0(@swc/core@1.15.26)(esbuild@0.27.3)(postcss@8.5.6)(webpack@5.110.1(@swc/core@1.15.26)(esbuild@0.27.3)(postcss@8.5.6)) neo-async: 2.6.2 schema-utils: 4.3.3 - tapable: 2.3.2 + tapable: 2.3.3 watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd77a2389..fd2f49212 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,11 @@ catalog: '@temporalio/testing': ^1.23.0 '@temporalio/worker': ^1.23.0 '@temporalio/workflow': ^1.23.0 + # AI SDK. The provider major is tied to the `ai` major — v2 speaks to `ai` v6, + # v3 to `ai` v7 — so the two only move together, and the backend and the worker + # have to agree or they build the same model against different request shapes. + 'ai': ^6.0.168 + '@ai-sdk/openai-compatible': ^2.0.74 useNodeVersion: 22.12.0 engineStrict: true diff --git a/tools/check-offline-build.mjs b/tools/check-offline-build.mjs new file mode 100644 index 000000000..20286c7a5 --- /dev/null +++ b/tools/check-offline-build.mjs @@ -0,0 +1,104 @@ +// Guards the air-gap boundary of deploy/ai-studio/Dockerfile: every RUN after +// `pnpm fetch` must carry --network=none, so BuildKit blocks egress from installs, +// lifecycle scripts and build commands alike. `--offline` alone only stops pnpm's +// own resolver. After the boundary, `ADD ` and `COPY --from=` are rejected +// too (downloads no RUN flag governs), as is a `# syntax=` directive anywhere: it would +// pull the build frontend from Docker Hub unpinned. Run with `pnpm check:offline-build`. +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DOCKERFILE = 'deploy/ai-studio/Dockerfile'; +// The boundary step must be exactly `pnpm fetch`: anything chained onto it runs with network. +const NETWORK_BOUNDARY = /^RUN(?:\s+--\S+)*\s+pnpm fetch$/i; + +// Dockerfile instructions span continuation lines (trailing `\`) and may hold +// comment lines in between; both are folded into one instruction here. +function parseInstructions(text) { + const instructions = []; + let current = null; + text.split('\n').forEach((raw, index) => { + const line = raw.trim(); + if (line === '' || line.startsWith('#')) return; + // An even run of trailing backslashes is literal, not a continuation. + const continues = /(^|[^\\])(\\\\)*\\$/.test(line); + const content = continues ? line.slice(0, -1).trim() : line; + if (current) { + current.text += ' ' + content; + } else { + current = { line: index + 1, text: content }; + } + if (!continues) { + instructions.push(current); + current = null; + } + }); + if (current) instructions.push(current); + return instructions; +} + +const dockerfile = readFileSync(path.join(ROOT, DOCKERFILE), 'utf8'); + +if (/^#\s*syntax\s*=/im.test(dockerfile)) { + console.error(`${DOCKERFILE}: remove the \`# syntax=\` directive; it downloads the build frontend from Docker Hub.`); + process.exit(1); +} + +const instructions = parseInstructions(dockerfile); +const isRun = ({ text }) => /^RUN\b/i.test(text); + +const boundary = instructions.findIndex(({ text }) => NETWORK_BOUNDARY.test(text)); +if (boundary === -1) { + const chained = instructions.find(({ text }) => /\bpnpm fetch\b/.test(text)); + console.error( + chained + ? `${DOCKERFILE}: line ${chained.line}: the \`pnpm fetch\` step must run nothing else; chained commands keep network access.` + : `${DOCKERFILE}: no \`pnpm fetch\` step found; cannot locate the network boundary.`, + ); + process.exit(1); +} + +// Only the instruction's own flags count; the token inside a shell command means nothing to BuildKit. +const instructionFlags = (text) => /^[A-Z]+((?:\s+--\S+)*)/i.exec(text)[1].trim().split(/\s+/).filter(Boolean); +const isolated = (text) => { + const network = instructionFlags(text).filter((flag) => flag.startsWith('--network=')); + return network.length > 0 && network.every((flag) => flag === '--network=none'); +}; + +// `COPY --from` may name a stage (or its index); anything else is an image to pull. +const stages = new Set( + instructions + .map(({ text }) => /^FROM(?:\s+--\S+)*\s+\S+\s+AS\s+(\S+)/i.exec(text)?.[1].toLowerCase()) + .filter(Boolean), +); +const reachesNetwork = ({ text }) => { + if (isRun({ text })) return !isolated(text); + if (/^ADD\b/i.test(text)) return /(^|\s)(?:[a-z]+:\/\/|git@)/i.test(text.replace(/^ADD/i, '')); + if (/^COPY\b/i.test(text)) { + const from = instructionFlags(text) + .find((flag) => flag.startsWith('--from=')) + ?.slice('--from='.length); + return from !== undefined && !/^\d+$/.test(from) && !stages.has(from.toLowerCase()); + } + return false; +}; +const leaking = instructions.slice(boundary + 1).filter(reachesNetwork); + +if (leaking.length > 0) { + console.error( + `${DOCKERFILE}: steps after \`pnpm fetch\` that may reach the network (RUN without --network=none, ADD , COPY --from=):`, + ); + for (const { line, text } of leaking) { + console.error(` line ${line}: ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`); + } + process.exit(1); +} + +const runsBefore = instructions.slice(0, boundary + 1).filter(isRun).length; +const runsAfter = instructions.slice(boundary + 1).filter(isRun).length; +console.log( + `${DOCKERFILE}: ${runsAfter} post-fetch RUN steps are --network=none, no ADD or COPY --from= ` + + `(${runsBefore} steps before the boundary may use the registry).`, +); diff --git a/tools/preflight.mjs b/tools/preflight.mjs index 44e6d65fa..9924b62f3 100644 --- a/tools/preflight.mjs +++ b/tools/preflight.mjs @@ -5,7 +5,6 @@ // // Named `preflight` rather than `doctor` because `pnpm doctor` is a built-in // pnpm command and would shadow a user script of the same name. - import { spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import net from 'node:net'; @@ -118,7 +117,7 @@ async function checkDocker() { const { code } = await runCmd('docker', ['info']); return code === 0 ? { name: 'docker', status: 'pass', detail: 'running' } - : { name: 'docker', status: 'warn', detail: 'not running (only needed for Path B)' }; + : { name: 'docker', status: 'warn', detail: 'not running (only needed for Path C)' }; } // App ports may be held by the user's own running dev server, another local @@ -152,11 +151,37 @@ async function checkServicePort(port, label) { }; } +// Names only; values never leave this function (the files hold API keys). +function envKeys(relPath) { + return new Set( + readFileSync(path.join(ROOT, relPath), 'utf8') + .split('\n') + .map((line) => line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/)) + .filter((m) => m && m[2].trim() !== '') + .map((m) => m[1]), + ); +} + async function checkEnvFile(relPath) { - const present = existsSync(path.join(ROOT, relPath)); - return present - ? { name: relPath, status: 'pass', detail: 'present' } - : { name: relPath, status: 'warn', detail: `missing — copy from ${relPath}.example` }; + if (!existsSync(path.join(ROOT, relPath))) { + return { name: relPath, status: 'warn', detail: `missing — copy from ${relPath}.example` }; + } + const keys = envKeys(relPath); + if (keys.has('OPENROUTER_API_KEY')) { + return { + name: relPath, + status: 'warn', + detail: 'OPENROUTER_API_KEY is retired — rename it to AI_API_KEY and add AI_BASE_URL (see .env.example)', + }; + } + if (keys.has('AI_MODEL') && !(keys.has('AI_API_KEY') && keys.has('AI_BASE_URL'))) { + return { + name: relPath, + status: 'warn', + detail: 'AI_MODEL is set but AI_API_KEY or AI_BASE_URL is missing — AI Agent nodes fail with ai_not_configured', + }; + } + return { name: relPath, status: 'pass', detail: 'present' }; } // ---------- Composition ----------