diff --git a/.dockerignore b/.dockerignore index 8ec9bf851..19d349853 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,3 +31,15 @@ examples/ **/.env **/.env.* !**/.env.example + +# certificate material is mounted at runtime — never built into an image. The +# Dockerfile needs one file from deploy/, so the rest stays out of the context: +# a TEMPORAL_TLS_DIR under deploy/ cannot reach COPY . . whatever it is named. +deploy/ +!deploy/ai-studio/nginx +**/*.pem +**/*.key +**/*.crt +**/*.cer +**/*.p12 +**/*.pfx diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml index 0a1850c10..8c203cd73 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,39 @@ 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 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 && 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..7f2cdfa77 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -219,10 +219,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 +245,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/CLAUDE.md b/CLAUDE.md index ae6a3d247..5af220bce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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,7 @@ 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. ## Code Quality 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/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..da1eab64b 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,35 @@ 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 when the connection opens, rather than being silently ignored. + +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.ts b/apps/backend/src/engine/index.ts index 179170e7d..b4d641dce 100644 --- a/apps/backend/src/engine/index.ts +++ b/apps/backend/src/engine/index.ts @@ -2,10 +2,9 @@ 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'; - let engine: WorkflowEnginePort | undefined; export function getWorkflowEngine(): WorkflowEnginePort { @@ -13,7 +12,13 @@ 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 }) }), + // Misconfigured TEMPORAL_* values therefore surface on that first submit + // rather than at boot. + client: async () => { + const temporal = temporalConfig(); + 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..348d84bdc --- /dev/null +++ b/apps/backend/src/env.test.ts @@ -0,0 +1,37 @@ +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(); + }); +}); 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.ts b/apps/backend/src/routes/visualize.ts index 8ebc5066c..3ab610b98 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 } 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,12 @@ 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) { 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 +65,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..7defeb33c 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`. | +| Worker exits at boot with a `TEMPORAL_TLS` or `TEMPORAL_TLS_*_PATH` error, or the backend returns 500 on the first Play | Contradictory Temporal settings (half an mTLS pair, API key plus client cert, credentials with `TEMPORAL_TLS=false`). The backend connects on first use, so it boots and passes its healthcheck regardless | Remove one side, as the message says; the backend logs it on that first Play. 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..569341e71 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -3,22 +3,31 @@ import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal'; import 'dotenv/config'; import { fileURLToPath } from 'node:url'; -import { executeAiAgent } from '../../activities/ai-agent'; +import { aiConfig } 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) { + logger.warn('AI not configured — AI Agent nodes will fail; every other node type runs as usual', { + missing: ai.missing, + }); +} + +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 +35,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/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index 6d250ca1d..c70d6e44f 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -1,15 +1,26 @@ -# 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: compose refuses to start while the old name is +# still set, so rename it here rather than adding this one next to it. +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 @@ -35,6 +46,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/README.md b/deploy/ai-studio/README.md index 530155ac3..e361053b4 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -5,15 +5,21 @@ 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` | 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` | + +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 ``` @@ -77,9 +83,43 @@ 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`. + +**Upgrading from `OPENROUTER_API_KEY`.** The key is now `AI_API_KEY`, and the +endpoint and model are no longer built in. In `.env`, rename the key and add +`AI_BASE_URL` and `AI_MODEL` (the OpenRouter values are in `.env.example`). +Compose refuses to start while the old name is still set, so a stale `.env` +fails loudly instead of coming up with AI silently off. + +**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 the worker at boot (`docker compose logs worker`); +the backend connects on first use, so it still passes its healthcheck and fails +on the first Play — check the worker, not `/api/health`. 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 +131,16 @@ 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. 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 +148,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 +160,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..fd8e4067a --- /dev/null +++ b/deploy/ai-studio/docker-compose.override.yml @@ -0,0 +1,61 @@ +# 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: + 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 + + # 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..48637cac4 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -1,14 +1,46 @@ # AI Studio production stack (WB-229). Usage: cp .env.example .env, set -# OPENROUTER_API_KEY, then `docker compose up -d --build`. Only `web` +# 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 +# Fails fast while a pre-rename .env is still in place. OPENROUTER_API_KEY was +# renamed to AI_API_KEY and is no longer read; without this, a stale .env would +# come up with AI silently off. The inner :? only fires when the outer :+ does. +x-retired-openrouter-key: ${OPENROUTER_API_KEY:+${OPENROUTER_API_KEY_RETIRED:?OPENROUTER_API_KEY was renamed to AI_API_KEY - rename it and set AI_BASE_URL and AI_MODEL too, see .env.example}} + x-runtime-build: &runtime-build context: ../.. 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 @@ -25,57 +57,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 +74,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 +97,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 +135,3 @@ services: volumes: app-db-data: - temporal-db-data: 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..0737955c1 100644 --- a/knip.config.js +++ b/knip.config.js @@ -48,6 +48,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/packages/ai-config/README.md b/packages/ai-config/README.md new file mode 100644 index 000000000..58b8e929c --- /dev/null +++ b/packages/ai-config/README.md @@ -0,0 +1,36 @@ +# @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. + +```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'] } +``` + +`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`). + +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..8cd7ea649 --- /dev/null +++ b/packages/ai-config/src/index.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { aiConfig } 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(); + } + }); +}); diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts new file mode 100644 index 000000000..2f4b68697 --- /dev/null +++ b/packages/ai-config/src/index.ts @@ -0,0 +1,24 @@ +const AI_VARIABLES = ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const; + +export type AiVariable = (typeof 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)) }; +} 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..a0b3fedc4 --- /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 at import time. `temporalConfig` reads `process.env` (or the environment it is given) when called and throws on a bad combination, so each app calls it where it wants the failure surfaced — the backend in its first-connection factory, the worker before it starts polling. + +```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. 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..82e7a3644 --- /dev/null +++ b/packages/temporal-connection/test/tls.test.ts @@ -0,0 +1,210 @@ +// 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); +}); + +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..61ab82a5c 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 @@ -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'} @@ -3108,6 +3171,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==} @@ -4836,8 +4902,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: @@ -6247,6 +6313,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 +7462,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 +7714,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 +8380,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 +10090,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,17 +10431,6 @@ 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)': - dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) - camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.6.3) - snake-case: 3.0.4 - transitivePeerDependencies: - - supports-color - - typescript - '@svgr/core@8.1.0(typescript@5.9.3)': dependencies: '@babel/core': 7.26.7 @@ -10375,16 +10447,6 @@ 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))': - 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/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))': dependencies: '@babel/core': 7.26.7 @@ -10525,6 +10587,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 +10950,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 +11244,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': @@ -12016,15 +12120,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 @@ -12951,7 +13046,7 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.1.1: {} expect-type@1.1.0: {} @@ -13591,12 +13686,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 @@ -14836,6 +14925,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 +15442,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 +16348,6 @@ snapshots: tailwind-merge@3.5.0: {} - tapable@2.3.2: {} - tapable@2.3.3: {} tar@7.5.11: @@ -16511,6 +16591,8 @@ snapshots: undici-types@6.20.0: {} + undici@6.28.0: {} + undici@7.24.4: {} unified@11.0.5: @@ -16733,6 +16815,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 +16850,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 +17056,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 +17091,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