diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12c03a54e6..bb9d443de0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,11 @@ jobs: key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}-${{ hashFiles('packages/*/package.json') }} - name: Install & Bootstrap run: yarn && yarn bootstrap --ci + # After bootstrap, @forestadmin packages can end up nested inside packages/*/node_modules + # causing duplicate module instances at build time. Removing them forces Node to resolve + # from the root node_modules only. + - name: Remove nested workspace packages (force root hoisting) + run: rm -rf packages/*/node_modules/@forestadmin - name: Build run: yarn build - uses: actions/cache/save@v4 diff --git a/WORKFLOW-EXECUTOR-CONTRACT.md b/WORKFLOW-EXECUTOR-CONTRACT.md index f9378428f6..0a7bcf98ef 100644 --- a/WORKFLOW-EXECUTOR-CONTRACT.md +++ b/WORKFLOW-EXECUTOR-CONTRACT.md @@ -1,17 +1,49 @@ # Workflow Executor — Contract Types > Types exchanged between the **orchestrator (server)**, the **executor (agent-nodejs)**, and the **frontend**. -> Last updated: 2026-03-26 +> Last updated: 2026-04-28 + +--- + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| GET | `/api/workflow-orchestrator/pending-run` | Batch poll — all pending runs | +| GET | `/api/workflow-orchestrator/available-run/:runId` | Single run fetch (HTTP trigger path) | +| POST | `/api/workflow-orchestrator/update-step` | Report step outcome + receive next step | +| GET | `/api/workflow-orchestrator/collection-schema/:collectionName?runId=:runId` | Collection schema | +| GET | `/api/workflow-orchestrator/run/:runId/access-check?userId=:userId` | Authorization check | +| GET | `/liana/mcp-server-configs-with-details` | MCP server configurations | --- ## 1. Polling -**`GET /liana/v1/workflow-step-executions/pending?runId=`** +### Batch poll — `GET /api/workflow-orchestrator/pending-run` + +Returns `ServerHydratedWorkflowRun[]`. The executor maps each run to an `AvailableStepExecution`. +Runs that fail to map are reported as malformed (error outcome posted, run stops re-dispatching). + +### Single-run fetch — `GET /api/workflow-orchestrator/available-run/:runId` -The executor polls for the current pending step of a run. The server must return **one object** (not an array), or `null` if the run is not found. +Returns `ServerHydratedWorkflowRun | null`. Used by the HTTP trigger path only. +`null` → no available step (run finished, awaiting input, or not found). + +Both responses use the same envelope, mapped to: ```typescript +interface AvailableStepExecution { + runId: string; + stepId: string; + stepIndex: number; + collectionId: string; + baseRecordRef: RecordRef; + stepDefinition: StepDefinition; + previousSteps: Step[]; + user: StepUser; +} + interface StepUser { id: number; email: string; @@ -23,52 +55,14 @@ interface StepUser { permissionLevel: string; tags: Record; } - -interface PendingStepExecution { - runId: string; - stepId: string; - stepIndex: number; - baseRecordRef: RecordRef; - stepDefinition: StepDefinition; - previousSteps: Step[]; - user: StepUser; // identity of the user who initiated the step -} ``` -> **`null` response** → executor throws `RunNotFoundError` → HTTP 404 returned to caller. - -### CollectionSchema - -Schema of a collection, returned by the orchestrator via `GET /liana/v1/collections/:collectionName`. Used by the executor to resolve primary keys and action endpoints. - -```typescript -interface CollectionSchema { - collectionName: string; - collectionDisplayName: string; - primaryKeyFields: string[]; - fields: FieldSchema[]; - actions: ActionSchema[]; -} - -interface FieldSchema { - fieldName: string; - displayName: string; - isRelationship: boolean; - relationType?: "BelongsTo" | "HasMany" | "HasOne"; - relatedCollectionName?: string; -} - -interface ActionSchema { - name: string; - displayName: string; - endpoint: string; // route path used by the agent to execute the action -} -``` +Each dispatch also carries a `forestServerToken` (from `userProfile.serverToken`) used for +per-step API calls (activity logs, collection schema). It is **not** part of `AvailableStepExecution` — +it lives in `AvailableRunDispatch.auth`. ### RecordRef -Lightweight pointer to a specific record. - ```typescript interface RecordRef { collectionName: string; @@ -77,9 +71,7 @@ interface RecordRef { } ``` -### Step - -History entry for an already-executed step (used in `previousSteps`). +### Step (history entry for `previousSteps`) ```typescript interface Step { @@ -90,196 +82,269 @@ interface Step { ### StepDefinition -Discriminated union on `type`. +Discriminated union on `type`: ```typescript type StepDefinition = | ConditionStepDefinition - | RecordTaskStepDefinition - | McpTaskStepDefinition; + | ReadRecordStepDefinition + | UpdateRecordStepDefinition + | TriggerActionStepDefinition + | LoadRelatedRecordStepDefinition + | McpStepDefinition + | GuidanceStepDefinition; interface ConditionStepDefinition { type: "condition"; - options: [string, ...string[]]; // at least one option required + options: string[]; // minimum 2 options prompt?: string; aiConfigName?: string; } -interface RecordTaskStepDefinition { - type: "read-record" - | "update-record" - | "trigger-action" - | "load-related-record"; +interface ReadRecordStepDefinition { + type: "read-record"; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; + preRecordedArgs?: { + selectedRecordStepIndex?: number; + fieldDisplayNames?: string[]; // display names of fields to read + }; +} + +interface UpdateRecordStepDefinition { + type: "update-record"; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; + preRecordedArgs?: { + selectedRecordStepIndex?: number; + fieldDisplayName?: string; // display name of field to update + value?: string; + }; +} + +interface TriggerActionStepDefinition { + type: "trigger-action"; prompt?: string; aiConfigName?: string; automaticExecution?: boolean; + preRecordedArgs?: { + selectedRecordStepIndex?: number; + actionDisplayName?: string; // display name of action to trigger + }; } -interface McpTaskStepDefinition { - type: "mcp-task"; +interface LoadRelatedRecordStepDefinition { + type: "load-related-record"; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; + preRecordedArgs?: { + selectedRecordStepIndex?: number; + relationDisplayName?: string; // display name of relation to follow + selectedRecordIndex?: number; + }; +} + +interface McpStepDefinition { + type: "mcp"; mcpServerId?: string; prompt?: string; aiConfigName?: string; automaticExecution?: boolean; } + +// Manual guidance step — saves user input, no AI call. +interface GuidanceStepDefinition { + type: "guidance"; + prompt?: string; + aiConfigName?: string; +} ``` ### StepOutcome -What the executor previously reported for each past step (used in `previousSteps`). +What the executor reports per step, and what `previousSteps` carries for past steps. ```typescript type StepOutcome = | ConditionStepOutcome - | RecordTaskStepOutcome - | McpTaskStepOutcome; + | RecordStepOutcome + | McpStepOutcome + | GuidanceStepOutcome; interface ConditionStepOutcome { type: "condition"; stepId: string; stepIndex: number; status: "success" | "error"; - selectedOption?: string; // present when status = "success" - error?: string; // present when status = "error" + selectedOption?: string; // present when status = "success" + error?: string; // present when status = "error" } -interface RecordTaskStepOutcome { - type: "record-task"; +// Covers read-record, update-record, trigger-action, load-related-record +interface RecordStepOutcome { + type: "record"; stepId: string; stepIndex: number; status: "success" | "error" | "awaiting-input"; - error?: string; // present when status = "error" + error?: string; } -interface McpTaskStepOutcome { - type: "mcp-task"; +interface McpStepOutcome { + type: "mcp"; stepId: string; stepIndex: number; status: "success" | "error" | "awaiting-input"; - error?: string; // present when status = "error" + error?: string; +} + +interface GuidanceStepOutcome { + type: "guidance"; + stepId: string; + stepIndex: number; + status: "success" | "error"; + error?: string; } ``` +> **NEVER contains client data** (field values, AI reasoning, etc.) — those stay in the `RunStore` +> on the client side. + --- -## 2. Step Result +## 2. Step Result + Auto-chain -**`POST /liana/v1/workflow-step-executions//complete`** +**`POST /api/workflow-orchestrator/update-step`** -After executing a step, the executor posts the outcome back to the server. The body is one of the `StepOutcome` shapes above. +After executing a step, the executor posts the outcome. Response: `ServerHydratedWorkflowRun | null`. -> **NEVER contains client data** (field values, AI reasoning, etc.) — those stay in the `RunStore` on the client side. +- **`null`** → run is finished / awaiting input / errored → executor stops the chain, yields to next poll. +- **non-null** → a next step is available → executor runs it **inline** (auto-chain) without waiting for + the next poll cycle. Chain continues until `null`, a non-progressing `stepIndex`, the depth cap + (`maxChainDepth`, default 50), or graceful shutdown. ---- +Request body: -## 3. Pending Data - -Steps that require user input pause with `status: "awaiting-input"`. The executor writes its AI-selected data to `pendingData` in the RunStore. The frontend can then override fields and confirm via the pending-data endpoint. +```typescript +interface UpdateStepRequest { + runId: number; + stepUpdate: { + stepIndex: number; + attributes: { + done?: boolean; + context?: Record; // stores status, error, selectedOption for round-trip + }; + }; + executionStatus: + | { type: "success" } + | { type: "error"; message: string } + | { type: "awaiting-input" }; +} +``` -**`PATCH /runs/:runId/steps/:stepIndex/pending-data`** +**Idempotency requirement**: the server must deduplicate identical outcomes for a given +`(runId, stepIndex)`. The port retries `POST /update-step` on transient failures (network, 5xx) — +without server-side dedup, retries cause double side-effects. -The frontend writes user overrides + confirmation to the executor HTTP server. Request bodies are validated per step type with strict Zod schemas — unknown fields are rejected with `400`. +--- -Once written, the frontend calls `POST /runs/:runId/trigger`. On the next execution, the executor reads `pendingData` from the RunStore and checks `userConfirmed`: -- `undefined` → returns `awaiting-input` again (the step is not yet actionable) -- `true` → execute the confirmed action -- `false` → skip the step (mark as success) +## 3. Pending Data (awaiting-input flow) -### update-record — user picks a field + value to write +Steps that require user input pause with `status: "awaiting-input"`. The executor writes its +AI-selected data to `pendingData` in the RunStore. The frontend reads it via `GET /runs/:runId` +(executor HTTP server), then confirms by calling `POST /runs/:runId/trigger` with the data. -The executor writes the AI's field selection to `pendingData`. The frontend can override `value` and confirm. +**`POST /runs/:runId/trigger`** — executor HTTP server -Stored in RunStore: +Request body: ```typescript -interface UpdateRecordPendingData { - name: string; // technical field name (set by executor) - displayName: string; // label shown in the UI (set by executor) - value: string; // AI-proposed value; overridable by frontend - userConfirmed?: boolean; // set by frontend via PATCH -} +{ pendingData?: unknown } ``` -PATCH request body: +On re-execution, the executor reads `pendingData` from the RunStore and checks `userConfirmed`: +- `undefined` → returns `awaiting-input` again (step not yet actionable) +- `true` → executes the confirmed action +- `false` → skips the step (marks as success) + +### update-record + ```typescript -{ - userConfirmed: boolean; - value?: string; // optional override of AI-proposed value +// Stored in RunStore (pendingData written by executor): +interface UpdateRecordPendingData { + name: string; // technical field name + displayName: string; // label shown in UI + value: string; // AI-proposed value; overridable by frontend + userConfirmed?: boolean; } -``` -### trigger-action & mcp-task — user confirmation only +// pendingData field of POST /runs/:runId/trigger body: +{ userConfirmed: boolean; value?: string; } +``` -The executor selects the action (or MCP tool) and writes `pendingData` to the RunStore. The frontend cannot override any executor-selected data — it only confirms or rejects. +### trigger-action & mcp -PATCH request body (same for both types): ```typescript -{ - userConfirmed: boolean; -} +// pendingData field of POST /runs/:runId/trigger body: +{ userConfirmed: boolean; } ``` -### load-related-record — user picks the relation and/or the record - -The executor writes the AI's relation selection to `pendingData`. The frontend can override the relation, the selected record, or both. +### load-related-record -Stored in RunStore: ```typescript +// Stored in RunStore (pendingData written by executor): interface LoadRelatedRecordPendingData { - name: string; // technical relation name - displayName: string; // label shown in the UI - suggestedFields?: string[]; // fields suggested for display (set by executor) - selectedRecordId: Array; // AI's pick; overridable by frontend - userConfirmed?: boolean; // set by frontend via PATCH + name: string; + displayName: string; + suggestedFields?: string[]; + selectedRecordId: Array; + userConfirmed?: boolean; } -``` - -> `relatedCollectionName` is **not** stored in `pendingData` — the executor re-derives it from the `FieldSchema` at execution time using the (possibly overridden) relation `name`. -PATCH request body: -```typescript +// pendingData field of POST /runs/:runId/trigger body: { - userConfirmed: boolean; - name?: string; // override relation - displayName?: string; // override relation label - selectedRecordId?: Array; // override selected record (min 1 element) + userConfirmed: boolean; + name?: string; // override relation + displayName?: string; + selectedRecordId?: Array; // min 1 element } ``` -### Responses - -| Status | Meaning | -|---|---| -| `204 No Content` | Pending data updated successfully | -| `400` | Invalid body — type mismatch, unknown fields, or empty `selectedRecordId` | -| `404` | Step not found, no `pendingData`, or step type does not support confirmation | - --- ## Flow Summary +### Polling loop + +``` +Executor ──► GET /api/workflow-orchestrator/pending-run + │ + [ServerHydratedWorkflowRun, ...] + │ + map → AvailableStepExecution[] + (malformed runs reported as error) + │ + for each run, execute step + │ + POST /api/workflow-orchestrator/update-step + │ + ┌──────────────┴──────────────┐ + null non-null + (done / error / (next step) + awaiting-input) │ + │ auto-chain inline ──► (loop) + stop chain +``` + +### HTTP trigger + ``` -Orchestrator ──► GET pending?runId=X ──► Executor - │ - executes step - │ - ┌───────────────┴───────────────┐ - needs input done - │ │ - status: awaiting-input POST /complete - │ (StepOutcome) - │ - Executor writes pendingData - to RunStore (AI selection) - │ - Frontend reads pendingData - via GET /runs/:runId - │ - Frontend overrides + confirms - PATCH /runs/:runId/steps/:stepIndex/pending-data - { userConfirmed: true/false } → 204 - │ - POST /runs/:runId/trigger - │ - Executor resumes - (reads userConfirmed from pendingData) +Frontend ──► POST /runs/:runId/trigger + │ + GET /api/workflow-orchestrator/available-run/:runId + │ + execute step + auto-chain + │ + POST /api/workflow-orchestrator/update-step ``` diff --git a/package.json b/package.json index 4d2a9a76c3..ad0cf48c58 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "test:coverage": "yarn test --coverage" }, "workspaces": [ - "packages/*" + "packages/*", + "packages/workflow-executor/example" ], "resolutions": { "tar": ">=7.5.11", @@ -58,7 +59,12 @@ "micromatch": "^4.0.8", "semantic-release": "^25.0.0", "qs": ">=6.14.1", - "lerna/js-yaml": "4.1.1", - "@lerna/create/js-yaml": "4.1.1" + "axios": "^1.15.0", + "follow-redirects": "^1.16.0", + "hono": "^4.12.12", + "@hono/node-server": "^1.19.13", + "langsmith": "^0.5.18", + "lodash": "^4.18.0", + "lodash-es": "^4.18.0" } } diff --git a/packages/_example/.env.example b/packages/_example/.env.example index 3db55c1f76..ee5c43ae8f 100644 --- a/packages/_example/.env.example +++ b/packages/_example/.env.example @@ -16,3 +16,8 @@ FOREST_AUTH_SECRET= # Production # FOREST_ENV_SECRET= # FOREST_AUTH_SECRET= + +# Workflow executor +EXECUTOR_AGENT_URL=http://localhost:3351 +WORKFLOW_EXECUTOR_URL=http://localhost:3400 +EXECUTOR_DATABASE_URL=postgresql://executor:password@localhost:5459/workflow_executor diff --git a/packages/_example/README.md b/packages/_example/README.md index 1aa6081fd5..30ed234d46 100644 --- a/packages/_example/README.md +++ b/packages/_example/README.md @@ -50,3 +50,56 @@ To start the development server, run the following command: ```bash yarn start ``` + +## Running the Agent with the Workflow Executor + +`start:with-executor` launches both the agent and the workflow executor side-by-side using `concurrently`. The executor waits for the agent to be ready before starting. + +### 1. Start the executor's Postgres database + +```bash +yarn db:executor:up +``` + +Starts a dedicated Postgres container on `localhost:5452` (separate from the agent databases). + +### 2. Add executor variables to `.env` + +The script sources the `_example` `.env` file and maps two variables to the executor's expected names. Add these to your `.env`: + +```dotenv +# Workflow executor +EXECUTOR_AGENT_URL=http://localhost:3310 # must match the port your agent listens on +EXECUTOR_DATABASE_URL=postgres://executor:password@localhost:5452/workflow_executor +``` + +`FOREST_ENV_SECRET` and `FOREST_AUTH_SECRET` are already required by the agent and are reused by the executor automatically. + +### 3. Install `tsx` (if not already available) + +The executor CLI uses `tsx` for fast TypeScript execution without a build step: + +```bash +npm install -g tsx +``` + +### 4. Start both processes + +```bash +yarn start:with-executor +``` + +Expected output (two prefixed streams): + +``` +[agent] Forest Admin agent listening on port 3310 +[executor] [forest-workflow-executor] Starting (database mode) +[executor] [forest-workflow-executor] Ready on http://localhost:3400 +[executor] {"message":"Poll cycle completed","fetched":0,"dispatching":0} +``` + +### Teardown + +```bash +yarn db:executor:down # stop the executor DB container +``` diff --git a/packages/_example/package.json b/packages/_example/package.json index bfcb9ce9d1..84a3eb9c85 100644 --- a/packages/_example/package.json +++ b/packages/_example/package.json @@ -43,10 +43,15 @@ "start:watch": "node --enable-source-maps --async-stack-traces -r ts-node/register --watch src/serve.ts", "start:watch:inspect": "node --enable-source-maps --async-stack-traces -r ts-node/register --watch --inspect src/serve.ts", "test": "jest", - "db:seed": "ts-node scripts/db-seed.ts" + "db:seed": "ts-node scripts/db-seed.ts", + "start:with-executor": "concurrently --kill-others --names 'agent,executor' \"yarn start\" \"bash -c 'set -a && source .env && AGENT_URL=\\$EXECUTOR_AGENT_URL && DATABASE_URL=\\$EXECUTOR_DATABASE_URL && until curl -s \\$EXECUTOR_AGENT_URL >/dev/null 2>&1; do sleep 1; done && tsx watch ../workflow-executor/src/cli.ts --pretty'\"", + "db:executor:up": "cd ../workflow-executor/example && docker compose up -d", + "db:executor:down": "cd ../workflow-executor/example && docker compose down", + "db:executor:reset": "cd ../workflow-executor/example && docker compose down -v && docker compose up -d" }, "devDependencies": { "@types/node": "^20.12.12", + "concurrently": "^9.0.0", "ts-node": "^10.9.2", "tslib": "^2.8.1", "typescript": "^5.6.3" diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index c77f51ba9c..8534595db3 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -31,6 +31,7 @@ export default function makeAgent() { envSecret: process.env.FOREST_ENV_SECRET, forestServerUrl: process.env.FOREST_SERVER_URL, forestAppUrl: process.env.FOREST_APP_URL, + workflowExecutorUrl: process.env.WORKFLOW_EXECUTOR_URL, isProduction: false, loggerLevel: 'Info', typingsPath: 'src/forest/typings.ts', diff --git a/packages/agent-client/CHANGELOG.md b/packages/agent-client/CHANGELOG.md index 961d0b281e..7bb03a0e6e 100644 --- a/packages/agent-client/CHANGELOG.md +++ b/packages/agent-client/CHANGELOG.md @@ -1,3 +1,121 @@ +## @forestadmin/agent-client [1.5.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.5.5...@forestadmin/agent-client@1.5.6) (2026-04-30) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.5 + +## @forestadmin/agent-client [1.5.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.5.4...@forestadmin/agent-client@1.5.5) (2026-04-24) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.4 + +## @forestadmin/agent-client [1.5.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.5.3...@forestadmin/agent-client@1.5.4) (2026-04-22) + + +### Bug Fixes + +* **agent-client:** accept composite PKs as arrays ([#1565](https://github.com/ForestAdmin/agent-nodejs/issues/1565)) ([a72fda0](https://github.com/ForestAdmin/agent-nodejs/commit/a72fda0a187a0226e7dfcd351f0c8b2ed91da615)) + +## @forestadmin/agent-client [1.5.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.5.2...@forestadmin/agent-client@1.5.3) (2026-04-22) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.3 + +## @forestadmin/agent-client [1.5.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.5.1...@forestadmin/agent-client@1.5.2) (2026-04-21) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.2 + +## @forestadmin/agent-client [1.5.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.5.0...@forestadmin/agent-client@1.5.1) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.1 + +# @forestadmin/agent-client [1.5.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.23...@forestadmin/agent-client@1.5.0) (2026-04-17) + + +### Features + +* **mcp-server:** expose polymorphic relations in describeCollection ([#1551](https://github.com/ForestAdmin/agent-nodejs/issues/1551)) ([f73b417](https://github.com/ForestAdmin/agent-nodejs/commit/f73b41704c246ef86bd7b02299894bcd517f91b6)) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.0 + +## @forestadmin/agent-client [1.4.23](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.22...@forestadmin/agent-client@1.4.23) (2026-04-14) + + +### Bug Fixes + +* **mcp server:** do not pass the whole query in requests ([#1548](https://github.com/ForestAdmin/agent-nodejs/issues/1548)) ([ca4810f](https://github.com/ForestAdmin/agent-nodejs/commit/ca4810fd0ea0c1bc593f7ef5470b7462bc700d37)) + +## @forestadmin/agent-client [1.4.22](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.21...@forestadmin/agent-client@1.4.22) (2026-04-10) + + +### Bug Fixes + +* **agent-client:** convert filter operators to snake_case for Ruby compatibility ([#1544](https://github.com/ForestAdmin/agent-nodejs/issues/1544)) ([00e8ad5](https://github.com/ForestAdmin/agent-nodejs/commit/00e8ad54c59d7f141c54a756f7a5921bb47f66dc)) + +## @forestadmin/agent-client [1.4.21](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.20...@forestadmin/agent-client@1.4.21) (2026-04-10) + + +### Bug Fixes + +* **mcp-server:** action execution on v1 agent ([#1542](https://github.com/ForestAdmin/agent-nodejs/issues/1542)) ([01b1a64](https://github.com/ForestAdmin/agent-nodejs/commit/01b1a64a7e0fbf8d5d47ebf0f9b1fc933c6709aa)) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.38.4 + +## @forestadmin/agent-client [1.4.20](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.19...@forestadmin/agent-client@1.4.20) (2026-04-10) + + +### Bug Fixes + +* **mcp-server:** add snake_case JWT claims for Ruby backend compatibility ([#1538](https://github.com/ForestAdmin/agent-nodejs/issues/1538)) ([b3b7dcd](https://github.com/ForestAdmin/agent-nodejs/commit/b3b7dcd5605867447eb2a996c4db35ba98402e62)) + +## @forestadmin/agent-client [1.4.19](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.18...@forestadmin/agent-client@1.4.19) (2026-04-08) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.38.3 + ## @forestadmin/agent-client [1.4.18](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.4.17...@forestadmin/agent-client@1.4.18) (2026-04-01) diff --git a/packages/agent-client/package.json b/packages/agent-client/package.json index ab0254c030..8bde980c13 100644 --- a/packages/agent-client/package.json +++ b/packages/agent-client/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/agent-client", - "version": "1.4.18", + "version": "1.5.6", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -13,7 +13,7 @@ }, "dependencies": { "@forestadmin/datasource-toolkit": "1.53.1", - "@forestadmin/forestadmin-client": "1.38.2", + "@forestadmin/forestadmin-client": "1.39.5", "jsonapi-serializer": "^3.6.9", "superagent": "^10.3.0" }, diff --git a/packages/agent-client/src/action-fields/field-form-states.ts b/packages/agent-client/src/action-fields/field-form-states.ts index a7b313c20c..bceba72938 100644 --- a/packages/agent-client/src/action-fields/field-form-states.ts +++ b/packages/agent-client/src/action-fields/field-form-states.ts @@ -1,7 +1,10 @@ import type { PlainField, ResponseBody } from './types'; -import type HttpRequester from '../http-requester'; -import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client'; +import type { + ForestSchemaAction, + ForestServerActionFormLayoutElement, +} from '@forestadmin/forestadmin-client'; +import HttpRequester from '../http-requester'; import ActionFieldMultipleChoice from './action-field-multiple-choice'; import FieldGetter from './field-getter'; @@ -13,6 +16,8 @@ export default class FieldFormStates { private readonly httpRequester: HttpRequester; private readonly ids: string[]; private readonly layout: ForestServerActionFormLayoutElement[]; + private readonly hooks?: ForestSchemaAction['hooks']; + private readonly fallbackFields?: ForestSchemaAction['fields']; constructor( actionName: string, @@ -20,6 +25,8 @@ export default class FieldFormStates { collectionName: string, httpRequester: HttpRequester, ids: string[], + hooks?: ForestSchemaAction['hooks'], + fallbackFields?: ForestSchemaAction['fields'], ) { this.fields = []; this.actionName = actionName; @@ -28,6 +35,8 @@ export default class FieldFormStates { this.httpRequester = httpRequester; this.ids = ids; this.layout = []; + this.hooks = hooks; + this.fallbackFields = fallbackFields; } getFieldValues(): Record { @@ -59,7 +68,10 @@ export default class FieldFormStates { if (!field) throw new Error(`Field "${name}" not found in action "${this.actionName}"`); field.getPlainField().value = value; - await this.loadChanges(name); + + if (!this.hooks || this.hooks.change.length > 0) { + await this.loadChanges(name); + } } async loadInitialState(): Promise { @@ -74,15 +86,51 @@ export default class FieldFormStates { }, }; - const queryResults = await this.httpRequester.query({ - method: 'post', - path: `${this.actionPath}/hooks/load`, - body: requestBody, - }); - - this.clearFieldsAndLayout(); - this.layout.push(...queryResults.layout); - this.addFields(queryResults.fields); + try { + const queryResults = await this.httpRequester.query({ + method: 'post', + path: `${this.actionPath}/hooks/load`, + body: requestBody, + }); + + this.clearFieldsAndLayout(); + this.layout.push(...(queryResults.layout ?? [])); + this.addFields(queryResults.fields); + } catch (error) { + // When hooks.load is false, the behavior differs between backends: + // + // - Node agent (@forestadmin/agent): always responds to POST /hooks/load + // with the form fields, even when hooks.load is false in the schema. + // In this case the call above succeeds and fields are loaded normally. + // + // - Ruby agent (forest_liana): does NOT register a route for /hooks/load + // when hooks.load is false. The POST returns a 404. + // In this case we catch the 404 and continue with an empty form, + // which matches the expected behavior (no dynamic fields to load). + // + // We always attempt the call so Node users get their fields, + // and only swallow 404 errors for Ruby users. Other errors (401, 500, + // network failures) are rethrown so they surface properly. + if (this.hooks && !this.hooks.load && HttpRequester.is404Error(error)) { + this.clearFieldsAndLayout(); + + if (this.fallbackFields?.length) { + this.addFields( + this.fallbackFields.map(f => ({ + field: f.field, + type: f.type, + isRequired: f.isRequired ?? false, + isReadOnly: false, + value: f.defaultValue, + })), + ); + } + + return; + } + + throw error; + } } private addFields(plainFields: PlainField[]): void { @@ -115,6 +163,6 @@ export default class FieldFormStates { this.clearFieldsAndLayout(); this.addFields(queryResults.fields); - this.layout.push(...queryResults.layout); + this.layout.push(...(queryResults.layout ?? [])); } } diff --git a/packages/agent-client/src/action-fields/types.ts b/packages/agent-client/src/action-fields/types.ts index b8badad0cc..a9b1301fab 100644 --- a/packages/agent-client/src/action-fields/types.ts +++ b/packages/agent-client/src/action-fields/types.ts @@ -2,7 +2,7 @@ import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadm export type ResponseBody = { fields: PlainField[]; - layout: ForestServerActionFormLayoutElement[]; + layout?: ForestServerActionFormLayoutElement[]; }; export type PlainFieldOption = { diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index 690b9d67f0..89073cbca4 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -1,6 +1,8 @@ import type ActionField from '../action-fields/action-field'; import type FieldFormStates from '../action-fields/field-form-states'; import type HttpRequester from '../http-requester'; +import type { RecordId } from '../types'; +import type { ForestSchemaAction } from '@forestadmin/forestadmin-client'; import ActionFieldCheckbox from '../action-fields/action-field-checkbox'; import ActionFieldCheckboxGroup from '../action-fields/action-field-checkbox-group'; @@ -17,13 +19,13 @@ import ActionFieldStringList from '../action-fields/action-field-string-list'; import ActionLayoutRoot from '../action-layout/action-layout-root'; export type BaseActionContext = { - recordId?: string | number; - recordIds?: string[] | number[]; + recordId?: RecordId; + recordIds?: RecordId[]; }; export type ActionEndpointsByCollection = { [collectionName: string]: { - [actionName: string]: { name: string; endpoint: string }; + [actionName: string]: Pick; }; }; export default class Action { @@ -32,6 +34,7 @@ export default class Action { private readonly httpRequester: HttpRequester; protected readonly fieldsFormStates: FieldFormStates; private readonly ids: (string | number)[]; + private readonly actionId: string | undefined; private actionPath: string; constructor( @@ -40,12 +43,14 @@ export default class Action { actionPath: string, fieldsFormStates: FieldFormStates, ids?: (string | number)[], + actionId?: string, ) { this.collectionName = collectionName; this.httpRequester = httpRequester; this.ids = ids ?? undefined; this.actionPath = actionPath; this.fieldsFormStates = fieldsFormStates; + this.actionId = actionId; } async execute( @@ -58,6 +63,7 @@ export default class Action { ids: this.ids, values: this.fieldsFormStates.getFieldValues(), signed_approval_request: signedApprovalRequest, + ...(this.actionId !== undefined && { smart_action_id: this.actionId }), }, type: 'custom-action-requests', }, diff --git a/packages/agent-client/src/domains/collection.ts b/packages/agent-client/src/domains/collection.ts index 04185ec774..1996674006 100644 --- a/packages/agent-client/src/domains/collection.ts +++ b/packages/agent-client/src/domains/collection.ts @@ -1,6 +1,7 @@ -import type { ExportOptions, LiveQueryOptions, SelectOptions } from '../types'; +import type { ExportOptions, LiveQueryOptions, RecordId, SelectOptions } from '../types'; import type { ActionEndpointsByCollection, BaseActionContext } from './action'; import type HttpRequester from '../http-requester'; +import type { ForestSchemaAction } from '@forestadmin/forestadmin-client'; import type { WriteStream } from 'fs'; import Action from './action'; @@ -9,6 +10,7 @@ import Relation from './relation'; import Segment from './segment'; import FieldFormStates from '../action-fields/field-form-states'; import QuerySerializer from '../query-serializer'; +import serializeRecordId from '../record-id'; export default class Collection extends CollectionChart { protected readonly name: string; @@ -26,18 +28,29 @@ export default class Collection extends CollectionChart { } async action(actionName: string, actionContext?: BaseActionContext): Promise { - const actionPath = this.getActionPath(this.actionEndpoints, this.name, actionName); - const ids = (actionContext?.recordIds ?? [actionContext?.recordId]).filter(Boolean).map(String); + const actionInfo = this.getActionInfo(this.actionEndpoints, this.name, actionName); + const ids = (actionContext?.recordIds ?? [actionContext?.recordId]) + .filter((id): id is RecordId => Boolean(id)) + .map(serializeRecordId); const fieldsFormStates = new FieldFormStates( actionName, - actionPath, + actionInfo.endpoint, this.name, this.httpRequester, ids, + actionInfo.hooks, + actionInfo.fields, ); - const action = new Action(this.name, this.httpRequester, actionPath, fieldsFormStates, ids); + const action = new Action( + this.name, + this.httpRequester, + actionInfo.endpoint, + fieldsFormStates, + ids, + actionInfo.id, + ); await fieldsFormStates.loadInitialState(); @@ -53,7 +66,7 @@ export default class Collection extends CollectionChart { return new Segment(undefined, this.name, this.httpRequester, options); } - relation(name: string, parentId: string | number): Relation { + relation(name: string, parentId: RecordId): Relation { return new Relation(name, this.name, parentId, this.httpRequester); } @@ -121,8 +134,8 @@ export default class Collection extends CollectionChart { return { fields: collection.fields }; } - async delete(ids: string[] | number[]): Promise { - const serializedIds = ids.map((id: string | number) => id.toString()); + async delete(ids: RecordId[]): Promise { + const serializedIds = ids.map(serializeRecordId); const requestBody = { data: { attributes: { collection_name: this.name, ids: serializedIds }, @@ -147,24 +160,22 @@ export default class Collection extends CollectionChart { }); } - async update( - id: string | number, - attributes: Record, - ): Promise { - const requestBody = { data: { attributes, type: this.name, id: id.toString() } }; + async update(id: RecordId, attributes: Record): Promise { + const serializedId = serializeRecordId(id); + const requestBody = { data: { attributes, type: this.name, id: serializedId } }; return this.httpRequester.query({ method: 'put', - path: `/forest/${this.name}/${id.toString()}`, + path: `/forest/${this.name}/${serializedId}`, body: requestBody, }); } - private getActionPath( + private getActionInfo( actionEndpoints: ActionEndpointsByCollection, collectionName: string, actionName: string, - ): string { + ): Pick { const collection = actionEndpoints[collectionName]; if (!collection) throw new Error(`Collection ${collectionName} not found in schema`); @@ -178,6 +189,6 @@ export default class Collection extends CollectionChart { throw new Error(`Action ${actionName} not found in collection ${collectionName}`); } - return action.endpoint; + return { id: action.id, endpoint: action.endpoint, hooks: action.hooks, fields: action.fields }; } } diff --git a/packages/agent-client/src/domains/relation.ts b/packages/agent-client/src/domains/relation.ts index a5b2c1d3ef..ed7c951248 100644 --- a/packages/agent-client/src/domains/relation.ts +++ b/packages/agent-client/src/domains/relation.ts @@ -1,24 +1,25 @@ import type HttpRequester from '../http-requester'; -import type { SelectOptions } from '../types'; +import type { RecordId, SelectOptions } from '../types'; import QuerySerializer from '../query-serializer'; +import serializeRecordId from '../record-id'; export default class Relation { private readonly name: string; private readonly collectionName: string; - private readonly parentId: string | number; + private readonly parentId: string; private readonly httpRequester: HttpRequester; constructor( name: string, collectionName: string, - parentId: string | number, + parentId: RecordId, httpRequester: HttpRequester, ) { this.name = name; this.collectionName = collectionName; this.httpRequester = httpRequester; - this.parentId = parentId; + this.parentId = serializeRecordId(parentId); } list(options?: SelectOptions): Promise { @@ -41,24 +42,24 @@ export default class Relation { ); } - async associate(targetRecordId: string | number): Promise { + async associate(targetRecordId: RecordId): Promise { await this.httpRequester.query({ method: 'post', path: `/forest/${this.collectionName}/${this.parentId}/relationships/${this.name}`, body: { - data: [{ id: String(targetRecordId), type: this.name }], + data: [{ id: serializeRecordId(targetRecordId), type: this.name }], }, }); } - async dissociate(targetRecordIds: (string | number)[]): Promise { + async dissociate(targetRecordIds: RecordId[]): Promise { await this.httpRequester.query({ method: 'delete', path: `/forest/${this.collectionName}/${this.parentId}/relationships/${this.name}`, body: { data: { attributes: { - ids: targetRecordIds.map(String), + ids: targetRecordIds.map(serializeRecordId), collection_name: this.name, all_records: false, all_records_ids_excluded: [], diff --git a/packages/agent-client/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index cbc3efd45e..9e70ad2287 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -35,12 +35,13 @@ export default class HttpRequester { contentType?: 'application/json' | 'text/csv'; }): Promise { try { - const url = new URL(`${this.baseUrl}${HttpRequester.escapeUrlSlug(path)}`).toString(); + const url = this.buildUrl(path); const req = superagent[method](url) .timeout(maxTimeAllowed ?? 10_000) .set('Authorization', `Bearer ${this.token}`) .set('Content-Type', contentType ?? 'application/json') + .set('Accept', contentType ?? 'application/json') .query({ timezone: 'Europe/Paris', ...query }); if (body) req.send(body); @@ -72,7 +73,7 @@ export default class HttpRequester { maxTimeAllowed?: number; stream: WriteStream; }): Promise { - const url = new URL(`${this.baseUrl}${HttpRequester.escapeUrlSlug(reqPath)}`).toString(); + const url = this.buildUrl(reqPath); return new Promise((resolve, reject) => { superagent @@ -96,4 +97,20 @@ export default class HttpRequester { static escapeUrlSlug(name: string): string { return encodeURI(name).replace(/([+?*])/g, '\\$1'); } + + static is404Error(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + try { + return JSON.parse(error.message)?.error?.status === 404; + } catch { + return false; + } + } + + private buildUrl(path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + return new URL(`${this.baseUrl}${HttpRequester.escapeUrlSlug(normalizedPath)}`).toString(); + } } diff --git a/packages/agent-client/src/index.ts b/packages/agent-client/src/index.ts index fbcfaf801e..a7b31b22e2 100644 --- a/packages/agent-client/src/index.ts +++ b/packages/agent-client/src/index.ts @@ -33,4 +33,4 @@ export function createRemoteAgentClient(params: { }); } -export type { SelectOptions } from './types'; +export type { RecordId, SelectOptions } from './types'; diff --git a/packages/agent-client/src/query-serializer.ts b/packages/agent-client/src/query-serializer.ts index f00f60c8b3..a89fb48e54 100644 --- a/packages/agent-client/src/query-serializer.ts +++ b/packages/agent-client/src/query-serializer.ts @@ -7,15 +7,27 @@ export default class QuerySerializer { static serialize(query: SelectOptions, collectionName: string): Record { if (!query) return {}; + const { + fields, + sort, + filters, + shouldSearchInRelation, + pagination, + search, + segmentQuery, + connectionName, + } = query; + return { - ...query, - ...query.filters, - sort: QuerySerializer.formatSort(query.sort), - filters: QuerySerializer.formatFilters(query.filters), - searchExtended: !!query.shouldSearchInRelation, - 'page[size]': query.pagination?.size, - 'page[number]': query.pagination?.number, - ...(query.fields?.length ? QuerySerializer.formatFields(collectionName, query.fields) : {}), + search, + segmentQuery, + connectionName, + sort: QuerySerializer.formatSort(sort), + filters: QuerySerializer.formatFilters(filters), + searchExtended: !!shouldSearchInRelation, + 'page[size]': pagination?.size, + 'page[number]': pagination?.number, + ...(fields?.length ? QuerySerializer.formatFields(collectionName, fields) : {}), }; } @@ -25,10 +37,58 @@ export default class QuerySerializer { return sort.ascending ? sort.field : `-${sort.field}`; } + /** + * Serialize filters to JSON with snake_case operators and aggregators. + * + * Internally, operators use PascalCase (e.g. `Equal`, `GreaterThan`) to match + * the datasource-toolkit convention. However, HTTP backends expect snake_case: + * - Ruby (forest_liana): requires `equal`, `greater_than`, etc. + * - Node (@forestadmin/agent): accepts snake_case via ConditionTreeParser.toPascalCase() + * + * Converting to snake_case here ensures compatibility with both backends. + */ private static formatFilters(filters: PlainFilter['conditionTree']): string { if (!filters) return undefined; - return JSON.stringify(filters); + return JSON.stringify(QuerySerializer.toSnakeCaseOperators(filters)); + } + + /** + * Recursively walk the condition tree and convert operators/aggregators to snake_case. + */ + private static toSnakeCaseOperators(node: unknown): unknown { + if (!node || typeof node !== 'object') return node; + + const obj = node as Record; + + if ('operator' in obj) { + return { + ...obj, + operator: QuerySerializer.toSnakeCase(obj.operator as string), + }; + } + + if ('aggregator' in obj && Array.isArray(obj.conditions)) { + return { + aggregator: (obj.aggregator as string).toLowerCase(), + conditions: obj.conditions.map(c => QuerySerializer.toSnakeCaseOperators(c)), + }; + } + + return obj; + } + + /** + * Convert PascalCase to snake_case. + * Two passes handle different patterns: + * - Pass 1: lowercase→uppercase boundaries (e.g. `greaterThan` → `greater_Than`) + * - Pass 2: uppercase sequences (e.g. `IContains` → `I_Contains`, `PreviousXDays` → `PreviousX_Days`) + */ + private static toSnakeCase(value: string): string { + return value + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') + .toLowerCase(); } private static formatFields(collectionName: string, fields: string[]): Record { diff --git a/packages/agent-client/src/record-id.ts b/packages/agent-client/src/record-id.ts new file mode 100644 index 0000000000..18f22410d1 --- /dev/null +++ b/packages/agent-client/src/record-id.ts @@ -0,0 +1,30 @@ +import type { RecordId } from './types'; + +// Forest Admin backend expects composite PKs pipe-joined (e.g. "k1|k2"). Centralizing the +// serialization here lets callers pass structured arrays and keeps the convention out of their +// code. URL-encoding of the resulting string (e.g. "|" → "%7C" in paths) is handled downstream +// by HttpRequester. Throws rather than silently corrupting the key when parts are nullish or +// contain the "|" separator — either case would produce a malformed id that could match the +// wrong record. +export default function serializeRecordId(id: RecordId): string { + if (!Array.isArray(id)) return String(id); + if (id.length === 0) throw new Error('Composite record id cannot be empty'); + + return id + .map(part => { + if (part === null || part === undefined) { + throw new Error('Composite record id parts cannot be null or undefined'); + } + + const serialized = String(part); + + if (serialized.includes('|')) { + throw new Error( + `Composite record id part "${serialized}" cannot contain the "|" separator`, + ); + } + + return serialized; + }) + .join('|'); +} diff --git a/packages/agent-client/src/types.ts b/packages/agent-client/src/types.ts index bd644b9b3c..08a5bdb857 100644 --- a/packages/agent-client/src/types.ts +++ b/packages/agent-client/src/types.ts @@ -1,5 +1,7 @@ import type { PlainFilter, PlainSortClause } from '@forestadmin/datasource-toolkit'; +export type RecordId = string | number | Array; + export type BaseOptions = { filters?: PlainFilter['conditionTree']; // Filters to apply to the query sort?: PlainSortClause; // Sort clause for the query @@ -27,4 +29,6 @@ export type SelectOptions = BaseOptions & { size?: number; // number of items per page number?: number; // current page number }; + segmentQuery?: string; // SQL query for live query segments + connectionName?: string; // Connection name for live query segments }; diff --git a/packages/agent-client/test/action-fields/action-fields.test.ts b/packages/agent-client/test/action-fields/action-fields.test.ts index d33fd608a4..b258fbbe76 100644 --- a/packages/agent-client/test/action-fields/action-fields.test.ts +++ b/packages/agent-client/test/action-fields/action-fields.test.ts @@ -1,3 +1,4 @@ +import type { PlainField } from '../../src/action-fields/types'; import type HttpRequester from '../../src/http-requester'; import ActionFieldCheckbox from '../../src/action-fields/action-field-checkbox'; @@ -22,7 +23,7 @@ describe('ActionField implementations', () => { beforeEach(() => { jest.clearAllMocks(); - httpRequester = { query: jest.fn() } as any; + httpRequester = { query: jest.fn() } as unknown as jest.Mocked; fieldFormStates = new FieldFormStates( 'testAction', '/forest/actions/test', @@ -32,7 +33,7 @@ describe('ActionField implementations', () => { ); }); - const setupFields = async (fields: any[]) => { + const setupFields = async (fields: PlainField[]) => { httpRequester.query.mockResolvedValue({ fields, layout: [] }); await fieldFormStates.loadInitialState(); }; diff --git a/packages/agent-client/test/action-fields/field-form-states.test.ts b/packages/agent-client/test/action-fields/field-form-states.test.ts index c6966b29fa..dbda12f1f7 100644 --- a/packages/agent-client/test/action-fields/field-form-states.test.ts +++ b/packages/agent-client/test/action-fields/field-form-states.test.ts @@ -2,7 +2,11 @@ import type HttpRequester from '../../src/http-requester'; import FieldFormStates from '../../src/action-fields/field-form-states'; -jest.mock('../../src/http-requester'); +jest.mock('../../src/http-requester', () => { + const actual = jest.requireActual('../../src/http-requester'); + + return { __esModule: true, default: actual.default }; +}); describe('FieldFormStates', () => { let httpRequester: jest.Mocked; @@ -12,7 +16,7 @@ describe('FieldFormStates', () => { jest.clearAllMocks(); httpRequester = { query: jest.fn(), - } as any; + } as unknown as jest.Mocked; fieldFormStates = new FieldFormStates( 'testAction', '/forest/actions/test-action', @@ -262,4 +266,176 @@ describe('FieldFormStates', () => { expect(fieldFormStates.getLayout()).toEqual(newLayout); }); }); + + describe('hooks configuration', () => { + it('should not throw when hooks.load is false and server returns 404', async () => { + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: false, change: [] }, + ); + + const error404 = new Error( + JSON.stringify({ error: { status: 404, text: 'Not Found' }, body: null }), + ); + httpRequester.query.mockRejectedValue(error404); + + await formStates.loadInitialState(); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ path: '/forest/actions/test-action/hooks/load' }), + ); + expect(formStates.getFields()).toHaveLength(0); + }); + + it('should use fallback fields when hooks.load is false and server returns 404', async () => { + const fallbackFields = [ + { field: 'percentage', type: 'Number', isRequired: true, defaultValue: 10 }, + { field: 'note', type: 'String' }, + ]; + + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: false, change: [] }, + fallbackFields, + ); + + const error404 = new Error( + JSON.stringify({ error: { status: 404, text: 'Not Found' }, body: null }), + ); + httpRequester.query.mockRejectedValue(error404); + + await formStates.loadInitialState(); + + expect(formStates.getFields()).toHaveLength(2); + expect(formStates.getFields()[0].getName()).toBe('percentage'); + expect(formStates.getFields()[0].getValue()).toBe(10); + expect(formStates.getFields()[1].getName()).toBe('note'); + expect(formStates.getFields()[1].getValue()).toBeUndefined(); + }); + + it('should throw when hooks.load is false but server returns 500', async () => { + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: false, change: [] }, + ); + + const error500 = new Error( + JSON.stringify({ error: { status: 500, text: 'Internal Server Error' }, body: null }), + ); + httpRequester.query.mockRejectedValue(error500); + + await expect(formStates.loadInitialState()).rejects.toThrow(); + }); + + it('should load fields when hooks.load is false but server responds successfully', async () => { + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: false, change: [] }, + ); + + httpRequester.query.mockResolvedValue({ + fields: [ + { field: 'percentage', type: 'Number', isRequired: true, isReadOnly: false, value: 10 }, + ], + layout: [], + }); + + await formStates.loadInitialState(); + + expect(formStates.getFields()).toHaveLength(1); + expect(formStates.getFields()[0].getName()).toBe('percentage'); + }); + + it('should call loadInitialState when hooks.load is true', async () => { + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: true, change: [] }, + ); + + httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); + + await formStates.loadInitialState(); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ path: '/forest/actions/test-action/hooks/load' }), + ); + }); + + it('should skip change hook when hooks.change is empty', async () => { + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: true, change: [] }, + ); + + httpRequester.query.mockResolvedValue({ + fields: [ + { field: 'name', type: 'String', isRequired: false, isReadOnly: false, value: 'initial' }, + ], + layout: [], + }); + await formStates.loadInitialState(); + + httpRequester.query.mockClear(); + await formStates.setFieldValue('name', 'updated'); + + expect(httpRequester.query).not.toHaveBeenCalled(); + }); + + it('should call change hook when hooks.change is non-empty', async () => { + const formStates = new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + { load: true, change: ['name'] }, + ); + + httpRequester.query.mockResolvedValue({ + fields: [ + { field: 'name', type: 'String', isRequired: false, isReadOnly: false, value: 'initial' }, + ], + layout: [], + }); + await formStates.loadInitialState(); + + httpRequester.query.mockClear(); + httpRequester.query.mockResolvedValue({ + fields: [ + { field: 'name', type: 'String', isRequired: false, isReadOnly: false, value: 'updated' }, + ], + layout: [], + }); + + await formStates.setFieldValue('name', 'updated'); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ path: '/forest/actions/test-action/hooks/change' }), + ); + }); + }); }); diff --git a/packages/agent-client/test/action-layout/action-layout.test.ts b/packages/agent-client/test/action-layout/action-layout.test.ts index f9eb546f74..1db58ec382 100644 --- a/packages/agent-client/test/action-layout/action-layout.test.ts +++ b/packages/agent-client/test/action-layout/action-layout.test.ts @@ -1,4 +1,7 @@ -import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client'; +import type { + ForestServerActionFormElementFieldReference, + ForestServerActionFormLayoutElement, +} from '@forestadmin/forestadmin-client'; import ActionLayoutElement from '../../src/action-layout/action-layout-element'; import ActionLayoutInput from '../../src/action-layout/action-layout-input'; @@ -26,7 +29,10 @@ describe('Action Layout', () => { describe('ActionLayoutInput', () => { it('should return the field id', () => { - const layoutItem = { component: 'input', fieldId: 'myField' } as any; + const layoutItem: ForestServerActionFormElementFieldReference = { + component: 'input', + fieldId: 'myField', + }; const input = new ActionLayoutInput(layoutItem); expect(input.getInputId()).toBe('myField'); @@ -36,24 +42,32 @@ describe('Action Layout', () => { describe('ActionLayoutElement', () => { describe('isRow', () => { it('should return true for row component', () => { - const element = new ActionLayoutElement({ component: 'row', fields: [] } as any); + const element = new ActionLayoutElement({ component: 'row', fields: [] }); expect(element.isRow()).toBe(true); }); it('should return false for non-row component', () => { - const element = new ActionLayoutElement({ component: 'input', fieldId: 'test' } as any); + const element = new ActionLayoutElement({ + component: 'input', + fieldId: 'test', + } as ForestServerActionFormLayoutElement); expect(element.isRow()).toBe(false); }); }); describe('isInput', () => { it('should return true for input component', () => { - const element = new ActionLayoutElement({ component: 'input', fieldId: 'test' } as any); + const element = new ActionLayoutElement({ + component: 'input', + fieldId: 'test', + } as ForestServerActionFormLayoutElement); expect(element.isInput()).toBe(true); }); it('should return false for non-input component', () => { - const element = new ActionLayoutElement({ component: 'separator' } as any); + const element = new ActionLayoutElement({ + component: 'separator', + } as ForestServerActionFormLayoutElement); expect(element.isInput()).toBe(false); }); }); @@ -63,24 +77,32 @@ describe('Action Layout', () => { const element = new ActionLayoutElement({ component: 'htmlBlock', content: '

Hello

', - } as any); + } as ForestServerActionFormLayoutElement); expect(element.isHTMLBlock()).toBe(true); }); it('should return false for non-htmlBlock component', () => { - const element = new ActionLayoutElement({ component: 'input', fieldId: 'test' } as any); + const element = new ActionLayoutElement({ + component: 'input', + fieldId: 'test', + } as ForestServerActionFormLayoutElement); expect(element.isHTMLBlock()).toBe(false); }); }); describe('isSeparator', () => { it('should return true for separator component', () => { - const element = new ActionLayoutElement({ component: 'separator' } as any); + const element = new ActionLayoutElement({ + component: 'separator', + } as ForestServerActionFormLayoutElement); expect(element.isSeparator()).toBe(true); }); it('should return false for non-separator component', () => { - const element = new ActionLayoutElement({ component: 'input', fieldId: 'test' } as any); + const element = new ActionLayoutElement({ + component: 'input', + fieldId: 'test', + } as ForestServerActionFormLayoutElement); expect(element.isSeparator()).toBe(false); }); }); @@ -91,13 +113,16 @@ describe('Action Layout', () => { const element = new ActionLayoutElement({ component: 'htmlBlock', content: htmlContent, - } as any); + } as ForestServerActionFormLayoutElement); expect(element.getHtmlBlockContent()).toBe(htmlContent); }); it('should throw error when element is not htmlBlock', () => { - const element = new ActionLayoutElement({ component: 'input', fieldId: 'test' } as any); + const element = new ActionLayoutElement({ + component: 'input', + fieldId: 'test', + } as ForestServerActionFormLayoutElement); expect(() => element.getHtmlBlockContent()).toThrow(NotRightElementError); expect(() => element.getHtmlBlockContent()).toThrow( @@ -111,13 +136,15 @@ describe('Action Layout', () => { const element = new ActionLayoutElement({ component: 'input', fieldId: 'email', - } as any); + } as ForestServerActionFormLayoutElement); expect(element.getInputId()).toBe('email'); }); it('should throw error when element is not input', () => { - const element = new ActionLayoutElement({ component: 'separator' } as any); + const element = new ActionLayoutElement({ + component: 'separator', + } as ForestServerActionFormLayoutElement); expect(() => element.getInputId()).toThrow(NotRightElementError); expect(() => element.getInputId()).toThrow( @@ -134,7 +161,7 @@ describe('Action Layout', () => { { component: 'input', fieldId: 'field1' }, { component: 'input', fieldId: 'field2' }, ], - } as any); + } as ForestServerActionFormLayoutElement); const input = element.rowElement(1); expect(input.getInputId()).toBe('field2'); @@ -144,7 +171,7 @@ describe('Action Layout', () => { const element = new ActionLayoutElement({ component: 'row', fields: [{ component: 'input', fieldId: 'field1' }], - } as any); + } as ForestServerActionFormLayoutElement); expect(() => element.rowElement(5)).toThrow(NotFoundElementError); }); @@ -153,7 +180,7 @@ describe('Action Layout', () => { const element = new ActionLayoutElement({ component: 'row', fields: [{ component: 'input', fieldId: 'field1' }], - } as any); + } as ForestServerActionFormLayoutElement); expect(() => element.rowElement(-1)).toThrow(NotFoundElementError); }); @@ -162,7 +189,7 @@ describe('Action Layout', () => { const element = new ActionLayoutElement({ component: 'input', fieldId: 'test', - } as any); + } as ForestServerActionFormLayoutElement); expect(() => element.rowElement(0)).toThrow(NotRightElementError); expect(() => element.rowElement(0)).toThrow("This is not a row, it's a input element"); @@ -177,7 +204,7 @@ describe('Action Layout', () => { elements: [{ component: 'input', fieldId: 'name' }, { component: 'separator' }], nextButtonLabel: 'Next', previousButtonLabel: 'Back', - } as any; + } as ForestServerActionFormLayoutElement; const page = new ActionLayoutPage(pageLayout); @@ -186,7 +213,10 @@ describe('Action Layout', () => { }); it('should throw error when layout is not a page', () => { - const nonPageLayout = { component: 'input', fieldId: 'test' } as any; + const nonPageLayout: ForestServerActionFormLayoutElement = { + component: 'input', + fieldId: 'test', + }; expect(() => new ActionLayoutPage(nonPageLayout)).toThrow(NotRightElementError); expect(() => new ActionLayoutPage(nonPageLayout)).toThrow( @@ -200,7 +230,7 @@ describe('Action Layout', () => { elements: [{ component: 'input', fieldId: 'email' }], nextButtonLabel: 'Submit', previousButtonLabel: 'Cancel', - } as any; + } as ForestServerActionFormLayoutElement; const page = new ActionLayoutPage(pageLayout); const element = page.element(0); @@ -225,7 +255,7 @@ describe('Action Layout', () => { nextButtonLabel: 'Submit', previousButtonLabel: 'Previous', }, - ] as any; + ] as ForestServerActionFormLayoutElement[]; const root = new ActionLayoutRoot(layout); const page = root.page(1); @@ -241,7 +271,7 @@ describe('Action Layout', () => { nextButtonLabel: 'Next', previousButtonLabel: 'Back', }, - ] as any; + ] as ForestServerActionFormLayoutElement[]; const root = new ActionLayoutRoot(layout); @@ -257,7 +287,7 @@ describe('Action Layout', () => { nextButtonLabel: 'Next', previousButtonLabel: 'Back', }, - ] as any; + ] as ForestServerActionFormLayoutElement[]; const root = new ActionLayoutRoot(layout); @@ -273,7 +303,7 @@ describe('Action Layout', () => { nextButtonLabel: 'Next', previousButtonLabel: 'Back', }, - ] as any; + ] as ForestServerActionFormLayoutElement[]; const root = new ActionLayoutRoot(layout); @@ -283,7 +313,10 @@ describe('Action Layout', () => { describe('element', () => { it('should return element at specified index', () => { - const layout = [{ component: 'input', fieldId: 'test' }, { component: 'separator' }] as any; + const layout: ForestServerActionFormLayoutElement[] = [ + { component: 'input', fieldId: 'test' }, + { component: 'separator' }, + ]; const root = new ActionLayoutRoot(layout); const element = root.element(0); @@ -292,7 +325,9 @@ describe('Action Layout', () => { }); it('should throw error when index is out of bounds', () => { - const layout = [{ component: 'input', fieldId: 'test' }] as any; + const layout: ForestServerActionFormLayoutElement[] = [ + { component: 'input', fieldId: 'test' }, + ]; const root = new ActionLayoutRoot(layout); @@ -300,7 +335,9 @@ describe('Action Layout', () => { }); it('should throw error when index is negative', () => { - const layout = [{ component: 'input', fieldId: 'test' }] as any; + const layout: ForestServerActionFormLayoutElement[] = [ + { component: 'input', fieldId: 'test' }, + ]; const root = new ActionLayoutRoot(layout); @@ -315,7 +352,7 @@ describe('Action Layout', () => { nextButtonLabel: 'Next', previousButtonLabel: 'Back', }, - ] as any; + ] as ForestServerActionFormLayoutElement[]; const root = new ActionLayoutRoot(layout); @@ -333,7 +370,7 @@ describe('Action Layout', () => { nextButtonLabel: 'Next', previousButtonLabel: 'Back', }, - ] as any; + ] as ForestServerActionFormLayoutElement[]; const root = new ActionLayoutRoot(layout); @@ -341,7 +378,9 @@ describe('Action Layout', () => { }); it('should return false when element is not a page', () => { - const layout = [{ component: 'input', fieldId: 'test' }] as any; + const layout: ForestServerActionFormLayoutElement[] = [ + { component: 'input', fieldId: 'test' }, + ]; const root = new ActionLayoutRoot(layout); @@ -349,7 +388,7 @@ describe('Action Layout', () => { }); it('should return false for undefined element', () => { - const layout = [] as any; + const layout: ForestServerActionFormLayoutElement[] = []; const root = new ActionLayoutRoot(layout); diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 341d429d4c..5edfc90297 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -59,6 +59,43 @@ describe('Action', () => { expect(result).toEqual({ success: 'Action executed' }); }); + it('should include smart_action_id when actionId is provided', async () => { + const actionWithId = new Action( + 'users', + httpRequester, + '/forest/actions/send-email', + fieldsFormStates, + ['1'], + 'users-0-send-email', + ); + + httpRequester.query.mockResolvedValue({ success: 'Action executed' }); + + await actionWithId.execute(); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/actions/send-email', + body: { + data: { + attributes: expect.objectContaining({ + smart_action_id: 'users-0-send-email', + }), + type: 'custom-action-requests', + }, + }, + }); + }); + + it('should not include smart_action_id when actionId is not provided', async () => { + httpRequester.query.mockResolvedValue({ success: 'Action executed' }); + + await action.execute(); + + const body = httpRequester.query.mock.calls[0][0].body as any; + expect(body.data.attributes).not.toHaveProperty('smart_action_id'); + }); + it('should include signed approval request when provided', async () => { httpRequester.query.mockResolvedValue({ success: 'Action executed' }); const signedApprovalRequest = { token: 'approval-token', requesterId: '123' }; diff --git a/packages/agent-client/test/domains/collection.test.ts b/packages/agent-client/test/domains/collection.test.ts index 1b31dff973..bae15fe19e 100644 --- a/packages/agent-client/test/domains/collection.test.ts +++ b/packages/agent-client/test/domains/collection.test.ts @@ -1,3 +1,4 @@ +import type { ActionEndpointsByCollection } from '../../src/domains/action'; import type HttpRequester from '../../src/http-requester'; import Collection from '../../src/domains/collection'; @@ -10,9 +11,15 @@ describe('Collection', () => { let collection: Collection; const actionEndpoints = { users: { - sendEmail: { name: 'Send Email', endpoint: '/forest/actions/send-email' }, + sendEmail: { + name: 'Send Email', + endpoint: '/forest/actions/send-email', + id: 'Send@@@Email', + hooks: { load: false, change: [] }, + fields: [], + }, }, - }; + } as ActionEndpointsByCollection; beforeEach(() => { jest.clearAllMocks(); @@ -151,6 +158,20 @@ describe('Collection', () => { }), }); }); + + it('should pipe-encode composite primary keys', async () => { + httpRequester.query.mockResolvedValue({}); + + await collection.update([1, 'abc'], { name: 'Test' }); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'put', + path: '/forest/users/1|abc', + body: expect.objectContaining({ + data: expect.objectContaining({ id: '1|abc' }), + }), + }); + }); }); describe('delete', () => { @@ -193,6 +214,29 @@ describe('Collection', () => { }, }); }); + + it('should pipe-encode composite primary keys', async () => { + httpRequester.query.mockResolvedValue({}); + + await collection.delete([ + [1, 'abc'], + [2, 'def'], + ]); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'delete', + path: '/forest/users', + body: { + data: { + attributes: { + collection_name: 'users', + ids: ['1|abc', '2|def'], + }, + type: 'action-requests', + }, + }, + }); + }); }); describe('exportCsv', () => { @@ -249,6 +293,18 @@ describe('Collection', () => { const relation = collection.relation('posts', 1); expect(relation).toBeDefined(); }); + + it('should pipe-encode composite parent ids when querying the relationship', async () => { + httpRequester.query.mockResolvedValue([]); + + await collection.relation('posts', [1, 'abc']).list(); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'get', + path: '/forest/users/1|abc/relationships/posts', + query: expect.any(Object), + }); + }); }); describe('action', () => { @@ -312,6 +368,47 @@ describe('Collection', () => { expect(result).toBeDefined(); }); + + it('should pipe-encode composite recordIds when executing the action', async () => { + const action = await collection.action('sendEmail', { + recordIds: [ + [1, 'abc'], + [2, 'def'], + ], + }); + httpRequester.query.mockResolvedValue({ success: 'ok' }); + + await action.execute(); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + path: '/forest/actions/send-email', + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ ids: ['1|abc', '2|def'] }), + }), + }), + }), + ); + }); + + it('should pipe-encode a composite recordId when executing the action', async () => { + const action = await collection.action('sendEmail', { recordId: [42, 'x'] }); + httpRequester.query.mockResolvedValue({ success: 'ok' }); + + await action.execute(); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ ids: ['42|x'] }), + }), + }), + }), + ); + }); }); describe('capabilities', () => { diff --git a/packages/agent-client/test/domains/relation.test.ts b/packages/agent-client/test/domains/relation.test.ts index 2033df78fc..688f3037bf 100644 --- a/packages/agent-client/test/domains/relation.test.ts +++ b/packages/agent-client/test/domains/relation.test.ts @@ -44,6 +44,19 @@ describe('Relation', () => { }); }); + it('should pipe-encode composite parent id', async () => { + const relation = new Relation('posts', 'users', [1, 'abc'], httpRequester); + httpRequester.query.mockResolvedValue([]); + + await relation.list(); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'get', + path: '/forest/users/1|abc/relationships/posts', + query: expect.any(Object), + }); + }); + it('should pass options to query serializer', async () => { const relation = new Relation('posts', 'users', 1, httpRequester); httpRequester.query.mockResolvedValue([]); @@ -145,6 +158,21 @@ describe('Relation', () => { }, }); }); + + it('should pipe-encode composite target record id', async () => { + const relation = new Relation('tags', 'posts', [1, 'abc'], httpRequester); + httpRequester.query.mockResolvedValue(undefined); + + await relation.associate([42, 'x']); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/posts/1|abc/relationships/tags', + body: { + data: [{ id: '42|x', type: 'tags' }], + }, + }); + }); }); describe('dissociate', () => { @@ -216,5 +244,31 @@ describe('Relation', () => { }, }); }); + + it('should pipe-encode composite target record ids', async () => { + const relation = new Relation('tags', 'posts', [1, 'abc'], httpRequester); + httpRequester.query.mockResolvedValue(undefined); + + await relation.dissociate([ + [42, 'x'], + [43, 'y'], + ]); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'delete', + path: '/forest/posts/1|abc/relationships/tags', + body: { + data: { + attributes: { + ids: ['42|x', '43|y'], + collection_name: 'tags', + all_records: false, + all_records_ids_excluded: [], + }, + type: 'action-requests', + }, + }, + }); + }); }); }); diff --git a/packages/agent-client/test/domains/remote-agent-client.test.ts b/packages/agent-client/test/domains/remote-agent-client.test.ts index b0faac4d5a..a62c946163 100644 --- a/packages/agent-client/test/domains/remote-agent-client.test.ts +++ b/packages/agent-client/test/domains/remote-agent-client.test.ts @@ -20,7 +20,13 @@ describe('RemoteAgentClient', () => { httpRequester, actionEndpoints: { users: { - sendEmail: { name: 'Send Email', endpoint: '/forest/actions/send-email' }, + sendEmail: { + name: 'Send Email', + endpoint: '/forest/actions/send-email', + id: 'Send@@@Email', + hooks: { load: false, change: [] }, + fields: [], + }, }, }, overridePermissions: overridePermissionsMock, diff --git a/packages/agent-client/test/http-requester.test.ts b/packages/agent-client/test/http-requester.test.ts index 2dc836c38b..acae9b9cc2 100644 --- a/packages/agent-client/test/http-requester.test.ts +++ b/packages/agent-client/test/http-requester.test.ts @@ -42,6 +42,27 @@ describe('HttpRequester', () => { }); }); + describe('is404Error', () => { + it('should return true for a 404 error', () => { + const error = new Error(JSON.stringify({ error: { status: 404 }, body: null })); + expect(HttpRequester.is404Error(error)).toBe(true); + }); + + it('should return false for a 500 error', () => { + const error = new Error(JSON.stringify({ error: { status: 500 }, body: null })); + expect(HttpRequester.is404Error(error)).toBe(false); + }); + + it('should return false for a non-Error', () => { + expect(HttpRequester.is404Error('not an error')).toBe(false); + }); + + it('should return false for non-JSON error message', () => { + const error = new Error('plain text error'); + expect(HttpRequester.is404Error(error)).toBe(false); + }); + }); + describe('query', () => { let requester: HttpRequester; let mockRequest: any; @@ -75,9 +96,21 @@ describe('HttpRequester', () => { expect(mockRequest.timeout).toHaveBeenCalledWith(10_000); expect(mockRequest.set).toHaveBeenCalledWith('Authorization', 'Bearer test-token'); expect(mockRequest.set).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(mockRequest.set).toHaveBeenCalledWith('Accept', 'application/json'); expect(mockRequest.query).toHaveBeenCalledWith({ timezone: 'Europe/Paris' }); }); + it('should set Accept header matching content type', async () => { + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve(onFulfilled({ body: {} })); + }); + + await requester.query({ method: 'get', path: '/forest/users', contentType: 'text/csv' }); + + expect(mockRequest.set).toHaveBeenCalledWith('Content-Type', 'text/csv'); + expect(mockRequest.set).toHaveBeenCalledWith('Accept', 'text/csv'); + }); + it('should make a POST request with body', async () => { const body = { data: { attributes: { name: 'Test' } } }; mockRequest.then = jest.fn((onFulfilled: any) => { @@ -148,6 +181,18 @@ describe('HttpRequester', () => { }); }); + it('should normalize path without leading slash', async () => { + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve(onFulfilled({ body: {} })); + }); + + await requester.query({ method: 'get', path: 'forest/actions/my-action' }); + + expect(mockSuperagent.get).toHaveBeenCalledWith( + 'https://api.example.com/forest/actions/my-action', + ); + }); + it('should handle URL with prefix', async () => { const requesterWithPrefix = new HttpRequester('test-token', { url: 'https://api.example.com', diff --git a/packages/agent-client/test/index.test.ts b/packages/agent-client/test/index.test.ts index 90c6858923..73f19954f2 100644 --- a/packages/agent-client/test/index.test.ts +++ b/packages/agent-client/test/index.test.ts @@ -1,5 +1,5 @@ import RemoteAgentClient from '../src/domains/remote-agent-client'; -import { createRemoteAgentClient } from '../src/index'; +import { type ActionEndpointsByCollection, createRemoteAgentClient } from '../src/index'; describe('createRemoteAgentClient', () => { it('should create a RemoteAgentClient instance', () => { @@ -20,9 +20,15 @@ describe('createRemoteAgentClient', () => { }); it('should pass action endpoints to the client', () => { - const actionEndpoints = { + const actionEndpoints: ActionEndpointsByCollection = { users: { - sendEmail: { name: 'Send Email', endpoint: '/forest/actions/send-email' }, + sendEmail: { + name: 'Send Email', + endpoint: '/forest/actions/send-email', + id: 'Send@@@Email', + hooks: { load: false, change: [] }, + fields: [], + }, }, }; @@ -61,7 +67,13 @@ describe('createRemoteAgentClient', () => { token: 'test-token', actionEndpoints: { products: { - archive: { name: 'Archive', endpoint: '/forest/actions/archive' }, + archive: { + name: 'Archive', + endpoint: '/forest/actions/archive', + id: 'Archive', + hooks: { load: false, change: [] }, + fields: [], + }, }, }, }); diff --git a/packages/agent-client/test/integration/composite-pk.integration.test.ts b/packages/agent-client/test/integration/composite-pk.integration.test.ts new file mode 100644 index 0000000000..437e2e27ff --- /dev/null +++ b/packages/agent-client/test/integration/composite-pk.integration.test.ts @@ -0,0 +1,212 @@ +import http from 'http'; + +import { createRemoteAgentClient } from '../../src/index'; + +// End-to-end wire check — spins up a real HTTP server and asserts that composite PKs +// passed as arrays (e.g. [1, 'abc']) arrive at the agent as pipe-joined ids. On the wire +// the pipe is %7C-encoded by HttpRequester.escapeUrlSlug (standard HTTP URL encoding), +// which is identical to how scalar callers passing "1|abc" as a string have always been +// encoded — so this PR changes nothing in what the backend receives. decodeURIComponent +// on the received path yields the same "1|abc" the agent has always decoded. +describe('Composite primary keys (integration)', () => { + let server: http.Server; + let serverPort: number; + let requestLog: Array<{ + method: string; + pathname: string; + rawUrl: string; + body: string; + }>; + + beforeAll(() => { + requestLog = []; + + server = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + + req.on('end', () => { + const parsed = new URL(req.url!, `http://localhost:${serverPort}`); + requestLog.push({ + method: req.method!, + pathname: parsed.pathname, + rawUrl: req.url!, + body, + }); + + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'PUT') { + const parsedBody = JSON.parse(body); + res.statusCode = 200; + res.end( + JSON.stringify({ + data: { + id: parsedBody.data.id, + type: 'record', + attributes: { ...parsedBody.data.attributes, id: parsedBody.data.id }, + }, + }), + ); + + return; + } + + if (req.method === 'DELETE') { + res.statusCode = 204; + res.end(); + + return; + } + + if (req.method === 'GET') { + res.statusCode = 200; + res.end(JSON.stringify({ data: [] })); + + return; + } + + if (req.method === 'POST') { + res.statusCode = 200; + res.end(JSON.stringify({ data: null })); + + return; + } + + res.statusCode = 404; + res.end(); + }); + }); + + return new Promise(resolve => { + server.listen(0, () => { + serverPort = (server.address() as any).port; + resolve(); + }); + }); + }); + + afterAll(() => { + return new Promise(resolve => { + server.close(() => resolve()); + }); + }); + + beforeEach(() => { + requestLog = []; + }); + + it('update should send "1|abc" in both URL path and body', async () => { + const client = createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + }); + + await client.collection('users').update([1, 'abc'], { name: 'John' }); + + expect(requestLog).toHaveLength(1); + expect(requestLog[0].method).toBe('PUT'); + // The wire URL is %7C-encoded (standard HTTP); decoding yields the pipe-joined id. + expect(requestLog[0].rawUrl).toContain('1%7Cabc'); + expect(decodeURIComponent(requestLog[0].pathname)).toBe('/forest/users/1|abc'); + + const parsedBody = JSON.parse(requestLog[0].body); + expect(parsedBody.data.id).toBe('1|abc'); + }); + + it('delete should send composite ids as "k1|k2" strings in the body', async () => { + const client = createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + }); + + await client.collection('users').delete([ + [1, 'abc'], + [2, 'def'], + ]); + + expect(requestLog).toHaveLength(1); + expect(requestLog[0].method).toBe('DELETE'); + + const parsedBody = JSON.parse(requestLog[0].body); + expect(parsedBody.data.attributes.ids).toEqual(['1|abc', '2|def']); + }); + + it('relation.list should send "1|abc" as parent id in the URL path', async () => { + const client = createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + }); + + await client.collection('users').relation('posts', [1, 'abc']).list(); + + expect(requestLog).toHaveLength(1); + expect(requestLog[0].method).toBe('GET'); + expect(requestLog[0].rawUrl).toContain('1%7Cabc'); + expect(decodeURIComponent(requestLog[0].pathname)).toBe( + '/forest/users/1|abc/relationships/posts', + ); + }); + + it('associate should send composite parent id in path and composite target id in body', async () => { + const client = createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + }); + + await client.collection('users').relation('tags', [1, 'abc']).associate([42, 'x']); + + expect(requestLog).toHaveLength(1); + expect(requestLog[0].method).toBe('POST'); + expect(decodeURIComponent(requestLog[0].pathname)).toBe( + '/forest/users/1|abc/relationships/tags', + ); + + const parsedBody = JSON.parse(requestLog[0].body); + expect(parsedBody.data).toEqual([{ id: '42|x', type: 'tags' }]); + }); + + it('dissociate should send composite target ids as pipe-joined strings', async () => { + const client = createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + }); + + await client + .collection('users') + .relation('tags', [1, 'abc']) + .dissociate([ + [42, 'x'], + [43, 'y'], + ]); + + expect(requestLog).toHaveLength(1); + expect(requestLog[0].method).toBe('DELETE'); + expect(decodeURIComponent(requestLog[0].pathname)).toBe( + '/forest/users/1|abc/relationships/tags', + ); + + const parsedBody = JSON.parse(requestLog[0].body); + expect(parsedBody.data.attributes.ids).toEqual(['42|x', '43|y']); + }); + + it('should produce the same wire format as a legacy pipe-encoded string caller', async () => { + // Regression guard: passing a composite array must produce the exact same HTTP + // request as passing the already pipe-encoded string "1|abc". This proves the + // refactor is wire-compatible and the backend cannot distinguish the two. + const client = createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + }); + + await client.collection('users').update([1, 'abc'], { name: 'John' }); + await client.collection('users').update('1|abc', { name: 'John' }); + + expect(requestLog).toHaveLength(2); + expect(requestLog[0].method).toBe(requestLog[1].method); + expect(requestLog[0].rawUrl).toBe(requestLog[1].rawUrl); + expect(requestLog[0].body).toBe(requestLog[1].body); + }); +}); diff --git a/packages/agent-client/test/query-serializer.test.ts b/packages/agent-client/test/query-serializer.test.ts index 7e395f0c9b..61b2b243cd 100644 --- a/packages/agent-client/test/query-serializer.test.ts +++ b/packages/agent-client/test/query-serializer.test.ts @@ -106,14 +106,64 @@ describe('QuerySerializer', () => { expect(result.sort).toBe('-name'); }); - it('should serialize filters with conditionTree', () => { + it('should serialize filters with conditionTree and convert operators to snake_case', () => { const filters = { field: 'status', operator: 'Equal' as const, value: 'active', }; const result = QuerySerializer.serialize({ filters }, 'users'); - expect(result.filters).toBe(JSON.stringify(filters)); + expect(result.filters).toBe( + JSON.stringify({ field: 'status', operator: 'equal', value: 'active' }), + ); + }); + + it('should convert PascalCase operators to snake_case in nested conditions', () => { + const filters = { + aggregator: 'And' as const, + conditions: [ + { field: 'status', operator: 'Equal' as const, value: 'active' }, + { field: 'age', operator: 'GreaterThan' as const, value: 18 }, + ], + }; + const result = QuerySerializer.serialize({ filters }, 'users'); + const parsed = JSON.parse(result.filters as string); + expect(parsed.aggregator).toBe('and'); + expect(parsed.conditions[0].operator).toBe('equal'); + expect(parsed.conditions[1].operator).toBe('greater_than'); + }); + + it('should convert multi-word PascalCase operators to snake_case', () => { + const filters = { + aggregator: 'Or' as const, + conditions: [ + { field: 'date', operator: 'PreviousXDaysToDate' as const, value: 7 }, + { field: 'name', operator: 'NotContains' as const, value: 'test' }, + { field: 'time', operator: 'BeforeXHoursAgo' as const, value: 24 }, + ], + }; + const result = QuerySerializer.serialize({ filters }, 'users'); + const parsed = JSON.parse(result.filters as string); + expect(parsed.aggregator).toBe('or'); + expect(parsed.conditions[0].operator).toBe('previous_x_days_to_date'); + expect(parsed.conditions[1].operator).toBe('not_contains'); + expect(parsed.conditions[2].operator).toBe('before_x_hours_ago'); + }); + + it('should convert I-prefixed operators to snake_case', () => { + const filters = { + aggregator: 'And' as const, + conditions: [ + { field: 'name', operator: 'IContains' as const, value: 'foo' }, + { field: 'name', operator: 'ILike' as const, value: '%bar%' }, + { field: 'name', operator: 'NotIContains' as const, value: 'baz' }, + ], + }; + const result = QuerySerializer.serialize({ filters }, 'users'); + const parsed = JSON.parse(result.filters as string); + expect(parsed.conditions[0].operator).toBe('i_contains'); + expect(parsed.conditions[1].operator).toBe('i_like'); + expect(parsed.conditions[2].operator).toBe('not_i_contains'); }); it('should serialize complex query with multiple options', () => { @@ -140,7 +190,9 @@ describe('QuerySerializer', () => { expect(result['page[number]']).toBe(1); expect(result['fields[users]']).toEqual(['id', 'name']); expect(result.sort).toBe('-createdAt'); - expect(result.filters).toBe(JSON.stringify(filters)); + const parsed = JSON.parse(result.filters as string); + expect(parsed.conditions[0].operator).toBe('equal'); + expect(parsed.conditions[1].operator).toBe('greater_than'); }); it('should handle collection names with special characters', () => { diff --git a/packages/agent-client/test/record-id.test.ts b/packages/agent-client/test/record-id.test.ts new file mode 100644 index 0000000000..8f72f98cd9 --- /dev/null +++ b/packages/agent-client/test/record-id.test.ts @@ -0,0 +1,41 @@ +import serializeRecordId from '../src/record-id'; + +describe('serializeRecordId', () => { + it('should return a string as-is', () => { + expect(serializeRecordId('abc')).toBe('abc'); + }); + + it('should coerce a number to string', () => { + expect(serializeRecordId(42)).toBe('42'); + }); + + it('should pipe-join a composite array of strings and numbers', () => { + expect(serializeRecordId([1, 'abc', 2])).toBe('1|abc|2'); + }); + + it('should handle a single-element array', () => { + expect(serializeRecordId([42])).toBe('42'); + }); + + it('should throw on an empty composite array', () => { + expect(() => serializeRecordId([])).toThrow('Composite record id cannot be empty'); + }); + + it('should throw when a composite part is null', () => { + expect(() => serializeRecordId([1, null as unknown as string])).toThrow( + 'Composite record id parts cannot be null or undefined', + ); + }); + + it('should throw when a composite part is undefined', () => { + expect(() => serializeRecordId([1, undefined as unknown as string])).toThrow( + 'Composite record id parts cannot be null or undefined', + ); + }); + + it('should throw when a composite part contains the pipe separator', () => { + expect(() => serializeRecordId(['1|abc', 2])).toThrow( + 'Composite record id part "1|abc" cannot contain the "|" separator', + ); + }); +}); diff --git a/packages/agent-testing/CHANGELOG.md b/packages/agent-testing/CHANGELOG.md index 722028c33a..d9fc01ddc3 100644 --- a/packages/agent-testing/CHANGELOG.md +++ b/packages/agent-testing/CHANGELOG.md @@ -1,3 +1,197 @@ +## @forestadmin/agent-testing [1.1.18](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.17...@forestadmin/agent-testing@1.1.18) (2026-04-30) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.6 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.5 +* **@forestadmin/agent:** upgraded to 1.78.8 + +## @forestadmin/agent-testing [1.1.17](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.16...@forestadmin/agent-testing@1.1.17) (2026-04-24) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.5 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.4 +* **@forestadmin/agent:** upgraded to 1.78.7 + +## @forestadmin/agent-testing [1.1.16](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.15...@forestadmin/agent-testing@1.1.16) (2026-04-22) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.4 +* **@forestadmin/agent:** upgraded to 1.78.6 + +## @forestadmin/agent-testing [1.1.15](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.14...@forestadmin/agent-testing@1.1.15) (2026-04-22) + + +### Bug Fixes + +* **mcp server:** create activity logs from modelname ([#1561](https://github.com/ForestAdmin/agent-nodejs/issues/1561)) ([0942c42](https://github.com/ForestAdmin/agent-nodejs/commit/0942c42767aeb5739c92a88b4309f107e24b51c8)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.3 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.3 +* **@forestadmin/agent:** upgraded to 1.78.5 + +## @forestadmin/agent-testing [1.1.14](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.13...@forestadmin/agent-testing@1.1.14) (2026-04-21) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.2 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.2 +* **@forestadmin/agent:** upgraded to 1.78.4 + +## @forestadmin/agent-testing [1.1.13](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.12...@forestadmin/agent-testing@1.1.13) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.1 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.1 +* **@forestadmin/agent:** upgraded to 1.78.3 + +## @forestadmin/agent-testing [1.1.12](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.11...@forestadmin/agent-testing@1.1.12) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.2 + +## @forestadmin/agent-testing [1.1.11](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.10...@forestadmin/agent-testing@1.1.11) (2026-04-17) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.0 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.0 +* **@forestadmin/agent:** upgraded to 1.78.1 + +## @forestadmin/agent-testing [1.1.10](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.9...@forestadmin/agent-testing@1.1.10) (2026-04-17) + + +### Bug Fixes + +* trigger a release ([#1562](https://github.com/ForestAdmin/agent-nodejs/issues/1562)) ([993c815](https://github.com/ForestAdmin/agent-nodejs/commit/993c8156e74d1c6b60a54268c507c25768c00b87)) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.0 + +## @forestadmin/agent-testing [1.1.9](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.8...@forestadmin/agent-testing@1.1.9) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + +## @forestadmin/agent-testing [1.1.8](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.7...@forestadmin/agent-testing@1.1.8) (2026-04-14) + + + + + +### Dependencies + + +## @forestadmin/agent-testing [1.1.7](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.6...@forestadmin/agent-testing@1.1.7) (2026-04-14) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.23 +* **@forestadmin/agent:** upgraded to 1.77.1 + +## @forestadmin/agent-testing [1.1.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.5...@forestadmin/agent-testing@1.1.6) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.22 +* **@forestadmin/agent:** upgraded to 1.76.6 + +## @forestadmin/agent-testing [1.1.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.4...@forestadmin/agent-testing@1.1.5) (2026-04-10) + + +### Bug Fixes + +* **mcp-server:** action execution on v1 agent ([#1542](https://github.com/ForestAdmin/agent-nodejs/issues/1542)) ([01b1a64](https://github.com/ForestAdmin/agent-nodejs/commit/01b1a64a7e0fbf8d5d47ebf0f9b1fc933c6709aa)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.21 +* **@forestadmin/forestadmin-client:** upgraded to 1.38.4 +* **@forestadmin/agent:** upgraded to 1.76.5 + +## @forestadmin/agent-testing [1.1.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.3...@forestadmin/agent-testing@1.1.4) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.20 +* **@forestadmin/agent:** upgraded to 1.76.4 + +## @forestadmin/agent-testing [1.1.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.2...@forestadmin/agent-testing@1.1.3) (2026-04-08) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.19 +* **@forestadmin/forestadmin-client:** upgraded to 1.38.3 +* **@forestadmin/agent:** upgraded to 1.76.3 + ## @forestadmin/agent-testing [1.1.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-testing@1.1.1...@forestadmin/agent-testing@1.1.2) (2026-04-01) diff --git a/packages/agent-testing/package.json b/packages/agent-testing/package.json index 338509ea4f..1c889f4008 100644 --- a/packages/agent-testing/package.json +++ b/packages/agent-testing/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/agent-testing", - "version": "1.1.2", + "version": "1.1.18", "description": "Testing utilities for Forest Admin agent", "author": "Vincent Molinié ", "homepage": "https://github.com/ForestAdmin/agent-nodejs#readme", @@ -26,16 +26,16 @@ "test": "jest" }, "dependencies": { - "@forestadmin/agent-client": "1.4.18", - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/agent-client": "1.5.6", + "@forestadmin/datasource-customizer": "1.69.3", "@forestadmin/datasource-toolkit": "1.53.1", - "@forestadmin/forestadmin-client": "1.38.2", + "@forestadmin/forestadmin-client": "1.39.5", "jsonapi-serializer": "^3.6.9", "jsonwebtoken": "^9.0.3", "superagent": "^10.3.0" }, "peerDependencies": { - "@forestadmin/agent": "1.76.2" + "@forestadmin/agent": "1.78.8" }, "peerDependenciesMeta": { "@forestadmin/agent": { @@ -43,7 +43,7 @@ } }, "devDependencies": { - "@forestadmin/agent": "1.76.2", + "@forestadmin/agent": "1.78.8", "@forestadmin/datasource-sql": "1.17.10", "@types/jsonwebtoken": "^9.0.1", "@types/superagent": "^8.1.9", diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 3d195bec4e..5bd4cb6342 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -58,6 +58,7 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly activityLogsService: ForestAdminClient['activityLogsService'] = { createActivityLog: () => Promise.resolve({ id: '1', attributes: { index: '1' } }), + createMcpActivityLog: () => Promise.resolve({ id: '1', attributes: { index: '1' } }), updateActivityLogStatus: () => Promise.resolve(), }; diff --git a/packages/agent-testing/src/schema-converter.ts b/packages/agent-testing/src/schema-converter.ts index dc0d6498ea..33806a3378 100644 --- a/packages/agent-testing/src/schema-converter.ts +++ b/packages/agent-testing/src/schema-converter.ts @@ -8,7 +8,13 @@ export default class SchemaConverter { actionEndpoints[c.name] = c.actions.reduce( (acc, action) => ({ ...acc, - [action.name]: { name: action.name, endpoint: action.endpoint }, + [action.name]: { + id: action.id, + name: action.name, + endpoint: action.endpoint, + hooks: action.hooks, + fields: action.fields, + }, }), {}, ); diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3686a29f09..7a78c96414 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,3 +1,186 @@ +## @forestadmin/agent [1.78.8](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.7...@forestadmin/agent@1.78.8) (2026-04-30) + + +### Bug Fixes + +* **logger:** add logger in case of start failure ([#1572](https://github.com/ForestAdmin/agent-nodejs/issues/1572)) ([f6a90c6](https://github.com/ForestAdmin/agent-nodejs/commit/f6a90c679884c78623b975bba75af5651de09b96)) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.5 +* **@forestadmin/mcp-server:** upgraded to 1.11.7 + +## @forestadmin/agent [1.78.7](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.6...@forestadmin/agent@1.78.7) (2026-04-24) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.4 +* **@forestadmin/mcp-server:** upgraded to 1.11.6 + +## @forestadmin/agent [1.78.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.5...@forestadmin/agent@1.78.6) (2026-04-22) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.11.5 + +## @forestadmin/agent [1.78.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.4...@forestadmin/agent@1.78.5) (2026-04-22) + + +### Bug Fixes + +* **mcp server:** create activity logs from modelname ([#1561](https://github.com/ForestAdmin/agent-nodejs/issues/1561)) ([0942c42](https://github.com/ForestAdmin/agent-nodejs/commit/0942c42767aeb5739c92a88b4309f107e24b51c8)) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.3 +* **@forestadmin/mcp-server:** upgraded to 1.11.4 + +## @forestadmin/agent [1.78.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.3...@forestadmin/agent@1.78.4) (2026-04-21) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.2 +* **@forestadmin/mcp-server:** upgraded to 1.11.3 + +## @forestadmin/agent [1.78.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.2...@forestadmin/agent@1.78.3) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.1 +* **@forestadmin/mcp-server:** upgraded to 1.11.2 + +## @forestadmin/agent [1.78.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.1...@forestadmin/agent@1.78.2) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.11.1 + +## @forestadmin/agent [1.78.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.78.0...@forestadmin/agent@1.78.1) (2026-04-17) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.39.0 +* **@forestadmin/mcp-server:** upgraded to 1.11.0 + +# @forestadmin/agent [1.78.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.77.1...@forestadmin/agent@1.78.0) (2026-04-17) + + +### Bug Fixes + +* trigger a release ([#1562](https://github.com/ForestAdmin/agent-nodejs/issues/1562)) ([993c815](https://github.com/ForestAdmin/agent-nodejs/commit/993c8156e74d1c6b60a54268c507c25768c00b87)) + + +### Features + +* **mcp-server:** add enabledTools allowlist option ([#1547](https://github.com/ForestAdmin/agent-nodejs/issues/1547)) ([22df2ec](https://github.com/ForestAdmin/agent-nodejs/commit/22df2ecd2c0e370f0ff9740289aa252d877b20a2)) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.10.0 + +## @forestadmin/agent [1.77.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.77.0...@forestadmin/agent@1.77.1) (2026-04-14) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.9.1 + +# @forestadmin/agent [1.77.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.76.6...@forestadmin/agent@1.77.0) (2026-04-13) + + +### Features + +* **mcp-server:** add disabledTools option to ForestMCPServerOptions ([#1543](https://github.com/ForestAdmin/agent-nodejs/issues/1543)) ([d36ad10](https://github.com/ForestAdmin/agent-nodejs/commit/d36ad10bfb18a5972174a9acb3d7821691868494)) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.9.0 + +## @forestadmin/agent [1.76.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.76.5...@forestadmin/agent@1.76.6) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.8.17 + +## @forestadmin/agent [1.76.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.76.4...@forestadmin/agent@1.76.5) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.38.4 +* **@forestadmin/mcp-server:** upgraded to 1.8.16 + +## @forestadmin/agent [1.76.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.76.3...@forestadmin/agent@1.76.4) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/mcp-server:** upgraded to 1.8.15 + +## @forestadmin/agent [1.76.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.76.2...@forestadmin/agent@1.76.3) (2026-04-08) + + + + + +### Dependencies + +* **@forestadmin/forestadmin-client:** upgraded to 1.38.3 +* **@forestadmin/mcp-server:** upgraded to 1.8.14 + ## @forestadmin/agent [1.76.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.76.1...@forestadmin/agent@1.76.2) (2026-04-01) diff --git a/packages/agent/package.json b/packages/agent/package.json index e180fc1717..81099e2d19 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/agent", - "version": "1.76.2", + "version": "1.78.8", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -14,10 +14,10 @@ "dependencies": { "@fast-csv/format": "^4.3.5", "@forestadmin/agent-toolkit": "1.2.0", - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/datasource-customizer": "1.69.3", "@forestadmin/datasource-toolkit": "1.53.1", - "@forestadmin/forestadmin-client": "1.38.2", - "@forestadmin/mcp-server": "1.8.13", + "@forestadmin/forestadmin-client": "1.39.5", + "@forestadmin/mcp-server": "1.11.7", "@koa/bodyparser": "^6.0.0", "@koa/cors": "^5.0.0", "@koa/router": "^13.1.0", diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 730e163c96..edad2de466 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -12,6 +12,7 @@ import type { } from '@forestadmin/datasource-customizer'; import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; +import type { ToolName } from '@forestadmin/mcp-server'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; import bodyParser from '@koa/bodyparser'; @@ -47,6 +48,7 @@ export default class Agent extends FrameworkMounter /** Whether MCP server should be mounted */ private mcpEnabled = false; + private mcpEnabledTools?: ToolName[]; /** * Create a new Agent Builder. @@ -80,13 +82,19 @@ export default class Agent extends FrameworkMounter * Start the agent. */ async start(): Promise { - const { router, mcpHttpCallback } = await this.buildRouterAndSendSchema(); + try { + const { router, mcpHttpCallback } = await this.buildRouterAndSendSchema(); - await this.options.forestAdminClient.subscribeToServerEvents(); - this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this)); + await this.options.forestAdminClient.subscribeToServerEvents(); + this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this)); - this.setMcpCallback(mcpHttpCallback ?? null); - await this.mount(router); + this.setMcpCallback(mcpHttpCallback ?? null); + await this.mount(router); + } catch (error) { + const { message } = error as Error; + this.options.logger('Error', `Forest Admin agent startup failure: ${message}`); + throw error; + } } /** @@ -206,9 +214,12 @@ export default class Agent extends FrameworkMounter * @see {@link https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/ai/mcp-server} * @example * agent.mountAiMcpServer(); + * // Example: read-only mode (only browse data, no create/update/delete/actions) + * agent.mountAiMcpServer({ enabledTools: ['describeCollection', 'list', 'listRelated'] }); */ - mountAiMcpServer(): this { + mountAiMcpServer(options?: { enabledTools?: ToolName[] }): this { this.mcpEnabled = true; + this.mcpEnabledTools = options?.enabledTools; return this; } @@ -326,6 +337,7 @@ export default class Agent extends FrameworkMounter authSecret: this.options.authSecret, logger: mcpLogger, forestServerClient, + enabledTools: this.mcpEnabledTools, }); const httpCallback = await mcpServer.getHttpCallback(); diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index 24476bee42..6d626180e2 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -31,6 +31,7 @@ import ScopeInvalidation from './security/scope-invalidation'; import ErrorHandling from './system/error-handling'; import HealthCheck from './system/healthcheck'; import Logger from './system/logger'; +import WorkflowExecutorProxyRoute from './workflow/workflow-executor-proxy'; export const ROOT_ROUTES_CTOR = [ Authentication, @@ -172,6 +173,12 @@ function getAiRoutes(options: Options, services: Services, aiRouter: AiRouter | return [new AiProxyRoute(services, options, aiRouter)]; } +function getWorkflowExecutorRoutes(options: Options, services: Services): BaseRoute[] { + if (!options.workflowExecutorUrl) return []; + + return [new WorkflowExecutorProxyRoute(services, options)]; +} + export default function makeRoutes( dataSource: DataSource, options: Options, @@ -187,6 +194,7 @@ export default function makeRoutes( ...getRelatedRoutes(dataSource, options, services), ...getActionRoutes(dataSource, options, services), ...getAiRoutes(options, services, aiRouter), + ...getWorkflowExecutorRoutes(options, services), ]; // Ensure routes and middlewares are loaded in the right order. diff --git a/packages/agent/src/routes/workflow/workflow-executor-proxy.ts b/packages/agent/src/routes/workflow/workflow-executor-proxy.ts new file mode 100644 index 0000000000..0fc391cffe --- /dev/null +++ b/packages/agent/src/routes/workflow/workflow-executor-proxy.ts @@ -0,0 +1,97 @@ +import type { ForestAdminHttpDriverServices } from '../../services'; +import type { AgentOptionsWithDefaults } from '../../types'; +import type KoaRouter from '@koa/router'; +import type { Context } from 'koa'; + +import { request as httpRequest } from 'http'; +import { request as httpsRequest } from 'https'; + +import { HttpCode, RouteType } from '../../types'; +import BaseRoute from '../base-route'; + +type ForwardedHeaders = { + authorization?: string; + cookie?: string; +}; + +export default class WorkflowExecutorProxyRoute extends BaseRoute { + readonly type = RouteType.PrivateRoute; + private readonly executorUrl: URL; + + constructor(services: ForestAdminHttpDriverServices, options: AgentOptionsWithDefaults) { + super(services, options); + // Remove trailing slash for clean URL joining + this.executorUrl = new URL(options.workflowExecutorUrl.replace(/\/+$/, '')); + } + + setupRoutes(router: KoaRouter): void { + router.get('/_internal/workflow-executions/:runId', this.handleProxy.bind(this)); + router.post('/_internal/workflow-executions/:runId/trigger', this.handleProxy.bind(this)); + } + + private async handleProxy(context: Context): Promise { + const { runId } = context.params; + const isTrigger = context.method === 'POST'; + const qs = context.querystring ? `?${context.querystring}` : ''; + const executorRelativeUrl = isTrigger ? `/runs/${runId}/trigger${qs}` : `/runs/${runId}${qs}`; + const targetUrl = new URL(executorRelativeUrl, this.executorUrl); + + const forwardedHeaders: ForwardedHeaders = { + authorization: context.request.header.authorization, + cookie: context.request.header.cookie, + }; + + const response = await this.forwardRequest( + context.method, + targetUrl, + context.request.body, + forwardedHeaders, + ); + + context.response.status = response.status; + context.response.body = response.body; + } + + private forwardRequest( + method: string, + url: URL, + body?: unknown, + forwardedHeaders: ForwardedHeaders = {}, + ): Promise<{ status: number; body: unknown }> { + const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest; + const headers: Record = { 'Content-Type': 'application/json' }; + + // Forward the caller's auth so the executor's JWT middleware can validate it. + // Agent and executor share the same FOREST_AUTH_SECRET so the token is valid on both. + if (forwardedHeaders.authorization) headers.Authorization = forwardedHeaders.authorization; + if (forwardedHeaders.cookie) headers.Cookie = forwardedHeaders.cookie; + + return new Promise((resolve, reject) => { + const req = requestFn(url, { method, headers }, res => { + const chunks: Uint8Array[] = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + + resolve({ status: res.statusCode ?? HttpCode.InternalServerError, body: parsed }); + }); + res.on('error', reject); + }); + + req.on('error', reject); + + if (body && method !== 'GET') { + req.write(JSON.stringify(body)); + } + + req.end(); + }); + } +} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index d90d83b084..53785b8c25 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -45,6 +45,13 @@ export type AgentOptions = { */ ignoreMissingSchemaElementErrors?: boolean; useUnsafeActionEndpoint?: boolean; + /** + * Base URL of the workflow executor to proxy requests to. + * When set, the agent exposes routes at `/_internal/workflow-executions/` + * that forward to the executor, benefiting from the agent's authentication layer. + * @example 'http://localhost:4001' + */ + workflowExecutorUrl?: string | null; }; export type AgentOptionsWithDefaults = Readonly>; diff --git a/packages/agent/src/utils/options-validator.ts b/packages/agent/src/utils/options-validator.ts index 3178192798..a6fa72548b 100644 --- a/packages/agent/src/utils/options-validator.ts +++ b/packages/agent/src/utils/options-validator.ts @@ -38,6 +38,7 @@ export default class OptionsValidator { copyOptions.loggerLevel = copyOptions.loggerLevel || 'Info'; copyOptions.skipSchemaUpdate = copyOptions.skipSchemaUpdate || false; copyOptions.instantCacheRefresh = copyOptions.instantCacheRefresh ?? true; + copyOptions.workflowExecutorUrl = copyOptions.workflowExecutorUrl ?? null; copyOptions.maxBodySize = copyOptions.maxBodySize || '50mb'; copyOptions.bodyParserOptions = copyOptions.bodyParserOptions || { jsonLimit: '50mb', diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index 5c293c0fcc..ab7189ccc8 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -51,6 +51,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ }, activityLogsService: { createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }, subscribeToServerEvents: jest.fn(), diff --git a/packages/agent/test/__factories__/forest-admin-http-driver-options.ts b/packages/agent/test/__factories__/forest-admin-http-driver-options.ts index bf64613f23..5189d2b9a4 100644 --- a/packages/agent/test/__factories__/forest-admin-http-driver-options.ts +++ b/packages/agent/test/__factories__/forest-admin-http-driver-options.ts @@ -29,4 +29,5 @@ export default Factory.define(() => ({ }, ignoreMissingSchemaElementErrors: false, useUnsafeActionEndpoint: false, + workflowExecutorUrl: null, })); diff --git a/packages/agent/test/__factories__/router.ts b/packages/agent/test/__factories__/router.ts index 226a1bf2ab..78cb0e55ba 100644 --- a/packages/agent/test/__factories__/router.ts +++ b/packages/agent/test/__factories__/router.ts @@ -7,6 +7,7 @@ export class RouterFactory extends Factory { router.get = jest.fn(); router.delete = jest.fn(); router.use = jest.fn(); + router.patch = jest.fn(); router.post = jest.fn(); router.put = jest.fn(); router.all = jest.fn(); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 647b9a755a..7d41be47cf 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -282,6 +282,44 @@ describe('Agent', () => { }); }); + describe('start error handling', () => { + test('should log the error and re-throw when buildRouterAndSendSchema fails', async () => { + const mockLogger = jest.fn(); + const options = factories.forestAdminHttpDriverOptions.build({ logger: mockLogger }); + const agent = new Agent(options); + + jest + .mocked(DataSourceCustomizer.prototype.getDataSource) + .mockRejectedValueOnce(new Error('datasource connection failed')); + + await expect(() => agent.start()).rejects.toThrow('datasource connection failed'); + + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Forest Admin agent startup failure: datasource connection failed', + ); + }); + + test('should log the error and re-throw when subscribeToServerEvents fails', async () => { + const mockLogger = jest.fn(); + const forestAdminClient = factories.forestAdminClient.build({ + subscribeToServerEvents: jest.fn().mockRejectedValue(new Error('subscription failed')), + }); + const options = factories.forestAdminHttpDriverOptions.build({ + logger: mockLogger, + forestAdminClient, + }); + const agent = new Agent(options); + + await expect(() => agent.start()).rejects.toThrow('subscription failed'); + + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Forest Admin agent startup failure: subscription failed', + ); + }); + }); + describe('stop', () => { test('stop should close the Forest Admin client', async () => { const options = factories.forestAdminHttpDriverOptions.build(); @@ -392,6 +430,20 @@ describe('Agent', () => { expect(mockLogger).toHaveBeenCalledWith('Info', '[MCP] Server initialized successfully'); }); + test('should pass enabledTools to ForestMCPServer', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + + agent.mountAiMcpServer({ enabledTools: ['describeCollection', 'list', 'listRelated'] }); + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + enabledTools: ['describeCollection', 'list', 'listRelated'], + }), + ); + }); + test('should log error when MCP initialization fails', async () => { const mockLogger = jest.fn(); const options = factories.forestAdminHttpDriverOptions.build({ logger: mockLogger }); diff --git a/packages/agent/test/routes/workflow/workflow-executor-proxy.test.ts b/packages/agent/test/routes/workflow/workflow-executor-proxy.test.ts new file mode 100644 index 0000000000..d5cbad16b1 --- /dev/null +++ b/packages/agent/test/routes/workflow/workflow-executor-proxy.test.ts @@ -0,0 +1,247 @@ +import { createMockContext } from '@shopify/jest-koa-mocks'; +import http from 'http'; + +import WorkflowExecutorProxyRoute from '../../../src/routes/workflow/workflow-executor-proxy'; +import { RouteType } from '../../../src/types'; +import * as factories from '../../__factories__'; + +describe('WorkflowExecutorProxyRoute', () => { + const services = factories.forestAdminHttpDriverServices.build(); + const router = factories.router.mockAllMethods().build(); + + let executorServer: http.Server; + let executorPort: number; + let receivedHeaders: Record = {}; + let receivedUrl: string | undefined; + + // Start a real HTTP server to act as the workflow executor + beforeAll(async () => { + executorServer = http.createServer((req, res) => { + receivedHeaders = { ...req.headers }; + receivedUrl = req.url; + const chunks: Uint8Array[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf-8'); + + res.setHeader('Content-Type', 'application/json'); + + if (req.url?.includes('not-found')) { + res.writeHead(404); + res.end(JSON.stringify({ error: 'Run not found or unavailable' })); + } else if (req.method === 'GET' && req.url?.match(/^\/runs\/[\w-]+(\/.*)?(\?.*)?$/)) { + res.writeHead(200); + res.end(JSON.stringify({ steps: [{ stepId: 's1', status: 'success' }] })); + } else if (req.method === 'POST' && req.url?.match(/^\/runs\/[\w-]+\/trigger(\?.*)?$/)) { + const parsed = body ? JSON.parse(body) : {}; + res.writeHead(200); + res.end(JSON.stringify({ triggered: true, received: parsed })); + } else { + res.writeHead(404); + res.end(JSON.stringify({ error: 'Not found' })); + } + }); + }); + + await new Promise((resolve, reject) => { + executorServer.listen(0, () => { + executorPort = (executorServer.address() as { port: number }).port; + resolve(); + }); + executorServer.on('error', reject); + }); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + executorServer.close(err => (err ? reject(err) : resolve())); + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + receivedHeaders = {}; + receivedUrl = undefined; + }); + + const buildOptions = (url: string) => + factories.forestAdminHttpDriverOptions.build({ workflowExecutorUrl: url }); + + describe('constructor', () => { + test('should have RouteType.PrivateRoute', () => { + const route = new WorkflowExecutorProxyRoute(services, buildOptions('http://localhost:4001')); + + expect(route.type).toBe(RouteType.PrivateRoute); + }); + }); + + describe('setupRoutes', () => { + test('registers GET and POST routes (PATCH pending-data retired)', () => { + const route = new WorkflowExecutorProxyRoute(services, buildOptions('http://localhost:4001')); + route.setupRoutes(router); + + expect(router.get).toHaveBeenCalledWith( + '/_internal/workflow-executions/:runId', + expect.any(Function), + ); + expect(router.post).toHaveBeenCalledWith( + '/_internal/workflow-executions/:runId/trigger', + expect.any(Function), + ); + expect(router.patch).not.toHaveBeenCalled(); + }); + }); + + describe('handleProxy', () => { + test('should forward GET /runs/:runId and return executor response', async () => { + const route = new WorkflowExecutorProxyRoute( + services, + buildOptions(`http://localhost:${executorPort}`), + ); + + const context = createMockContext({ + customProperties: { params: { runId: 'run-123' } }, + }); + Object.defineProperty(context, 'url', { + value: '/_internal/workflow-executions/run-123', + }); + + await (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy( + context, + ); + + expect(context.response.status).toBe(200); + expect(context.response.body).toEqual({ + steps: [{ stepId: 's1', status: 'success' }], + }); + }); + + test('should forward POST /runs/:runId/trigger and pass body', async () => { + const route = new WorkflowExecutorProxyRoute( + services, + buildOptions(`http://localhost:${executorPort}`), + ); + + const context = createMockContext({ + method: 'POST', + customProperties: { params: { runId: 'run-456' } }, + requestBody: { pendingData: { answer: 'yes' } }, + }); + Object.defineProperty(context, 'url', { + value: '/_internal/workflow-executions/run-456/trigger', + }); + + await (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy( + context, + ); + + expect(context.response.status).toBe(200); + expect(context.response.body).toEqual({ + triggered: true, + received: { pendingData: { answer: 'yes' } }, + }); + }); + + test('should forward error status from executor', async () => { + const route = new WorkflowExecutorProxyRoute( + services, + buildOptions(`http://localhost:${executorPort}`), + ); + + const context = createMockContext({ + customProperties: { params: { runId: 'not-found' } }, + }); + Object.defineProperty(context, 'url', { + value: '/_internal/workflow-executions/not-found', + }); + + await (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy( + context, + ); + + expect(context.response.status).toBe(404); + expect(context.response.body).toEqual({ error: 'Run not found or unavailable' }); + }); + + test('should forward Authorization and Cookie headers to the executor', async () => { + const route = new WorkflowExecutorProxyRoute( + services, + buildOptions(`http://localhost:${executorPort}`), + ); + + const context = createMockContext({ + customProperties: { params: { runId: 'run-123' } }, + headers: { + authorization: 'Bearer jwt-token-value', + cookie: 'forest_session_token=cookie-token', + }, + }); + Object.defineProperty(context, 'url', { + value: '/_internal/workflow-executions/run-123', + }); + + await (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy( + context, + ); + + expect(receivedHeaders.authorization).toBe('Bearer jwt-token-value'); + expect(receivedHeaders.cookie).toBe('forest_session_token=cookie-token'); + }); + + test('should not add empty auth headers when the request has none', async () => { + const route = new WorkflowExecutorProxyRoute( + services, + buildOptions(`http://localhost:${executorPort}`), + ); + + const context = createMockContext({ + customProperties: { params: { runId: 'run-123' } }, + }); + Object.defineProperty(context, 'url', { + value: '/_internal/workflow-executions/run-123', + }); + + await (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy( + context, + ); + + expect(receivedHeaders.authorization).toBeUndefined(); + expect(receivedHeaders.cookie).toBeUndefined(); + }); + + test('should reject when executor is unreachable', async () => { + const route = new WorkflowExecutorProxyRoute(services, buildOptions('http://localhost:1')); + + const context = createMockContext({ + customProperties: { params: { runId: 'run-789' } }, + }); + Object.defineProperty(context, 'url', { + value: '/_internal/workflow-executions/run-789', + }); + + await expect( + (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy(context), + ).rejects.toThrow(); + }); + + test('should forward query params to the executor', async () => { + const route = new WorkflowExecutorProxyRoute( + services, + buildOptions(`http://localhost:${executorPort}`), + ); + + const context = createMockContext({ + customProperties: { params: { runId: 'run-123' } }, + }); + Object.defineProperty(context, 'querystring', { + value: 'foo=bar&page=2', + }); + + await (route as unknown as { handleProxy: (ctx: unknown) => Promise }).handleProxy( + context, + ); + + expect(receivedUrl).toBe('/runs/run-123?foo=bar&page=2'); + }); + }); +}); diff --git a/packages/ai-proxy/CHANGELOG.md b/packages/ai-proxy/CHANGELOG.md index 421b2d597a..db137be61f 100644 --- a/packages/ai-proxy/CHANGELOG.md +++ b/packages/ai-proxy/CHANGELOG.md @@ -1,3 +1,24 @@ +# @forestadmin/ai-proxy [1.8.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/ai-proxy@1.7.4...@forestadmin/ai-proxy@1.8.0) (2026-04-21) + + +### Features + +* **ai-proxy:** add kolar tools ([#1537](https://github.com/ForestAdmin/agent-nodejs/issues/1537)) ([a966400](https://github.com/ForestAdmin/agent-nodejs/commit/a9664009cffb4833e724efc352f361f278729f16)) + +## @forestadmin/ai-proxy [1.7.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/ai-proxy@1.7.3...@forestadmin/ai-proxy@1.7.4) (2026-04-20) + + +### Bug Fixes + +* **ai-proxy:** add status and recepient mail to zendesk tools ([#1563](https://github.com/ForestAdmin/agent-nodejs/issues/1563)) ([aee5a7b](https://github.com/ForestAdmin/agent-nodejs/commit/aee5a7b7293f97e03236647a0ee2113bbeec24be)) + +## @forestadmin/ai-proxy [1.7.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/ai-proxy@1.7.2...@forestadmin/ai-proxy@1.7.3) (2026-04-08) + + +### Bug Fixes + +* **ai-proxy:** update zendesk tools functionality ([#1533](https://github.com/ForestAdmin/agent-nodejs/issues/1533)) ([e29e63e](https://github.com/ForestAdmin/agent-nodejs/commit/e29e63e5859697e2929967894bc344ebcbf02f69)) + ## @forestadmin/ai-proxy [1.7.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/ai-proxy@1.7.1...@forestadmin/ai-proxy@1.7.2) (2026-04-01) diff --git a/packages/ai-proxy/package.json b/packages/ai-proxy/package.json index 4fa5da943c..364a98eaf0 100644 --- a/packages/ai-proxy/package.json +++ b/packages/ai-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/ai-proxy", - "version": "1.7.2", + "version": "1.8.0", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { diff --git a/packages/ai-proxy/src/forest-integration-client.ts b/packages/ai-proxy/src/forest-integration-client.ts index 0423ca3b73..b63e332e00 100644 --- a/packages/ai-proxy/src/forest-integration-client.ts +++ b/packages/ai-proxy/src/forest-integration-client.ts @@ -3,11 +3,13 @@ import type { ToolProvider } from './tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIBadRequestError } from './errors'; +import getKolarTools, { type KolarConfig } from './integrations/kolar/tools'; +import { validateKolarConfig } from './integrations/kolar/utils'; import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools'; import { validateZendeskConfig } from './integrations/zendesk/utils'; -export type CustomConfig = ZendeskConfig; -export type ForestIntegrationName = 'Zendesk'; +export type CustomConfig = ZendeskConfig | KolarConfig; +export type ForestIntegrationName = 'Zendesk' | 'Kolar'; export interface ForestIntegrationConfig { integrationName: ForestIntegrationName; @@ -40,6 +42,9 @@ export default class ForestIntegrationClient implements ToolProvider { case 'Zendesk': tools.push(...getZendeskTools(config as ZendeskConfig)); break; + case 'Kolar': + tools.push(...getKolarTools(config as KolarConfig)); + break; default: this.logger?.('Warn', `Unsupported integration: ${integrationName}`); } @@ -54,6 +59,8 @@ export default class ForestIntegrationClient implements ToolProvider { switch (integrationName) { case 'Zendesk': return validateZendeskConfig(config as ZendeskConfig); + case 'Kolar': + return validateKolarConfig(config as KolarConfig); default: throw new AIBadRequestError(`Unsupported integration: ${integrationName}`); } diff --git a/packages/ai-proxy/src/integrations/kolar/tools.ts b/packages/ai-proxy/src/integrations/kolar/tools.ts new file mode 100644 index 0000000000..e3ae18701f --- /dev/null +++ b/packages/ai-proxy/src/integrations/kolar/tools.ts @@ -0,0 +1,29 @@ +import type RemoteTool from '../../remote-tool'; + +import createMerchantApplicationTool from './tools/create-merchant-application'; +import createGetMerchantApplicationResultTool from './tools/get-merchant-application-result'; +import createGetScreeningResultTool from './tools/get-screening-result'; +import createScreenTransactionTool from './tools/screen-transaction'; +import { getKolarConfig } from './utils'; +import ServerRemoteTool from '../../server-remote-tool'; + +export interface KolarConfig { + apiKey: string; +} + +export default function getKolarTools(config: KolarConfig): RemoteTool[] { + const { baseUrl, headers } = getKolarConfig(config); + + return [ + createMerchantApplicationTool(headers, baseUrl), + createGetMerchantApplicationResultTool(headers, baseUrl), + createScreenTransactionTool(headers, baseUrl), + createGetScreeningResultTool(headers, baseUrl), + ].map( + tool => + new ServerRemoteTool({ + sourceId: 'kolar', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/kolar/tools/create-merchant-application.ts b/packages/ai-proxy/src/integrations/kolar/tools/create-merchant-application.ts new file mode 100644 index 0000000000..8a62973aa3 --- /dev/null +++ b/packages/ai-proxy/src/integrations/kolar/tools/create-merchant-application.ts @@ -0,0 +1,49 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +export default function createMerchantApplicationTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'kolar_create_merchant_application', + description: + 'Submit a merchant application for KYB analysis. Creates the application, triggers analysis, and returns the application ID to use with kolar_get_merchant_application_result.', + schema: z.object({ + companyName: z.string().describe('Company legal name'), + companySiren: z.string().describe('Company SIREN number'), + websiteUrl: z.string().describe('Company website URL'), + legalRepName: z.string().describe('Legal representative full name'), + emailDomain: z.string().optional().describe('Company email domain'), + phone: z.string().optional().describe('Company phone number'), + legalRepDob: z.string().optional().describe('Legal representative date of birth (ISO 8601)'), + expectedMonthlyPaymentVolume: z + .string() + .optional() + .describe('Expected monthly payment volume'), + expectedAverageBasketValue: z.string().optional().describe('Expected average basket value'), + businessDescription: z.string().optional().describe('Description of the business activity'), + }), + func: async inputs => { + const createResponse = await fetch(`${baseUrl}/merchant-application/create`, { + method: 'POST', + headers, + body: JSON.stringify(inputs), + }); + + await assertResponseOk(createResponse, 'create merchant application'); + const { id } = await createResponse.json(); + + const jobResponse = await fetch(`${baseUrl}/merchant-application-job/create/${id}`, { + method: 'POST', + headers, + }); + + await assertResponseOk(jobResponse, 'trigger merchant application analysis'); + + return JSON.stringify({ id }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/kolar/tools/get-merchant-application-result.ts b/packages/ai-proxy/src/integrations/kolar/tools/get-merchant-application-result.ts new file mode 100644 index 0000000000..f1bf4c94bd --- /dev/null +++ b/packages/ai-proxy/src/integrations/kolar/tools/get-merchant-application-result.ts @@ -0,0 +1,29 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +export default function createGetMerchantApplicationResultTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'kolar_get_merchant_application_result', + description: + 'Retrieve the KYB analysis result of a merchant application by ID. Returns jobStatus (PENDING, RUNNING, COMPLETED, FAILED) and result with decision (APPROVED, REJECTED, REVIEW), riskScore, and rationale when completed.', + schema: z.object({ + id: z + .number() + .int() + .positive() + .describe('The merchant application ID returned by kolar_create_merchant_application'), + }), + func: async ({ id }) => { + const response = await fetch(`${baseUrl}/merchant-application/${id}/result`, { headers }); + + await assertResponseOk(response, 'get merchant application result'); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/kolar/tools/get-screening-result.ts b/packages/ai-proxy/src/integrations/kolar/tools/get-screening-result.ts new file mode 100644 index 0000000000..57c3b3b2c8 --- /dev/null +++ b/packages/ai-proxy/src/integrations/kolar/tools/get-screening-result.ts @@ -0,0 +1,25 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +export default function createGetScreeningResultTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'kolar_get_screening_result', + description: + 'Retrieve the full result of a submitted screening by alert ID, including risk score and match details.', + schema: z.object({ + id: z.number().int().positive().describe('The alert ID returned by kolar_screen_transaction'), + }), + func: async ({ id }) => { + const response = await fetch(`${baseUrl}/alert/${id}/result`, { headers }); + + await assertResponseOk(response, 'get screening result'); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/kolar/tools/screen-transaction.ts b/packages/ai-proxy/src/integrations/kolar/tools/screen-transaction.ts new file mode 100644 index 0000000000..7a979e1796 --- /dev/null +++ b/packages/ai-proxy/src/integrations/kolar/tools/screen-transaction.ts @@ -0,0 +1,67 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +export default function createScreenTransactionTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'kolar_screen_transaction', + description: + 'Submit a transaction for AML risk screening. Creates an alert and triggers the screening job. Returns the alert ID to use with kolar_get_screening_result.', + schema: z.object({ + clientFirstName: z.string().describe('Client first name'), + clientLastName: z.string().describe('Client last name'), + matchFirstNames: z.string().describe('Match first names'), + matchLastNames: z.string().describe('Match last names'), + matchType: z + .enum(['PEP', 'SL']) + .describe('Match type: PEP (Politically Exposed Person) or SL (Sanctions List)'), + clientBirthDate: z.string().optional().describe('Client birth date (ISO 8601)'), + clientBirthPlace: z.string().optional().describe('Client birth place'), + clientIdFirstName: z.string().optional().describe('Client ID first name'), + clientIdLastName: z.string().optional().describe('Client ID last name'), + clientIdBirthDate: z.string().optional().describe('Client ID birth date (ISO 8601)'), + clientIdBirthPlace: z.string().optional().describe('Client ID birth place'), + clientIdCountryCode: z.string().optional().describe('Client ID country code'), + onfidoCountryCode: z.string().optional().describe('Onfido country code'), + clientPhoneCountryCode: z.string().optional().describe('Client phone country code'), + merchantBrand: z.string().optional().describe('Merchant brand'), + paymentIpAddress: z.string().optional().describe('Payment IP address'), + paymentCountry: z.string().optional().describe('Payment country'), + shippingCountry: z.string().optional().describe('Shipping country'), + billingCountry: z.string().optional().describe('Billing country'), + cardHolderName: z.string().optional().describe('Card holder name'), + cardCountryCode: z.string().optional().describe('Card country code'), + bankAccountHolderName: z.string().optional().describe('Bank account holder name'), + bankAccountCountryCode: z.string().optional().describe('Bank account country code'), + matchFullNames: z.string().optional().describe('Match full names'), + matchBirthDate: z.string().optional().describe('Match birth date (ISO 8601)'), + matchBirthPlace: z.string().optional().describe('Match birth place'), + matchCountryCode: z.string().optional().describe('Match country code'), + matchRole: z.string().optional().describe('Match role'), + additionalData: z.record(z.string(), z.unknown()).optional().describe('Additional data'), + }), + func: async inputs => { + const createResponse = await fetch(`${baseUrl}/alert/create`, { + method: 'POST', + headers, + body: JSON.stringify(inputs), + }); + + await assertResponseOk(createResponse, 'create alert'); + const { id } = await createResponse.json(); + + const jobResponse = await fetch(`${baseUrl}/alert-job/create/${id}`, { + method: 'POST', + headers, + }); + + await assertResponseOk(jobResponse, 'create alert job'); + + return JSON.stringify({ id }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/kolar/utils.ts b/packages/ai-proxy/src/integrations/kolar/utils.ts new file mode 100644 index 0000000000..6a971abfe2 --- /dev/null +++ b/packages/ai-proxy/src/integrations/kolar/utils.ts @@ -0,0 +1,48 @@ +import type { KolarConfig } from './tools'; + +import { McpConnectionError } from '../../errors'; + +const BASE_URL = 'https://api.kolar.ai'; + +export function getKolarConfig({ apiKey }: KolarConfig) { + const headers = { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }; + + return { baseUrl: BASE_URL, headers }; +} + +export async function assertResponseOk(response: Response, action: string) { + if (!response.ok) { + let errorMessage = response.statusText || 'Unknown error'; + + try { + const json = await response.json(); + errorMessage = json.error || json.message || json.description || errorMessage; + } catch { + // Response body is not JSON + } + + throw new Error(`Kolar ${action} failed (${response.status}): ${errorMessage}`); + } +} + +export async function validateKolarConfig(config: KolarConfig) { + const { baseUrl, headers } = getKolarConfig(config); + + const response = await fetch(`${baseUrl}/auth/verify`, { headers }); + + if (!response.ok) { + let errorMessage = response.statusText || 'Unknown error'; + + try { + const json = await response.json(); + errorMessage = json.message || json.error || errorMessage; + } catch { + // Response body is not JSON + } + + throw new McpConnectionError(`Failed to validate Kolar config: ${errorMessage}`); + } +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts index ce3201d961..022fefb88d 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -33,6 +33,17 @@ export default function createCreateTicketTool( ) .optional() .describe('Custom fields to set on the ticket'), + requester_email: z.string().email().optional().describe('The email of the requester'), + requester_name: z.string().optional().describe('The name of the requester'), + recipient_email: z + .string() + .email() + .optional() + .describe('The email of the recipient to notify about the ticket creation'), + status: z + .enum(['new', 'open', 'pending', 'solved', 'closed']) + .optional() + .describe('Status for the ticket'), }), func: async ({ subject, @@ -43,7 +54,15 @@ export default function createCreateTicketTool( type, tags, custom_fields, + requester_email, + requester_name, + recipient_email, + status, }) => { + const requester = { + ...(requester_email && { email: requester_email }), + ...(requester_name && { name: requester_name }), + }; const ticketData: Record = { ticket: { subject, @@ -54,6 +73,9 @@ export default function createCreateTicketTool( ...(type && { type }), ...(tags && { tags }), ...(custom_fields && { custom_fields }), + ...(Object.keys(requester).length > 0 && { requester }), + ...(recipient_email && { recipient: recipient_email }), + ...(status && { status }), }, }; diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts index feed9aef9e..6a51caf560 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -3,48 +3,146 @@ import { z } from 'zod'; import { assertResponseOk } from '../utils'; +const searchFiltersSchema = z.object({ + requester_email: z.string().email().optional().describe('Filter tickets by requester email'), + assignee_email: z.string().email().optional().describe('Filter tickets by assignee email'), + submitter_email: z.string().email().optional().describe('Filter tickets by submitter email'), + cc_email: z.string().email().optional().describe('Filter tickets where this email is in CC'), + status: z + .enum(['new', 'open', 'pending', 'hold', 'solved', 'closed']) + .optional() + .describe('Filter by ticket status'), + priority: z + .enum(['urgent', 'high', 'normal', 'low']) + .optional() + .describe('Filter by ticket priority'), + ticket_type: z + .enum(['ticket', 'question', 'incident', 'problem', 'task']) + .optional() + .describe('Filter by ticket type'), + group: z.string().optional().describe('Filter by group name'), + brand: z.string().optional().describe('Filter by brand name'), + tags: z.array(z.string()).optional().describe('Filter by tags'), + subject: z.string().optional().describe('Search in ticket subject'), + description: z.string().optional().describe('Search in ticket description'), + created_after: z.string().optional().describe('Tickets created after this date (YYYY-MM-DD)'), + created_before: z.string().optional().describe('Tickets created before this date (YYYY-MM-DD)'), + updated_after: z.string().optional().describe('Tickets updated after this date (YYYY-MM-DD)'), + solved_after: z.string().optional().describe('Tickets solved after this date (YYYY-MM-DD)'), + keyword: z.string().optional().describe('Free text search across ticket fields'), +}); +const baseSchema = z.object({ + page: z.number().int().positive().optional().describe('Page number for pagination (default: 1)'), + per_page: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe('Number of tickets per page, max 100 (default: 25)'), + sort_by: z + .enum(['created_at', 'updated_at', 'priority', 'status']) + .optional() + .describe('Field to sort tickets by (default: created_at)'), + sort_order: z.enum(['asc', 'desc']).optional().describe('Sort order (default: desc)'), +}); +const schema = searchFiltersSchema.extend(baseSchema.shape); + +type SearchFilters = z.infer; +type Input = z.infer; + +function quote(v: unknown): string { + const s = String(v); + if (!s.includes(' ')) return s; + + return `"${s.replace(/"/g, '\\"')}"`; +} + +const FILTER_TO_QUERY: Record string> = { + requester_email: v => `requester:${v}`, + assignee_email: v => `assignee:${v}`, + submitter_email: v => `submitter:${v}`, + cc_email: v => `cc:${v}`, + status: v => `status:${v}`, + priority: v => `priority:${v}`, + ticket_type: v => `type:${v}`, + group: v => `group:${quote(v)}`, + brand: v => `brand:${quote(v)}`, + tags: v => (v as string[]).map(t => `tags:${quote(t)}`).join(' '), + subject: v => `subject:${quote(v)}`, + description: v => `description:${quote(v)}`, + created_after: v => `created>${v}`, + created_before: v => `created<${v}`, + updated_after: v => `updated>${v}`, + solved_after: v => `solved>${v}`, + keyword: v => `${quote(v)}`, +}; + +function buildSearchQuery(filters: SearchFilters): string | null { + const parts: string[] = []; + + for (const [key, toQuery] of Object.entries(FILTER_TO_QUERY)) { + const value = filters[key as keyof SearchFilters]; + + if (value !== undefined && value !== null) { + const query = toQuery(value); + if (query) parts.push(query); + } + } + + if (parts.length === 0) return null; + + if (!filters.ticket_type) parts.push('type:ticket'); + + return parts.join(' '); +} + export default function createGetTicketsTool( headers: Record, baseUrl: string, ): DynamicStructuredTool { return new DynamicStructuredTool({ name: 'zendesk_get_tickets', - description: 'Fetch a paginated list of Zendesk tickets with sorting options', - schema: z.object({ - page: z - .number() - .int() - .positive() - .optional() - .default(1) - .describe('Page number for pagination'), - per_page: z - .number() - .int() - .positive() - .max(100) - .optional() - .default(25) - .describe('Number of tickets per page (max 100)'), - sort_by: z - .enum(['created_at', 'updated_at', 'priority', 'status']) - .optional() - .default('created_at') - .describe('Field to sort tickets by'), - sort_order: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort order'), - }), - func: async ({ page, per_page, sort_by, sort_order }) => { + description: + 'Fetch Zendesk tickets. Without filters, returns a paginated list. With filters, searches using the Zendesk Search API.', + schema, + func: async (input: Input) => { + const { + page: rawPage, + per_page: rawPerPage, + sort_by: rawSortBy, + sort_order: rawSortOrder, + ...filters + } = input; + const page = rawPage ?? 1; + const perPage = rawPerPage ?? 25; + const sortBy = rawSortBy ?? 'created_at'; + const sortOrder = rawSortOrder ?? 'desc'; + const query = buildSearchQuery(filters); + + if (query) { + const params = new URLSearchParams({ + query, + page: page.toString(), + per_page: perPage.toString(), + sort_by: sortBy, + sort_order: sortOrder, + }); + + const response = await fetch(`${baseUrl}/search.json?${params}`, { headers }); + await assertResponseOk(response, 'search tickets'); + + return JSON.stringify(await response.json()); + } + const params = new URLSearchParams({ page: page.toString(), - per_page: per_page.toString(), - sort_by, - sort_order, - }); - - const response = await fetch(`${baseUrl}/tickets.json?${params}`, { - headers, + per_page: perPage.toString(), + sort_by: sortBy, + sort_order: sortOrder, }); + const response = await fetch(`${baseUrl}/tickets.json?${params}`, { headers }); await assertResponseOk(response, 'get tickets'); return JSON.stringify(await response.json()); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts index 906cda8a74..c5e7a9e70c 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -14,7 +14,7 @@ export default function createUpdateTicketTool( ticket_id: z.number().int().positive().describe('The ID of the ticket to update'), subject: z.string().min(1).optional().describe('New subject for the ticket'), status: z - .enum(['new', 'open', 'pending', 'hold', 'solved', 'closed']) + .enum(['new', 'open', 'pending', 'solved', 'closed']) .optional() .describe('New status for the ticket'), priority: z diff --git a/packages/ai-proxy/test/forest-integration-client.test.ts b/packages/ai-proxy/test/forest-integration-client.test.ts index a4d2601f4d..121759c68d 100644 --- a/packages/ai-proxy/test/forest-integration-client.test.ts +++ b/packages/ai-proxy/test/forest-integration-client.test.ts @@ -1,14 +1,22 @@ import ForestIntegrationClient from '../src/forest-integration-client'; +import { validateKolarConfig } from '../src/integrations/kolar/utils'; import { validateZendeskConfig } from '../src/integrations/zendesk/utils'; const mockZendeskTools = [{ name: 'zendesk_get_tickets' }, { name: 'zendesk_get_ticket' }]; +const mockKolarTools = [{ name: 'kolar_screen_transaction' }, { name: 'kolar_get_result' }]; jest.mock('../src/integrations/zendesk/tools', () => ({ __esModule: true, default: jest.fn(() => mockZendeskTools), })); +jest.mock('../src/integrations/kolar/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockKolarTools), +})); + jest.mock('../src/integrations/zendesk/utils'); +jest.mock('../src/integrations/kolar/utils'); describe('ForestIntegrationClient', () => { beforeEach(() => jest.clearAllMocks()); @@ -41,6 +49,20 @@ describe('ForestIntegrationClient', () => { expect(logger).toHaveBeenCalledWith('Warn', 'Unsupported integration: unknown'); }); + it('should load kolar tools when integration is Kolar', async () => { + const client = new ForestIntegrationClient([ + { + integrationName: 'Kolar', + config: { apiKey: 'key' }, + isForestConnector: true, + }, + ]); + + const tools = await client.loadTools(); + + expect(tools).toEqual(mockKolarTools); + }); + it('should return empty array when no configs', async () => { const client = new ForestIntegrationClient([]); @@ -93,6 +115,17 @@ describe('ForestIntegrationClient', () => { expect(result).toBe(true); }); + it('should call validateKolarConfig for Kolar integration', async () => { + const kolarConfig = { apiKey: 'key' }; + const client = new ForestIntegrationClient([ + { integrationName: 'Kolar', config: kolarConfig, isForestConnector: true }, + ]); + + await client.checkConnection(); + + expect(validateKolarConfig).toHaveBeenCalledWith(kolarConfig); + }); + it('should throw for unsupported integration', async () => { const client = new ForestIntegrationClient([ // @ts-expect-error Testing unsupported integration diff --git a/packages/ai-proxy/test/integrations/kolar/tools.test.ts b/packages/ai-proxy/test/integrations/kolar/tools.test.ts new file mode 100644 index 0000000000..df670e3c72 --- /dev/null +++ b/packages/ai-proxy/test/integrations/kolar/tools.test.ts @@ -0,0 +1,29 @@ +import getKolarTools from '../../../src/integrations/kolar/tools'; +import ServerRemoteTool from '../../../src/server-remote-tool'; + +describe('getKolarTools', () => { + const config = { apiKey: 'test-api-key' }; + + it('should return 4 tools wrapped in ServerRemoteTool', () => { + const tools = getKolarTools(config); + + expect(tools).toHaveLength(4); + tools.forEach(tool => { + expect(tool).toBeInstanceOf(ServerRemoteTool); + expect(tool.sourceId).toBe('kolar'); + expect(tool.sourceType).toBe('server'); + }); + }); + + it('should return tools with expected names', () => { + const tools = getKolarTools(config); + const names = tools.map(t => t.base.name); + + expect(names).toEqual([ + 'kolar_create_merchant_application', + 'kolar_get_merchant_application_result', + 'kolar_screen_transaction', + 'kolar_get_screening_result', + ]); + }); +}); diff --git a/packages/ai-proxy/test/integrations/kolar/tools/create-merchant-application.test.ts b/packages/ai-proxy/test/integrations/kolar/tools/create-merchant-application.test.ts new file mode 100644 index 0000000000..b25c1719eb --- /dev/null +++ b/packages/ai-proxy/test/integrations/kolar/tools/create-merchant-application.test.ts @@ -0,0 +1,123 @@ +import createMerchantApplicationTool from '../../../../src/integrations/kolar/tools/create-merchant-application'; + +describe('createMerchantApplicationTool', () => { + const headers = { Authorization: 'Basic abc', 'Content-Type': 'application/json' }; + const baseUrl = 'https://backend-partners.up.railway.app'; + + beforeEach(() => jest.clearAllMocks()); + + beforeAll(() => { + global.fetch = jest.fn() as jest.Mock; + }); + + it('should throw on HTTP error during application creation', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ error: 'Validation failed' }), + }); + + const tool = createMerchantApplicationTool(headers, baseUrl); + + await expect( + tool.invoke({ + companyName: 'LEETCHI', + companySiren: '508289828', + websiteUrl: 'https://leetchi.com', + legalRepName: 'Céline Lazorthes', + }), + ).rejects.toThrow('Kolar create merchant application failed (400): Validation failed'); + }); + + it('should throw on HTTP error during job creation', async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42 }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Job creation failed' }), + }); + + const tool = createMerchantApplicationTool(headers, baseUrl); + + await expect( + tool.invoke({ + companyName: 'LEETCHI', + companySiren: '508289828', + websiteUrl: 'https://leetchi.com', + legalRepName: 'Céline Lazorthes', + }), + ).rejects.toThrow( + 'Kolar trigger merchant application analysis failed (500): Job creation failed', + ); + }); + + it('should create application and trigger analysis with required fields', async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const tool = createMerchantApplicationTool(headers, baseUrl); + const input = { + companyName: 'LEETCHI', + companySiren: '508289828', + websiteUrl: 'https://leetchi.com', + legalRepName: 'Céline Lazorthes', + }; + + const result = await tool.invoke(input); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/merchant-application/create`, { + method: 'POST', + headers, + body: JSON.stringify(input), + }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/merchant-application-job/create/42`, { + method: 'POST', + headers, + }); + expect(result).toBe(JSON.stringify({ id: 42 })); + }); + + it('should create application with optional fields', async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 99 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const tool = createMerchantApplicationTool(headers, baseUrl); + + await tool.invoke({ + companyName: 'LEETCHI', + companySiren: '508289828', + websiteUrl: 'https://leetchi.com', + legalRepName: 'Céline Lazorthes', + emailDomain: 'leetchi.com', + phone: '+33184170000', + businessDescription: 'Cagnotte en ligne', + }); + + expect(fetch).toHaveBeenCalledWith( + `${baseUrl}/merchant-application/create`, + expect.objectContaining({ + body: expect.stringContaining('"emailDomain":"leetchi.com"'), + }), + ); + }); +}); diff --git a/packages/ai-proxy/test/integrations/kolar/tools/get-merchant-application-result.test.ts b/packages/ai-proxy/test/integrations/kolar/tools/get-merchant-application-result.test.ts new file mode 100644 index 0000000000..6c759cf29d --- /dev/null +++ b/packages/ai-proxy/test/integrations/kolar/tools/get-merchant-application-result.test.ts @@ -0,0 +1,42 @@ +import createGetMerchantApplicationResultTool from '../../../../src/integrations/kolar/tools/get-merchant-application-result'; + +const mockResponse = { + jobStatus: 'COMPLETED', + result: { decision: 'APPROVED', riskScore: 75, rationale: 'Low risk merchant.' }, +}; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createGetMerchantApplicationResultTool', () => { + const headers = { Authorization: 'Basic abc' }; + const baseUrl = 'https://backend-partners.up.railway.app'; + + beforeEach(() => jest.clearAllMocks()); + + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ message: 'Application not found' }), + }); + + const tool = createGetMerchantApplicationResultTool(headers, baseUrl); + + await expect(tool.invoke({ id: 999 })).rejects.toThrow( + 'Kolar get merchant application result failed (404): Application not found', + ); + }); + + it('should fetch merchant application result by id', async () => { + const tool = createGetMerchantApplicationResultTool(headers, baseUrl); + + const result = await tool.invoke({ id: 42 }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/merchant-application/42/result`, { headers }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); +}); diff --git a/packages/ai-proxy/test/integrations/kolar/tools/get-screening-result.test.ts b/packages/ai-proxy/test/integrations/kolar/tools/get-screening-result.test.ts new file mode 100644 index 0000000000..dc9d4b8ade --- /dev/null +++ b/packages/ai-proxy/test/integrations/kolar/tools/get-screening-result.test.ts @@ -0,0 +1,42 @@ +import createGetScreeningResultTool from '../../../../src/integrations/kolar/tools/get-screening-result'; + +const mockResponse = { + jobStatus: 'COMPLETED', + result: { decision: 'FALSE_POSITIVE', rationale: 'Names do not match.' }, +}; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createGetScreeningResultTool', () => { + const headers = { Authorization: 'Basic abc' }; + const baseUrl = 'https://backend-partners.up.railway.app'; + + beforeEach(() => jest.clearAllMocks()); + + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ message: 'Alert not found' }), + }); + + const tool = createGetScreeningResultTool(headers, baseUrl); + + await expect(tool.invoke({ id: 999 })).rejects.toThrow( + 'Kolar get screening result failed (404): Alert not found', + ); + }); + + it('should fetch screening result by alert id', async () => { + const tool = createGetScreeningResultTool(headers, baseUrl); + + const result = await tool.invoke({ id: 42 }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/alert/42/result`, { headers }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); +}); diff --git a/packages/ai-proxy/test/integrations/kolar/tools/screen-transaction.test.ts b/packages/ai-proxy/test/integrations/kolar/tools/screen-transaction.test.ts new file mode 100644 index 0000000000..e5ca3ad037 --- /dev/null +++ b/packages/ai-proxy/test/integrations/kolar/tools/screen-transaction.test.ts @@ -0,0 +1,125 @@ +import createScreenTransactionTool from '../../../../src/integrations/kolar/tools/screen-transaction'; + +describe('createScreenTransactionTool', () => { + const headers = { Authorization: 'Basic abc', 'Content-Type': 'application/json' }; + const baseUrl = 'https://backend-partners.up.railway.app'; + + beforeEach(() => jest.clearAllMocks()); + + beforeAll(() => { + global.fetch = jest.fn() as jest.Mock; + }); + + it('should throw on HTTP error during alert creation', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ error: 'Validation failed' }), + }); + + const tool = createScreenTransactionTool(headers, baseUrl); + + await expect( + tool.invoke({ + clientFirstName: 'John', + clientLastName: 'Doe', + matchFirstNames: 'John', + matchLastNames: 'Doe', + matchType: 'PEP', + }), + ).rejects.toThrow('Kolar create alert failed (422): Validation failed'); + }); + + it('should throw on HTTP error during job creation', async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42 }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Job creation failed' }), + }); + + const tool = createScreenTransactionTool(headers, baseUrl); + + await expect( + tool.invoke({ + clientFirstName: 'John', + clientLastName: 'Doe', + matchFirstNames: 'John', + matchLastNames: 'Doe', + matchType: 'PEP', + }), + ).rejects.toThrow('Kolar create alert job failed (500): Job creation failed'); + }); + + it('should create alert and trigger job with required fields', async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const tool = createScreenTransactionTool(headers, baseUrl); + const input = { + clientFirstName: 'John', + clientLastName: 'Doe', + matchFirstNames: 'John', + matchLastNames: 'Doe', + matchType: 'PEP' as const, + }; + + const result = await tool.invoke(input); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/alert/create`, { + method: 'POST', + headers, + body: JSON.stringify(input), + }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/alert-job/create/42`, { + method: 'POST', + headers, + }); + expect(result).toBe(JSON.stringify({ id: 42 })); + }); + + it('should create alert with optional fields', async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 99 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const tool = createScreenTransactionTool(headers, baseUrl); + + await tool.invoke({ + clientFirstName: 'John', + clientLastName: 'Doe', + matchFirstNames: 'John', + matchLastNames: 'Doe', + matchType: 'SL', + merchantBrand: 'Acme', + paymentCountry: 'FR', + additionalData: { ref: '12345' }, + }); + + expect(fetch).toHaveBeenCalledWith( + `${baseUrl}/alert/create`, + expect.objectContaining({ + body: expect.stringContaining('"merchantBrand":"Acme"'), + }), + ); + }); +}); diff --git a/packages/ai-proxy/test/integrations/kolar/utils.test.ts b/packages/ai-proxy/test/integrations/kolar/utils.test.ts new file mode 100644 index 0000000000..32591f0e97 --- /dev/null +++ b/packages/ai-proxy/test/integrations/kolar/utils.test.ts @@ -0,0 +1,116 @@ +import { McpConnectionError } from '../../../src/errors'; +import { + assertResponseOk, + getKolarConfig, + validateKolarConfig, +} from '../../../src/integrations/kolar/utils'; + +describe('kolar/utils', () => { + describe('getKolarConfig', () => { + it('should return baseUrl and headers with api key', () => { + const config = { apiKey: 'test-api-key' }; + + const result = getKolarConfig(config); + + expect(result).toEqual({ + baseUrl: 'https://api.kolar.ai', + headers: { + 'X-Api-Key': 'test-api-key', + 'Content-Type': 'application/json', + }, + }); + }); + }); + + describe('assertResponseOk', () => { + it('should not throw when response is ok', async () => { + const response = { ok: true } as Response; + await expect(assertResponseOk(response, 'test')).resolves.toBeUndefined(); + }); + + it('should throw with error field from JSON body', async () => { + const response = { + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ error: 'Invalid credentials' }), + } as unknown as Response; + + await expect(assertResponseOk(response, 'get screening result')).rejects.toThrow( + 'Kolar get screening result failed (401): Invalid credentials', + ); + }); + + it('should throw with message from JSON body', async () => { + const response = { + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ message: 'Alert not found' }), + } as unknown as Response; + + await expect(assertResponseOk(response, 'get screening result')).rejects.toThrow( + 'Kolar get screening result failed (404): Alert not found', + ); + }); + + it('should fall back to statusText when JSON parsing fails', async () => { + const response = { + ok: false, + status: 502, + statusText: 'Bad Gateway', + json: async () => { + throw new Error('not json'); + }, + } as unknown as Response; + + await expect(assertResponseOk(response, 'create alert')).rejects.toThrow( + 'Kolar create alert failed (502): Bad Gateway', + ); + }); + }); + + describe('validateKolarConfig', () => { + const config = { apiKey: 'test-api-key' }; + + beforeEach(() => jest.restoreAllMocks()); + + it('should not throw when response is ok', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await expect(validateKolarConfig(config)).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith( + 'https://api.kolar.ai/auth/verify', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Api-Key': 'test-api-key' }), + }), + ); + }); + + it('should throw McpConnectionError when response has message', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + json: async () => ({ message: 'Unauthorized' }), + } as Response); + + await expect(validateKolarConfig(config)).rejects.toThrow(McpConnectionError); + await expect(validateKolarConfig(config)).rejects.toThrow( + 'Failed to validate Kolar config: Unauthorized', + ); + }); + + it('should fall back to statusText when response body is not JSON', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + statusText: 'Bad Gateway', + json: async () => { + throw new Error('not json'); + }, + } as unknown as Response); + + await expect(validateKolarConfig(config)).rejects.toThrow( + 'Failed to validate Kolar config: Bad Gateway', + ); + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts index 6981813a92..bfe54bfddf 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts @@ -53,6 +53,7 @@ describe('createCreateTicketTool', () => { type: 'incident', tags: ['urgent'], custom_fields: [{ id: 100, value: 'foo' }], + status: 'open', }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { @@ -68,6 +69,118 @@ describe('createCreateTicketTool', () => { type: 'incident', tags: ['urgent'], custom_fields: [{ id: 100, value: 'foo' }], + status: 'open', + }, + }), + }); + }); + + it('should create a ticket with recipient email', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + await tool.invoke({ + subject: 'Bug', + description: 'Broken', + recipient_email: 'notify@example.com', + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Bug', + comment: { body: 'Broken' }, + recipient: 'notify@example.com', + }, + }), + }); + }); + + it('should create a ticket with status', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + await tool.invoke({ + subject: 'Bug', + description: 'Broken', + status: 'pending', + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Bug', + comment: { body: 'Broken' }, + status: 'pending', + }, + }), + }); + }); + + it('should create a ticket with requester email', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + await tool.invoke({ + subject: 'Bug', + description: 'Broken', + requester_email: 'user@example.com', + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Bug', + comment: { body: 'Broken' }, + requester: { email: 'user@example.com' }, + }, + }), + }); + }); + + it('should create a ticket with requester email and name', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + await tool.invoke({ + subject: 'Bug', + description: 'Broken', + requester_email: 'user@example.com', + requester_name: 'John Doe', + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Bug', + comment: { body: 'Broken' }, + requester: { email: 'user@example.com', name: 'John Doe' }, + }, + }), + }); + }); + + it('should create a ticket with requester name only', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + await tool.invoke({ + subject: 'Bug', + description: 'Broken', + requester_name: 'John Doe', + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Bug', + comment: { body: 'Broken' }, + requester: { name: 'John Doe' }, }, }), }); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts index c19b7bbb92..bb2b685345 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts @@ -56,4 +56,211 @@ describe('createGetTicketsTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { headers }); }); + + it('should search tickets by requester email', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + const result = await tool.invoke({ requester_email: 'user@example.com' }); + + const expectedParams = new URLSearchParams({ + query: 'requester:user@example.com type:ticket', + page: '1', + per_page: '25', + sort_by: 'created_at', + sort_order: 'desc', + }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/search.json?${expectedParams}`, { headers }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); + + it('should search tickets by submitter email', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ submitter_email: 'submitter@example.com' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=submitter%3Asubmitter%40example.com+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by cc email', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ cc_email: 'cc@example.com' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=cc%3Acc%40example.com+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by assignee email', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ assignee_email: 'agent@example.com' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=assignee%3Aagent%40example.com+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by status and priority', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ status: 'open', priority: 'urgent' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=status%3Aopen+priority%3Aurgent+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by date range', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ created_after: '2024-01-01', created_before: '2024-06-01' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=created%3E2024-01-01+created%3C2024-06-01+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by updated_after', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ updated_after: '2024-03-01' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=updated%3E2024-03-01+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by solved_after', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ solved_after: '2024-05-01' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=solved%3E2024-05-01+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by description', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ description: 'login error' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=description%3A%22login+error%22+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by tags', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ tags: ['vip', 'billing'] }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=tags%3Avip+tags%3Abilling+type%3Aticket'), + { headers }, + ); + }); + + it('should search tickets by subject and keyword', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ subject: 'refund', keyword: 'payment issue' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=subject%3Arefund+%22payment+issue%22+type%3Aticket'), + { headers }, + ); + }); + + it('should combine multiple filters', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ + requester_email: 'user@example.com', + status: 'open', + priority: 'high', + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + 'query=requester%3Auser%40example.com+status%3Aopen+priority%3Ahigh+type%3Aticket', + ), + { headers }, + ); + }); + + it('should not add type:ticket when ticket_type is specified', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ ticket_type: 'incident' }); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('query=type%3Aincident'), { + headers, + }); + expect(fetch).not.toHaveBeenCalledWith( + expect.stringContaining('type%3Aticket'), + expect.anything(), + ); + }); + + it('should search by group and brand', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ group: 'support', brand: 'acme' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=group%3Asupport+brand%3Aacme+type%3Aticket'), + { headers }, + ); + }); + + it('should quote multi-word filter values', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ group: 'tier 1 support', subject: 'billing issue', brand: 'acme' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + 'query=group%3A%22tier+1+support%22+brand%3Aacme+subject%3A%22billing+issue%22+type%3Aticket', + ), + { headers }, + ); + }); + + it('should skip empty tags array', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ tags: [], status: 'open' }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('query=status%3Aopen+type%3Aticket'), + { headers }, + ); + }); + + it('should throw on HTTP error when searching', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ error: 'Invalid query' }), + }); + + const tool = createGetTicketsTool(headers, baseUrl); + + await expect(tool.invoke({ requester_email: 'user@example.com' })).rejects.toThrow( + 'Zendesk search tickets failed (422): Invalid query', + ); + }); }); diff --git a/packages/datasource-customizer/CHANGELOG.md b/packages/datasource-customizer/CHANGELOG.md index 4647749a38..25c27a5c64 100644 --- a/packages/datasource-customizer/CHANGELOG.md +++ b/packages/datasource-customizer/CHANGELOG.md @@ -1,3 +1,10 @@ +## @forestadmin/datasource-customizer [1.69.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-customizer@1.69.2...@forestadmin/datasource-customizer@1.69.3) (2026-04-15) + + +### Bug Fixes + +* **vulnerability:** use magic-bytes instead of FileType ([#1554](https://github.com/ForestAdmin/agent-nodejs/issues/1554)) ([4f9c004](https://github.com/ForestAdmin/agent-nodejs/commit/4f9c004e1aa39120c13cb2db7d9bc7a96bde87dd)) + ## @forestadmin/datasource-customizer [1.69.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-customizer@1.69.1...@forestadmin/datasource-customizer@1.69.2) (2026-03-31) diff --git a/packages/datasource-customizer/package.json b/packages/datasource-customizer/package.json index 7f31bf4cc2..32382d9708 100644 --- a/packages/datasource-customizer/package.json +++ b/packages/datasource-customizer/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-customizer", - "version": "1.69.2", + "version": "1.69.3", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -31,7 +31,7 @@ "dependencies": { "@forestadmin/datasource-toolkit": "1.53.1", "antlr4": "^4.13.1-patch-1", - "file-type": "^16.5.4", + "magic-bytes.js": "^1.13.0", "luxon": "^3.2.1", "object-hash": "^3.0.0", "uuid": "11.0.2" diff --git a/packages/datasource-customizer/src/decorators/binary/collection.ts b/packages/datasource-customizer/src/decorators/binary/collection.ts index 9bce499577..7c985e3048 100644 --- a/packages/datasource-customizer/src/decorators/binary/collection.ts +++ b/packages/datasource-customizer/src/decorators/binary/collection.ts @@ -17,7 +17,7 @@ import type { } from '@forestadmin/datasource-toolkit'; import { CollectionDecorator, SchemaUtils } from '@forestadmin/datasource-toolkit'; -import FileType from 'file-type'; +import { filetypemime } from 'magic-bytes.js'; /** * As the transport layer between the forest admin agent and the frontend is JSON-API, binary data @@ -249,7 +249,7 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { const buffer = value as Buffer; if (useHex) return buffer.toString('hex'); - const mime = (await FileType.fromBuffer(buffer))?.mime ?? 'application/octet-stream'; + const mime = filetypemime([...buffer])?.[0] ?? 'application/octet-stream'; const data = buffer.toString('base64'); return `data:${mime};base64,${data}`; diff --git a/packages/datasource-dummy/CHANGELOG.md b/packages/datasource-dummy/CHANGELOG.md index 81bdbaf6d0..a6fa6b0471 100644 --- a/packages/datasource-dummy/CHANGELOG.md +++ b/packages/datasource-dummy/CHANGELOG.md @@ -1,3 +1,13 @@ +## @forestadmin/datasource-dummy [1.1.69](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.1.68...@forestadmin/datasource-dummy@1.1.69) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + ## @forestadmin/datasource-dummy [1.1.68](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.1.67...@forestadmin/datasource-dummy@1.1.68) (2026-03-31) diff --git a/packages/datasource-dummy/package.json b/packages/datasource-dummy/package.json index 7b5403174a..c89b2dfa43 100644 --- a/packages/datasource-dummy/package.json +++ b/packages/datasource-dummy/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-dummy", - "version": "1.1.68", + "version": "1.1.69", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -12,7 +12,7 @@ "directory": "packages/datasource-dummy" }, "dependencies": { - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/datasource-customizer": "1.69.3", "@forestadmin/datasource-toolkit": "1.53.1" }, "files": [ diff --git a/packages/datasource-mongo/CHANGELOG.md b/packages/datasource-mongo/CHANGELOG.md index 5cfcfa72cc..d203afc5f4 100644 --- a/packages/datasource-mongo/CHANGELOG.md +++ b/packages/datasource-mongo/CHANGELOG.md @@ -1,3 +1,10 @@ +## @forestadmin/datasource-mongo [1.6.9](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-mongo@1.6.8...@forestadmin/datasource-mongo@1.6.9) (2026-04-14) + + +### Bug Fixes + +* **datasource-mongo:** fix flaky create integration test ([#1550](https://github.com/ForestAdmin/agent-nodejs/issues/1550)) ([ef90e90](https://github.com/ForestAdmin/agent-nodejs/commit/ef90e90cecefbd85dfd4f2a3e923bff543116788)) + ## @forestadmin/datasource-mongo [1.6.8](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-mongo@1.6.7...@forestadmin/datasource-mongo@1.6.8) (2026-03-31) diff --git a/packages/datasource-mongo/package.json b/packages/datasource-mongo/package.json index 0f3ce04430..03219c2720 100644 --- a/packages/datasource-mongo/package.json +++ b/packages/datasource-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-mongo", - "version": "1.6.8", + "version": "1.6.9", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { diff --git a/packages/datasource-mongo/test/create.integration.test.ts b/packages/datasource-mongo/test/create.integration.test.ts index 443d087c1e..05a8a799f8 100644 --- a/packages/datasource-mongo/test/create.integration.test.ts +++ b/packages/datasource-mongo/test/create.integration.test.ts @@ -10,11 +10,12 @@ describe('create', () => { 'mongodb://forest:secret@127.0.0.1:27017/movies?authSource=admin', ); - connection.dropDatabase(); + await connection.dropDatabase(); try { const movieSchema = new Schema({ title: String }); const Movie = connection.model('Movies', movieSchema); + await Movie.createCollection(); await new Movie({ title: 'Inception' }).save(); } finally { await connection.close(true); diff --git a/packages/datasource-mongo/test/index.ssh.integration.test.ts b/packages/datasource-mongo/test/index.ssh.integration.test.ts index 7bd7a5b0b9..44c3554f7f 100644 --- a/packages/datasource-mongo/test/index.ssh.integration.test.ts +++ b/packages/datasource-mongo/test/index.ssh.integration.test.ts @@ -12,11 +12,12 @@ describe('Datasource Mongo', () => { 'mongodb://forest:secret@127.0.0.1:27017/movies-ssh?authSource=admin', ); - connection.dropDatabase(); + await connection.dropDatabase(); try { const movieSchema = new Schema({ title: String }); const Movie = connection.model('Movies', movieSchema); + await Movie.createCollection(); await new Movie({ title: 'Inception' }).save(); } finally { await connection.close(true); diff --git a/packages/datasource-replica/CHANGELOG.md b/packages/datasource-replica/CHANGELOG.md index 80e6859d74..e2581f87bc 100644 --- a/packages/datasource-replica/CHANGELOG.md +++ b/packages/datasource-replica/CHANGELOG.md @@ -1,3 +1,13 @@ +## @forestadmin/datasource-replica [1.8.8](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.8.7...@forestadmin/datasource-replica@1.8.8) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + ## @forestadmin/datasource-replica [1.8.7](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.8.6...@forestadmin/datasource-replica@1.8.7) (2026-03-31) diff --git a/packages/datasource-replica/package.json b/packages/datasource-replica/package.json index 77fc59a32b..6ead15beb6 100644 --- a/packages/datasource-replica/package.json +++ b/packages/datasource-replica/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-replica", - "version": "1.8.7", + "version": "1.8.8", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -23,7 +23,7 @@ "test": "jest" }, "dependencies": { - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/datasource-customizer": "1.69.3", "@forestadmin/datasource-sequelize": "1.13.8", "@forestadmin/datasource-sql": "1.17.10", "@forestadmin/datasource-toolkit": "1.53.1", diff --git a/packages/forest-cloud/CHANGELOG.md b/packages/forest-cloud/CHANGELOG.md index 7ceb478334..c35242599f 100644 --- a/packages/forest-cloud/CHANGELOG.md +++ b/packages/forest-cloud/CHANGELOG.md @@ -1,3 +1,168 @@ +## @forestadmin/forest-cloud [1.12.119](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.118...@forestadmin/forest-cloud@1.12.119) (2026-04-30) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.8 + +## @forestadmin/forest-cloud [1.12.118](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.117...@forestadmin/forest-cloud@1.12.118) (2026-04-24) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.7 + +## @forestadmin/forest-cloud [1.12.117](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.116...@forestadmin/forest-cloud@1.12.117) (2026-04-22) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.6 + +## @forestadmin/forest-cloud [1.12.116](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.115...@forestadmin/forest-cloud@1.12.116) (2026-04-22) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.5 + +## @forestadmin/forest-cloud [1.12.115](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.114...@forestadmin/forest-cloud@1.12.115) (2026-04-21) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.4 + +## @forestadmin/forest-cloud [1.12.114](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.113...@forestadmin/forest-cloud@1.12.114) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.3 + +## @forestadmin/forest-cloud [1.12.113](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.112...@forestadmin/forest-cloud@1.12.113) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.2 + +## @forestadmin/forest-cloud [1.12.112](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.111...@forestadmin/forest-cloud@1.12.112) (2026-04-17) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.1 + +## @forestadmin/forest-cloud [1.12.111](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.110...@forestadmin/forest-cloud@1.12.111) (2026-04-17) + + +### Bug Fixes + +* trigger a release ([#1562](https://github.com/ForestAdmin/agent-nodejs/issues/1562)) ([993c815](https://github.com/ForestAdmin/agent-nodejs/commit/993c8156e74d1c6b60a54268c507c25768c00b87)) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.78.0 + +## @forestadmin/forest-cloud [1.12.110](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.109...@forestadmin/forest-cloud@1.12.110) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + +## @forestadmin/forest-cloud [1.12.109](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.108...@forestadmin/forest-cloud@1.12.109) (2026-04-14) + + + + + +### Dependencies + +* **@forestadmin/datasource-mongo:** upgraded to 1.6.9 + +## @forestadmin/forest-cloud [1.12.108](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.107...@forestadmin/forest-cloud@1.12.108) (2026-04-14) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.77.1 + +## @forestadmin/forest-cloud [1.12.107](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.106...@forestadmin/forest-cloud@1.12.107) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.76.6 + +## @forestadmin/forest-cloud [1.12.106](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.105...@forestadmin/forest-cloud@1.12.106) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.76.5 + +## @forestadmin/forest-cloud [1.12.105](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.104...@forestadmin/forest-cloud@1.12.105) (2026-04-10) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.76.4 + +## @forestadmin/forest-cloud [1.12.104](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.103...@forestadmin/forest-cloud@1.12.104) (2026-04-08) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.76.3 + ## @forestadmin/forest-cloud [1.12.103](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forest-cloud@1.12.102...@forestadmin/forest-cloud@1.12.103) (2026-04-01) diff --git a/packages/forest-cloud/package.json b/packages/forest-cloud/package.json index 0ef1f6797c..df49f3a923 100644 --- a/packages/forest-cloud/package.json +++ b/packages/forest-cloud/package.json @@ -1,11 +1,11 @@ { "name": "@forestadmin/forest-cloud", - "version": "1.12.103", + "version": "1.12.119", "description": "Utility to bootstrap and publish forest admin cloud projects customization", "dependencies": { - "@forestadmin/agent": "1.76.2", - "@forestadmin/datasource-customizer": "1.69.2", - "@forestadmin/datasource-mongo": "1.6.8", + "@forestadmin/agent": "1.78.8", + "@forestadmin/datasource-customizer": "1.69.3", + "@forestadmin/datasource-mongo": "1.6.9", "@forestadmin/datasource-mongoose": "1.13.4", "@forestadmin/datasource-sequelize": "1.13.8", "@forestadmin/datasource-sql": "1.17.10", @@ -14,7 +14,7 @@ "apollo-cache-inmemory": "^1.6.6", "apollo-client": "^2.6.10", "apollo-link-ws": "^1.0.20", - "axios": "^1.13.5", + "axios": "^1.15.0", "commander": "^11.1.0", "dotenv": "^16.4.1", "forest-cli": "5.3.9", diff --git a/packages/forestadmin-client/CHANGELOG.md b/packages/forestadmin-client/CHANGELOG.md index 276b9c9fbc..86466aa472 100644 --- a/packages/forestadmin-client/CHANGELOG.md +++ b/packages/forestadmin-client/CHANGELOG.md @@ -1,3 +1,68 @@ +## @forestadmin/forestadmin-client [1.39.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.39.4...@forestadmin/forestadmin-client@1.39.5) (2026-04-30) + + +### Bug Fixes + +* **activity-logs:** remove errorMessage from updateActivityLogStatus ([#1576](https://github.com/ForestAdmin/agent-nodejs/issues/1576)) ([19585fc](https://github.com/ForestAdmin/agent-nodejs/commit/19585fcda32250f46962d744ee3b258206a3e8aa)) + +## @forestadmin/forestadmin-client [1.39.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.39.3...@forestadmin/forestadmin-client@1.39.4) (2026-04-24) + + +### Bug Fixes + +* **forestadmin-client:** introduce HttpError on wrapped HTTP errors ([#1571](https://github.com/ForestAdmin/agent-nodejs/issues/1571)) ([f96a2ca](https://github.com/ForestAdmin/agent-nodejs/commit/f96a2caae21e1680d5edbc3bf31e4585bd36e0d5)) + +## @forestadmin/forestadmin-client [1.39.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.39.2...@forestadmin/forestadmin-client@1.39.3) (2026-04-22) + + +### Bug Fixes + +* **mcp server:** create activity logs from modelname ([#1561](https://github.com/ForestAdmin/agent-nodejs/issues/1561)) ([0942c42](https://github.com/ForestAdmin/agent-nodejs/commit/0942c42767aeb5739c92a88b4309f107e24b51c8)) + +## @forestadmin/forestadmin-client [1.39.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.39.1...@forestadmin/forestadmin-client@1.39.2) (2026-04-21) + + + + + +### Dependencies + +* **@forestadmin/ai-proxy:** upgraded to 1.8.0 + +## @forestadmin/forestadmin-client [1.39.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.39.0...@forestadmin/forestadmin-client@1.39.1) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/ai-proxy:** upgraded to 1.7.4 + +# @forestadmin/forestadmin-client [1.39.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.38.4...@forestadmin/forestadmin-client@1.39.0) (2026-04-17) + + +### Features + +* **mcp-server:** expose polymorphic relations in describeCollection ([#1551](https://github.com/ForestAdmin/agent-nodejs/issues/1551)) ([f73b417](https://github.com/ForestAdmin/agent-nodejs/commit/f73b41704c246ef86bd7b02299894bcd517f91b6)) + +## @forestadmin/forestadmin-client [1.38.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.38.3...@forestadmin/forestadmin-client@1.38.4) (2026-04-10) + + +### Bug Fixes + +* **mcp-server:** action execution on v1 agent ([#1542](https://github.com/ForestAdmin/agent-nodejs/issues/1542)) ([01b1a64](https://github.com/ForestAdmin/agent-nodejs/commit/01b1a64a7e0fbf8d5d47ebf0f9b1fc933c6709aa)) + +## @forestadmin/forestadmin-client [1.38.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.38.2...@forestadmin/forestadmin-client@1.38.3) (2026-04-08) + + + + + +### Dependencies + +* **@forestadmin/ai-proxy:** upgraded to 1.7.3 + ## @forestadmin/forestadmin-client [1.38.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.38.1...@forestadmin/forestadmin-client@1.38.2) (2026-04-01) diff --git a/packages/forestadmin-client/package.json b/packages/forestadmin-client/package.json index de7800f93b..5d048e1df2 100644 --- a/packages/forestadmin-client/package.json +++ b/packages/forestadmin-client/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/forestadmin-client", - "version": "1.38.2", + "version": "1.39.5", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -31,7 +31,7 @@ "test": "jest" }, "devDependencies": { - "@forestadmin/ai-proxy": "1.7.2", + "@forestadmin/ai-proxy": "1.8.0", "@forestadmin/datasource-toolkit": "1.53.1", "@types/json-api-serializer": "^2.6.3", "@types/jsonwebtoken": "^9.0.1", diff --git a/packages/forestadmin-client/src/activity-logs/index.ts b/packages/forestadmin-client/src/activity-logs/index.ts index d921a3206f..78ffbbdb71 100644 --- a/packages/forestadmin-client/src/activity-logs/index.ts +++ b/packages/forestadmin-client/src/activity-logs/index.ts @@ -66,14 +66,39 @@ export default class ActivityLogsService { ); } - async updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise { - const { forestServerToken, activityLog, status, errorMessage } = params; + async createMcpActivityLog(params: CreateActivityLogParams): Promise { + const { + forestServerToken, + renderingId, + action, + type, + collectionName, + recordId, + recordIds, + label, + } = params; const body = { - status, - ...(errorMessage && { errorMessage }), + type, + action, + label, + status: 'pending', + records: (recordIds || (recordId ? [recordId] : [])).map(String), + renderingId, + collectionModelName: collectionName, }; + return this.forestAdminServerInterface.createMcpActivityLog( + this.getHttpOptions(forestServerToken), + body, + ); + } + + async updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise { + const { forestServerToken, activityLog, status } = params; + + const body = { status }; + await this.forestAdminServerInterface.updateActivityLogStatus( this.getHttpOptions(forestServerToken), activityLog.attributes.index, diff --git a/packages/forestadmin-client/src/auth/errors.ts b/packages/forestadmin-client/src/auth/errors.ts index 35c6578277..74ecb0d5b0 100644 --- a/packages/forestadmin-client/src/auth/errors.ts +++ b/packages/forestadmin-client/src/auth/errors.ts @@ -1,6 +1,6 @@ -/* eslint-disable max-classes-per-file */ import type { errors } from 'openid-client'; +// eslint-disable-next-line import/prefer-default-export export class AuthenticationError extends Error { public readonly description: string; public readonly state: string; @@ -15,7 +15,3 @@ export class AuthenticationError extends Error { this.stack = e.stack; } } - -export class ForbiddenError extends Error {} - -export class NotFoundError extends Error {} diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 1d50616c52..257f30330b 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -96,3 +96,4 @@ export { default as SchemaService, SchemaServiceOptions } from './schema'; export { default as ActivityLogsService, ActivityLogsOptions } from './activity-logs'; export * from './auth/errors'; +export * from './utils/errors'; diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 5bb9f34a6b..708c1ce608 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -110,6 +110,24 @@ export default class ForestHttpApi implements ForestAdminServerInterface { return activityLog; } + async createMcpActivityLog( + options: ActivityLogHttpOptions, + body: object, + ): Promise { + const { data: activityLog } = await ServerUtils.queryWithBearerToken<{ + data: ActivityLogResponse; + }>({ + forestServerUrl: options.forestServerUrl, + method: 'post', + path: '/api/activity-logs-requests/mcp', + bearerToken: options.bearerToken, + body, + headers: options.headers, + }); + + return activityLog; + } + async updateActivityLogStatus( options: ActivityLogHttpOptions, index: string, diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 41c9cdedee..6259b4e8a2 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -178,6 +178,7 @@ export interface ForestSchemaField { defaultValue?: unknown; isPrimaryKey: boolean; relationship?: 'HasMany' | 'BelongsToMany' | 'BelongsTo' | 'HasOne' | null; + polymorphicReferencedModels?: string[]; } /** @@ -191,7 +192,13 @@ export interface ForestSchemaAction { description?: string; submitButtonLabel?: string; download: boolean; - fields: { field: string }[]; + fields: { + field: string; + type: string; + isRequired?: boolean; + defaultValue?: unknown; + label?: string; + }[]; hooks: { load: boolean; change: unknown[]; @@ -248,7 +255,6 @@ export interface UpdateActivityLogStatusParams { forestServerToken: string; activityLog: ActivityLogResponse; status: 'completed' | 'failed'; - errorMessage?: string; } /** @@ -256,6 +262,7 @@ export interface UpdateActivityLogStatusParams { */ export interface ActivityLogsServiceInterface { createActivityLog: (params: CreateActivityLogParams) => Promise; + createMcpActivityLog: (params: CreateActivityLogParams) => Promise; updateActivityLogStatus: (params: UpdateActivityLogStatusParams) => Promise; } @@ -287,6 +294,10 @@ export interface ForestAdminServerInterface { options: ActivityLogHttpOptions, body: object, ) => Promise; + createMcpActivityLog?: ( + options: ActivityLogHttpOptions, + body: object, + ) => Promise; updateActivityLogStatus?: ( options: ActivityLogHttpOptions, index: string, diff --git a/packages/forestadmin-client/src/utils/errors.ts b/packages/forestadmin-client/src/utils/errors.ts new file mode 100644 index 0000000000..bc5dcf66cd --- /dev/null +++ b/packages/forestadmin-client/src/utils/errors.ts @@ -0,0 +1,24 @@ +/* eslint-disable max-classes-per-file */ +export class HttpError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'HttpError'; + this.status = status; + } +} + +export class ForbiddenError extends HttpError { + constructor(message?: string) { + super(message ?? 'Forbidden', 403); + this.name = 'ForbiddenError'; + } +} + +export class NotFoundError extends HttpError { + constructor(message?: string) { + super(message ?? 'Not found', 404); + this.name = 'NotFoundError'; + } +} diff --git a/packages/forestadmin-client/src/utils/server.ts b/packages/forestadmin-client/src/utils/server.ts index a5dfd62cf5..cfdce3b20c 100644 --- a/packages/forestadmin-client/src/utils/server.ts +++ b/packages/forestadmin-client/src/utils/server.ts @@ -3,7 +3,7 @@ import type { ResponseError } from 'superagent'; import superagent from 'superagent'; -import { ForbiddenError, NotFoundError } from '..'; +import { ForbiddenError, HttpError, NotFoundError } from './errors'; type HttpOptions = Pick; @@ -83,10 +83,11 @@ export default class ServerUtils { return response.body; } catch (error) { if (error.timeout) { - throw new Error( + throw new HttpError( `The request to Forest Admin server has timed out while trying to reach ${url} at ${new Date().toISOString()}. Message: ${ error.message }`, + 408, ); } @@ -105,12 +106,13 @@ export default class ServerUtils { } if ((e as ResponseError).response) { - const status = (e as ResponseError)?.response?.status; + // Fall back to 0 if the response exists but has no status — treated as "offline". + const status = (e as ResponseError).response?.status ?? 0; const message = (e as ResponseError)?.response?.body?.errors?.[0]?.detail; // 0 == offline, 502 == bad gateway from proxy if (status === 0 || status === 502) { - throw new Error('Failed to reach Forest Admin server. Are you online?'); + throw new HttpError('Failed to reach Forest Admin server. Are you online?', status); } if (status === 403) { @@ -125,18 +127,20 @@ export default class ServerUtils { } if (status === 503) { - throw new Error( + throw new HttpError( 'Forest is in maintenance for a few minutes. We are upgrading your experience in ' + 'the forest. We just need a few more minutes to get it right.', + 503, ); } // If the server has something to say about the error, we display it. - if (message) throw new Error(message); + if (message) throw new HttpError(message, status); - throw new Error( + throw new HttpError( 'An unexpected error occurred while contacting the Forest Admin server. ' + 'Please contact support@forestadmin.com for further investigations.', + status, ); } diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index a74529fe93..d89fb5817e 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -16,6 +16,7 @@ const forestAdminServerInterface = { getIpWhitelistRules: jest.fn(), // Activity logs operations createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/activity-logs/index.test.ts b/packages/forestadmin-client/test/activity-logs/index.test.ts index 912f47bcf6..a4a84bda78 100644 --- a/packages/forestadmin-client/test/activity-logs/index.test.ts +++ b/packages/forestadmin-client/test/activity-logs/index.test.ts @@ -157,6 +157,119 @@ describe('ActivityLogsService', () => { }); }); + describe('createMcpActivityLog', () => { + it('should create an MCP activity log with flat body and collectionModelName', async () => { + const mockActivityLog = { + id: 'log-123', + attributes: { index: 'idx-456' }, + }; + mockForestAdminServerInterface.createMcpActivityLog.mockResolvedValue(mockActivityLog); + + const service = new ActivityLogsService(mockForestAdminServerInterface, options); + const result = await service.createMcpActivityLog({ + forestServerToken: 'test-token', + renderingId: '12345', + action: 'index', + type: 'read', + collectionName: 'users', + recordId: '42', + label: 'Custom Label', + }); + + expect(result).toEqual(mockActivityLog); + expect(mockForestAdminServerInterface.createMcpActivityLog).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + { + type: 'read', + action: 'index', + label: 'Custom Label', + status: 'pending', + records: ['42'], + renderingId: '12345', + collectionModelName: 'users', + }, + ); + }); + + it('should map recordIds array to records', async () => { + const mockActivityLog = { + id: 'log-123', + attributes: { index: 'idx-456' }, + }; + mockForestAdminServerInterface.createMcpActivityLog.mockResolvedValue(mockActivityLog); + + const service = new ActivityLogsService(mockForestAdminServerInterface, options); + await service.createMcpActivityLog({ + forestServerToken: 'test-token', + renderingId: '12345', + action: 'delete', + type: 'write', + collectionName: 'users', + recordIds: ['1', '2', '3'], + }); + + expect(mockForestAdminServerInterface.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ bearerToken: 'test-token' }), + expect.objectContaining({ + records: ['1', '2', '3'], + collectionModelName: 'users', + }), + ); + }); + + it('should send undefined collectionModelName when collection is omitted', async () => { + const mockActivityLog = { + id: 'log-123', + attributes: { index: 'idx-456' }, + }; + mockForestAdminServerInterface.createMcpActivityLog.mockResolvedValue(mockActivityLog); + + const service = new ActivityLogsService(mockForestAdminServerInterface, options); + await service.createMcpActivityLog({ + forestServerToken: 'test-token', + renderingId: '12345', + action: 'search', + type: 'read', + }); + + expect(mockForestAdminServerInterface.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ bearerToken: 'test-token' }), + expect.objectContaining({ + records: [], + collectionModelName: undefined, + }), + ); + }); + + it('should pass custom headers when provided', async () => { + const mockActivityLog = { + id: 'log-123', + attributes: { index: 'idx-456' }, + }; + mockForestAdminServerInterface.createMcpActivityLog.mockResolvedValue(mockActivityLog); + + const optionsWithHeaders = { + ...options, + headers: { 'Custom-Header': 'value' }, + }; + const service = new ActivityLogsService(mockForestAdminServerInterface, optionsWithHeaders); + await service.createMcpActivityLog({ + forestServerToken: 'test-token', + renderingId: '12345', + action: 'search', + type: 'read', + }); + + expect(mockForestAdminServerInterface.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Custom-Header': 'value' }, + }), + expect.anything(), + ); + }); + }); + describe('updateActivityLogStatus', () => { it('should update activity log status to completed', async () => { mockForestAdminServerInterface.updateActivityLogStatus.mockResolvedValue(undefined); @@ -178,7 +291,7 @@ describe('ActivityLogsService', () => { ); }); - it('should update activity log status to failed with error message', async () => { + it('should update activity log status to failed', async () => { mockForestAdminServerInterface.updateActivityLogStatus.mockResolvedValue(undefined); const service = new ActivityLogsService(mockForestAdminServerInterface, options); @@ -188,14 +301,13 @@ describe('ActivityLogsService', () => { forestServerToken: 'test-token', activityLog, status: 'failed', - errorMessage: 'Something went wrong', }); expect(mockForestAdminServerInterface.updateActivityLogStatus).toHaveBeenCalledWith( { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, 'idx-456', 'log-123', - { status: 'failed', errorMessage: 'Something went wrong' }, + { status: 'failed' }, ); }); diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 9dcccc94eb..ee01302311 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -148,6 +148,38 @@ describe('ForestHttpApi', () => { }); }); + describe('createMcpActivityLog', () => { + it('should call the mcp endpoint with body', async () => { + const mockActivityLog = { id: 'log-123', attributes: { index: 'idx-456' } }; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ data: mockActivityLog }); + + const body = { + type: 'read', + action: 'index', + status: 'pending', + records: [], + renderingId: '12345', + collectionModelName: 'users', + }; + const activityLogOptions = { + forestServerUrl: options.forestServerUrl, + bearerToken: 'bearer-token', + headers: { 'Custom-Header': 'value' }, + }; + const result = await new ForestHttpApi().createMcpActivityLog(activityLogOptions, body); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'post', + path: '/api/activity-logs-requests/mcp', + bearerToken: 'bearer-token', + body, + headers: { 'Custom-Header': 'value' }, + }); + expect(result).toEqual(mockActivityLog); + }); + }); + describe('updateActivityLogStatus', () => { it('should call the right endpoint with status', async () => { const body = { status: 'completed' }; diff --git a/packages/forestadmin-client/test/utils/server.test.ts b/packages/forestadmin-client/test/utils/server.test.ts index c27486f0c6..7a5f48164f 100644 --- a/packages/forestadmin-client/test/utils/server.test.ts +++ b/packages/forestadmin-client/test/utils/server.test.ts @@ -1,6 +1,6 @@ import nock from 'nock'; -import { ForbiddenError } from '../../src'; +import { ForbiddenError, HttpError, NotFoundError } from '../../src'; import ServerUtils from '../../src/utils/server'; const options = { envSecret: '123', forestServerUrl: 'http://forestadmin-server.com' }; @@ -149,6 +149,97 @@ describe('ServerUtils', () => { }); }); + describe('error metadata', () => { + it('should throw HttpError with status=408 on timeout', async () => { + nock(options.forestServerUrl) + .get('/endpoint') + .reply(() => { + return new Promise(resolve => { + setTimeout(resolve, 50); + }); + }); + + await expect( + ServerUtils.query(options, 'get', '/endpoint', {}, undefined, 10), + ).rejects.toMatchObject({ status: 408 }); + }); + + it('should attach .status=503 when the server is in maintenance', async () => { + nock(options.forestServerUrl).get('/endpoint').reply(503, { error: 'maintenance' }); + + await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toMatchObject({ + status: 503, + }); + }); + + it('should attach .status=502 when the upstream proxy fails', async () => { + nock(options.forestServerUrl).get('/endpoint').reply(502, { error: 'bad gateway' }); + + await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toMatchObject({ + status: 502, + }); + }); + + it('should attach .status on generic non-specific HTTP errors', async () => { + nock(options.forestServerUrl).get('/endpoint').reply(500, { error: 'boom' }); + + await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toMatchObject({ + status: 500, + }); + }); + + it('should attach .status on errors that forward the server message', async () => { + nock(options.forestServerUrl) + .get('/endpoint') + .reply(429, { errors: [{ detail: 'rate limited' }] }); + + await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toMatchObject({ + message: 'rate limited', + status: 429, + }); + }); + + it('should attach .status=403 on ForbiddenError', async () => { + nock(options.forestServerUrl) + .get('/endpoint') + .reply(403, { errors: [{ detail: 'nope' }] }); + + await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toMatchObject({ + status: 403, + message: 'nope', + }); + }); + + it('should attach .status=404 on NotFoundError', async () => { + nock(options.forestServerUrl) + .get('/endpoint') + .reply(404, { errors: [{ detail: 'missing' }] }); + + await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toMatchObject({ + status: 404, + message: 'missing', + }); + }); + }); + + describe('error class hierarchy', () => { + it('ForbiddenError extends HttpError and carries status=403 by default', () => { + const err = new ForbiddenError(); + + expect(err).toBeInstanceOf(HttpError); + expect(err).toBeInstanceOf(ForbiddenError); + expect(err.status).toBe(403); + }); + + it('NotFoundError extends HttpError and carries status=404 by default', () => { + const err = new NotFoundError(); + + expect(err).toBeInstanceOf(HttpError); + expect(err).toBeInstanceOf(NotFoundError); + expect(err.status).toBe(404); + }); + }); + describe('queryWithBearerToken', () => { it('should make a request with Bearer token authorization', async () => { nock(options.forestServerUrl, { diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 569a5d77ae..5bf6a49b41 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -1,3 +1,182 @@ +## @forestadmin/mcp-server [1.11.7](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.6...@forestadmin/mcp-server@1.11.7) (2026-04-30) + + +### Bug Fixes + +* **activity-logs:** remove errorMessage from updateActivityLogStatus ([#1576](https://github.com/ForestAdmin/agent-nodejs/issues/1576)) ([19585fc](https://github.com/ForestAdmin/agent-nodejs/commit/19585fcda32250f46962d744ee3b258206a3e8aa)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.6 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.5 + +## @forestadmin/mcp-server [1.11.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.5...@forestadmin/mcp-server@1.11.6) (2026-04-24) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.5 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.4 + +## @forestadmin/mcp-server [1.11.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.4...@forestadmin/mcp-server@1.11.5) (2026-04-22) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.4 + +## @forestadmin/mcp-server [1.11.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.3...@forestadmin/mcp-server@1.11.4) (2026-04-22) + + +### Bug Fixes + +* **mcp server:** create activity logs from modelname ([#1561](https://github.com/ForestAdmin/agent-nodejs/issues/1561)) ([0942c42](https://github.com/ForestAdmin/agent-nodejs/commit/0942c42767aeb5739c92a88b4309f107e24b51c8)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.3 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.3 + +## @forestadmin/mcp-server [1.11.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.2...@forestadmin/mcp-server@1.11.3) (2026-04-21) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.2 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.2 + +## @forestadmin/mcp-server [1.11.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.1...@forestadmin/mcp-server@1.11.2) (2026-04-20) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.1 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.1 + +## @forestadmin/mcp-server [1.11.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.11.0...@forestadmin/mcp-server@1.11.1) (2026-04-20) + + +### Bug Fixes + +* **mcp-server:** add resource_metadata to WWW-Authenticate header ([#1558](https://github.com/ForestAdmin/agent-nodejs/issues/1558)) ([c795d67](https://github.com/ForestAdmin/agent-nodejs/commit/c795d6742841ce207321491a84e925ca2e64cd72)) + +# @forestadmin/mcp-server [1.11.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.10.0...@forestadmin/mcp-server@1.11.0) (2026-04-17) + + +### Features + +* **mcp-server:** expose polymorphic relations in describeCollection ([#1551](https://github.com/ForestAdmin/agent-nodejs/issues/1551)) ([f73b417](https://github.com/ForestAdmin/agent-nodejs/commit/f73b41704c246ef86bd7b02299894bcd517f91b6)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.5.0 +* **@forestadmin/forestadmin-client:** upgraded to 1.39.0 + +# @forestadmin/mcp-server [1.10.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.9.1...@forestadmin/mcp-server@1.10.0) (2026-04-17) + + +### Features + +* **mcp-server:** add enabledTools allowlist option ([#1547](https://github.com/ForestAdmin/agent-nodejs/issues/1547)) ([22df2ec](https://github.com/ForestAdmin/agent-nodejs/commit/22df2ecd2c0e370f0ff9740289aa252d877b20a2)) + +## @forestadmin/mcp-server [1.9.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.9.0...@forestadmin/mcp-server@1.9.1) (2026-04-14) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.23 + +# @forestadmin/mcp-server [1.9.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.8.17...@forestadmin/mcp-server@1.9.0) (2026-04-13) + + +### Features + +* **mcp-server:** add disabledTools option to ForestMCPServerOptions ([#1543](https://github.com/ForestAdmin/agent-nodejs/issues/1543)) ([d36ad10](https://github.com/ForestAdmin/agent-nodejs/commit/d36ad10bfb18a5972174a9acb3d7821691868494)) + +## @forestadmin/mcp-server [1.8.17](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.8.16...@forestadmin/mcp-server@1.8.17) (2026-04-10) + + +### Bug Fixes + +* **agent-client:** convert filter operators to snake_case for Ruby compatibility ([#1544](https://github.com/ForestAdmin/agent-nodejs/issues/1544)) ([00e8ad5](https://github.com/ForestAdmin/agent-nodejs/commit/00e8ad54c59d7f141c54a756f7a5921bb47f66dc)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.22 + +## @forestadmin/mcp-server [1.8.16](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.8.15...@forestadmin/mcp-server@1.8.16) (2026-04-10) + + +### Bug Fixes + +* **mcp-server:** action execution on v1 agent ([#1542](https://github.com/ForestAdmin/agent-nodejs/issues/1542)) ([01b1a64](https://github.com/ForestAdmin/agent-nodejs/commit/01b1a64a7e0fbf8d5d47ebf0f9b1fc933c6709aa)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.21 +* **@forestadmin/forestadmin-client:** upgraded to 1.38.4 + +## @forestadmin/mcp-server [1.8.15](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.8.14...@forestadmin/mcp-server@1.8.15) (2026-04-10) + + +### Bug Fixes + +* **mcp-server:** add snake_case JWT claims for Ruby backend compatibility ([#1538](https://github.com/ForestAdmin/agent-nodejs/issues/1538)) ([b3b7dcd](https://github.com/ForestAdmin/agent-nodejs/commit/b3b7dcd5605867447eb2a996c4db35ba98402e62)) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.20 + +## @forestadmin/mcp-server [1.8.14](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.8.13...@forestadmin/mcp-server@1.8.14) (2026-04-08) + + + + + +### Dependencies + +* **@forestadmin/agent-client:** upgraded to 1.4.19 +* **@forestadmin/forestadmin-client:** upgraded to 1.38.3 + ## @forestadmin/mcp-server [1.8.13](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.8.12...@forestadmin/mcp-server@1.8.13) (2026-04-01) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 7776185468..d092e9d0a0 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -6,6 +6,21 @@ Model Context Protocol (MCP) server for Forest Admin with OAuth authentication s This MCP server provides HTTP REST API access to Forest Admin operations, enabling AI assistants and other MCP clients to interact with your Forest Admin data through a standardized protocol. +### Available Tools + +| Tool | Description | +|------|-------------| +| `describeCollection` | Get the schema of a collection (fields, types, relations) | +| `list` | Retrieve records from a collection | +| `listRelated` | Retrieve related records | +| `create` | Create a new record | +| `update` | Update an existing record | +| `delete` | Delete records | +| `associate` | Associate records in a relation | +| `dissociate` | Dissociate records from a relation | +| `getActionForm` | Get the form fields for a custom action | +| `executeAction` | Execute a custom action | + ## Usage ### With Forest Admin Agent @@ -27,50 +42,89 @@ The MCP server will be automatically initialized and mounted on your application ### Standalone Server -You can also run the MCP server standalone using the CLI: +You can run the MCP server standalone using the CLI: ```bash npx forest-mcp-server ``` -Or programmatically: +Or from the package directory: ```bash -node dist/index.js +yarn start # Production +yarn start:dev # Development (loads .env file automatically) ``` #### Environment Variables -The following environment variables are required to run the server as a standalone: - | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `FOREST_ENV_SECRET` | **Yes** | - | Your Forest Admin environment secret | | `FOREST_AUTH_SECRET` | **Yes** | - | Your Forest Admin authentication secret (must match your agent) | | `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server | +| `FOREST_MCP_ENABLED_TOOLS` | No | - | Comma-separated list of tools to enable (allowlist) | #### Example Configuration +Create a `.env` file in the package directory: + +```bash +FOREST_ENV_SECRET="your-env-secret" +FOREST_AUTH_SECRET="your-auth-secret" +``` + +Then run: + +```bash +yarn start:dev +``` + +Or set the variables inline: + ```bash -export FOREST_ENV_SECRET="your-env-secret" -export FOREST_AUTH_SECRET="your-auth-secret" -export MCP_SERVER_PORT=3931 +FOREST_ENV_SECRET="your-env-secret" FOREST_AUTH_SECRET="your-auth-secret" npx forest-mcp-server +``` +## Restrict Tools + +You can restrict which tools the MCP server exposes using `enabledTools`. Only the listed tools will be available. **New tools added in future releases will NOT be automatically enabled** — you must explicitly add them. + +For example, to set up a **read-only mode** where the AI assistant can only browse data (no create, update, delete or action execution): + +```typescript +// With Forest Admin Agent — read-only example +agent.mountAiMcpServer({ + enabledTools: ['describeCollection', 'list', 'listRelated'], +}); +``` + +```bash +# Standalone +export FOREST_MCP_ENABLED_TOOLS="describeCollection,list,listRelated" npx forest-mcp-server ``` -## API Endpoint +When `enabledTools` is not set, all tools are enabled by default. + +See [Available Tools](#available-tools) for the full list. `describeCollection` is always enabled as it is required for the MCP server to function properly. + +## API Endpoints -Once running, the MCP server exposes a single endpoint: +Once running, the MCP server exposes the following endpoints: -- **POST** `/mcp` - Main MCP protocol endpoint +| Method | Path | Description | +|--------|------|-------------| +| POST | `/mcp` | Main MCP protocol endpoint (requires Bearer token) | +| POST | `/oauth/authorize` | OAuth 2.0 authorization | +| POST | `/oauth/token` | OAuth 2.0 token exchange | +| GET | `/.well-known/oauth-protected-resource/mcp` | OAuth metadata discovery | -The server expects MCP protocol messages in the request body and returns MCP-formatted responses. +The `/mcp` endpoint expects MCP protocol messages (JSON-RPC 2.0) and requires a valid OAuth Bearer token with at least the `mcp:read` scope. ## Features - **HTTP Transport**: Uses streamable HTTP transport for MCP communication -- **OAuth Authentication**: Built-in support for Forest Admin OAuth +- **OAuth Authentication**: Built-in OAuth 2.0 with scopes (`mcp:read`, `mcp:write`, `mcp:action`, `mcp:admin`) - **CORS Enabled**: Allows cross-origin requests - **Express-based**: Built on top of Express.js for reliability and extensibility @@ -79,33 +133,42 @@ The server expects MCP protocol messages in the request body and returns MCP-for ### Building ```bash -npm run build +yarn build ``` ### Watch Mode ```bash -npm run build:watch +yarn build:watch ``` ### Linting ```bash -npm run lint +yarn lint ``` ### Testing ```bash -npm test +yarn test ``` ### Cleaning ```bash -npm run clean +yarn clean ``` +### Internal Environment Variables + +These are only needed by Forest Admin developers (e.g. to point to a local or staging server): + +| Variable | Default | Description | +|----------|---------|-------------| +| `FOREST_SERVER_URL` | `https://api.forestadmin.com` | Forest Admin API URL | +| `FOREST_APP_URL` | `https://app.forestadmin.com` | Forest Admin application URL | + ## Architecture The server consists of: diff --git a/packages/mcp-server/jest.config.ts b/packages/mcp-server/jest.config.ts index 22aa92d742..fb12372f1f 100644 --- a/packages/mcp-server/jest.config.ts +++ b/packages/mcp-server/jest.config.ts @@ -6,6 +6,7 @@ export default { collectCoverageFrom: [ '/src/**/*.ts', '!/src/version.ts', // Mocked due to import.meta.url issues in Jest + '!/src/cli.ts', // CLI entrypoint with side effects, not unit-testable '!/src/__mocks__/**', ], testMatch: ['/test/**/*.test.ts'], diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index a525ad4bd7..0bf50a56f9 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/mcp-server", - "version": "1.8.13", + "version": "1.11.7", "description": "Model Context Protocol server for Forest Admin with OAuth authentication", "main": "dist/index.js", "bin": { @@ -16,8 +16,8 @@ "directory": "packages/mcp-server" }, "dependencies": { - "@forestadmin/agent-client": "1.4.18", - "@forestadmin/forestadmin-client": "1.38.2", + "@forestadmin/agent-client": "1.5.6", + "@forestadmin/forestadmin-client": "1.39.5", "@modelcontextprotocol/sdk": "^1.28.0", "cors": "^2.8.5", "express": "^5.2.1", diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 75fce46c71..1762be25ec 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import ForestMCPServer from './server'; +import parseToolList from './utils/parse-tool-list'; // Start the server when run directly as CLI const server = new ForestMCPServer({ @@ -8,6 +9,7 @@ const server = new ForestMCPServer({ forestAppUrl: process.env.FOREST_APP_URL || 'https://app.forestadmin.com', envSecret: process.env.FOREST_ENV_SECRET, authSecret: process.env.FOREST_AUTH_SECRET, + enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS), }); server.run().catch(error => { diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index a4043a4372..1349889a94 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -345,7 +345,14 @@ export default class ForestOAuthProvider implements OAuthServerProvider { const expiresIn = expirationDate - Math.floor(Date.now() / 1000); const tokenScopes = scope ? scope.split(' ') : ['mcp:read', 'mcp:write', 'mcp:action']; const accessToken = jsonwebtoken.sign( - { ...user, serverToken: forestServerAccessToken, scopes: tokenScopes }, + { + ...user, + ...ForestOAuthProvider.toSnakeCaseKeys(user), + rendering_id: String(renderingId), + tags: user.tags ? Object.entries(user.tags).map(([key, value]) => ({ key, value })) : [], + serverToken: forestServerAccessToken, + scopes: tokenScopes, + }, this.authSecret, { expiresIn }, ); @@ -432,6 +439,17 @@ export default class ForestOAuthProvider implements OAuthServerProvider { void request; } + private static toSnakeCaseKeys(obj: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); + result[snakeKey] = value; + } + + return result; + } + // Skip PKCE validation to match original implementation skipLocalPkceValidation = true; } diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index d6ad55a4ef..45a94b2cb6 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -26,6 +26,10 @@ export default class ForestServerClientImpl implements ForestServerClient { return this.activityLogsService.createActivityLog(params); } + async createMcpActivityLog(params: CreateActivityLogParams): Promise { + return this.activityLogsService.createMcpActivityLog(params); + } + async updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise { return this.activityLogsService.updateActivityLogStatus(params); } diff --git a/packages/mcp-server/src/http-client/types.d.ts b/packages/mcp-server/src/http-client/types.d.ts index 8da3d343bd..572db67844 100644 --- a/packages/mcp-server/src/http-client/types.d.ts +++ b/packages/mcp-server/src/http-client/types.d.ts @@ -40,6 +40,11 @@ export interface ForestServerClient { */ createActivityLog(params: CreateActivityLogParams): Promise; + /** + * Creates a pending activity log using the MCP-dedicated route. + */ + createMcpActivityLog(params: CreateActivityLogParams): Promise; + /** * Updates an activity log status. */ diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 2b25c58b38..7165f683cb 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,6 +1,6 @@ // Library exports only - no side effects export { default as ForestMCPServer } from './server'; -export type { ForestMCPServerOptions, HttpCallback } from './server'; +export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server'; export { MCP_PATHS, isMcpRoute } from './mcp-paths'; export { ForestServerClientImpl, createForestServerClient } from './http-client'; export type { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 1cb02fd303..e1c1331fed 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -87,6 +87,18 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], }; +export type ToolName = + | 'describeCollection' + | 'list' + | 'listRelated' + | 'create' + | 'update' + | 'delete' + | 'associate' + | 'dissociate' + | 'getActionForm' + | 'executeAction'; + /** * Options for configuring the Forest Admin MCP Server */ @@ -103,6 +115,8 @@ export interface ForestMCPServerOptions { logger?: Logger; /** Optional Forest server client for dependency injection (from agent integration) */ forestServerClient?: ForestServerClient; + /** List of tool names to enable (allowlist). Only these tools will be exposed. New tools in future releases will NOT be auto-enabled. */ + enabledTools?: ToolName[]; } /** @@ -123,6 +137,7 @@ export default class ForestMCPServer { private authSecret?: string; private logger: Logger; private collectionNames: string[] = []; + private enabledTools: Set; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -130,6 +145,7 @@ export default class ForestMCPServer { this.envSecret = options?.envSecret; this.authSecret = options?.authSecret; this.logger = options?.logger || defaultLogger; + this.enabledTools = this.resolveEnabledTools(options); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -161,39 +177,165 @@ export default class ForestMCPServer { icons: [{ src: LOGO_URL, mimeType: 'image/png' }], }); - const toolNames = [ - declareDescribeCollectionTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - declareListTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareListRelatedTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareCreateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareUpdateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareDeleteTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareAssociateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareDissociateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - declareGetActionFormTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - declareExecuteActionTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), + const allTools: Array<{ name: ToolName; register: () => string }> = [ + { + name: 'describeCollection', + register: () => + declareDescribeCollectionTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, + { + name: 'list', + register: () => + declareListTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), + }, + { + name: 'listRelated', + register: () => + declareListRelatedTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, + { + name: 'create', + register: () => + declareCreateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), + }, + { + name: 'update', + register: () => + declareUpdateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), + }, + { + name: 'delete', + register: () => + declareDeleteTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), + }, + { + name: 'associate', + register: () => + declareAssociateTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, + { + name: 'dissociate', + register: () => + declareDissociateTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, + { + name: 'getActionForm', + register: () => + declareGetActionFormTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, + { + name: 'executeAction', + register: () => + declareExecuteActionTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, ]; - this.logger('Debug', `Registered ${toolNames.length} tools: ${toolNames.join(', ')}`); + const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); + const disabledToolNames = allTools + .filter(tool => !this.enabledTools.has(tool.name)) + .map(tool => tool.name); + + const toolNames = enabledToolEntries.map(tool => tool.register()); + + this.logger( + 'Info', + `Tools enabled: ${toolNames.join(', ')} (${toolNames.length}/${allTools.length})`, + ); + + if (disabledToolNames.length > 0) { + const total = allTools.length; + this.logger( + 'Info', + `Tools disabled: ${disabledToolNames.join(', ')} (${disabledToolNames.length}/${total})`, + ); + } return mcpServer; } + private resolveEnabledTools(options?: ForestMCPServerOptions): Set { + const allToolNames: ToolName[] = [ + 'describeCollection', + 'list', + 'listRelated', + 'create', + 'update', + 'delete', + 'associate', + 'dissociate', + 'getActionForm', + 'executeAction', + ]; + + const enabled = new Set(options?.enabledTools ?? allToolNames); + + if (options?.enabledTools) { + const allToolNamesSet = new Set(allToolNames); + const unknownTools = options.enabledTools.filter(name => !allToolNamesSet.has(name)); + + if (unknownTools.length > 0) { + this.logger( + 'Warn', + `Unknown tool names in enabledTools: ${unknownTools.join(', ')}. These will be ignored.`, + ); + } + + if (!options.enabledTools.includes('describeCollection')) { + this.logger( + 'Warn', + 'describeCollection was automatically enabled — it is required for the MCP server to function properly.', + ); + } + + const notEnabled = allToolNames.filter( + name => name !== 'describeCollection' && !enabled.has(name), + ); + + if (notEnabled.length > 0) { + const toolList = notEnabled.join(', '); + this.logger( + 'Info', + `Available tools not enabled: ${toolList}. Add them to enabledTools to use them.`, + ); + } + } + + // describeCollection is always required + enabled.add('describeCollection'); + + return enabled; + } + private ensureSecretsAreSet(): { envSecret: string; authSecret: string } { if (!this.envSecret) { throw new Error( @@ -405,6 +547,8 @@ export default class ForestMCPServer { requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['mcp:read'], + resourceMetadataUrl: new URL('/.well-known/oauth-protected-resource/mcp', effectiveBaseUrl) + .href, }), (req, res) => { this.handleMcpRequest(req, res).catch(error => { diff --git a/packages/mcp-server/src/tools/create.ts b/packages/mcp-server/src/tools/create.ts index a6510f19ca..f9bd31de55 100644 --- a/packages/mcp-server/src/tools/create.ts +++ b/packages/mcp-server/src/tools/create.ts @@ -57,7 +57,7 @@ export default function declareCreateTool( forestServerClient, request: extra, action: 'create', - context: { collectionName: options.collectionName }, + context: { collectionName: options.collectionName, label: 'created' }, logger, operation: async () => { const record = await rpcClient diff --git a/packages/mcp-server/src/tools/delete.ts b/packages/mcp-server/src/tools/delete.ts index da5036ca8d..522944a719 100644 --- a/packages/mcp-server/src/tools/delete.ts +++ b/packages/mcp-server/src/tools/delete.ts @@ -51,6 +51,7 @@ export default function declareDeleteTool( action: 'delete', context: { collectionName: options.collectionName, + label: 'deleted', recordIds, }, logger, diff --git a/packages/mcp-server/src/tools/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts index 29ba67272b..e3ea582298 100644 --- a/packages/mcp-server/src/tools/describe-collection.ts +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -98,6 +98,8 @@ Actions properties: - hasForm: true if action requires form input (use getActionForm to see fields) - download: true if action returns a file download (not executable via AI) +Polymorphic relations (isPolymorphic=true) point to multiple collections. When creating/updating, you must set both the _id and _type fields (e.g. commentable_id and commentable_type). + Check \`_meta\` for data availability context.`, inputSchema: argumentShape, }, @@ -152,21 +154,34 @@ Check \`_meta\` for data availability context.`, // Extract relations from schema const relations = schemaFields .filter(f => f.relationship) - .map(f => ({ - name: f.field, - type: mapRelationType(f.relationship), - targetCollection: f.reference?.split('.')[0] || null, - })); + .map(f => { + const polymorphicTargets = f.polymorphicReferencedModels; + const isPolymorphic = + Array.isArray(polymorphicTargets) && polymorphicTargets.length > 0; + + return { + name: f.field, + type: mapRelationType(f.relationship), + targetCollection: isPolymorphic ? null : f.reference?.split('.')[0] || null, + ...(isPolymorphic && { isPolymorphic: true, polymorphicTargets }), + }; + }); // Extract actions from schema const schemaActions = getActionsOfCollection(schema, options.collectionName); - const actions = schemaActions.map(action => ({ - name: action.name, - type: action.type, // 'single', 'bulk', or 'global' - description: action.description || null, - hasForm: action.fields.length > 0 || action.hooks.load, - download: action.download, - })); + const actions = schemaActions + .filter(action => action.endpoint) + .map(action => ({ + name: action.name, + type: action.type, // 'single', 'bulk', or 'global' + description: action.description || null, + hasForm: action.fields.length > 0 || action.hooks.load, + download: action.download, + })); + + const skippedActions = schemaActions + .filter(action => !action.endpoint) + .map(action => ({ name: action.name, reason: 'no endpoint configured' })); const result = { collection: options.collectionName, @@ -180,6 +195,7 @@ Check \`_meta\` for data availability context.`, : { note: 'Operators unavailable (older agent version). Fields have operators: null.', }), + ...(skippedActions.length > 0 && { skippedActions }), }, }; diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts index 4047e8e861..89615aa668 100644 --- a/packages/mcp-server/src/tools/list.ts +++ b/packages/mcp-server/src/tools/list.ts @@ -28,12 +28,16 @@ const listArgumentSchema = z.object({ search: z.string().optional(), filters: filtersWithPreprocess .describe( - 'Filters to apply on collection. To filter on a nested field, use "@@@" to separate relations, e.g. "relationName@@@fieldName". One level deep max.', + 'Filters to apply on collection. To filter on a relation field, use ":" as separator in the field name, e.g. { "field": "relationName:fieldName", "operator": "Equal", "value": "..." }. One level deep max. For polymorphic relations, filtering via relation:field is not supported and will fail. Instead use two steps: 1) list the target collection to get IDs, 2) filter on the _type and _id fields directly, e.g. { "field": "commentable_type", "operator": "Equal", "value": "Post" }.', ) .optional(), sort: z .object({ - field: z.string(), + field: z + .string() + .describe( + 'Field to sort by. For relation fields, use ":" as separator, e.g. "relationName:fieldName".', + ), ascending: z.boolean().optional().default(true), }) .optional(), diff --git a/packages/mcp-server/src/tools/update.ts b/packages/mcp-server/src/tools/update.ts index c370d0e6bb..75081bcf1c 100644 --- a/packages/mcp-server/src/tools/update.ts +++ b/packages/mcp-server/src/tools/update.ts @@ -62,6 +62,7 @@ export default function declareUpdateTool( context: { collectionName: options.collectionName, recordId: options.recordId, + label: 'updated', }, logger, operation: async () => { diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 80adc9e46f..e245c5d28d 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -57,7 +57,7 @@ export default async function createPendingActivityLog( const type = ACTION_TO_TYPE[action]; const { forestServerToken, renderingId } = getAuthContext(request); - return forestServerClient.createActivityLog({ + return forestServerClient.createMcpActivityLog({ forestServerToken, renderingId, action, @@ -74,7 +74,6 @@ interface UpdateActivityLogOptions { request: RequestHandlerExtra; activityLog: ActivityLogResponse; status: 'completed' | 'failed'; - errorMessage?: string; logger: Logger; } @@ -120,19 +119,17 @@ interface MarkActivityLogAsFailedOptions { forestServerClient: ForestServerClient; request: RequestHandlerExtra; activityLog: ActivityLogResponse; - errorMessage: string; logger: Logger; } export function markActivityLogAsFailed(options: MarkActivityLogAsFailedOptions): void { - const { forestServerClient, request, activityLog, errorMessage, logger } = options; + const { forestServerClient, request, activityLog, logger } = options; // Fire-and-forget: don't block error response on activity log update updateActivityLogStatus({ forestServerClient, request, activityLog, status: 'failed', - errorMessage, logger, }).catch(error => { logger('Error', `Unexpected error updating activity log to 'failed': ${error}`); diff --git a/packages/mcp-server/src/utils/agent-caller.ts b/packages/mcp-server/src/utils/agent-caller.ts index 1397c23f64..e9f47d7ac4 100644 --- a/packages/mcp-server/src/utils/agent-caller.ts +++ b/packages/mcp-server/src/utils/agent-caller.ts @@ -1,4 +1,4 @@ -import type { ActionEndpointsByCollection } from './schema-fetcher'; +import type { ActionEndpoints } from './schema-fetcher'; import type { ForestServerClient } from '../http-client'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; @@ -9,7 +9,7 @@ import { fetchForestSchema, getActionEndpoints } from './schema-fetcher'; interface BuildClientOptions { request: RequestHandlerExtra; - actionEndpoints?: ActionEndpointsByCollection; + actionEndpoints?: ActionEndpoints; } export type AuthData = { diff --git a/packages/mcp-server/src/utils/parse-tool-list.ts b/packages/mcp-server/src/utils/parse-tool-list.ts new file mode 100644 index 0000000000..4d0d3c1c16 --- /dev/null +++ b/packages/mcp-server/src/utils/parse-tool-list.ts @@ -0,0 +1,10 @@ +import type { ToolName } from '../server'; + +export default function parseToolList(envValue?: string): ToolName[] | undefined { + if (!envValue) return undefined; + + return envValue + .split(',') + .map(t => t.trim()) + .filter(Boolean) as ToolName[]; +} diff --git a/packages/mcp-server/src/utils/schema-fetcher.ts b/packages/mcp-server/src/utils/schema-fetcher.ts index 1318163df7..b43fdb12f5 100644 --- a/packages/mcp-server/src/utils/schema-fetcher.ts +++ b/packages/mcp-server/src/utils/schema-fetcher.ts @@ -4,6 +4,7 @@ import type { ForestSchemaField, ForestServerClient, } from '../http-client'; +import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; /** * Schema Fetcher Utility @@ -16,6 +17,7 @@ import type { export type ForestField = ForestSchemaField; export type ForestAction = ForestSchemaAction; export type ForestCollection = ForestSchemaCollection; +export type ActionEndpoints = ActionEndpointsByCollection; export interface ForestSchema { collections: ForestCollection[]; @@ -111,12 +113,6 @@ export function getActionsOfCollection( return collection.actions || []; } -export type ActionEndpointsByCollection = { - [collectionName: string]: { - [actionName: string]: { name: string; endpoint: string }; - }; -}; - /** * Builds a mapping of action endpoints by collection from the Forest Admin schema. * This is used by the agent client to resolve action endpoints. @@ -124,18 +120,23 @@ export type ActionEndpointsByCollection = { * @param schema - The Forest Admin schema * @returns A mapping of collection names to action names to their endpoints */ -export function getActionEndpoints(schema: ForestSchema): ActionEndpointsByCollection { - const actionEndpoints: ActionEndpointsByCollection = {}; +export function getActionEndpoints(schema: ForestSchema): ActionEndpoints { + const actionEndpoints: ActionEndpoints = {}; for (const collection of schema.collections) { if (collection.actions && collection.actions.length > 0) { actionEndpoints[collection.name] = {}; for (const action of collection.actions) { - actionEndpoints[collection.name][action.name] = { - name: action.name, - endpoint: action.endpoint, - }; + if (action.endpoint) { + actionEndpoints[collection.name][action.name] = { + id: action.id, + name: action.name, + endpoint: action.endpoint, + hooks: action.hooks, + fields: action.fields, + }; + } } } } diff --git a/packages/mcp-server/src/utils/with-activity-log.ts b/packages/mcp-server/src/utils/with-activity-log.ts index 4bca7e6e82..20eb05beb3 100644 --- a/packages/mcp-server/src/utils/with-activity-log.ts +++ b/packages/mcp-server/src/utils/with-activity-log.ts @@ -42,6 +42,7 @@ export default async function withActivityLog(options: WithActivityLogOptions // We want to create the activity log before executing the operation // If activity log creation fails, we must prevent the execution of the operation + const activityLog = await createPendingActivityLog(forestServerClient, request, action, context); try { @@ -77,7 +78,6 @@ export default async function withActivityLog(options: WithActivityLogOptions forestServerClient, request, activityLog, - errorMessage, logger, }); diff --git a/packages/mcp-server/test/cli.test.ts b/packages/mcp-server/test/cli.test.ts new file mode 100644 index 0000000000..23dfde7fff --- /dev/null +++ b/packages/mcp-server/test/cli.test.ts @@ -0,0 +1,27 @@ +import parseToolList from '../src/utils/parse-tool-list'; + +describe('parseToolList', () => { + it('should return undefined when env value is undefined', () => { + expect(parseToolList(undefined)).toBeUndefined(); + }); + + it('should return undefined when env value is empty string', () => { + expect(parseToolList('')).toBeUndefined(); + }); + + it('should parse comma-separated tool names', () => { + expect(parseToolList('create,update,delete')).toEqual(['create', 'update', 'delete']); + }); + + it('should trim whitespace around tool names', () => { + expect(parseToolList(' create , update , delete ')).toEqual(['create', 'update', 'delete']); + }); + + it('should filter out empty entries from trailing commas', () => { + expect(parseToolList('create,,delete,')).toEqual(['create', 'delete']); + }); + + it('should handle a single tool name', () => { + expect(parseToolList('delete')).toEqual(['delete']); + }); +}); diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 845f06bc23..84aa6694e5 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -502,6 +502,15 @@ describe('ForestOAuthProvider', () => { expect.objectContaining({ id: 123, email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + renderingId: 456, + // snake_case duplicates for Ruby (forest_liana) compatibility + first_name: 'Test', + last_name: 'User', + rendering_id: '456', + permission_level: 'admin', + tags: [], serverToken: 'forest-access-token', }), 'test-auth-secret', @@ -522,6 +531,93 @@ describe('ForestOAuthProvider', () => { ); }); + it('should automatically convert all camelCase user claims to snake_case', async () => { + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .post('/oauth/token', { + access_token: 'forest-access-token', + refresh_token: 'forest-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'mcp:read', + }); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + await provider.exchangeAuthorizationCode( + mockClient, + 'auth-code-123', + 'code-verifier-456', + 'https://example.com/callback', + ); + + const signedPayload = mockJwtSign.mock.calls[0][0]; + + // All camelCase UserInfo fields should have snake_case equivalents + expect(signedPayload).toHaveProperty('first_name', 'Test'); + expect(signedPayload).toHaveProperty('last_name', 'User'); + expect(signedPayload).toHaveProperty('permission_level', 'admin'); + expect(signedPayload).toHaveProperty('rendering_id', '456'); + + // Original camelCase fields should still be present + expect(signedPayload).toHaveProperty('firstName', 'Test'); + expect(signedPayload).toHaveProperty('lastName', 'User'); + expect(signedPayload).toHaveProperty('permissionLevel', 'admin'); + expect(signedPayload).toHaveProperty('renderingId', 456); + + // Non-user fields should not be snake_cased + expect(signedPayload).toHaveProperty('serverToken'); + expect(signedPayload).not.toHaveProperty('server_token'); + }); + + it('should convert non-empty tags to array format for Ruby compatibility', async () => { + mockGetUserInfo.mockResolvedValue({ + id: 123, + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + team: 'Operations', + role: 'Admin', + tags: { region: 'EU', plan: 'enterprise' }, + renderingId: 456, + permissionLevel: 'admin', + }); + + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .post('/oauth/token', { + access_token: 'forest-access-token', + refresh_token: 'forest-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'mcp:read', + }); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + await provider.exchangeAuthorizationCode( + mockClient, + 'auth-code-123', + 'code-verifier-456', + 'https://example.com/callback', + ); + + expect(mockJwtSign).toHaveBeenCalledWith( + expect.objectContaining({ + tags: [ + { key: 'region', value: 'EU' }, + { key: 'plan', value: 'enterprise' }, + ], + }), + 'test-auth-secret', + { expiresIn: expect.any(Number) }, + ); + }); + it('should throw error when token exchange fails', async () => { mockServer .get('/liana/environment', { diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 79a216fde2..74bc697dac 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -9,6 +9,10 @@ export default function createMockForestServerClient( id: 'mock-log-id', attributes: { index: 'mock-index' }, }), + createMcpActivityLog: jest.fn().mockResolvedValue({ + id: 'mock-log-id', + attributes: { index: 'mock-index' }, + }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), ...overrides, } as jest.Mocked; diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index bb4c88030d..d2125ff788 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -18,6 +18,7 @@ describe('ForestServerClientImpl', () => { }; mockActivityLogsService = { createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; client = new ForestServerClientImpl(mockSchemaService, mockActivityLogsService); @@ -62,6 +63,26 @@ describe('ForestServerClientImpl', () => { }); }); + describe('createMcpActivityLog', () => { + it('should delegate to activityLogsService.createMcpActivityLog()', async () => { + const mockActivityLog = { id: 'log-123', attributes: { index: 'idx-456' } }; + mockActivityLogsService.createMcpActivityLog.mockResolvedValue(mockActivityLog); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + action: 'index' as const, + type: 'read' as const, + collectionName: 'users', + }; + + const result = await client.createMcpActivityLog(params); + + expect(mockActivityLogsService.createMcpActivityLog).toHaveBeenCalledWith(params); + expect(result).toBe(mockActivityLog); + }); + }); + describe('updateActivityLogStatus', () => { it('should delegate to activityLogsService.updateActivityLogStatus()', async () => { mockActivityLogsService.updateActivityLogStatus.mockResolvedValue(undefined); @@ -106,6 +127,7 @@ describe('createForestServerClient', () => { expect(client).toBeDefined(); expect(client.fetchSchema).toBeDefined(); expect(client.createActivityLog).toBeDefined(); + expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 49bd0524e0..dd59443e09 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -991,6 +991,20 @@ describe('ForestMCPServer Instance', () => { expect(response.status).toBe(401); }); + it('should include resource_metadata in WWW-Authenticate header on 401', async () => { + const response = await request(listHttpServer).post('/mcp').send({ + jsonrpc: '2.0', + method: 'tools/list', + id: 1, + }); + + expect(response.status).toBe(401); + const wwwAuth = response.headers['www-authenticate']; + expect(wwwAuth).toBeDefined(); + expect(wwwAuth).toContain('resource_metadata='); + expect(wwwAuth).toContain('/.well-known/oauth-protected-resource/mcp'); + }); + it('should reject requests with invalid bearer token', async () => { const response = await request(listHttpServer) .post('/mcp') @@ -1138,11 +1152,9 @@ describe('ForestMCPServer Instance', () => { // The tool call should succeed (or fail on agent call, but activity log should be created first) expect(response.status).toBe(200); - // Verify activity log API was called with the correct forestServerToken - // The mock httpClient captures all createActivityLog calls - expect(listMockForestServerClient.createActivityLog).toHaveBeenCalled(); + expect(listMockForestServerClient.createMcpActivityLog).toHaveBeenCalled(); - const activityLogCall = listMockForestServerClient.createActivityLog.mock.calls[0][0]; + const activityLogCall = listMockForestServerClient.createMcpActivityLog.mock.calls[0][0]; expect(activityLogCall.forestServerToken).toBe(forestServerToken); expect(activityLogCall.action).toBe('index'); expect(activityLogCall.collectionName).toBe('users'); @@ -1500,8 +1512,8 @@ describe('ForestMCPServer Instance', () => { expect(response.status).toBe(200); const filters = JSON.parse(capturedQueryParams.filters as string); expect(filters).toEqual({ - aggregator: 'And', - conditions: [{ field: 'name', operator: 'Equal', value: 'John' }], + aggregator: 'and', + conditions: [{ field: 'name', operator: 'equal', value: 'John' }], }); }); @@ -1534,10 +1546,10 @@ describe('ForestMCPServer Instance', () => { expect(response.status).toBe(200); const filters = JSON.parse(capturedQueryParams.filters as string); expect(filters).toEqual({ - aggregator: 'And', + aggregator: 'and', conditions: [ - { field: 'name', operator: 'Contains', value: 'John' }, - { field: 'email', operator: 'EndsWith', value: '@example.com' }, + { field: 'name', operator: 'contains', value: 'John' }, + { field: 'email', operator: 'ends_with', value: '@example.com' }, ], }); }); @@ -1576,7 +1588,7 @@ describe('ForestMCPServer Instance', () => { expect(response.status).toBe(200); const filters = JSON.parse(capturedQueryParams.filters as string); - expect(filters.aggregator).toBe('Or'); + expect(filters.aggregator).toBe('or'); expect(filters.conditions).toHaveLength(2); }); @@ -1609,7 +1621,10 @@ describe('ForestMCPServer Instance', () => { expect(response.status).toBe(200); const filters = JSON.parse(capturedQueryParams.filters as string); - expect(filters).toEqual(filterObject); + expect(filters).toEqual({ + aggregator: 'and', + conditions: [{ field: 'name', operator: 'equal', value: 'Jane' }], + }); }); }); @@ -1913,8 +1928,12 @@ describe('ForestMCPServer Instance', () => { const listParams = JSON.parse(listCalls[0]); const countParams = JSON.parse(countCalls[0]); - expect(JSON.parse(listParams.filters)).toEqual(filterCondition); - expect(JSON.parse(countParams.filters)).toEqual(filterCondition); + const expectedSnakeCase = { + aggregator: 'and', + conditions: [{ field: 'id', operator: 'greater_than', value: 5 }], + }; + expect(JSON.parse(listParams.filters)).toEqual(expectedSnakeCase); + expect(JSON.parse(countParams.filters)).toEqual(expectedSnakeCase); }); }); @@ -1957,8 +1976,8 @@ describe('ForestMCPServer Instance', () => { const filters = JSON.parse(capturedQueryParams.filters as string); expect(filters).toEqual({ - aggregator: 'And', - conditions: [{ field: 'email', operator: 'Present' }], + aggregator: 'and', + conditions: [{ field: 'email', operator: 'present' }], }); }); }); @@ -2726,6 +2745,260 @@ describe('handleMcpRequest cleanup', () => { }); }); +describe('enabledTools', () => { + const savedFetch = global.fetch; + const savedPort = process.env.MCP_SERVER_PORT; + let enabledToolsServer: ForestMCPServer; + let enabledToolsHttpServer: http.Server; + let mockServer: MockServer; + + beforeAll(async () => { + process.env.MCP_SERVER_PORT = (await getAvailablePort()).toString(); + mockServer = new MockServer(); + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + ], + included: [], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = mockServer.fetch; + + enabledToolsServer = new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + enabledTools: ['list', 'listRelated'], + }); + + const app = await enabledToolsServer.buildExpressApp(); + enabledToolsHttpServer = app.listen(Number(process.env.MCP_SERVER_PORT)) as http.Server; + + await new Promise(resolve => { + enabledToolsHttpServer.on('listening', resolve); + }); + }); + + afterAll(async () => { + global.fetch = savedFetch; + process.env.MCP_SERVER_PORT = savedPort; + await new Promise(resolve => { + if (enabledToolsHttpServer) { + enabledToolsHttpServer.close(() => resolve()); + } else { + resolve(); + } + }); + }); + + it('should only expose enabled tools plus describeCollection', async () => { + const validToken = jsonwebtoken.sign( + { id: 123, email: 'user@example.com', renderingId: 456 }, + 'test-auth-secret', + { expiresIn: '1h' }, + ); + + const response = await request(enabledToolsHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${validToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + + expect(response.status).toBe(200); + + let responseData: { result: { tools: Array<{ name: string }> } }; + + if (response.body && Object.keys(response.body).length > 0) { + responseData = response.body; + } else { + const dataLine = response.text.split('\n').find((line: string) => line.startsWith('data: ')); + if (!dataLine) throw new Error('Expected SSE data line not found in response'); + responseData = JSON.parse(dataLine.replace('data: ', '')); + } + + const toolNames = responseData.result.tools.map(t => t.name); + + // Only enabled tools + describeCollection (always forced) + expect(toolNames).toContain('describeCollection'); + expect(toolNames).toContain('list'); + expect(toolNames).toContain('listRelated'); + expect(toolNames).toHaveLength(3); + + // Write tools should NOT be present + expect(toolNames).not.toContain('create'); + expect(toolNames).not.toContain('update'); + expect(toolNames).not.toContain('delete'); + }); + + it('should warn when describeCollection is not in enabledTools', () => { + const logger = jest.fn(); + + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + logger, + enabledTools: ['list'], + }); + + expect(server).toBeDefined(); + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'describeCollection was automatically enabled — it is required for the MCP server to function properly.', + ); + }); + + it('should log available tools not included in enabledTools', () => { + const logger = jest.fn(); + + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + logger, + enabledTools: ['describeCollection', 'list', 'listRelated'], + }); + + expect(server).toBeDefined(); + expect(logger).toHaveBeenCalledWith( + 'Info', + expect.stringContaining('Available tools not enabled:'), + ); + expect(logger).toHaveBeenCalledWith('Info', expect.stringContaining('create')); + expect(logger).toHaveBeenCalledWith( + 'Info', + expect.stringContaining('Add them to enabledTools to use them.'), + ); + }); + + it('should not log discovery message when all tools are enabled', () => { + const logger = jest.fn(); + + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + logger, + enabledTools: [ + 'describeCollection', + 'list', + 'listRelated', + 'create', + 'update', + 'delete', + 'associate', + 'dissociate', + 'getActionForm', + 'executeAction', + ], + }); + + expect(server).toBeDefined(); + const infoCalls = logger.mock.calls.filter( + ([level, msg]: [string, string]) => + level === 'Info' && msg.includes('Available tools not enabled'), + ); + expect(infoCalls).toHaveLength(0); + }); + + it('should warn about unknown tool names in enabledTools', () => { + const logger = jest.fn(); + + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + logger, + enabledTools: ['list', 'lst', 'creat' as any], + }); + + expect(server).toBeDefined(); + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'Unknown tool names in enabledTools: lst, creat. These will be ignored.', + ); + }); + + it('should only expose describeCollection when enabledTools is empty', async () => { + const savedFetch2 = global.fetch; + const savedPort2 = process.env.MCP_SERVER_PORT; + process.env.MCP_SERVER_PORT = (await getAvailablePort()).toString(); + const mockServer2 = new MockServer(); + mockServer2 + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + ], + included: [], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = mockServer2.fetch; + + const emptyServer = new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + enabledTools: [], + }); + + const emptyApp = await emptyServer.buildExpressApp(); + const emptyHttpServer = emptyApp.listen(Number(process.env.MCP_SERVER_PORT)) as http.Server; + + await new Promise(resolve => { + emptyHttpServer.on('listening', resolve); + }); + + const validToken = jsonwebtoken.sign( + { id: 123, email: 'user@example.com', renderingId: 456 }, + 'test-auth-secret', + { expiresIn: '1h' }, + ); + + const response = await request(emptyHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${validToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + + expect(response.status).toBe(200); + + let responseData: { result: { tools: Array<{ name: string }> } }; + + if (response.body && Object.keys(response.body).length > 0) { + responseData = response.body; + } else { + const dataLine = response.text.split('\n').find((line: string) => line.startsWith('data: ')); + if (!dataLine) throw new Error('Expected SSE data line not found in response'); + responseData = JSON.parse(dataLine.replace('data: ', '')); + } + + const toolNames = responseData.result.tools.map(t => t.name); + + expect(toolNames).toEqual(['describeCollection']); + + await new Promise(resolve => { + emptyHttpServer.close(() => resolve()); + }); + global.fetch = savedFetch2; + process.env.MCP_SERVER_PORT = savedPort2; + }); +}); + describe('Logo URL', () => { it('should reference an accessible PNG image', async () => { const response = await fetch(LOGO_URL, { method: 'HEAD' }); diff --git a/packages/mcp-server/test/tools/create.test.ts b/packages/mcp-server/test/tools/create.test.ts index f56ca77095..f667995310 100644 --- a/packages/mcp-server/test/tools/create.test.ts +++ b/packages/mcp-server/test/tools/create.test.ts @@ -192,7 +192,7 @@ describe('declareCreateTool', () => { forestServerClient: mockForestServerClient, request: mockExtra, action: 'create', - context: { collectionName: 'users' }, + context: { collectionName: 'users', label: 'created' }, logger: mockLogger, operation: expect.any(Function), }); diff --git a/packages/mcp-server/test/tools/delete.test.ts b/packages/mcp-server/test/tools/delete.test.ts index 79e5a03e03..e3c8c36f80 100644 --- a/packages/mcp-server/test/tools/delete.test.ts +++ b/packages/mcp-server/test/tools/delete.test.ts @@ -211,7 +211,7 @@ describe('declareDeleteTool', () => { forestServerClient: mockForestServerClient, request: mockExtra, action: 'delete', - context: { collectionName: 'users', recordIds }, + context: { collectionName: 'users', recordIds, label: 'deleted' }, logger: mockLogger, operation: expect.any(Function), }); diff --git a/packages/mcp-server/test/tools/describe-collection.test.ts b/packages/mcp-server/test/tools/describe-collection.test.ts index da54adb950..e7a9abce96 100644 --- a/packages/mcp-server/test/tools/describe-collection.test.ts +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -607,6 +607,101 @@ describe('declareDescribeCollectionTool', () => { targetCollection: null, }); }); + + it('should detect polymorphic BelongsTo relations from forest-rails schema', async () => { + const mockFields = [ + { + field: 'commentable', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'commentable.id', + relationship: 'BelongsTo', + polymorphicReferencedModels: ['Post', 'Video'], + }, + ] as unknown as schemaFetcher.ForestField[]; + mockFetchForestSchema.mockResolvedValue({ + collections: [{ name: 'comments', fields: mockFields }], + }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler({ collectionName: 'comments' }, mockExtra)) as { + content: { type: string; text: string }[]; + }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'commentable', + type: 'many-to-one', + targetCollection: null, + isPolymorphic: true, + polymorphicTargets: ['Post', 'Video'], + }); + }); + + it('should not add polymorphic fields to non-polymorphic relations', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'user', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'users.id', + relationship: 'BelongsTo', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ + collections: [{ name: 'comments', fields: mockFields }], + }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler({ collectionName: 'comments' }, mockExtra)) as { + content: { type: string; text: string }[]; + }; + + const parsed = JSON.parse(result.content[0].text); + const relation = parsed.relations[0]; + expect(relation.targetCollection).toBe('users'); + expect(relation.isPolymorphic).toBeUndefined(); + expect(relation.polymorphicTargets).toBeUndefined(); + }); + + it('should treat empty polymorphic-referenced-models as non-polymorphic', async () => { + const mockFields = [ + { + field: 'commentable', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'commentable.id', + relationship: 'BelongsTo', + polymorphicReferencedModels: [], + }, + ] as unknown as schemaFetcher.ForestField[]; + mockFetchForestSchema.mockResolvedValue({ + collections: [{ name: 'comments', fields: mockFields }], + }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler({ collectionName: 'comments' }, mockExtra)) as { + content: { type: string; text: string }[]; + }; + + const parsed = JSON.parse(result.content[0].text); + const relation = parsed.relations[0]; + expect(relation.targetCollection).toBe('commentable'); + expect(relation.isPolymorphic).toBeUndefined(); + expect(relation.polymorphicTargets).toBeUndefined(); + }); }); describe('actions extraction', () => { @@ -630,7 +725,7 @@ describe('declareDescribeCollectionTool', () => { type: 'single', endpoint: '/forest/actions/send-email', description: 'Send an email to the user', - fields: [{ field: 'subject' }], + fields: [{ field: 'subject', type: 'String' }], hooks: { load: false, change: [] }, download: false, }, @@ -659,7 +754,7 @@ describe('declareDescribeCollectionTool', () => { name: 'Action With Fields', type: 'bulk', endpoint: '/forest/actions/action-with-fields', - fields: [{ field: 'reason' }], + fields: [{ field: 'reason', type: 'String' }], hooks: { load: false, change: [] }, download: false, }, @@ -767,6 +862,50 @@ describe('declareDescribeCollectionTool', () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.actions).toEqual([]); }); + + it('should exclude actions without endpoint and expose them in _meta.skippedActions', async () => { + mockGetActionsOfCollection.mockReturnValue([ + { + id: 'action-with-endpoint', + name: 'Valid Action', + type: 'single', + endpoint: '/forest/actions/valid', + fields: [], + hooks: { load: false, change: [] }, + download: false, + }, + { + id: 'action-without-endpoint', + name: 'Invalid Action', + type: 'single', + endpoint: '', + fields: [], + hooks: { load: false, change: [] }, + download: false, + }, + { + id: 'action-null-endpoint', + name: 'Null Endpoint Action', + type: 'global', + endpoint: null, + fields: [], + hooks: { load: false, change: [] }, + download: false, + }, + ]); + + const result = (await registeredToolHandler({ collectionName: 'users' }, mockExtra)) as { + content: { type: string; text: string }[]; + }; + + const { actions, _meta: meta } = JSON.parse(result.content[0].text); + expect(actions).toHaveLength(1); + expect(actions[0].name).toBe('Valid Action'); + expect(meta.skippedActions).toEqual([ + { name: 'Invalid Action', reason: 'no endpoint configured' }, + { name: 'Null Endpoint Action', reason: 'no endpoint configured' }, + ]); + }); }); describe('response format', () => { diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index c883c572c4..e54d2af725 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -15,6 +15,7 @@ const mockLogger: Logger = jest.fn(); const mockForestServerClient: ForestServerClient = { fetchSchema: jest.fn(), createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index b48ad99b86..54c9400fff 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -13,6 +13,7 @@ const mockLogger: Logger = jest.fn(); const mockForestServerClient: ForestServerClient = { fetchSchema: jest.fn(), createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; diff --git a/packages/mcp-server/test/tools/update.test.ts b/packages/mcp-server/test/tools/update.test.ts index ada9d276cd..aecd8a9e97 100644 --- a/packages/mcp-server/test/tools/update.test.ts +++ b/packages/mcp-server/test/tools/update.test.ts @@ -204,7 +204,7 @@ describe('declareUpdateTool', () => { forestServerClient: mockForestServerClient, request: mockExtra, action: 'update', - context: { collectionName: 'users', recordId: 42 }, + context: { collectionName: 'users', recordId: 42, label: 'updated' }, logger: mockLogger, operation: expect.any(Function), }); diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index 8c1a44630b..bbb79e8b70 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -45,7 +45,7 @@ describe('createPendingActivityLog', () => { await createPendingActivityLog(mockForestServerClient, request, action); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ action, type: expectedType, @@ -55,12 +55,12 @@ describe('createPendingActivityLog', () => { }); describe('request formatting', () => { - it('should call createActivityLog with forestServerToken from authInfo.extra', async () => { + it('should call createMcpActivityLog with forestServerToken from authInfo.extra', async () => { const request = createMockRequest(); await createPendingActivityLog(mockForestServerClient, request, 'index'); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ forestServerToken: 'test-forest-token', renderingId: '12345', @@ -81,7 +81,7 @@ describe('createPendingActivityLog', () => { await createPendingActivityLog(mockForestServerClient, request, 'index'); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ forestServerToken: 'original-forest-server-token', }), @@ -131,7 +131,7 @@ describe('createPendingActivityLog', () => { await createPendingActivityLog(mockForestServerClient, request, 'index'); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ renderingId: '456', // should be converted to string }), @@ -145,7 +145,7 @@ describe('createPendingActivityLog', () => { collectionName: 'users', }); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ collectionName: 'users', }), @@ -157,7 +157,7 @@ describe('createPendingActivityLog', () => { await createPendingActivityLog(mockForestServerClient, request, 'index'); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ collectionName: undefined, }), @@ -171,7 +171,7 @@ describe('createPendingActivityLog', () => { label: 'Custom Action Label', }); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ label: 'Custom Action Label', }), @@ -185,7 +185,7 @@ describe('createPendingActivityLog', () => { recordId: 42, }); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ recordId: 42, }), @@ -199,7 +199,7 @@ describe('createPendingActivityLog', () => { recordIds: [1, 2, 3], }); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ recordIds: [1, 2, 3], }), @@ -214,7 +214,7 @@ describe('createPendingActivityLog', () => { recordIds: [1, 2, 3], }); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ recordId: 99, recordIds: [1, 2, 3], @@ -227,7 +227,7 @@ describe('createPendingActivityLog', () => { await createPendingActivityLog(mockForestServerClient, request, 'search'); - expect(mockForestServerClient.createActivityLog).toHaveBeenCalledWith( + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( expect.objectContaining({ action: 'search', }), @@ -236,8 +236,8 @@ describe('createPendingActivityLog', () => { }); describe('error handling', () => { - it('should propagate error when createActivityLog fails', async () => { - mockForestServerClient.createActivityLog.mockRejectedValue( + it('should propagate error when createMcpActivityLog fails', async () => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue( new Error('Failed to create activity log: Server error message'), ); @@ -248,7 +248,7 @@ describe('createPendingActivityLog', () => { ).rejects.toThrow('Failed to create activity log: Server error message'); }); - it('should not throw when createActivityLog succeeds', async () => { + it('should not throw when createMcpActivityLog succeeds', async () => { const request = createMockRequest(); await expect( @@ -279,7 +279,7 @@ describe('markActivityLogAsFailed', () => { } as unknown as RequestHandlerExtra; } - it('should call updateActivityLogStatus with failed status and error message', async () => { + it('should call updateActivityLogStatus with failed status', async () => { const request = createMockRequest(); const activityLog = { id: 'log-123', attributes: { index: 'idx-456' } }; const mockLogger = jest.fn(); @@ -288,7 +288,6 @@ describe('markActivityLogAsFailed', () => { forestServerClient: mockForestServerClient, request, activityLog, - errorMessage: 'Something went wrong', logger: mockLogger, }); @@ -323,7 +322,6 @@ describe('markActivityLogAsFailed', () => { forestServerClient: mockForestServerClient, request, activityLog, - errorMessage: 'Something went wrong', logger: mockLogger, }); @@ -357,7 +355,6 @@ describe('markActivityLogAsFailed', () => { forestServerClient: mockForestServerClient, request, activityLog, - errorMessage: 'Something went wrong', logger: mockLogger, }); @@ -387,7 +384,6 @@ describe('markActivityLogAsFailed', () => { forestServerClient: mockForestServerClient, request, activityLog, - errorMessage: 'Something went wrong', logger: mockLogger, }); @@ -441,33 +437,6 @@ describe('markActivityLogAsSucceeded', () => { // Wait for the fire-and-forget promise to resolve await jest.advanceTimersByTimeAsync(0); - expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith({ - forestServerToken: 'test-forest-token', - activityLog, - status: 'completed', - errorMessage: undefined, - }); - - jest.useRealTimers(); - }); - - it('should not include errorMessage in completed status', async () => { - jest.useFakeTimers(); - - const request = createMockRequest(); - const activityLog = { id: 'log-123', attributes: { index: 'idx-456' } }; - const mockLogger = jest.fn(); - - markActivityLogAsSucceeded({ - forestServerClient: mockForestServerClient, - request, - activityLog, - logger: mockLogger, - }); - - // Wait for the fire-and-forget promise to resolve - await jest.advanceTimersByTimeAsync(0); - expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith({ forestServerToken: 'test-forest-token', activityLog, diff --git a/packages/mcp-server/test/utils/agent-caller.test.ts b/packages/mcp-server/test/utils/agent-caller.test.ts index 45a86c4c0d..2616d9589b 100644 --- a/packages/mcp-server/test/utils/agent-caller.test.ts +++ b/packages/mcp-server/test/utils/agent-caller.test.ts @@ -143,7 +143,13 @@ describe('buildClientWithActions', () => { }; const mockActionEndpoints = { users: { - 'Send Email': { name: 'Send Email', endpoint: '/forest/_actions/users/0/send-email' }, + 'Send Email': { + name: 'Send Email', + endpoint: '/forest/_actions/users/0/send-email', + id: 'Send@@@Email', + hooks: { load: false, change: [] }, + fields: [], + }, }, }; @@ -179,8 +185,20 @@ describe('buildClientWithActions', () => { }; const mockActionEndpoints = { orders: { - Refund: { name: 'Refund', endpoint: '/forest/_actions/orders/0/refund' }, - Ship: { name: 'Ship', endpoint: '/forest/_actions/orders/1/ship' }, + Refund: { + name: 'Refund', + endpoint: '/forest/_actions/orders/0/refund', + id: 'Refund', + hooks: { load: false, change: [] }, + fields: [], + }, + Ship: { + name: 'Ship', + endpoint: '/forest/_actions/orders/1/ship', + id: 'Ship', + hooks: { load: false, change: [] }, + fields: [], + }, }, }; diff --git a/packages/mcp-server/test/utils/schema-fetcher.test.ts b/packages/mcp-server/test/utils/schema-fetcher.test.ts index 644d172564..5b53132cc4 100644 --- a/packages/mcp-server/test/utils/schema-fetcher.test.ts +++ b/packages/mcp-server/test/utils/schema-fetcher.test.ts @@ -201,16 +201,34 @@ describe('schema-fetcher', () => { const result = getActionEndpoints(schema); + const hooks = { load: false, change: [] }; + const fields = []; + expect(result).toEqual({ users: { - 'Send Email': { name: 'Send Email', endpoint: '/forest/_actions/users/0/send-email' }, + 'Send Email': { + id: 'action-send-email', + name: 'Send Email', + endpoint: '/forest/_actions/users/0/send-email', + hooks, + fields, + }, 'Reset Password': { + id: 'action-reset-password', name: 'Reset Password', endpoint: '/forest/_actions/users/1/reset-password', + hooks, + fields, }, }, orders: { - Refund: { name: 'Refund', endpoint: '/forest/_actions/orders/0/refund' }, + Refund: { + id: 'action-refund', + name: 'Refund', + endpoint: '/forest/_actions/orders/0/refund', + hooks, + fields, + }, }, }); }); @@ -244,7 +262,13 @@ describe('schema-fetcher', () => { expect(result).toEqual({ orders: { - Ship: { name: 'Ship', endpoint: '/forest/_actions/orders/0/ship' }, + Ship: { + id: 'action-ship', + name: 'Ship', + endpoint: '/forest/_actions/orders/0/ship', + hooks: { load: false, change: [] }, + fields: [], + }, }, }); }); @@ -258,5 +282,42 @@ describe('schema-fetcher', () => { expect(result).toEqual({}); }); + + it('should skip actions without endpoint', () => { + const schema: ForestSchema = { + collections: [ + { + name: 'users', + fields: [], + actions: [ + createAction('Send Email', '/forest/_actions/users/0/send-email'), + { + id: 'action-no-endpoint', + name: 'No Endpoint Action', + type: 'single' as const, + endpoint: '', + download: false, + fields: [], + hooks: { load: false, change: [] }, + }, + ], + }, + ], + }; + + const result = getActionEndpoints(schema); + + expect(result).toEqual({ + users: { + 'Send Email': { + id: 'action-send-email', + name: 'Send Email', + endpoint: '/forest/_actions/users/0/send-email', + hooks: { load: false, change: [] }, + fields: [], + }, + }, + }); + }); }); }); diff --git a/packages/mcp-server/test/utils/with-activity-log.test.ts b/packages/mcp-server/test/utils/with-activity-log.test.ts index 9593aee549..edd792a518 100644 --- a/packages/mcp-server/test/utils/with-activity-log.test.ts +++ b/packages/mcp-server/test/utils/with-activity-log.test.ts @@ -116,7 +116,6 @@ describe('withActivityLog', () => { forestServerClient: mockForestServerClient, request: mockRequest, activityLog: mockActivityLog, - errorMessage: 'Operation failed', logger: mockLogger, }); }); @@ -162,7 +161,6 @@ describe('withActivityLog', () => { forestServerClient: mockForestServerClient, request: mockRequest, activityLog: mockActivityLog, - errorMessage: 'Invalid field value', logger: mockLogger, }); }); @@ -185,7 +183,6 @@ describe('withActivityLog', () => { forestServerClient: mockForestServerClient, request: mockRequest, activityLog: mockActivityLog, - errorMessage: 'string error', logger: mockLogger, }); }); @@ -258,7 +255,6 @@ describe('withActivityLog', () => { forestServerClient: mockForestServerClient, request: mockRequest, activityLog: mockActivityLog, - errorMessage: 'Enhanced error message', logger: mockLogger, }); }); @@ -282,7 +278,6 @@ describe('withActivityLog', () => { forestServerClient: mockForestServerClient, request: mockRequest, activityLog: mockActivityLog, - errorMessage: 'Original error', logger: mockLogger, }); }); diff --git a/packages/plugin-aws-s3/CHANGELOG.md b/packages/plugin-aws-s3/CHANGELOG.md index 05bc2c60df..412175517e 100644 --- a/packages/plugin-aws-s3/CHANGELOG.md +++ b/packages/plugin-aws-s3/CHANGELOG.md @@ -1,3 +1,13 @@ +## @forestadmin/plugin-aws-s3 [1.5.13](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.5.12...@forestadmin/plugin-aws-s3@1.5.13) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + ## @forestadmin/plugin-aws-s3 [1.5.12](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.5.11...@forestadmin/plugin-aws-s3@1.5.12) (2026-03-31) diff --git a/packages/plugin-aws-s3/package.json b/packages/plugin-aws-s3/package.json index fe0fc69159..4eb6dc930b 100644 --- a/packages/plugin-aws-s3/package.json +++ b/packages/plugin-aws-s3/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/plugin-aws-s3", - "version": "1.5.12", + "version": "1.5.13", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -17,7 +17,7 @@ "@forestadmin/datasource-toolkit": "1.53.1" }, "devDependencies": { - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/datasource-customizer": "1.69.3", "@forestadmin/datasource-toolkit": "1.53.1" }, "files": [ diff --git a/packages/plugin-export-advanced/CHANGELOG.md b/packages/plugin-export-advanced/CHANGELOG.md index 3890c7ef85..3265618a1e 100644 --- a/packages/plugin-export-advanced/CHANGELOG.md +++ b/packages/plugin-export-advanced/CHANGELOG.md @@ -1,3 +1,13 @@ +## @forestadmin/plugin-export-advanced [1.1.43](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.1.42...@forestadmin/plugin-export-advanced@1.1.43) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + ## @forestadmin/plugin-export-advanced [1.1.42](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.1.41...@forestadmin/plugin-export-advanced@1.1.42) (2026-03-31) diff --git a/packages/plugin-export-advanced/package.json b/packages/plugin-export-advanced/package.json index 34d59abd74..3c4c978710 100644 --- a/packages/plugin-export-advanced/package.json +++ b/packages/plugin-export-advanced/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/plugin-export-advanced", - "version": "1.1.42", + "version": "1.1.43", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -15,7 +15,7 @@ "excel4node": "^1.8.0" }, "devDependencies": { - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/datasource-customizer": "1.69.3", "@forestadmin/datasource-toolkit": "1.53.1" }, "files": [ diff --git a/packages/plugin-flattener/CHANGELOG.md b/packages/plugin-flattener/CHANGELOG.md index 98b637f994..0520b0ac4b 100644 --- a/packages/plugin-flattener/CHANGELOG.md +++ b/packages/plugin-flattener/CHANGELOG.md @@ -1,3 +1,13 @@ +## @forestadmin/plugin-flattener [1.4.27](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.4.26...@forestadmin/plugin-flattener@1.4.27) (2026-04-15) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.69.3 + ## @forestadmin/plugin-flattener [1.4.26](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.4.25...@forestadmin/plugin-flattener@1.4.26) (2026-03-31) diff --git a/packages/plugin-flattener/package.json b/packages/plugin-flattener/package.json index c923f36f0d..612eccbe62 100644 --- a/packages/plugin-flattener/package.json +++ b/packages/plugin-flattener/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/plugin-flattener", - "version": "1.4.26", + "version": "1.4.27", "description": "A plugin that allows to flatten columns and relations in Forest Admin", "main": "dist/index.js", "license": "GPL-3.0", @@ -24,7 +24,7 @@ "test": "jest" }, "devDependencies": { - "@forestadmin/datasource-customizer": "1.69.2", + "@forestadmin/datasource-customizer": "1.69.3", "@types/object-hash": "^3.0.2" }, "dependencies": { diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 6287614aa3..c6f8b581e6 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -45,11 +45,13 @@ src/ ├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError, NoActionsError, StepPersistenceError, NoRelationshipFieldsError, RelatedRecordNotFoundError, InvalidPreRecordedArgsError ├── runner.ts # Runner class — main entry point (start/stop/triggerPoll, HTTP server wiring, graceful drain) ├── types/ # Core type definitions (@draft) -│ ├── step-definition.ts # StepType enum + step definition interfaces -│ ├── step-outcome.ts # Step outcome tracking types (StepOutcome, sent to orchestrator) -│ ├── step-execution-data.ts # Runtime state for in-progress steps -│ ├── record.ts # Record references and data types -│ └── execution.ts # Top-level execution types (context, results) +│ ├── validated/ # Types validated at a trust boundary (zod schemas + inferred types) +│ │ ├── collection.ts # CollectionSchema, FieldSchema, ActionSchema, RecordRef, RecordData +│ │ ├── execution.ts # AvailableStepExecution, StepUser, Step +│ │ ├── step-definition.ts # StepType enum + 7 step definition variants +│ │ └── step-outcome.ts # StepOutcome + 4 variants (validated when input via previousSteps) +│ ├── execution-context.ts # ExecutionContext + StepExecutionResult + IStepExecutor (runtime, not validated) +│ └── step-execution-data.ts # Runtime state for in-progress steps (not validated) ├── ports/ # IO boundary interfaces (@draft) │ ├── agent-port.ts # Interface to the Forest Admin agent (datasource) │ ├── workflow-port.ts # Interface to the orchestrator @@ -76,7 +78,7 @@ src/ ## Architecture Principles -- **Pull-based** — The executor polls for pending steps via a port interface (`WorkflowPort.getPendingStepExecutions`; polling loop not yet implemented). +- **Pull-based** — The executor polls for pending steps via a port interface (`WorkflowPort.getAvailableRuns`; polling loop not yet implemented). - **Atomic** — Each step executes in isolation. A run store (scoped per run) maintains continuity between steps. - **Privacy** — Zero client data leaves the client's infrastructure. `StepOutcome` is sent to the orchestrator and must NEVER contain client data. Privacy-sensitive information (e.g. AI reasoning) must stay in `StepExecutionData` (persisted in the RunStore, client-side only). - **Ports (IO injection)** — All external IO goes through injected port interfaces, keeping the core pure and testable. @@ -86,10 +88,12 @@ src/ - **Boundary errors** (`extends Error`) — Thrown outside step execution, at the HTTP or Runner layer (e.g. `RunNotFoundError`, `PendingDataNotFoundError`, `ConfigurationError`). Caught by the HTTP server and translated into HTTP status codes (404, 400, etc.). These intentionally do NOT extend `WorkflowExecutorError` to prevent `base-step-executor` from catching them as step failures. - **Dual error messages** — `WorkflowExecutorError` carries two messages: `message` (technical, for dev logs) and `userMessage` (human-readable, surfaced to the Forest Admin UI via `stepOutcome.error`). The mapping happens in a single place: `base-step-executor.ts` uses `error.userMessage` when building the error outcome. When adding a new error subclass, always provide a distinct `userMessage` oriented toward end-users (no collection names, field names, or AI internals). If `userMessage` is omitted in the constructor call, it falls back to `message`. - **displayName in AI tools** — All `DynamicStructuredTool` schemas and system message prompts must use `displayName`, never `fieldName`. `displayName` is a Forest Admin frontend feature that replaces the technical field/relation/action name with a product-oriented label configured by the Forest Admin admin. End users write their workflow prompts using these display names, not the underlying technical names. After an AI tool call returns display names, map them back to `fieldName`/`name` before using them in datasource operations (e.g. filtering record values, calling `getRecord`). -- **No recovery/retry** — Once the executor returns a step result to the orchestrator, the step is considered executed. There is no mechanism to re-dispatch a step, so executors must NOT include recovery checks (e.g. checking the RunStore for cached results before executing). Each step executes exactly once. -- **Fetched steps must be executed** — Any step retrieved from the orchestrator via `getPendingStepExecutions()` must be executed. Silently discarding a fetched step (e.g. filtering it out by `runId` after fetching) violates the executor contract: the orchestrator assumes execution is guaranteed once the step is dispatched. The only valid filter before executing is deduplication via `inFlightSteps` (to avoid running the same step twice concurrently). +- **Idempotency in mutating executors** — `update-record`, `trigger-action`, and `mcp` executors protect against duplicate side effects via a write-ahead log in the `RunStore`. Before the side effect fires, the executor saves `idempotencyPhase: 'executing'`. After, it saves `idempotencyPhase: 'done'` alongside the normal `executionResult`. On re-dispatch (same `runId + stepIndex`): `done` → reconstruct success outcome via `buildOutcomeResult` without re-executing or emitting an activity log; `executing` → throw `StepStateError` (user retries manually, also no activity log). The `checkIdempotency()` hook in `BaseStepExecutor` is called before `runWithActivityLog()` so neither cache hits nor uncertain-state errors emit activity log entries. Non-mutating executors (`condition`, `read-record`, `guidance`, `load-related-record`) do not override `checkIdempotency()` — replaying them is safe. +- **Fetched steps must be executed** — Any step retrieved from the orchestrator via `getAvailableRuns()` must be executed. Silently discarding a fetched step (e.g. filtering it out by `runId` after fetching) violates the executor contract: the orchestrator assumes execution is guaranteed once the step is dispatched. The only valid filter before executing is deduplication via `inFlightRuns` (keyed by `runId`, to avoid running the same run twice concurrently; the key is the run, not the step, because a chain advances the `stepId` between iterations). +- **Auto-chain from `/update-step` response** — `WorkflowPort.updateStepExecution` returns `AvailableRunDispatch | null`: when non-null, the `Runner` executes the next step inline instead of waiting for the next poll. The chain exits on `null` (awaiting-input / finished / error), on a non-progressing `stepIndex` (server bug defense), at `maxChainDepth` (config, default 50), or when `stop()` is called. Each chained step uses the `forestServerToken` from its own dispatch — token freshness is preserved across the chain. The port retries `POST /update-step` on transient failures (network, 5xx) — this relies on server-side idempotency: the orchestrator MUST deduplicate identical outcomes for a given `(runId, stepIndex)` to prevent double side-effects on retry. - **Pre-recorded AI decisions** — Record step executors support `preRecordedArgs` in the step definition to bypass AI calls. When provided, executors use the pre-recorded values (display names) directly instead of invoking the AI. Each record step type has its own typed `preRecordedArgs` shape. Validation happens via schema resolution — invalid display names throw `InvalidPreRecordedArgsError`. Partial args are supported: only the provided fields skip AI, the rest still use AI. - **Graceful shutdown** — `stop()` drains in-flight steps before closing resources. The `state` getter exposes the lifecycle: `idle → running → draining → stopped`. `stopTimeoutMs` (default 30s) prevents `stop()` from hanging forever if a step is stuck. The HTTP server stays up during drain so the frontend can still query run status. Signal handling (`SIGTERM`/`SIGINT`) is the consumer's responsibility — the Runner is a library class. +- **Boundary validation** — Types that cross a trust boundary (wire from the orchestrator, or mapper output) live under `src/types/validated/` and are declared as zod schemas with TS types inferred via `z.infer<>`. Every schema uses `.strict()` by default. Validation runs at the boundary where the data enters the executor (`forest-server-workflow-port.getCollectionSchema` → `CollectionSchemaSchema.parse`, `run-to-available-step-mapper.toAvailableStepExecution` → `AvailableStepExecutionSchema.parse`). On parse failure: throw `DomainValidationError` (extends `WorkflowExecutorError`) → bucketized as malformed → reported to the orchestrator. Types outside `validated/` (`execution-context.ts`, `step-execution-data.ts`) are internal runtime state and are not zod-validated. Note: `StepOutcome` is validated when it arrives as input via `previousSteps`; outputs produced by executors are trusted by construction. ## Commands diff --git a/packages/workflow-executor/README.md b/packages/workflow-executor/README.md new file mode 100644 index 0000000000..31cdd752ac --- /dev/null +++ b/packages/workflow-executor/README.md @@ -0,0 +1,141 @@ +# @forestadmin/workflow-executor + +Run Forest Admin workflow steps on your own infrastructure. + +The executor polls the Forest orchestrator for pending steps, runs them locally +(with access to your data via the Forest agent), and reports results back. No +client data ever leaves your infrastructure. + +## Running in production + +### Install + +```bash +npm install -g @forestadmin/workflow-executor +``` + +### Configure via environment + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `FOREST_ENV_SECRET` | ✓ | — | Forest Admin project environment secret | +| `FOREST_AUTH_SECRET` | ✓ | — | JWT signing secret (shared with your agent) | +| `AGENT_URL` | ✓ | — | URL of your running Forest Admin agent | +| `DATABASE_URL` | ✓* | — | Postgres connection string (*not needed with `--in-memory`) | +| `HTTP_PORT` | — | `3400` | Port for the executor HTTP server | +| `FOREST_SERVER_URL` | — | `https://api.forestadmin.com` | Orchestrator URL | +| `POLLING_INTERVAL_MS` | — | `5000` | Poll cadence for pending steps | +| `STOP_TIMEOUT_MS` | — | `30000` | Graceful shutdown deadline | + +Optional AI configuration (all-or-nothing — falls back to server AI if any is missing): + +| Variable | Description | +|----------|-------------| +| `AI_PROVIDER` | `anthropic` or `openai` | +| `AI_MODEL` | Model name (e.g. `claude-sonnet-4-6`) | +| `AI_API_KEY` | Provider API key | + +### Run + +```bash +forest-workflow-executor +``` + +You should see (pretty format when stdout is a TTY): + +``` +13:33:42 info Workflow executor starting mode="database" forestServerUrl="https://api.forestadmin.com" agentUrl="http://localhost:3351" httpPort=3400 pollingIntervalMs=5000 aiConfig="server fallback" +13:33:42 info Workflow executor ready url="http://localhost:3400" +13:33:47 info Poll cycle completed fetched=0 dispatching=0 +``` + +When stdout is piped, redirected or inside a container, logs are emitted as +structured JSON instead — ready to be ingested by Datadog, CloudWatch, Loki, etc.: + +```json +{"message":"Workflow executor ready","timestamp":"2026-04-20T13:33:42.000Z","url":"http://localhost:3400"} +{"message":"Poll cycle completed","timestamp":"2026-04-20T13:33:47.000Z","fetched":0,"dispatching":0} +``` + +### Log format overrides + +| Flag | Behavior | +|------|----------| +| `--pretty` | Force colorized human-readable logs | +| `--json` | Force structured JSON logs | +| (none) | Auto-detect: pretty when stdout is a TTY, JSON otherwise | + +Setting `NO_COLOR=1` disables ANSI codes while keeping the pretty format. + +### Health check + +```bash +curl http://localhost:3400/health +# → {"state":"running"} +``` + +### Graceful shutdown + +Send `SIGTERM` or `SIGINT`. The executor drains in-flight steps, closes the HTTP +server, and exits with code `0`. Steps that don't drain within `STOP_TIMEOUT_MS` +are force-killed and the process exits with code `1`. + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Graceful shutdown | +| `1` | Startup error (missing env, invalid config) or forced shutdown | + +### In-memory mode (dev only) + +```bash +forest-workflow-executor --in-memory +``` + +No Postgres needed. State is lost on restart — **not for production**. + +### All flags + +```bash +forest-workflow-executor --help +``` + +## Programmatic use + +If you prefer embedding the executor in your own Node entry point: + +```ts +import { buildDatabaseExecutor } from '@forestadmin/workflow-executor'; + +const executor = buildDatabaseExecutor({ + envSecret: process.env.FOREST_ENV_SECRET!, + authSecret: process.env.FOREST_AUTH_SECRET!, + agentUrl: process.env.AGENT_URL!, + httpPort: 3400, + database: { uri: process.env.DATABASE_URL! }, +}); + +await executor.start(); +// SIGTERM / SIGINT handling is built in +``` + +See `src/build-workflow-executor.ts` for the full options surface. + +## Dev with the example scaffold + +The `example/` folder contains a docker-compose setup with Postgres + a ready +`index.ts` entrypoint that loads `.env` via `dotenv`. Use it for local development +only — not for production deployments. + +```bash +cd example +docker compose up -d +cp .env.example .env # fill in your secrets +npx tsx index.ts +``` + +## Architecture + +See [CLAUDE.md](./CLAUDE.md) for the full package layout, architectural +principles, privacy boundaries, and extension points. diff --git a/packages/workflow-executor/example/.env.example b/packages/workflow-executor/example/.env.example index 7246d1331b..9e25c0d372 100644 --- a/packages/workflow-executor/example/.env.example +++ b/packages/workflow-executor/example/.env.example @@ -1,23 +1,19 @@ -# Forest Admin secrets (get from your project settings) -FOREST_ENV_SECRET=your-env-secret -FOREST_AUTH_SECRET=your-auth-secret +# Forest Admin secrets — copy from your project Settings → Environments +FOREST_ENV_SECRET= +FOREST_AUTH_SECRET= -# Forest Admin agent URL (the agent this executor will proxy to) -AGENT_URL=http://localhost:3310/forest +# Your locally running Forest Admin agent +AGENT_URL=http://localhost:3351 -# Executor HTTP server port -HTTP_PORT=3400 - -# Forest Admin server URL (default: https://api.forestadmin.com) -# FOREST_SERVER_URL=https://api.forestadmin.com +# Postgres (matches docker-compose.yml) +DATABASE_URL=postgres://executor:password@localhost:5459/workflow_executor -# Database connection for step execution persistence -DATABASE_URL=postgres://executor:password@localhost:5452/workflow_executor - -# AI provider configuration -AI_PROVIDER=anthropic -AI_MODEL=claude-sonnet-4-20250514 -AI_API_KEY=your-api-key +# Optional — defaults shown +HTTP_PORT=3400 +FOREST_SERVER_URL=https://api.forestadmin.com +POLLING_INTERVAL_MS=5000 -# Polling interval in ms (default: 5000) -# POLLING_INTERVAL_MS=5000 +# Optional local AI (all-or-nothing — falls back to server AI if any is missing) +# AI_PROVIDER=anthropic +# AI_MODEL=claude-sonnet-4-6 +# AI_API_KEY= diff --git a/packages/workflow-executor/example/README.md b/packages/workflow-executor/example/README.md index 42e99c774e..c18e5701c8 100644 --- a/packages/workflow-executor/example/README.md +++ b/packages/workflow-executor/example/README.md @@ -1,41 +1,64 @@ # Workflow Executor — Example -Minimal setup to run a workflow executor backed by PostgreSQL. +Local setup to run the workflow executor backed by PostgreSQL. ## Prerequisites - Docker -- Node.js 18+ +- Node.js 20.6+ (required for native `--env-file` support) - A running Forest Admin agent (the executor proxies record operations to it) ## Quick start +All commands below run from this directory (`packages/workflow-executor/example`). + ### 1. Start PostgreSQL ```bash -cd packages/workflow-executor/example -docker compose up -d +yarn db:up ``` +Exposes Postgres on `localhost:5452` with database `workflow_executor` +(user `executor`, password `password`). + ### 2. Configure environment ```bash cp .env.example .env ``` -Fill in your secrets in `.env`: +Fill in `FOREST_ENV_SECRET` and `FOREST_AUTH_SECRET` from your Forest Admin +project Settings → Environments. Adjust `AGENT_URL` if your agent doesn't run +on the default port. -- `FOREST_ENV_SECRET` / `FOREST_AUTH_SECRET` — from your Forest Admin project settings -- `AGENT_URL` — URL of your running Forest Admin agent -- `AI_API_KEY` — your AI provider API key +### 3. Build the executor -### 3. Run the executor +From the package root (one folder up): ```bash -npx tsx example/index.ts +cd .. && yarn build && cd - +``` + +### 4. Run the executor + +```bash +yarn start +``` + +Expected output: + +``` +[forest-workflow-executor] Starting (database mode) + Forest server : https://api.forestadmin.com + Agent URL : http://localhost:3351 + HTTP port : 3400 + Polling interval : 5000ms + AI config : server fallback (no local AI) +[forest-workflow-executor] Ready on http://localhost:3400 +{"message":"Poll cycle completed","timestamp":"...","fetched":0,"dispatching":0} ``` -### 4. Verify +### 5. Verify ```bash curl http://localhost:3400/health @@ -43,12 +66,31 @@ curl http://localhost:3400/health ``` The executor will: -- Auto-create the `workflow_step_executions` table in PostgreSQL (via umzug migrations) -- Poll the Forest Admin orchestrator for pending steps every 5 seconds +- Auto-create the `workflow_step_executions` table via Umzug migrations +- Poll the Forest Admin orchestrator every `POLLING_INTERVAL_MS` (5s default) - Execute steps locally and report results back +## Available scripts + +| Script | What it does | +|--------|--------------| +| `yarn start` | Run the executor (database mode) — requires `yarn build` first | +| `yarn start:memory` | Run the executor with an in-memory store (no DB, not for prod) | +| `yarn start:watch` | Run via `tsx watch` directly on source — no build, auto-restart on file change | +| `yarn db:up` | Start the Postgres container | +| `yarn db:down` | Stop the Postgres container (keeps data volume) | +| `yarn db:reset` | Drop and recreate the DB (wipes the volume) | +| `yarn db:psql` | Open a `psql` shell in the container | + ## Teardown ```bash -docker compose down -v +yarn db:down # keep the data volume +# or +yarn db:reset # wipe everything ``` + +## See also + +- Package [README](../README.md) — CLI flags, env vars reference, programmatic API +- Package [CLAUDE.md](../CLAUDE.md) — architecture, privacy boundaries diff --git a/packages/workflow-executor/example/docker-compose.yml b/packages/workflow-executor/example/docker-compose.yml index 55f5b8fa06..acedfed588 100644 --- a/packages/workflow-executor/example/docker-compose.yml +++ b/packages/workflow-executor/example/docker-compose.yml @@ -1,9 +1,10 @@ +name: workflow-executor-example + services: postgres: image: postgres:16 - container_name: workflow_executor_example_postgres ports: - - '5452:5432' + - '5459:5432' environment: - POSTGRES_DB=workflow_executor - POSTGRES_USER=executor diff --git a/packages/workflow-executor/example/index.ts b/packages/workflow-executor/example/index.ts deleted file mode 100644 index c3fd86f4d7..0000000000 --- a/packages/workflow-executor/example/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { config } from 'dotenv'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -config({ path: resolve(dirname(fileURLToPath(import.meta.url)), '.env') }); - -import { buildDatabaseExecutor } from '../src/index'; - -const executor = buildDatabaseExecutor({ - envSecret: process.env.FOREST_ENV_SECRET!, - authSecret: process.env.FOREST_AUTH_SECRET!, - agentUrl: process.env.AGENT_URL!, - httpPort: Number(process.env.HTTP_PORT ?? 3400), - forestServerUrl: process.env.FOREST_SERVER_URL, - pollingIntervalMs: Number(process.env.POLLING_INTERVAL_MS ?? 5000), - database: { - uri: process.env.DATABASE_URL!, - }, - aiConfigurations: [ - { - name: 'default', - provider: process.env.AI_PROVIDER as 'anthropic' | 'openai', - model: process.env.AI_MODEL!, - apiKey: process.env.AI_API_KEY!, - }, - ], -}); - -executor.start().then(() => { - console.log(`Workflow executor started on port ${process.env.HTTP_PORT ?? 3400}`); -}); diff --git a/packages/workflow-executor/example/package.json b/packages/workflow-executor/example/package.json new file mode 100644 index 0000000000..78118aa86a --- /dev/null +++ b/packages/workflow-executor/example/package.json @@ -0,0 +1,21 @@ +{ + "name": "workflow-executor-example", + "version": "0.0.0", + "license": "GPL-3.0", + "private": true, + "scripts": { + "start": "node --env-file=.env ../dist/cli.js", + "start:memory": "node --env-file=.env ../dist/cli.js --in-memory", + "start:watch": "tsx watch --env-file=.env ../src/cli.ts", + "db:up": "docker compose up -d", + "db:down": "docker compose down", + "db:reset": "docker compose down -v && docker compose up -d", + "db:psql": "docker compose exec postgres psql -U executor -d workflow_executor" + }, + "dependencies": { + "@forestadmin/workflow-executor": "*" + }, + "devDependencies": { + "tsx": "^4.19.2" + } +} diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index 2b3c5e518b..bf0217813b 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -2,6 +2,9 @@ "name": "@forestadmin/workflow-executor", "version": "1.0.0", "main": "dist/index.js", + "bin": { + "forest-workflow-executor": "dist/cli.js" + }, "license": "GPL-3.0", "publishConfig": { "access": "public" @@ -23,15 +26,16 @@ "test": "jest" }, "dependencies": { - "@forestadmin/agent-client": "1.4.18", - "@forestadmin/ai-proxy": "1.7.2", + "@forestadmin/agent-client": "1.5.6", + "@forestadmin/ai-proxy": "1.8.0", "@langchain/openai": "1.2.5", - "@forestadmin/forestadmin-client": "1.38.2", + "@forestadmin/forestadmin-client": "1.39.5", "@koa/bodyparser": "^6.1.0", "@koa/router": "^13.1.0", "jsonwebtoken": "^9.0.3", "koa": "^3.0.1", "koa-jwt": "^4.0.4", + "picocolors": "^1.1.1", "sequelize": "^6.37.8", "umzug": "^3.8.2", "zod": "4.3.6" diff --git a/packages/workflow-executor/src/adapters/activity-log-drainer.ts b/packages/workflow-executor/src/adapters/activity-log-drainer.ts new file mode 100644 index 0000000000..b8757a2247 --- /dev/null +++ b/packages/workflow-executor/src/adapters/activity-log-drainer.ts @@ -0,0 +1,18 @@ +export default class ActivityLogDrainer { + private readonly inFlight = new Set>(); + + track(fn: () => Promise): Promise { + const promise = fn(); + this.inFlight.add(promise); + // Swallow rejections on the cleanup chain so tracking a rejecting promise + // doesn't cause UnhandledPromiseRejection. The original promise returned + // to the caller still rejects normally. + promise.finally(() => this.inFlight.delete(promise)).catch(() => {}); + + return promise; + } + + async drain(): Promise { + await Promise.allSettled([...this.inFlight]); + } +} diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 08fd585805..24bec25231 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,19 +1,45 @@ import type { AgentPort, ExecuteActionQuery, + GetActionFormInfoQuery, GetRecordQuery, GetRelatedDataQuery, UpdateRecordQuery, } from '../ports/agent-port'; import type SchemaCache from '../schema-cache'; -import type { StepUser } from '../types/execution'; -import type { CollectionSchema, RecordData } from '../types/record'; -import type { SelectOptions } from '@forestadmin/agent-client'; +import type { StepUser } from '../types/execution-context'; +import type { CollectionSchema, RecordData } from '../types/validated/collection'; +import type { ActionEndpointsByCollection, SelectOptions } from '@forestadmin/agent-client'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; import jsonwebtoken from 'jsonwebtoken'; -import { RecordNotFoundError } from '../errors'; +import { + AgentPortError, + AgentProbeError, + RecordNotFoundError, + WorkflowExecutorError, + extractErrorMessage, +} from '../errors'; + +// The agent-client HTTP layer deserializes JSON:API responses with camelCase keys. +// Field names in the schema and in GetRecordQuery.fields use the original format (e.g. snake_case). +// This function restores the original field names so callers can look up values by schema fieldName. +function restoreFieldNames( + values: Record, + originalFieldNames: string[] | undefined, +): Record { + if (!originalFieldNames?.length) return values; + + const camelToOriginal: Record = {}; + + for (const name of originalFieldNames) { + const camelName = name.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase()); + camelToOriginal[camelName] = name; + } + + return Object.fromEntries(Object.entries(values).map(([k, v]) => [camelToOriginal[k] ?? k, v])); +} function buildPkFilter( primaryKeyFields: string[], @@ -33,18 +59,6 @@ function buildPkFilter( }; } -// agent-client methods (update, relation, action) still expect the pipe-encoded string format -function encodePk(id: Array): string { - return id.map(v => String(v)).join('|'); -} - -function extractRecordId( - primaryKeyFields: string[], - record: Record, -): Array { - return primaryKeyFields.map(field => record[field] as string | number); -} - export default class AgentClientAgentPort implements AgentPort { private readonly agentUrl: string; private readonly authSecret: string; @@ -57,67 +71,111 @@ export default class AgentClientAgentPort implements AgentPort { } async getRecord({ collection, id, fields }: GetRecordQuery, user: StepUser): Promise { - const client = this.createClient(user); - const schema = this.resolveSchema(collection); - const records = await client.collection(collection).list>({ - filters: buildPkFilter(schema.primaryKeyFields, id), - pagination: { size: 1, number: 1 }, - ...(fields?.length && { fields }), - }); + return this.callAgent('getRecord', async () => { + const client = this.createClient(user); + const schema = this.resolveSchema(collection); + const records = await client.collection(collection).list>({ + filters: buildPkFilter(schema.primaryKeyFields, id), + pagination: { size: 1, number: 1 }, + ...(fields?.length && { fields }), + }); - if (records.length === 0) { - throw new RecordNotFoundError(collection, encodePk(id)); - } + if (records.length === 0) { + throw new RecordNotFoundError(collection, id.join('|')); + } - return { collectionName: collection, recordId: id, values: records[0] }; + return { + collectionName: collection, + recordId: id, + values: restoreFieldNames(records[0], fields), + }; + }); } async updateRecord( { collection, id, values }: UpdateRecordQuery, user: StepUser, ): Promise { - const client = this.createClient(user); - const updatedRecord = await client - .collection(collection) - .update>(encodePk(id), values); + return this.callAgent('updateRecord', async () => { + const client = this.createClient(user); + const updatedRecord = await client + .collection(collection) + .update>(id, values); - return { collectionName: collection, recordId: id, values: updatedRecord }; + return { + collectionName: collection, + recordId: id, + values: restoreFieldNames(updatedRecord, Object.keys(values)), + }; + }); } async getRelatedData( { collection, id, relation, limit, fields }: GetRelatedDataQuery, user: StepUser, ): Promise { - const client = this.createClient(user); - const parentSchema = this.resolveSchema(collection); - const relationField = parentSchema.fields.find(f => f.fieldName === relation); - const relatedCollectionName = relationField?.relatedCollectionName ?? relation; - const relatedSchema = this.resolveSchema(relatedCollectionName); - - const records = await client - .collection(collection) - .relation(relation, encodePk(id)) - .list>({ - ...(limit !== null && { pagination: { size: limit, number: 1 } }), - ...(fields?.length && { fields }), - }); + return this.callAgent('getRelatedData', async () => { + const client = this.createClient(user); + const parentSchema = this.resolveSchema(collection); + const relationField = parentSchema.fields.find(f => f.fieldName === relation); + const relatedCollectionName = relationField?.relatedCollectionName ?? relation; + const relatedSchema = this.resolveSchema(relatedCollectionName); + + const records = await client + .collection(collection) + .relation(relation, id) + .list>({ + ...(limit !== null && { pagination: { size: limit, number: 1 } }), + ...(fields?.length && { fields }), + }); + + return records.map(record => { + const restored = restoreFieldNames(record, [ + ...relatedSchema.primaryKeyFields, + ...(fields ?? []), + ]); - return records.map(record => ({ - collectionName: relatedSchema.collectionName, - recordId: extractRecordId(relatedSchema.primaryKeyFields, record), - values: record, - })); + return { + collectionName: relatedSchema.collectionName, + recordId: relatedSchema.primaryKeyFields.map(f => restored[f] as string | number), + values: restored, + }; + }); + }); } async executeAction( { collection, action, id }: ExecuteActionQuery, user: StepUser, ): Promise { - const client = this.createClient(user); - const encodedId = id?.length ? [encodePk(id)] : []; - const act = await client.collection(collection).action(action, { recordIds: encodedId }); + return this.callAgent('executeAction', async () => { + const client = this.createClient(user); + const recordIds = id?.length ? [id] : []; + const act = await client.collection(collection).action(action, { recordIds }); + + return act.execute(); + }); + } + + async getActionFormInfo( + { collection, action, id }: GetActionFormInfoQuery, + user: StepUser, + ): Promise<{ hasForm: boolean }> { + return this.callAgent('getActionFormInfo', async () => { + const client = this.createClient(user); + const act = await client.collection(collection).action(action, { recordIds: [id] }); + + return { hasForm: act.getFields().length > 0 }; + }); + } - return act.execute(); + private async callAgent(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AgentPortError(operation, cause); + } } private createClient(user: StepUser) { @@ -132,16 +190,47 @@ export default class AgentClientAgentPort implements AgentPort { }); } - private buildActionEndpoints() { - const endpoints: Record> = {}; + // Hits GET /forest/ (public, no auth required across all agent versions). A 4xx here means + // the URL points to something that isn't a Forest agent. JWT is validated naturally on first step. + async probe(): Promise { + const url = `${this.agentUrl.replace(/\/+$/, '')}/forest/`; + + let response: Response; + + try { + response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(5_000) }); + } catch (error) { + const isTimeout = error instanceof Error && error.name === 'TimeoutError'; + const reason = isTimeout ? 'timeout after 5000ms' : extractErrorMessage(error); + throw new AgentProbeError(`cannot reach ${this.agentUrl} (${reason})`, { cause: error }); + } + + if (!response.ok) { + throw new AgentProbeError( + `${this.agentUrl} responded with ${response.status} ${response.statusText}`, + ); + } + } + + private buildActionEndpoints(): ActionEndpointsByCollection { + const endpoints: ActionEndpointsByCollection = {}; for (const [collectionName, schema] of this.schemaCache) { endpoints[collectionName] = {}; for (const action of schema.actions) { + // agent-client POSTs /hooks/load unconditionally; `hooks.load` tells it whether a 404 + // there is expected (Ruby agent, swallowed → fallback to the static `fields` below) or + // a real error. Both `hooks` and `fields` must mirror the agent's real schema for form + // detection to work on Ruby agents. endpoints[collectionName][action.name] = { + id: action.name, name: action.name, endpoint: action.endpoint, + hooks: action.hooks ?? { load: false, change: [] }, + // Zod envelope-validates `fields` as an array of opaque objects. Inner widget/parameters + // shape is owned by @forestadmin/forestadmin-client and consumed by agent-client below. + fields: (action.fields ?? []) as ActionEndpointsByCollection[string][string]['fields'], }; } } @@ -150,8 +239,18 @@ export default class AgentClientAgentPort implements AgentPort { } private resolveSchema(collectionName: string): CollectionSchema { + const cached = this.schemaCache.get(collectionName); + + if (!cached) { + // eslint-disable-next-line no-console + console.warn( + `[workflow-executor] Schema not found in cache for collection "${collectionName}". ` + + 'Falling back to primaryKeyFields: ["id"]. Call getCollectionSchema first.', + ); + } + return ( - this.schemaCache.get(collectionName) ?? { + cached ?? { collectionName, collectionDisplayName: collectionName, primaryKeyFields: ['id'], diff --git a/packages/workflow-executor/src/adapters/ai-client-adapter.ts b/packages/workflow-executor/src/adapters/ai-client-adapter.ts index e9ff43246b..c8485328b8 100644 --- a/packages/workflow-executor/src/adapters/ai-client-adapter.ts +++ b/packages/workflow-executor/src/adapters/ai-client-adapter.ts @@ -4,6 +4,8 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models' import { AiClient } from '@forestadmin/ai-proxy'; +import { AiModelPortError, WorkflowExecutorError } from '../errors'; + export default class AiClientAdapter implements AiModelPort { private readonly aiClient: AiClient; @@ -13,14 +15,28 @@ export default class AiClientAdapter implements AiModelPort { } getModel(aiConfigName?: string): BaseChatModel { - return this.aiClient.getModel(aiConfigName); + try { + return this.aiClient.getModel(aiConfigName); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AiModelPortError('getModel', cause); + } } loadRemoteTools(config: McpConfiguration): Promise { - return this.aiClient.loadRemoteTools(config); + return this.callPort('loadRemoteTools', () => this.aiClient.loadRemoteTools(config)); } closeConnections(): Promise { - return this.aiClient.closeConnections(); + return this.callPort('closeConnections', () => this.aiClient.closeConnections()); + } + + private async callPort(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AiModelPortError(operation, cause); + } } } diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index d3983db844..5fc111d2e2 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -1,69 +1,240 @@ -import type { McpConfiguration, WorkflowPort } from '../ports/workflow-port'; -import type { PendingStepExecution, StepUser } from '../types/execution'; -import type { CollectionSchema } from '../types/record'; -import type { StepOutcome } from '../types/step-outcome'; +import type { ServerHydratedWorkflowRun } from './server-types'; +import type { Logger } from '../ports/logger-port'; +import type { + AvailableRunDispatch, + AvailableRunsBatch, + MalformedRunInfo, + McpConfiguration, + WorkflowPort, +} from '../ports/workflow-port'; +import type { StepUser } from '../types/execution-context'; +import type { CollectionSchema } from '../types/validated/collection'; +import type { StepOutcome } from '../types/validated/step-outcome'; import type { HttpOptions } from '@forestadmin/forestadmin-client'; import { ServerUtils } from '@forestadmin/forestadmin-client'; +import { z } from 'zod'; + +import ConsoleLogger from './console-logger'; +import toAvailableStepExecution from './run-to-available-step-mapper'; +import toUpdateStepRequest from './step-outcome-to-update-step-mapper'; +import withRetry from './with-retry'; +import { + DomainValidationError, + InvalidStepDefinitionError, + MalformedRunError, + WorkflowExecutorError, + WorkflowPortError, + extractErrorMessage, +} from '../errors'; +import { CollectionSchemaSchema } from '../types/validated/collection'; -// TODO: finalize route paths with the team — these are placeholders const ROUTES = { - pendingStepExecutions: '/liana/v1/workflow-step-executions/pending', - pendingStepExecutionForRun: (runId: string) => - `/liana/v1/workflow-step-executions/pending?runId=${encodeURIComponent(runId)}`, - updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${runId}/complete`, - collectionSchema: (collectionName: string) => `/liana/v1/collections/${collectionName}`, + pendingRuns: '/api/workflow-orchestrator/pending-run', + availableRun: (runId: string) => + `/api/workflow-orchestrator/available-run/${encodeURIComponent(runId)}`, + updateStep: '/api/workflow-orchestrator/update-step', + collectionSchema: (collectionName: string, runId: string) => + `/api/workflow-orchestrator/collection-schema/${encodeURIComponent( + collectionName, + )}?runId=${encodeURIComponent(runId)}`, + accessCheck: (runId: string, userId: number) => + `/api/workflow-orchestrator/run/${encodeURIComponent(runId)}/access-check?userId=${userId}`, mcpServerConfigs: '/liana/mcp-server-configs-with-details', }; export default class ForestServerWorkflowPort implements WorkflowPort { private readonly options: HttpOptions; + private readonly logger: Logger; - constructor(params: { envSecret: string; forestServerUrl: string }) { + constructor(params: { envSecret: string; forestServerUrl: string; logger?: Logger }) { this.options = { envSecret: params.envSecret, forestServerUrl: params.forestServerUrl }; + this.logger = params.logger ?? new ConsoleLogger(); } - async getPendingStepExecutions(): Promise { - return ServerUtils.query( - this.options, - 'get', - ROUTES.pendingStepExecutions, + async getAvailableRuns(): Promise { + const runs = await this.callPort('getAvailableRuns', () => + ServerUtils.query(this.options, 'get', ROUTES.pendingRuns), ); + + const pending: AvailableRunDispatch[] = []; + const malformed: MalformedRunInfo[] = []; + + for (const run of runs) { + try { + const dispatch = this.toDispatch(run); + if (dispatch) pending.push(dispatch); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + malformed.push(this.toMalformedInfo(run, error)); + } else { + this.logger.error('Failed to hydrate pending run — unexpected error', { + runId: run.id, + error: extractErrorMessage(error), + }); + } + } + } + + return { pending, malformed }; } - async getPendingStepExecutionsForRun(runId: string): Promise { - return ServerUtils.query( - this.options, - 'get', - ROUTES.pendingStepExecutionForRun(runId), + async getAvailableRun(runId: string): Promise { + const run = await this.callPort('getAvailableRun', () => + ServerUtils.query( + this.options, + 'get', + ROUTES.availableRun(runId), + ), ); + + if (!run) return null; + + try { + return this.toDispatch(run); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + throw new MalformedRunError(this.toMalformedInfo(run, error)); + } + + /* istanbul ignore next — defensive fallback for unexpected non-domain errors */ + throw error; + } + } + + // Validates userProfile + serverToken at the adapter boundary. Split into two checks so an + // operator can diagnose "userProfile missing" vs "serverToken missing" from the error alone. + private toDispatch(run: ServerHydratedWorkflowRun): AvailableRunDispatch | null { + if (!run.userProfile) { + throw new InvalidStepDefinitionError( + `Run ${run.id} is missing required field userProfile — ` + + `the orchestrator must include it in the run payload`, + ); + } + + const token = run.userProfile.serverToken; + + if (typeof token !== 'string' || !token) { + throw new InvalidStepDefinitionError( + `Run ${run.id} userProfile is missing serverToken — ` + + `the orchestrator must include it in the run payload`, + ); + } + + const step = toAvailableStepExecution(run); + if (!step) return null; + + return { step, auth: { forestServerToken: token } }; } - async updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise { - await ServerUtils.query( - this.options, - 'post', - ROUTES.updateStepExecution(runId), - {}, - stepOutcome, + private toMalformedInfo( + run: ServerHydratedWorkflowRun, + err: WorkflowExecutorError, + ): MalformedRunInfo { + const pending = run.workflowHistory.at(-1) ?? null; + + return { + runId: String(run.id), + stepId: pending?.stepName ?? null, + stepIndex: pending?.stepIndex ?? null, + userMessage: err.userMessage, + technicalMessage: err.message, + }; + } + + async updateStepExecution( + runId: string, + stepOutcome: StepOutcome, + ): Promise { + return this.callPort( + 'updateStepExecution', + async () => { + const body = toUpdateStepRequest(runId, stepOutcome); + const run = await ServerUtils.query( + this.options, + 'post', + ROUTES.updateStep, + {}, + body, + ); + + if (!run) return null; + + try { + return this.toDispatch(run); + } catch (error) { + // The outcome was recorded server-side; only the chain parse failed. Fall back to the + // next poll cycle — don't let a malformed chain response mask the successful update. + this.logger.error('Failed to parse chained next step from /update-step response', { + runId: String(run.id), + error: extractErrorMessage(error), + }); + + return null; + } + }, + { retry: true }, ); } - async getCollectionSchema(collectionName: string): Promise { - return ServerUtils.query( - this.options, - 'get', - ROUTES.collectionSchema(collectionName), + async getCollectionSchema(collectionName: string, runId: string): Promise { + return this.callPort( + 'getCollectionSchema', + async () => { + const response = await ServerUtils.query( + this.options, + 'get', + ROUTES.collectionSchema(collectionName, runId), + ); + + try { + return CollectionSchemaSchema.parse(response); + } catch (err) { + if (err instanceof z.ZodError) { + // runId is passed for observability — the schema call is scoped to a run. + throw new DomainValidationError(Number(runId) || 0, err); + } + + /* istanbul ignore next — zod.parse only throws ZodError; defensive fallback */ + throw err; + } + }, + { retry: true }, ); } async getMcpServerConfigs(): Promise { - return ServerUtils.query(this.options, 'get', ROUTES.mcpServerConfigs); + return this.callPort( + 'getMcpServerConfigs', + () => ServerUtils.query(this.options, 'get', ROUTES.mcpServerConfigs), + { retry: true }, + ); + } + + async hasRunAccess(runId: string, user: StepUser): Promise { + return this.callPort('hasRunAccess', async () => { + const { hasAccess } = await ServerUtils.query<{ hasAccess: boolean }>( + this.options, + 'get', + ROUTES.accessCheck(runId, user.id), + ); + + return hasAccess === true; + }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async hasRunAccess(_runId: string, _user: StepUser): Promise { - // TODO: implement once GET /liana/v1/workflow-runs/:runId/access is available. - return true; + private async callPort( + operation: string, + fn: () => Promise, + options?: { retry?: boolean }, + ): Promise { + const run = options?.retry ? () => withRetry(operation, fn, { logger: this.logger }) : fn; + + try { + return await run(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new WorkflowPortError(operation, cause); + } } } diff --git a/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port-factory.ts b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port-factory.ts new file mode 100644 index 0000000000..25c1d2015a --- /dev/null +++ b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port-factory.ts @@ -0,0 +1,28 @@ +import type { ActivityLogPort, ActivityLogPortFactory } from '../ports/activity-log-port'; +import type { Logger } from '../ports/logger-port'; +import type { ActivityLogsServiceInterface } from '@forestadmin/forestadmin-client'; + +import ActivityLogDrainer from './activity-log-drainer'; +import ForestadminClientActivityLogPort from './forestadmin-client-activity-log-port'; + +export default class ForestadminClientActivityLogPortFactory implements ActivityLogPortFactory { + private readonly drainer = new ActivityLogDrainer(); + + constructor( + private readonly service: ActivityLogsServiceInterface, + private readonly logger: Logger, + ) {} + + forRun(forestServerToken: string): ActivityLogPort { + return new ForestadminClientActivityLogPort( + this.service, + this.logger, + forestServerToken, + this.drainer, + ); + } + + async drain(): Promise { + return this.drainer.drain(); + } +} diff --git a/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts new file mode 100644 index 0000000000..c3232cd770 --- /dev/null +++ b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts @@ -0,0 +1,99 @@ +import type ActivityLogDrainer from './activity-log-drainer'; +import type { + ActivityLogHandle, + ActivityLogPort, + CreateActivityLogArgs, +} from '../ports/activity-log-port'; +import type { Logger } from '../ports/logger-port'; +import type { + ActivityLogAction, + ActivityLogsServiceInterface, +} from '@forestadmin/forestadmin-client'; + +import withRetry from './with-retry'; +import { ActivityLogCreationError, extractErrorMessage } from '../errors'; + +export default class ForestadminClientActivityLogPort implements ActivityLogPort { + constructor( + private readonly service: ActivityLogsServiceInterface, + private readonly logger: Logger, + private readonly forestServerToken: string, + private readonly drainer: ActivityLogDrainer, + ) {} + + async createPending(args: CreateActivityLogArgs): Promise { + try { + const response = await withRetry( + 'Activity log createPending', + () => + this.service.createActivityLog({ + forestServerToken: this.forestServerToken, + renderingId: String(args.renderingId), + action: args.action as ActivityLogAction, + type: args.type, + // The lib writes this value verbatim into relationships.collection.data.id + // (JSON:API). The Forest server audit-trail API expects the numeric collectionId. + collectionName: args.collectionId, + recordId: args.recordId, + label: args.label, + }), + { logger: this.logger }, + ); + + return { id: response.id, index: response.attributes.index }; + } catch (cause) { + this.logger.error('Activity log creation failed', { + action: args.action, + collectionId: args.collectionId, + status: (cause as { status?: number }).status, + error: extractErrorMessage(cause), + }); + throw new ActivityLogCreationError(cause); + } + } + + async markSucceeded(handle: ActivityLogHandle): Promise { + return this.drainer.track(async () => { + try { + await withRetry( + 'Activity log markSucceeded', + () => + this.service.updateActivityLogStatus({ + forestServerToken: this.forestServerToken, + activityLog: { id: handle.id, attributes: { index: handle.index } }, + status: 'completed', + }), + { logger: this.logger }, + ); + } catch (err) { + this.logger.error('Activity log markSucceeded failed after retries', { + handleId: handle.id, + error: extractErrorMessage(err), + }); + } + }); + } + + async markFailed(handle: ActivityLogHandle, errorMessage: string): Promise { + return this.drainer.track(async () => { + try { + await withRetry( + 'Activity log markFailed', + () => + this.service.updateActivityLogStatus({ + forestServerToken: this.forestServerToken, + activityLog: { id: handle.id, attributes: { index: handle.index } }, + status: 'failed', + }), + { logger: this.logger }, + ); + } catch (err) { + this.logger.error('Activity log markFailed failed after retries', { + handleId: handle.id, + stepErrorMessage: errorMessage, + error: extractErrorMessage(err), + }); + } + }); + } +} diff --git a/packages/workflow-executor/src/adapters/pretty-logger.ts b/packages/workflow-executor/src/adapters/pretty-logger.ts new file mode 100644 index 0000000000..b6f4cc0f21 --- /dev/null +++ b/packages/workflow-executor/src/adapters/pretty-logger.ts @@ -0,0 +1,33 @@ +import type { Logger } from '../ports/logger-port'; + +import pc from 'picocolors'; + +// Colorized logger for TTY/dev. Pair with ConsoleLogger for piped output. +// CLI auto-picks via process.stdout.isTTY + --pretty/--json flags. NO_COLOR is honored. +export default class PrettyLogger implements Logger { + info(message: string, context: Record): void { + // eslint-disable-next-line no-console + console.info(this.format(pc.cyan('info '), message, context)); + } + + error(message: string, context: Record): void { + // eslint-disable-next-line no-console + console.error(this.format(pc.red('error'), message, context)); + } + + private format(level: string, message: string, context: Record): string { + const timestamp = pc.dim(new Date().toISOString().substring(11, 19)); + const contextStr = this.formatContext(context); + + return contextStr + ? `${timestamp} ${level} ${message} ${contextStr}` + : `${timestamp} ${level} ${message}`; + } + + private formatContext(context: Record): string { + const parts = Object.entries(context).map(([key, value]) => `${key}=${JSON.stringify(value)}`); + if (parts.length === 0) return ''; + + return pc.dim(parts.join(' ')); + } +} diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts new file mode 100644 index 0000000000..3e0f62a70e --- /dev/null +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -0,0 +1,170 @@ +import type { + ServerHydratedWorkflowRun, + ServerStepHistory, + ServerUserProfile, +} from './server-types'; +import type { + ConditionStepOutcome, + GuidanceStepOutcome, + McpStepOutcome, + RecordStepOutcome, + StepOutcome, +} from '../types/validated/step-outcome'; + +import { z } from 'zod'; + +import toStepDefinition from './step-definition-mapper'; +import { + DomainValidationError, + InvalidStepDefinitionError, + UnsupportedStepTypeError, +} from '../errors'; +import { + type AvailableStepExecution, + AvailableStepExecutionSchema, + type Step, + type StepUser, +} from '../types/validated/execution'; +import { stepTypeToOutcomeType } from '../types/validated/step-outcome'; + +function toRecordStatus(ctxStatus: unknown): RecordStepOutcome['status'] { + if (ctxStatus === 'error') return 'error'; + if (ctxStatus === 'awaiting-input') return 'awaiting-input'; + + return 'success'; +} + +// `context` may come from the executor (our StepOutcome, stored verbatim) or the legacy frontend +// (free-form). We whitelist known fields per type to avoid leaking legacy ones back to the +// orchestrator and to enforce the discriminated-union shape. +function toStepOutcome(s: ServerStepHistory): StepOutcome { + const stepDef = toStepDefinition(s.stepDefinition); + const outcomeType = stepTypeToOutcomeType(stepDef.type); + const ctx = (s.context ?? {}) as Record; + + const baseFromCtx = { + stepId: s.stepName, + stepIndex: s.stepIndex, + error: typeof ctx.error === 'string' ? ctx.error : undefined, + }; + + if (outcomeType === 'condition') { + const status: ConditionStepOutcome['status'] = ctx.status === 'error' ? 'error' : 'success'; + const selectedOption = typeof ctx.selectedOption === 'string' ? ctx.selectedOption : undefined; + + return { + type: 'condition', + ...baseFromCtx, + status, + ...(selectedOption !== undefined && { selectedOption }), + }; + } + + if (outcomeType === 'guidance') { + const status: GuidanceStepOutcome['status'] = ctx.status === 'error' ? 'error' : 'success'; + + return { type: 'guidance', ...baseFromCtx, status }; + } + + const status = toRecordStatus(ctx.status); + + if (outcomeType === 'mcp') { + return { type: 'mcp', ...baseFromCtx, status } satisfies McpStepOutcome; + } + + return { type: 'record', ...baseFromCtx, status } satisfies RecordStepOutcome; +} + +function tryMapStep(s: ServerStepHistory): Step | null { + try { + return { stepDefinition: toStepDefinition(s.stepDefinition), stepOutcome: toStepOutcome(s) }; + } catch (err) { + // Sub-workflow navigation steps (start-sub-workflow, close-sub-workflow) are not + // meaningful for AI context — skip them rather than failing the whole run. + if (err instanceof UnsupportedStepTypeError) return null; + throw err; + } +} + +function toPreviousSteps( + history: ServerStepHistory[], + pendingStepIndex: number, +): ReadonlyArray { + return history + .filter(s => s.done && s.stepIndex < pendingStepIndex) + .map(s => tryMapStep(s)) + .filter((s): s is Step => s !== null); +} + +function toStepUser(runId: number, profile: ServerUserProfile): StepUser { + // renderingId is stringified into the activity-log payload — reject non-finite so we don't + // silently post "undefined"/"NaN" to the audit trail. + if (typeof profile.renderingId !== 'number' || !Number.isFinite(profile.renderingId)) { + throw new InvalidStepDefinitionError( + `Run ${runId} userProfile has no valid renderingId (got "${String(profile.renderingId)}")`, + ); + } + + return { + id: profile.id, + email: profile.email, + firstName: profile.firstName ?? '', + lastName: profile.lastName ?? '', + team: profile.team ?? '', + renderingId: profile.renderingId, + role: profile.role ?? '', + permissionLevel: profile.permissionLevel ?? '', + tags: profile.tags, + }; +} + +// Returns null when the run has no available step (terminal state or all done/cancelled). +// Throws InvalidStepDefinitionError on missing required fields (collectionId, collectionName, +// userProfile) or an unmappable step definition. +export default function toAvailableStepExecution( + run: ServerHydratedWorkflowRun, +): AvailableStepExecution | null { + if (!run.collectionName) { + throw new InvalidStepDefinitionError( + `Run ${run.id} has no collectionName — cannot build baseRecordRef`, + ); + } + + if (!run.collectionId) { + throw new InvalidStepDefinitionError( + `Run ${run.id} has no collectionId — cannot build baseRecordRef`, + ); + } + + const pending = run.workflowHistory.at(-1) ?? null; + if (!pending) return null; + + const result = { + runId: String(run.id), + stepId: pending.stepName, + stepIndex: pending.stepIndex, + collectionId: run.collectionId, + baseRecordRef: { + collectionName: run.collectionName, + recordId: [run.selectedRecordId], + stepIndex: 0, + }, + stepDefinition: toStepDefinition(pending.stepDefinition), + previousSteps: toPreviousSteps(run.workflowHistory, pending.stepIndex), + user: toStepUser(run.id, run.userProfile), + }; + + // Defense against mapper bugs: zod asserts the shape we produce is what the domain expects, + // before any executor consumes it. Fails loudly with a typed error instead of crashing deep. + + try { + return AvailableStepExecutionSchema.parse(result); + } catch (err) { + if (err instanceof z.ZodError) { + throw new DomainValidationError(run.id, err); + } + + /* istanbul ignore next — zod.parse only throws ZodError; defensive fallback */ + throw err; + } +} diff --git a/packages/workflow-executor/src/adapters/server-ai-adapter.ts b/packages/workflow-executor/src/adapters/server-ai-adapter.ts index 25d06399e4..72e4a341b1 100644 --- a/packages/workflow-executor/src/adapters/server-ai-adapter.ts +++ b/packages/workflow-executor/src/adapters/server-ai-adapter.ts @@ -5,6 +5,8 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models' import { AiClient } from '@forestadmin/ai-proxy'; import { ChatOpenAI } from '@langchain/openai'; +import { AiModelPortError, WorkflowExecutorError } from '../errors'; + export interface ServerAiAdapterOptions { forestServerUrl: string; envSecret: string; @@ -22,32 +24,46 @@ export default class ServerAiAdapter implements AiModelPort { } getModel(): BaseChatModel { - const aiProxyUrl = `${this.forestServerUrl}/liana/v1/ai-proxy`; - const { envSecret } = this; - - return new ChatOpenAI({ - // Model has no effect — the server uses its own configured model. - // Set here only because ChatOpenAI requires it. - model: 'gpt-4.1', - maxRetries: 2, - configuration: { - apiKey: 'unused', - fetch: (_url: RequestInfo | URL, init?: RequestInit) => { - const headers = new Headers(init?.headers); - headers.delete('authorization'); - headers.set('forest-secret-key', envSecret); - - return fetch(aiProxyUrl, { ...init, headers }); + try { + const aiProxyUrl = `${this.forestServerUrl}/liana/v1/ai-proxy`; + const { envSecret } = this; + + return new ChatOpenAI({ + // Model has no effect — the server uses its own configured model. + // Set here only because ChatOpenAI requires it. + model: 'gpt-4.1', + maxRetries: 2, + configuration: { + apiKey: 'unused', + fetch: (_url: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.delete('authorization'); + headers.set('forest-secret-key', envSecret); + + return fetch(aiProxyUrl, { ...init, headers }); + }, }, - }, - }); + }); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AiModelPortError('getModel', cause); + } } loadRemoteTools(config: McpConfiguration): Promise { - return this.aiClient.loadRemoteTools(config); + return this.callPort('loadRemoteTools', () => this.aiClient.loadRemoteTools(config)); } closeConnections(): Promise { - return this.aiClient.closeConnections(); + return this.callPort('closeConnections', () => this.aiClient.closeConnections()); + } + + private async callPort(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AiModelPortError(operation, cause); + } } } diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts new file mode 100644 index 0000000000..358456aa10 --- /dev/null +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -0,0 +1,156 @@ +/** + * Local mirror of the orchestrator's contract. + * See forestadmin-server/packages/private-api/src/domain/workflow-orchestrator/types.ts + * + * Contains both step-level types (workflow step variants) and the run envelope + * (HydratedWorkflowRun + user profile + step history). + */ + +export interface ServerWorkflowTransition { + stepId: string; + buttonText: string | null; + buttonColor?: string | null; + answer?: string; +} + +export type ServerTaskType = + | 'guideline' + | 'trigger-action' + | 'get-data' + | 'update-data' + | 'load-related-record' + | 'mcp-server'; + +export interface ServerWorkflowTask { + type: 'task'; + taskType: ServerTaskType; + isSubTask?: boolean; + title: string; + prompt: string; + allowedTools?: string[]; + mcpServerId?: string; + automaticExecution?: boolean; + automaticCompletion?: boolean; + outgoing: ServerWorkflowTransition; +} + +export interface ServerWorkflowCondition { + type: 'condition'; + title: string; + prompt: string; + outgoing: ServerWorkflowTransition[]; + automaticExecution?: boolean; +} + +export interface ServerWorkflowEnd { + type: 'end'; + title: string; + prompt?: string; +} + +export interface ServerWorkflowEscalation { + type: 'escalation'; + title: string; + prompt: string; + outgoing: ServerWorkflowTransition; + inboxId: string | null; +} + +export interface ServerStartSubWorkflow { + type: 'start-sub-workflow'; + title: string; + prompt: string; + outgoing: ServerWorkflowTransition; + workflowId: string; +} + +export interface ServerCloseSubWorkflow { + type: 'close-sub-workflow'; + title?: string; + outgoing: ServerWorkflowTransition; + parentWorkflowId: string | null; +} + +export type ServerWorkflowStep = + | ServerWorkflowTask + | ServerWorkflowCondition + | ServerWorkflowEnd + | ServerWorkflowEscalation + | ServerStartSubWorkflow + | ServerCloseSubWorkflow; + +// --- Run envelope (returned by pending-run endpoints) --- + +export interface ServerUserProfile { + id: number; + email: string; + firstName: string | null; + lastName: string | null; + team: string | null; + renderingId: number; + role: string | null; + permissionLevel: string | null; + tags: Record; + // Forwarded by the orchestrator so the executor can post activity logs on behalf of the user. + serverToken: string; +} + +export interface ServerStepHistory { + stepName: string; + stepIndex: number; + done: boolean; + revised?: boolean; + cancelled?: boolean; + context?: Record; + childrenWorkflowId?: string; + stepDefinition: ServerWorkflowStep; +} + +/** Mirror of the server's `WorkflowRunState` enum (workflow-run-model.ts). */ +export type ServerWorkflowRunState = 'started' | 'pending' | 'loading' | 'aborted' | 'finished'; + +export interface ServerHydratedWorkflowRun { + id: number; + workflowId: string; + collectionId: string; + collectionName: string | null; + selectedRecordId: string; + bpmnVersion: string; + runState: ServerWorkflowRunState; + workflowHistory: ServerStepHistory[]; + /** Server types declare `Date`; Express serializes to ISO 8601 string on the wire. */ + createdAt: string; + updatedAt: string; + userId: number; + renderingId: number; + lockedAt?: string | null; + userProfile: ServerUserProfile; +} + +// --- Update step request (POST /api/workflow-orchestrator/update-step) --- + +export interface ServerStepHistoryUpdate { + /** Accepted by the server Joi schema; missing from the server TS type (server-side gap). */ + isLoading?: boolean; + done?: boolean; + revised?: boolean; + cancelled?: boolean; + context?: Record; +} + +export interface ServerStepUpdate { + stepIndex: number; + attributes: ServerStepHistoryUpdate; +} + +export type ServerExecutionStatus = + /** `nextStepId` is accepted by the server Joi schema; missing from the server TS type. */ + | { type: 'success'; nextStepId?: string } + | { type: 'error'; message: string } + | { type: 'awaiting-input' }; + +export interface ServerUpdateStepRequest { + runId: number; + stepUpdate: ServerStepUpdate; + executionStatus: ServerExecutionStatus; +} diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts new file mode 100644 index 0000000000..aeeab1cd6c --- /dev/null +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -0,0 +1,90 @@ +import type { + ServerTaskType, + ServerWorkflowCondition, + ServerWorkflowStep, + ServerWorkflowTask, +} from './server-types'; +import type { ConditionStepDefinition, StepDefinition } from '../types/validated/step-definition'; + +import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../errors'; +import { StepType } from '../types/validated/step-definition'; + +const TASK_TYPE_TO_STEP_TYPE: Record = { + 'get-data': StepType.ReadRecord, + 'update-data': StepType.UpdateRecord, + 'trigger-action': StepType.TriggerAction, + 'load-related-record': StepType.LoadRelatedRecord, + 'mcp-server': StepType.Mcp, + guideline: StepType.Guidance, +}; + +function mapTask(task: ServerWorkflowTask): StepDefinition { + const stepType = TASK_TYPE_TO_STEP_TYPE[task.taskType]; + + if (!stepType) { + throw new InvalidStepDefinitionError(`Unknown taskType: "${task.taskType}"`); + } + + const base: { prompt: string; automaticExecution?: boolean } = { prompt: task.prompt }; + if (task.automaticExecution !== undefined) base.automaticExecution = task.automaticExecution; + + switch (stepType) { + case StepType.Mcp: + return { + ...base, + type: StepType.Mcp, + ...(task.mcpServerId !== undefined && { mcpServerId: task.mcpServerId }), + }; + case StepType.Guidance: + return { ...base, type: StepType.Guidance }; + case StepType.ReadRecord: + return { ...base, type: StepType.ReadRecord }; + case StepType.UpdateRecord: + return { ...base, type: StepType.UpdateRecord }; + case StepType.TriggerAction: + return { ...base, type: StepType.TriggerAction }; + case StepType.LoadRelatedRecord: + return { ...base, type: StepType.LoadRelatedRecord }; + default: + throw new InvalidStepDefinitionError(`Unmapped step type: "${stepType}"`); + } +} + +function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefinition { + const options = condition.outgoing + .map(t => t.answer ?? t.buttonText) + .filter((v): v is string => typeof v === 'string' && v.length > 0); + + if (options.length < 2) { + throw new InvalidStepDefinitionError( + `Condition step requires at least 2 options, got ${options.length}`, + ); + } + + return { + type: StepType.Condition, + prompt: condition.prompt, + options, + }; +} + +// Server uses `type:'task' + taskType` for non-condition steps and `outgoing[]` for conditions; +// executor uses flat StepDefinition with `options[]`. Unsupported server types +// (end/escalation/sub-workflow) throw UnsupportedStepTypeError. +export default function toStepDefinition(serverStep: ServerWorkflowStep): StepDefinition { + switch (serverStep.type) { + case 'task': + return mapTask(serverStep); + case 'condition': + return mapCondition(serverStep); + case 'end': + case 'escalation': + case 'start-sub-workflow': + case 'close-sub-workflow': + throw new UnsupportedStepTypeError(serverStep.type); + default: + throw new InvalidStepDefinitionError( + `Unknown server step type: "${(serverStep as { type: string }).type}"`, + ); + } +} diff --git a/packages/workflow-executor/src/adapters/step-outcome-to-update-step-mapper.ts b/packages/workflow-executor/src/adapters/step-outcome-to-update-step-mapper.ts new file mode 100644 index 0000000000..bc050f6324 --- /dev/null +++ b/packages/workflow-executor/src/adapters/step-outcome-to-update-step-mapper.ts @@ -0,0 +1,45 @@ +import type { + ServerExecutionStatus, + ServerStepHistoryUpdate, + ServerUpdateStepRequest, +} from './server-types'; +import type { StepOutcome } from '../types/validated/step-outcome'; + +function toExecutionStatus(outcome: StepOutcome): ServerExecutionStatus { + if (outcome.status === 'error') { + // Joi.string().required() on the server rejects empty strings — fall back + // so an executor that produces error='' doesn't trigger an infinite re-dispatch. + return { type: 'error', message: outcome.error || 'Unknown error' }; + } + + if (outcome.status === 'awaiting-input') { + return { type: 'awaiting-input' }; + } + + return { type: 'success' }; +} + +// Write to `context` so the round-trip with run-to-available-step-mapper stays ISO (reverse mapper +// reads status/error/selectedOption from ServerStepHistory.context). +export default function toUpdateStepRequest( + runId: string, + outcome: StepOutcome, +): ServerUpdateStepRequest { + const context: Record = { status: outcome.status }; + if (outcome.error !== undefined) context.error = outcome.error; + + if (outcome.type === 'condition' && outcome.selectedOption !== undefined) { + context.selectedOption = outcome.selectedOption; + } + + const attributes: ServerStepHistoryUpdate = { + done: outcome.status !== 'awaiting-input', + context, + }; + + return { + runId: Number(runId), + stepUpdate: { stepIndex: outcome.stepIndex, attributes }, + executionStatus: toExecutionStatus(outcome), + }; +} diff --git a/packages/workflow-executor/src/adapters/with-retry.ts b/packages/workflow-executor/src/adapters/with-retry.ts new file mode 100644 index 0000000000..a5558df329 --- /dev/null +++ b/packages/workflow-executor/src/adapters/with-retry.ts @@ -0,0 +1,44 @@ +import type { Logger } from '../ports/logger-port'; + +import { extractErrorMessage } from '../errors'; + +const RETRY_DELAYS_MS = [100, 500, 2_000]; +const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); + +function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +function isRetryable(err: unknown): boolean { + const { status } = err as { status?: number }; + + return typeof status === 'number' && RETRYABLE_STATUS.has(status); +} + +export default async function withRetry( + label: string, + fn: () => Promise, + { logger }: { logger: Logger }, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop + return await fn(); + } catch (err) { + lastError = err; + if (!isRetryable(err) || attempt === RETRY_DELAYS_MS.length) throw err; + logger.info(`"${label}" failed, retrying`, { + attempt: attempt + 1, + error: extractErrorMessage(err), + }); + // eslint-disable-next-line no-await-in-loop + await sleep(RETRY_DELAYS_MS[attempt]); + } + } + + throw lastError; +} diff --git a/packages/workflow-executor/src/build-workflow-executor.ts b/packages/workflow-executor/src/build-workflow-executor.ts index 3a6d96d107..f06b0abce0 100644 --- a/packages/workflow-executor/src/build-workflow-executor.ts +++ b/packages/workflow-executor/src/build-workflow-executor.ts @@ -3,12 +3,14 @@ import type { RunnerState } from './runner'; import type { AiConfiguration } from '@forestadmin/ai-proxy'; import type { Options as SequelizeOptions } from 'sequelize'; +import { ActivityLogsService, ForestHttpApi } from '@forestadmin/forestadmin-client'; import { Sequelize } from 'sequelize'; import AgentClientAgentPort from './adapters/agent-client-agent-port'; import AiClientAdapter from './adapters/ai-client-adapter'; import ConsoleLogger from './adapters/console-logger'; import ForestServerWorkflowPort from './adapters/forest-server-workflow-port'; +import ForestadminClientActivityLogPortFactory from './adapters/forestadmin-client-activity-log-port-factory'; import ServerAiAdapter from './adapters/server-ai-adapter'; import ExecutorHttpServer from './http/executor-http-server'; import Runner from './runner'; @@ -18,6 +20,7 @@ import InMemoryStore from './stores/in-memory-store'; const DEFAULT_FOREST_SERVER_URL = 'https://api.forestadmin.com'; const DEFAULT_POLLING_INTERVAL_MS = 5000; +const DEFAULT_STEP_TIMEOUT_MS = 5 * 60_000; const FORCE_EXIT_DELAY_MS = 5000; export interface WorkflowExecutor { @@ -36,6 +39,9 @@ export interface ExecutorOptions { pollingIntervalMs?: number; logger?: Logger; stopTimeoutMs?: number; + stepTimeoutMs?: number; + // Max auto-chained steps per entry (see RunnerConfig.maxChainDepth). 0 disables chaining. + maxChainDepth?: number; } export type DatabaseExecutorOptions = ExecutorOptions & @@ -48,6 +54,7 @@ function buildCommonDependencies(options: ExecutorOptions) { const workflowPort = new ForestServerWorkflowPort({ envSecret: options.envSecret, forestServerUrl, + logger, }); const aiModelPort = options.aiConfigurations?.length @@ -62,16 +69,28 @@ function buildCommonDependencies(options: ExecutorOptions) { schemaCache, }); + const activityLogsService = new ActivityLogsService(new ForestHttpApi(), { + forestServerUrl, + headers: { 'Forest-Application-Source': 'WorkflowExecutor' }, + }); + const activityLogPortFactory = new ForestadminClientActivityLogPortFactory( + activityLogsService, + logger, + ); + return { agentPort, schemaCache, workflowPort, aiModelPort, + activityLogPortFactory, logger, pollingIntervalMs: options.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS, envSecret: options.envSecret, authSecret: options.authSecret, stopTimeoutMs: options.stopTimeoutMs, + stepTimeoutMs: options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS, + maxChainDepth: options.maxChainDepth, }; } @@ -161,7 +180,14 @@ export function buildInMemoryExecutor(options: ExecutorOptions): WorkflowExecuto export function buildDatabaseExecutor(options: DatabaseExecutorOptions): WorkflowExecutor { const deps = buildCommonDependencies(options); const { uri, ...sequelizeOptions } = options.database as SequelizeOptions & { uri?: string }; - const sequelize = uri ? new Sequelize(uri, sequelizeOptions) : new Sequelize(sequelizeOptions); + // Silence Sequelize's verbose SQL logger by default so our structured logs + // stay readable. Caller can still opt in via options.database.logging. + // An explicit `logging: undefined` in the caller overrides our default via + // spread, so we re-apply the default when the merged value ends up undefined. + const sequelizeDefaults: SequelizeOptions = { logging: false }; + const mergedOptions: SequelizeOptions = { ...sequelizeDefaults, ...sequelizeOptions }; + if (mergedOptions.logging === undefined) mergedOptions.logging = false; + const sequelize = uri ? new Sequelize(uri, mergedOptions) : new Sequelize(mergedOptions); const runner = new Runner({ ...deps, diff --git a/packages/workflow-executor/src/cli-core.ts b/packages/workflow-executor/src/cli-core.ts new file mode 100644 index 0000000000..6a902ff2f2 --- /dev/null +++ b/packages/workflow-executor/src/cli-core.ts @@ -0,0 +1,270 @@ +/* eslint-disable no-console */ + +import type { + DatabaseExecutorOptions, + ExecutorOptions, + WorkflowExecutor, +} from './build-workflow-executor'; +import type { Logger } from './ports/logger-port'; +import type { AiConfiguration } from '@forestadmin/ai-proxy'; + +import { z } from 'zod'; + +import ConsoleLogger from './adapters/console-logger'; +import PrettyLogger from './adapters/pretty-logger'; +import { ConfigurationError, extractErrorMessage } from './errors'; + +const POSITIVE_INT = z.coerce.number().int().positive(); + +function parsePositiveIntEnv(name: string, raw: string | undefined): number | undefined { + if (!raw) return undefined; + + const parsed = POSITIVE_INT.safeParse(raw); + + if (!parsed.success) { + throw new ConfigurationError(`${name} must be a positive integer (got "${raw}")`); + } + + return parsed.data; +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-dynamic-require, global-require +const { version } = require('../package.json') as { version: string }; + +const BINARY_NAME = 'forest-workflow-executor'; + +export interface CliArgs { + help: boolean; + version: boolean; + inMemory: boolean; + pretty: boolean; + json: boolean; +} + +export interface CliConfig { + executorOptions: ExecutorOptions; + databaseUrl?: string; + mode: 'in-memory' | 'database'; +} + +export interface CliFactories { + buildInMemory: (options: ExecutorOptions) => WorkflowExecutor; + buildDatabase: (options: DatabaseExecutorOptions) => WorkflowExecutor; +} + +export function parseArgs(argv: string[]): CliArgs { + const result: CliArgs = { + help: false, + version: false, + inMemory: false, + pretty: false, + json: false, + }; + + for (const arg of argv) { + switch (arg) { + case '--help': + case '-h': + result.help = true; + break; + case '--version': + case '-v': + result.version = true; + break; + case '--in-memory': + result.inMemory = true; + break; + case '--pretty': + result.pretty = true; + break; + case '--json': + result.json = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return result; +} + +// Priority: --json → Console; --pretty → Pretty; TTY → Pretty; else Console (piped/docker/k8s/CI). +// NO_COLOR is respected by picocolors so pretty output stays monochrome where ANSI is banned. +export function pickLogger(args: CliArgs, stdout: NodeJS.WriteStream = process.stdout): Logger { + if (args.json) return new ConsoleLogger(); + if (args.pretty) return new PrettyLogger(); + + return stdout.isTTY ? new PrettyLogger() : new ConsoleLogger(); +} + +function parseAiConfig(env: NodeJS.ProcessEnv): AiConfiguration[] | undefined { + const { AI_PROVIDER, AI_MODEL, AI_API_KEY } = env; + const fields = [AI_PROVIDER, AI_MODEL, AI_API_KEY]; + const setCount = fields.filter(Boolean).length; + + if (setCount === 0) return undefined; + + if (setCount !== fields.length) { + throw new Error( + 'AI config must be all-or-nothing: set AI_PROVIDER, AI_MODEL and AI_API_KEY together or leave all unset.', + ); + } + + if (AI_PROVIDER !== 'anthropic' && AI_PROVIDER !== 'openai') { + throw new Error(`AI_PROVIDER must be "anthropic" or "openai", got "${AI_PROVIDER}"`); + } + + return [ + { + name: 'default', + provider: AI_PROVIDER, + model: AI_MODEL as string, + apiKey: AI_API_KEY as string, + }, + ]; +} + +export function readEnvConfig(env: NodeJS.ProcessEnv, args: CliArgs): CliConfig { + const requiredBase = ['FOREST_ENV_SECRET', 'FOREST_AUTH_SECRET', 'AGENT_URL'] as const; + const missing: string[] = requiredBase.filter(key => !env[key]); + + if (!args.inMemory && !env.DATABASE_URL) { + missing.push('DATABASE_URL (required unless --in-memory)'); + } + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables:\n${missing.map(v => ` - ${v}`).join('\n')}\n\n` + + `Run \`${BINARY_NAME} --help\` for the full list.`, + ); + } + + const aiConfigurations = parseAiConfig(env); + + const executorOptions: ExecutorOptions = { + envSecret: env.FOREST_ENV_SECRET as string, + authSecret: env.FOREST_AUTH_SECRET as string, + agentUrl: env.AGENT_URL as string, + httpPort: parsePositiveIntEnv('HTTP_PORT', env.HTTP_PORT) ?? 3400, + forestServerUrl: env.FOREST_SERVER_URL, + pollingIntervalMs: parsePositiveIntEnv('POLLING_INTERVAL_MS', env.POLLING_INTERVAL_MS), + stopTimeoutMs: parsePositiveIntEnv('STOP_TIMEOUT_MS', env.STOP_TIMEOUT_MS), + stepTimeoutMs: parsePositiveIntEnv('STEP_TIMEOUT_MS', env.STEP_TIMEOUT_MS), + maxChainDepth: parsePositiveIntEnv('MAX_CHAIN_DEPTH', env.MAX_CHAIN_DEPTH), + ...(aiConfigurations && { aiConfigurations }), + }; + + return { + executorOptions, + databaseUrl: env.DATABASE_URL, + mode: args.inMemory ? 'in-memory' : 'database', + }; +} + +export function printHelp(): void { + console.log(`Usage: ${BINARY_NAME} [options] + +Run the Forest Admin workflow executor. + +Options: + --in-memory Use an in-memory run store (no DB needed, not for prod) + --pretty Force colorized human-readable logs (default when stdout is a TTY) + --json Force structured JSON logs (default when stdout is not a TTY) + --help, -h Show this help + --version, -v Show version + +Required environment variables: + FOREST_ENV_SECRET Forest Admin project environment secret + FOREST_AUTH_SECRET JWT signing secret (shared with your agent) + AGENT_URL URL of your running Forest Admin agent + DATABASE_URL Postgres connection string (not needed with --in-memory) + +Optional environment variables: + HTTP_PORT Default: 3400 + FOREST_SERVER_URL Default: https://api.forestadmin.com + POLLING_INTERVAL_MS Default: 5000 + STOP_TIMEOUT_MS Default: 30000 + STEP_TIMEOUT_MS Max duration of a step in ms (default: 300000 = 5 minutes) + MAX_CHAIN_DEPTH Max steps auto-executed per run before yielding (default: 50) + NO_COLOR Set to any value to disable ANSI colors in pretty logs + +AI configuration (all-or-nothing — falls back to server AI if any is missing): + AI_PROVIDER 'anthropic' | 'openai' + AI_MODEL Model name (e.g. claude-sonnet-4-6) + AI_API_KEY Provider API key + +Signals: + SIGTERM / SIGINT Graceful shutdown (drain in-flight, then exit)`); +} + +export function printVersion(): void { + console.log(version); +} + +export function logStartup(logger: Logger, config: CliConfig): void { + const { executorOptions: opts, mode } = config; + const aiLabel = opts.aiConfigurations?.length + ? `local (${opts.aiConfigurations[0].provider} / ${opts.aiConfigurations[0].model})` + : 'server fallback'; + + logger.info('Workflow executor starting', { + mode, + forestServerUrl: opts.forestServerUrl ?? 'https://api.forestadmin.com', + agentUrl: opts.agentUrl, + httpPort: opts.httpPort, + pollingIntervalMs: opts.pollingIntervalMs ?? 5000, + aiConfig: aiLabel, + }); +} + +export async function runCli( + argv: string[], + env: NodeJS.ProcessEnv, + factories: CliFactories, +): Promise { + const args = parseArgs(argv); + + if (args.help) { + printHelp(); + + return null; + } + + if (args.version) { + printVersion(); + + return null; + } + + const config = readEnvConfig(env, args); + const logger = pickLogger(args); + config.executorOptions.logger = logger; + + logStartup(logger, config); + + try { + let executor: WorkflowExecutor; + + if (config.mode === 'in-memory') { + executor = factories.buildInMemory(config.executorOptions); + } else { + const databaseOptions: DatabaseExecutorOptions = { + ...config.executorOptions, + database: { uri: config.databaseUrl as string }, + }; + executor = factories.buildDatabase(databaseOptions); + } + + await executor.start(); + logger.info('Workflow executor ready', { + url: `http://localhost:${config.executorOptions.httpPort}`, + }); + + return executor; + } catch (error) { + logger.error('Workflow executor failed to start', { + error: extractErrorMessage(error), + }); + throw error; + } +} diff --git a/packages/workflow-executor/src/cli.ts b/packages/workflow-executor/src/cli.ts new file mode 100644 index 0000000000..6ae8c9b2e0 --- /dev/null +++ b/packages/workflow-executor/src/cli.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +import { buildDatabaseExecutor, buildInMemoryExecutor } from './build-workflow-executor'; +import { runCli } from './cli-core'; +import { extractErrorMessage } from './errors'; + +if (require.main === module) { + runCli(process.argv.slice(2), process.env, { + buildDatabase: buildDatabaseExecutor, + buildInMemory: buildInMemoryExecutor, + }).catch((err: unknown) => { + console.error(`Error: ${extractErrorMessage(err)}`); + process.exit(1); + }); +} diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index d6b6f75870..6c1090d5d8 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -1,4 +1,6 @@ /* eslint-disable max-classes-per-file */ +import type { MalformedRunInfo } from './ports/workflow-port'; +import type { z } from 'zod'; export function causeMessage(error: unknown): string | undefined { const { cause } = error as { cause?: unknown }; @@ -6,6 +8,21 @@ export function causeMessage(error: unknown): string | undefined { return cause instanceof Error ? cause.message : undefined; } +// Cascades through err.message → err.parent.message (Sequelize) → err.cause.message → err.name, +// so wrapped infra errors (SequelizeConnectionRefusedError has an empty .message) don't log as empty. +export function extractErrorMessage(err: unknown): string { + if (!(err instanceof Error)) return String(err); + if (err.message) return err.message; + + const { parent } = err as { parent?: unknown }; + if (parent instanceof Error && parent.message) return parent.message; + + const { cause } = err as { cause?: unknown }; + if (cause instanceof Error && cause.message) return cause.message; + + return err.name || 'Unknown error'; +} + export abstract class WorkflowExecutorError extends Error { readonly userMessage: string; cause?: unknown; @@ -89,14 +106,22 @@ export class NoActionsError extends WorkflowExecutorError { } } -/** - * Thrown when a step's side effect succeeded (action/update/decision) - * but the resulting state could not be persisted to the RunStore. - */ -export class StepPersistenceError extends WorkflowExecutorError { - constructor(message: string, cause?: unknown) { - super(message, 'The step result could not be saved. Please retry.'); - if (cause !== undefined) this.cause = cause; +export class UnsupportedActionFormError extends WorkflowExecutorError { + constructor(actionDisplayName: string) { + super( + `Action "${actionDisplayName}" requires a form which is not supported by the executor`, + 'This action requires user input via a form, which is not yet supported in workflows.', + ); + } +} + +export class RunStorePortError extends WorkflowExecutorError { + constructor(operation: string, cause: unknown) { + super( + `Run store "${operation}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, + 'The step state could not be accessed. Please retry.', + ); + this.cause = cause; } } @@ -118,14 +143,12 @@ export class RelatedRecordNotFoundError extends WorkflowExecutorError { } } -/** Thrown when the AI returns a response that violates expected constraints (bad index, empty selection, unknown identifier, etc.). */ export class InvalidAIResponseError extends WorkflowExecutorError { constructor(message: string) { super(message, "The AI made an unexpected choice. Try rephrasing the step's prompt."); } } -/** Thrown when a named relation is not found in the collection schema. */ export class RelationNotFoundError extends WorkflowExecutorError { constructor(name: string, collectionName: string) { super( @@ -135,7 +158,6 @@ export class RelationNotFoundError extends WorkflowExecutorError { } } -/** Thrown when a named field is not found in the collection schema. */ export class FieldNotFoundError extends WorkflowExecutorError { constructor(name: string, collectionName: string) { super( @@ -145,7 +167,6 @@ export class FieldNotFoundError extends WorkflowExecutorError { } } -/** Thrown when a named action is not found in the collection schema. */ export class ActionNotFoundError extends WorkflowExecutorError { constructor(name: string, collectionName: string) { super( @@ -155,13 +176,32 @@ export class ActionNotFoundError extends WorkflowExecutorError { } } -/** Thrown when step execution state is invalid (missing execution record, missing pending data, etc.). */ export class StepStateError extends WorkflowExecutorError { constructor(message: string) { super(message, 'An unexpected error occurred while processing this step.'); } } +// Bubbles from base-step-executor, which converts it to a step error — no step runs without an audit log. +export class ActivityLogCreationError extends WorkflowExecutorError { + constructor(cause: unknown) { + super( + 'Failed to create activity log', + 'Could not record this step in the audit log. Please try again, or contact your administrator if the problem persists.', + ); + this.cause = cause; + } +} + +export class StepTimeoutError extends WorkflowExecutorError { + constructor(timeoutMs: number) { + super( + `Step execution exceeded timeout of ${timeoutMs}ms`, + 'The step took too long to complete. Please try again, or contact your administrator if the problem persists.', + ); + } +} + export class NoMcpToolsError extends WorkflowExecutorError { constructor() { super('No MCP tools available', 'No tools are available to execute this step.'); @@ -187,6 +227,28 @@ export class AgentPortError extends WorkflowExecutorError { } } +export class WorkflowPortError extends WorkflowExecutorError { + constructor(operation: string, cause: unknown) { + super( + `Workflow port "${operation}" failed: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + 'Failed to communicate with the workflow orchestrator. Please try again.', + ); + this.cause = cause; + } +} + +export class AiModelPortError extends WorkflowExecutorError { + constructor(operation: string, cause: unknown) { + super( + `AI model "${operation}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, + 'The AI service is unavailable. Please try again or contact your administrator.', + ); + this.cause = cause; + } +} + export class McpToolInvocationError extends WorkflowExecutorError { constructor(toolName: string, cause: unknown) { super( @@ -230,7 +292,7 @@ export class PendingDataNotFoundError extends Error { } } -/** Minimal mirror of ZodIssue — avoids importing Zod types into errors.ts. */ +// Minimal mirror of ZodIssue — avoids importing Zod types into errors.ts. export interface ValidationIssue { path: (string | number)[]; message: string; @@ -251,3 +313,67 @@ export class InvalidPreRecordedArgsError extends WorkflowExecutorError { super(`Invalid pre-recorded args: ${detail}`, 'The pre-configured step parameters are invalid'); } } + +// Boundary error — surfaces from Runner.start() and is caught at the CLI/HTTP layer, not by step executors. +export class AgentProbeError extends Error { + // Manual `cause` assignment: Error accepts it natively since Node 16.9 but our TS target is ES2020. + readonly cause?: unknown; + + constructor(message: string, options?: { cause?: unknown }) { + super(`Agent probe failed: ${message}`); + this.name = 'AgentProbeError'; + if (options?.cause !== undefined) this.cause = options.cause; + } +} + +export class UnsupportedStepTypeError extends WorkflowExecutorError { + constructor(stepType: string) { + super( + `Step type "${stepType}" is not supported by the executor`, + 'This step type is not yet supported.', + ); + } +} + +export class InvalidStepDefinitionError extends WorkflowExecutorError { + constructor(detail: string) { + super( + `Invalid step definition: ${detail}`, + 'The workflow step configuration is invalid. Please check the workflow designer.', + ); + } +} + +// Thrown when zod validation fails on a domain object produced internally (e.g. by the +// run-to-pending-step mapper). Distinct from InvalidStepDefinitionError (which flags wire-format +// bugs coming from the orchestrator) so the two can be triaged separately in Sentry. +export class DomainValidationError extends WorkflowExecutorError { + readonly issues: ReadonlyArray<{ path: string; message: string }>; + + constructor(runId: number, zodError: z.ZodError) { + const issues = zodError.issues.map(i => ({ + path: i.path.join('.') || '(root)', + message: i.message, + })); + const summary = issues.length + ? issues.map(i => `${i.path}: ${i.message}`).join('; ') + : '(no zod issues reported — unexpected empty ZodError)'; + + super( + `Run ${runId} mapper produced invalid AvailableStepExecution — ${summary}`, + 'Internal validation error occurred while preparing the step. Please contact support.', + ); + this.cause = zodError; + this.issues = issues; + } +} + +// Carries MalformedRunInfo so the Runner can report the run without re-parsing the message. +export class MalformedRunError extends WorkflowExecutorError { + readonly info: MalformedRunInfo; + + constructor(info: MalformedRunInfo) { + super(info.technicalMessage, info.userMessage); + this.info = info; + } +} diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 6d0a7af43b..8f6de37859 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,24 +1,30 @@ +import type { CreateActivityLogArgs } from '../ports/activity-log-port'; import type { AgentPort } from '../ports/agent-port'; -import type { ExecutionContext, IStepExecutor, StepExecutionResult } from '../types/execution'; -import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; -import type { StepDefinition } from '../types/step-definition'; +import type { + ExecutionContext, + IStepExecutor, + StepExecutionResult, +} from '../types/execution-context'; import type { StepExecutionData } from '../types/step-execution-data'; -import type { StepStatus } from '../types/step-outcome'; -import type { BaseMessage, StructuredToolInterface } from '@forestadmin/ai-proxy'; +import type { StepDefinition } from '../types/validated/step-definition'; +import type { StepStatus } from '../types/validated/step-outcome'; +import type { + BaseMessage, + DynamicStructuredTool, + StructuredToolInterface, +} from '@forestadmin/ai-proxy'; -import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; -import { z } from 'zod'; +import { SystemMessage } from '@forestadmin/ai-proxy'; import { - InvalidAIResponseError, MalformedToolCallError, MissingToolCallError, - NoRecordsError, StepStateError, + StepTimeoutError, WorkflowExecutorError, + extractErrorMessage, } from '../errors'; -import patchBodySchemas from '../pending-data-validators'; -import SafeAgentPort from './safe-agent-port'; +import patchBodySchemas from '../http/pending-data-validators'; import StepSummaryBuilder from './summary/step-summary-builder'; type WithPendingData = StepExecutionData & { pendingData?: object }; @@ -32,7 +38,7 @@ export default abstract class BaseStepExecutor) { this.context = context; - this.agentPort = new SafeAgentPort(context.agentPort); + this.agentPort = context.agentPort; } async execute(): Promise { @@ -47,7 +53,22 @@ export default abstract class BaseStepExecutor; - /** Find a field by displayName first, then fallback to fieldName. */ - protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { - return ( - schema.fields.find(f => f.displayName === name) ?? - schema.fields.find(f => f.fieldName === name) - ); + protected checkIdempotency(): Promise { + return Promise.resolve(null); + } + + // Return null when the frontend performs the action (e.g. TriggerAction with automaticExecution=false) + // — the front logs on its side. Override when the executor itself calls the agent. + protected buildActivityLogArgs(): CreateActivityLogArgs | null { + return null; + } + + private async runWithActivityLog(): Promise { + const args = this.buildActivityLogArgs(); + if (!args) return this.runWithTimeout(); + + const handle = await this.context.activityLogPort.createPending(args); + + let result: StepExecutionResult; + + try { + result = await this.runWithTimeout(); + } catch (err) { + // Use userMessage (not the technical message) — errorMessage is rendered to end-users + // in the Forest Admin UI. Privacy: no collection/field/AI internals in the audit trail. + const errorMessage = + err instanceof WorkflowExecutorError ? err.userMessage : 'Unexpected error'; + void this.context.activityLogPort.markFailed(handle, errorMessage); + throw err; + } + + if (result.stepOutcome.status === 'error') { + void this.context.activityLogPort.markFailed( + handle, + result.stepOutcome.error ?? 'Step failed', + ); + } else { + void this.context.activityLogPort.markSucceeded(handle); + } + + return result; + } + + // Promise.race doesn't abort the losing branch — it keeps running in the background. The .catch() + // on execPromise must be attached BEFORE the race so a late rejection doesn't trigger + // UnhandledPromiseRejection. Late resolutions are silently discarded. + private async runWithTimeout(): Promise { + const timeoutMs = this.context.stepTimeoutMs; + if (!timeoutMs || timeoutMs <= 0) return this.doExecute(); + + let timer: NodeJS.Timeout | undefined; + const execPromise = this.doExecute(); + + execPromise.catch(err => { + this.context.logger.info('Step work rejected after timeout — result discarded', { + runId: this.context.runId, + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + error: extractErrorMessage(err), + }); + }); + + try { + return await Promise.race([ + execPromise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new StepTimeoutError(timeoutMs)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } } - /** Builds a StepExecutionResult with the step-type-specific outcome shape. */ protected abstract buildOutcomeResult(outcome: { status: StepStatus; error?: string; }): StepExecutionResult; - /** - * Finds a step execution in the RunStore matching the given type and the current stepIndex. - * Returns undefined if no matching execution exists (first call → Branch B/C). - */ protected async findPendingExecution( type: string, ): Promise { @@ -119,11 +211,6 @@ export default abstract class BaseStepExecutor( pendingData?: unknown, ): Promise { @@ -156,13 +243,8 @@ export default abstract class BaseStepExecutor( execution: TExec, resolveAndExecute: (execution: TExec) => Promise, @@ -189,7 +271,6 @@ export default abstract class BaseStepExecutor { if (!this.context.previousSteps.length) return []; @@ -222,10 +299,6 @@ export default abstract class BaseStepExecutor>( messages: BaseMessage[], tools: StructuredToolInterface[], @@ -254,97 +327,10 @@ export default abstract class BaseStepExecutor>( messages: BaseMessage[], tool: DynamicStructuredTool, ): Promise { return (await this.invokeWithTools(messages, [tool])).args; } - - /** Returns baseRecordRef + any related records loaded by previous steps. */ - protected async getAvailableRecordRefs(): Promise { - const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const relatedRecords = stepExecutions.flatMap(e => { - if ( - e.type === 'load-related-record' && - e.executionResult !== undefined && - 'record' in e.executionResult - ) { - return [e.executionResult.record]; - } - - return []; - }); - - return [this.context.baseRecordRef, ...relatedRecords]; - } - - /** Selects a record ref via AI when multiple are available, returns directly when only one. */ - protected async selectRecordRef( - records: RecordRef[], - prompt: string | undefined, - ): Promise { - if (records.length === 0) throw new NoRecordsError(); - if (records.length === 1) return records[0]; - - const identifiers = await Promise.all(records.map(r => this.toRecordIdentifier(r))); - const identifierTuple = identifiers as [string, ...string[]]; - - const tool = new DynamicStructuredTool({ - name: 'select-record', - description: 'Select the most relevant record for this workflow step.', - schema: z.object({ - recordIdentifier: z.enum(identifierTuple), - }), - func: undefined, - }); - - const messages = [ - this.buildContextMessage(), - ...(await this.buildPreviousStepsMessages()), - new SystemMessage( - 'You are an AI agent selecting the most relevant record for a workflow step.\n' + - 'Choose the record whose collection best matches the user request.\n' + - 'Pay attention to the collection name of each record.', - ), - new HumanMessage(prompt ?? 'Select the most relevant record.'), - ]; - - const { recordIdentifier } = await this.invokeWithTool<{ recordIdentifier: string }>( - messages, - tool, - ); - - const selectedIndex = identifiers.indexOf(recordIdentifier); - - if (selectedIndex === -1) { - throw new InvalidAIResponseError( - `AI selected record "${recordIdentifier}" which does not match any available record`, - ); - } - - return records[selectedIndex]; - } - - /** Fetches a collection schema from WorkflowPort, with TTL-based caching. */ - protected async getCollectionSchema(collectionName: string): Promise { - const cached = this.context.schemaCache.get(collectionName); - if (cached) return cached; - - const schema = await this.context.workflowPort.getCollectionSchema(collectionName); - this.context.schemaCache.set(collectionName, schema); - - return schema; - } - - /** Formats a record ref as "Step X - CollectionDisplayName #id". */ - protected async toRecordIdentifier(record: RecordRef): Promise { - const schema = await this.getCollectionSchema(record.collectionName); - - return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; - } } diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 60bc1b66b8..3aeebac366 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -1,12 +1,13 @@ -import type { StepExecutionResult } from '../types/execution'; -import type { ConditionStepDefinition } from '../types/step-definition'; -import type { BaseStepStatus } from '../types/step-outcome'; +import type { StepExecutionResult } from '../types/execution-context'; +import type { ConditionStepDefinition } from '../types/validated/step-definition'; +import type { BaseStepStatus } from '../types/validated/step-outcome'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { StepPersistenceError } from '../errors'; +import { StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; +import patchBodySchemas from '../http/pending-data-validators'; interface GatewayToolArgs { option: string | null; @@ -14,6 +15,11 @@ interface GatewayToolArgs { question: string; } +interface GatewayDecision { + selectedOption: string | null; + reasoning: string; +} + const GATEWAY_SYSTEM_PROMPT = `You are an AI agent selecting the correct option for a workflow gateway decision. **Task**: Analyze the question and available options, then select the option that DIRECTLY answers the question. Options must be literal answers, not interpretations. @@ -53,8 +59,55 @@ export default class ConditionStepExecutor extends BaseStepExecutor { - const { stepDefinition: step } = this.context; + const { stepDefinition: step, incomingPendingData } = this.context; + + const { selectedOption, reasoning } = + incomingPendingData !== undefined + ? this.readUserChoice(step, incomingPendingData) + : await this.askAi(step); + + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'condition', + stepIndex: this.context.stepIndex, + executionParams: { answer: selectedOption, reasoning }, + executionResult: selectedOption ? { answer: selectedOption } : undefined, + }); + + if (!selectedOption) { + return this.buildOutcomeResult({ + status: 'error', + error: "The AI couldn't decide. Try rephrasing the step's prompt.", + }); + } + + return this.buildOutcomeResult({ status: 'success', selectedOption }); + } + + private readUserChoice( + step: ConditionStepDefinition, + incomingPendingData: unknown, + ): GatewayDecision { + const parsed = patchBodySchemas.condition!.safeParse(incomingPendingData); + + if (!parsed.success) { + throw new StepStateError( + `Invalid condition input: ${parsed.error.issues.map(i => i.message).join(', ')}`, + ); + } + + const { selectedOption } = parsed.data as { selectedOption: string }; + if (!step.options.includes(selectedOption)) { + const allowed = step.options.join(', '); + throw new StepStateError( + `Option "${selectedOption}" is not a valid choice (expected one of: ${allowed})`, + ); + } + + return { selectedOption, reasoning: 'Selected by user' }; + } + + private async askAi(step: ConditionStepDefinition): Promise { const tool = new DynamicStructuredTool({ name: 'choose-gateway-option', description: @@ -80,30 +133,7 @@ export default class ConditionStepExecutor extends BaseStepExecutor(messages, tool); - const { option: selectedOption, reasoning } = args; - try { - await this.context.runStore.saveStepExecution(this.context.runId, { - type: 'condition', - stepIndex: this.context.stepIndex, - executionParams: { answer: selectedOption, reasoning }, - executionResult: selectedOption ? { answer: selectedOption } : undefined, - }); - } catch (cause) { - throw new StepPersistenceError( - `Condition step state could not be persisted ` + - `(run "${this.context.runId}", step ${this.context.stepIndex})`, - cause, - ); - } - - if (!selectedOption) { - return this.buildOutcomeResult({ - status: 'error', - error: "The AI couldn't decide. Try rephrasing the step's prompt.", - }); - } - - return this.buildOutcomeResult({ status: 'success', selectedOption }); + return { selectedOption: args.option, reasoning: args.reasoning }; } } diff --git a/packages/workflow-executor/src/executors/guidance-step-executor.ts b/packages/workflow-executor/src/executors/guidance-step-executor.ts index 3b4e67adb5..e63028c122 100644 --- a/packages/workflow-executor/src/executors/guidance-step-executor.ts +++ b/packages/workflow-executor/src/executors/guidance-step-executor.ts @@ -1,17 +1,17 @@ -import type { StepExecutionResult } from '../types/execution'; -import type { GuidanceStepDefinition } from '../types/step-definition'; -import type { BaseStepStatus } from '../types/step-outcome'; +import type { StepExecutionResult } from '../types/execution-context'; +import type { GuidanceStepDefinition } from '../types/validated/step-definition'; +import type { RecordStepStatus } from '../types/validated/step-outcome'; import { StepStateError } from '../errors'; -import patchBodySchemas from '../pending-data-validators'; import BaseStepExecutor from './base-step-executor'; +import patchBodySchemas from '../http/pending-data-validators'; export default class GuidanceStepExecutor extends BaseStepExecutor { protected async doExecute(): Promise { const { incomingPendingData } = this.context; if (!incomingPendingData) { - throw new StepStateError('Guidance step triggered without pending data'); + return this.buildOutcomeResult({ status: 'awaiting-input' }); } const parsed = patchBodySchemas.guidance.safeParse(incomingPendingData); @@ -22,19 +22,19 @@ export default class GuidanceStepExecutor extends BaseStepExecutor { + protected override buildActivityLogArgs(): CreateActivityLogArgs | null { + return { + renderingId: this.context.user.renderingId, + action: 'listRelatedData', + type: 'read', + collectionId: this.context.collectionId, + recordId: this.context.baseRecordRef.recordId[0], + }; + } + protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( @@ -96,11 +106,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { selectedRecordRef, name, displayName } = target; @@ -131,12 +138,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { @@ -169,10 +172,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor, limit: number, @@ -243,10 +242,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor, limit: number, @@ -277,22 +273,14 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { selectedRecordRef, name, displayName } = target; - try { - await this.context.runStore.saveStepExecution(this.context.runId, { - ...existingExecution, - type: 'load-related-record', - stepIndex: this.context.stepIndex, - executionParams: { displayName, name }, - executionResult: { relation: { name, displayName }, record }, - selectedRecordRef, - }); - } catch (cause) { - throw new StepPersistenceError( - `Related record loaded but step state could not be persisted ` + - `(run "${this.context.runId}", step ${this.context.stepIndex})`, - cause, - ); - } + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'load-related-record', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { relation: { name, displayName }, record }, + selectedRecordRef, + }); return this.buildOutcomeResult({ status: 'success' }); } diff --git a/packages/workflow-executor/src/executors/mcp-step-executor.ts b/packages/workflow-executor/src/executors/mcp-step-executor.ts index b5fdd4e1e9..fca94387a5 100644 --- a/packages/workflow-executor/src/executors/mcp-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-step-executor.ts @@ -1,7 +1,8 @@ -import type { ExecutionContext, StepExecutionResult } from '../types/execution'; -import type { McpStepDefinition } from '../types/step-definition'; +import type { CreateActivityLogArgs } from '../ports/activity-log-port'; +import type { ExecutionContext, StepExecutionResult } from '../types/execution-context'; import type { McpStepExecutionData, McpToolCall } from '../types/step-execution-data'; -import type { RecordStepStatus } from '../types/step-outcome'; +import type { McpStepDefinition } from '../types/validated/step-definition'; +import type { RecordStepStatus } from '../types/validated/step-outcome'; import type { RemoteTool } from '@forestadmin/ai-proxy'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; @@ -11,7 +12,7 @@ import { McpToolInvocationError, McpToolNotFoundError, NoMcpToolsError, - StepPersistenceError, + StepStateError, } from '../errors'; import BaseStepExecutor from './base-step-executor'; @@ -30,6 +31,15 @@ export default class McpStepExecutor extends BaseStepExecutor this.remoteTools = remoteTools; } + protected override buildActivityLogArgs(): CreateActivityLogArgs | null { + return { + renderingId: this.context.user.renderingId, + action: 'action', + type: 'write', + label: this.context.stepDefinition.mcpServerId, + }; + } + protected buildOutcomeResult(outcome: { status: RecordStepStatus; error?: string; @@ -44,6 +54,20 @@ export default class McpStepExecutor extends BaseStepExecutor }; } + protected override async checkIdempotency(): Promise { + const existing = await this.findPendingExecution('mcp'); + + if (existing?.idempotencyPhase === 'done') { + return this.buildOutcomeResult({ status: 'success' }); + } + + if (existing?.idempotencyPhase === 'executing') { + throw new StepStateError('Step execution was interrupted. Please retry the step manually.'); + } + + return null; + } + protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( @@ -69,19 +93,11 @@ export default class McpStepExecutor extends BaseStepExecutor } // Branch C -- Awaiting confirmation - try { - await this.context.runStore.saveStepExecution(this.context.runId, { - type: 'mcp', - stepIndex: this.context.stepIndex, - pendingData: target, - }); - } catch (cause) { - throw new StepPersistenceError( - `MCP task step state could not be persisted ` + - `(run "${this.context.runId}", step ${this.context.stepIndex})`, - cause, - ); - } + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'mcp', + stepIndex: this.context.stepIndex, + pendingData: target, + }); return this.buildOutcomeResult({ status: 'awaiting-input' }); } @@ -94,6 +110,13 @@ export default class McpStepExecutor extends BaseStepExecutor const tool = tools.find(t => t.base.name === target.name && t.sourceId === target.sourceId); if (!tool) throw new McpToolNotFoundError(target.name); + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'mcp', + stepIndex: this.context.stepIndex, + idempotencyPhase: 'executing', + }); + let toolResult: unknown; try { @@ -110,17 +133,10 @@ export default class McpStepExecutor extends BaseStepExecutor stepIndex: this.context.stepIndex, executionParams: { name: target.name, sourceId: target.sourceId, input: target.input }, executionResult: baseExecutionResult, + idempotencyPhase: 'done', }; - try { - await this.context.runStore.saveStepExecution(this.context.runId, baseData); - } catch (cause) { - throw new StepPersistenceError( - `MCP tool "${target.name}" executed but step state could not be persisted ` + - `(run "${this.context.runId}", step ${this.context.stepIndex})`, - cause, - ); - } + await this.context.runStore.saveStepExecution(this.context.runId, baseData); // 2. AI formatting — non-blocking; errors are logged but do not fail the step let formattedResponse: string | null = null; diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 74b79d95cf..950f6c6bc5 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -1,7 +1,8 @@ -import type { StepExecutionResult } from '../types/execution'; -import type { CollectionSchema } from '../types/record'; -import type { ReadRecordStepDefinition } from '../types/step-definition'; +import type { CreateActivityLogArgs } from '../ports/activity-log-port'; +import type { StepExecutionResult } from '../types/execution-context'; import type { FieldReadResult } from '../types/step-execution-data'; +import type { CollectionSchema } from '../types/validated/collection'; +import type { ReadRecordStepDefinition } from '../types/validated/step-definition'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; @@ -18,6 +19,16 @@ Important rules: - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; export default class ReadRecordStepExecutor extends RecordStepExecutor { + protected override buildActivityLogArgs(): CreateActivityLogArgs | null { + return { + renderingId: this.context.user.renderingId, + action: 'index', + type: 'read', + collectionId: this.context.collectionId, + recordId: this.context.baseRecordRef.recordId[0], + }; + } + protected async doExecute(): Promise { const { stepDefinition: step } = this.context; const { preRecordedArgs } = step; diff --git a/packages/workflow-executor/src/executors/record-step-executor.ts b/packages/workflow-executor/src/executors/record-step-executor.ts index 8c88b270f9..aa01e0100e 100644 --- a/packages/workflow-executor/src/executors/record-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-step-executor.ts @@ -1,9 +1,12 @@ -import type { StepExecutionResult } from '../types/execution'; -import type { RecordRef } from '../types/record'; -import type { StepDefinition } from '../types/step-definition'; -import type { RecordStepStatus } from '../types/step-outcome'; +import type { StepExecutionResult } from '../types/execution-context'; +import type { CollectionSchema, FieldSchema, RecordRef } from '../types/validated/collection'; +import type { StepDefinition } from '../types/validated/step-definition'; +import type { RecordStepStatus } from '../types/validated/step-outcome'; -import { InvalidPreRecordedArgsError } from '../errors'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; + +import { InvalidAIResponseError, InvalidPreRecordedArgsError, NoRecordsError } from '../errors'; import BaseStepExecutor from './base-step-executor'; export default abstract class RecordStepExecutor< @@ -23,9 +26,6 @@ export default abstract class RecordStepExecutor< }; } - /** - * Resolves a record ref: uses pre-recorded stepIndex if provided, otherwise delegates to AI. - */ protected async resolveRecordRef( records: RecordRef[], prompt: string | undefined, @@ -45,4 +45,93 @@ export default abstract class RecordStepExecutor< return this.selectRecordRef(records, prompt); } + + protected async getAvailableRecordRefs(): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const relatedRecords = stepExecutions.flatMap(e => { + if ( + e.type === 'load-related-record' && + e.executionResult !== undefined && + 'record' in e.executionResult + ) { + return [e.executionResult.record]; + } + + return []; + }); + + return [this.context.baseRecordRef, ...relatedRecords]; + } + + protected async getCollectionSchema(collectionName: string): Promise { + const cached = this.context.schemaCache.get(collectionName); + if (cached) return cached; + + const schema = await this.context.workflowPort.getCollectionSchema( + collectionName, + this.context.runId, + ); + this.context.schemaCache.set(collectionName, schema); + + return schema; + } + + protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { + return ( + schema.fields.find(f => f.displayName === name) ?? + schema.fields.find(f => f.fieldName === name) + ); + } + + private async toRecordIdentifier(record: RecordRef): Promise { + const schema = await this.getCollectionSchema(record.collectionName); + + return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; + } + + private async selectRecordRef( + records: RecordRef[], + prompt: string | undefined, + ): Promise { + if (records.length === 0) throw new NoRecordsError(); + if (records.length === 1) return records[0]; + + const identifiers = await Promise.all(records.map(r => this.toRecordIdentifier(r))); + const identifierTuple = identifiers as [string, ...string[]]; + + const tool = new DynamicStructuredTool({ + name: 'select-record', + description: 'Select the most relevant record for this workflow step.', + schema: z.object({ + recordIdentifier: z.enum(identifierTuple), + }), + func: undefined, + }); + + const messages = [ + this.buildContextMessage(), + ...(await this.buildPreviousStepsMessages()), + new SystemMessage( + 'You are an AI agent selecting the most relevant record for a workflow step.\n' + + 'Choose the record whose collection best matches the user request.\n' + + 'Pay attention to the collection name of each record.', + ), + new HumanMessage(prompt ?? 'Select the most relevant record.'), + ]; + + const { recordIdentifier } = await this.invokeWithTool<{ recordIdentifier: string }>( + messages, + tool, + ); + + const selectedIndex = identifiers.indexOf(recordIdentifier); + + if (selectedIndex === -1) { + throw new InvalidAIResponseError( + `AI selected record "${recordIdentifier}" which does not match any available record`, + ); + } + + return records[selectedIndex]; + } } diff --git a/packages/workflow-executor/src/executors/safe-agent-port.ts b/packages/workflow-executor/src/executors/safe-agent-port.ts deleted file mode 100644 index 7187611ef1..0000000000 --- a/packages/workflow-executor/src/executors/safe-agent-port.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - AgentPort, - ExecuteActionQuery, - GetRecordQuery, - GetRelatedDataQuery, - UpdateRecordQuery, -} from '../ports/agent-port'; -import type { StepUser } from '../types/execution'; -import type { RecordData } from '../types/record'; - -import { AgentPortError, WorkflowExecutorError } from '../errors'; - -export default class SafeAgentPort implements AgentPort { - constructor(private readonly port: AgentPort) {} - - async getRecord(query: GetRecordQuery, user: StepUser): Promise { - return this.call('getRecord', () => this.port.getRecord(query, user)); - } - - async updateRecord(query: UpdateRecordQuery, user: StepUser): Promise { - return this.call('updateRecord', () => this.port.updateRecord(query, user)); - } - - async getRelatedData(query: GetRelatedDataQuery, user: StepUser): Promise { - return this.call('getRelatedData', () => this.port.getRelatedData(query, user)); - } - - async executeAction(query: ExecuteActionQuery, user: StepUser): Promise { - return this.call('executeAction', () => this.port.executeAction(query, user)); - } - - private async call(operation: string, fn: () => Promise): Promise { - try { - return await fn(); - } catch (cause) { - if (cause instanceof WorkflowExecutorError) throw cause; - throw new AgentPortError(operation, cause); - } - } -} diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 18affd71ec..736b633215 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -1,3 +1,4 @@ +import type { ActivityLogPort } from '../ports/activity-log-port'; import type { AgentPort } from '../ports/agent-port'; import type { AiModelPort } from '../ports/ai-model-port'; import type { Logger } from '../ports/logger-port'; @@ -5,11 +6,11 @@ import type { RunStore } from '../ports/run-store'; import type { WorkflowPort } from '../ports/workflow-port'; import type SchemaCache from '../schema-cache'; import type { + AvailableStepExecution, ExecutionContext, IStepExecutor, - PendingStepExecution, StepExecutionResult, -} from '../types/execution'; +} from '../types/execution-context'; import type { ConditionStepDefinition, GuidanceStepDefinition, @@ -18,10 +19,10 @@ import type { ReadRecordStepDefinition, TriggerActionStepDefinition, UpdateRecordStepDefinition, -} from '../types/step-definition'; +} from '../types/validated/step-definition'; import type { RemoteTool } from '@forestadmin/ai-proxy'; -import { StepStateError, causeMessage } from '../errors'; +import { StepStateError, causeMessage, extractErrorMessage } from '../errors'; import ConditionStepExecutor from './condition-step-executor'; import GuidanceStepExecutor from './guidance-step-executor'; import LoadRelatedRecordStepExecutor from './load-related-record-step-executor'; @@ -29,8 +30,8 @@ import McpStepExecutor from './mcp-step-executor'; import ReadRecordStepExecutor from './read-record-step-executor'; import TriggerRecordActionStepExecutor from './trigger-record-action-step-executor'; import UpdateRecordStepExecutor from './update-record-step-executor'; -import { StepType } from '../types/step-definition'; -import { stepTypeToOutcomeType } from '../types/step-outcome'; +import { StepType } from '../types/validated/step-definition'; +import { stepTypeToOutcomeType } from '../types/validated/step-outcome'; export interface StepContextConfig { aiModelPort: AiModelPort; @@ -39,17 +40,24 @@ export interface StepContextConfig { runStore: RunStore; schemaCache: SchemaCache; logger: Logger; + stepTimeoutMs?: number; } export default class StepExecutorFactory { static async create( - step: PendingStepExecution, + step: AvailableStepExecution, contextConfig: StepContextConfig, + activityLogPort: ActivityLogPort, loadTools: () => Promise, incomingPendingData?: unknown, ): Promise { try { - const context = StepExecutorFactory.buildContext(step, contextConfig, incomingPendingData); + const context = StepExecutorFactory.buildContext( + step, + contextConfig, + activityLogPort, + incomingPendingData, + ); switch (step.stepDefinition.type) { case StepType.Condition: @@ -85,7 +93,7 @@ export default class StepExecutorFactory { runId: step.runId, stepId: step.stepId, stepIndex: step.stepIndex, - error: error instanceof Error ? error.message : String(error), + error: extractErrorMessage(error), cause: causeMessage(error), stack: error instanceof Error ? error.stack : undefined, }); @@ -105,12 +113,20 @@ export default class StepExecutorFactory { } private static buildContext( - step: PendingStepExecution, + step: AvailableStepExecution, cfg: StepContextConfig, + activityLogPort: ActivityLogPort, incomingPendingData?: unknown, ): ExecutionContext { return { - ...step, + runId: step.runId, + stepId: step.stepId, + stepIndex: step.stepIndex, + collectionId: step.collectionId, + baseRecordRef: step.baseRecordRef, + stepDefinition: step.stepDefinition, + previousSteps: step.previousSteps, + user: step.user, model: cfg.aiModelPort.getModel(step.stepDefinition.aiConfigName), agentPort: cfg.agentPort, workflowPort: cfg.workflowPort, @@ -118,6 +134,8 @@ export default class StepExecutorFactory { schemaCache: cfg.schemaCache, logger: cfg.logger, incomingPendingData, + stepTimeoutMs: cfg.stepTimeoutMs, + activityLogPort, }; } } diff --git a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts index bdfd8938cf..c9df62c0a8 100644 --- a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts +++ b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts @@ -5,19 +5,9 @@ import type { StepExecutionData, } from '../../types/step-execution-data'; -/** - * Stateless utility class — all methods are static. - * Provides type-specific formatting for step execution results. - * Add one private static method per step type that needs a non-generic display format, - * and dispatch from `format`. - */ export default class StepExecutionFormatters { - /** - * Returns the full output line (indent + label + content) for the given execution, or null when: - * - No custom format is defined for this step type (switch default) — caller uses generic fallback, or - * - The execution data does not satisfy the formatter's preconditions (e.g. skipped/incomplete). - * In both cases, `StepSummaryBuilder` renders the generic Input:/Output: fallback. - */ + // Returns null when no custom format is defined for the step type or when execution data + // doesn't satisfy formatter preconditions — caller falls back to generic Input:/Output:. static format(execution: StepExecutionData): string | null { switch (execution.type) { case 'load-related-record': @@ -46,7 +36,7 @@ export default class StepExecutionFormatters { } private static formatGuidance(execution: GuidanceStepExecutionData): string | null { - if (!execution.executionResult) return null; + if (!execution.executionResult?.userInput) return null; return ` The user provided the following input: "${execution.executionResult.userInput}"`; } diff --git a/packages/workflow-executor/src/executors/summary/step-summary-builder.ts b/packages/workflow-executor/src/executors/summary/step-summary-builder.ts index c593545fed..aafbbb6013 100644 --- a/packages/workflow-executor/src/executors/summary/step-summary-builder.ts +++ b/packages/workflow-executor/src/executors/summary/step-summary-builder.ts @@ -1,6 +1,6 @@ -import type { StepDefinition } from '../../types/step-definition'; import type { StepExecutionData } from '../../types/step-execution-data'; -import type { StepOutcome } from '../../types/step-outcome'; +import type { StepDefinition } from '../../types/validated/step-definition'; +import type { StepOutcome } from '../../types/validated/step-outcome'; import StepExecutionFormatters from './step-execution-formatters'; @@ -15,6 +15,24 @@ export default class StepSummaryBuilder { const lines = [header, ` Prompt: ${prompt}`]; if (execution !== undefined) { + // Detect "handled manually": executor proposed an action (pendingData) but the user + // completed the step on the frontend without going through the trigger endpoint, so the + // executor never wrote executionResult. Normal completions (confirmation flow, skip, Branch B) + // always set executionResult before the step is marked done. + if ( + stepOutcome.status === 'success' && + 'pendingData' in execution && + execution.pendingData !== undefined && + execution.executionResult === undefined + ) { + lines.push(` Proposed: ${JSON.stringify(execution.pendingData)}`); + lines.push( + ` Note: the user handled this step manually — the actual outcome may differ from the proposal above.`, + ); + + return lines.join('\n'); + } + // Try custom formatting — if it fires, it owns the entire output section (no Input: line) const customLine = execution.executionResult ? StepExecutionFormatters.format(execution) diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index 6837a49eb3..4b75633601 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -1,12 +1,18 @@ -import type { StepExecutionResult } from '../types/execution'; -import type { CollectionSchema, RecordRef } from '../types/record'; -import type { TriggerActionStepDefinition } from '../types/step-definition'; +import type { CreateActivityLogArgs } from '../ports/activity-log-port'; +import type { StepExecutionResult } from '../types/execution-context'; import type { ActionRef, TriggerRecordActionStepExecutionData } from '../types/step-execution-data'; +import type { CollectionSchema, RecordRef } from '../types/validated/collection'; +import type { TriggerActionStepDefinition } from '../types/validated/step-definition'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { ActionNotFoundError, NoActionsError, StepPersistenceError } from '../errors'; +import { + ActionNotFoundError, + NoActionsError, + StepStateError, + UnsupportedActionFormError, +} from '../errors'; import RecordStepExecutor from './record-step-executor'; const TRIGGER_ACTION_SYSTEM_PROMPT = `You are an AI agent triggering an action on a record based on a user request. @@ -22,6 +28,36 @@ interface ActionTarget extends ActionRef { } export default class TriggerRecordActionStepExecutor extends RecordStepExecutor { + protected override buildActivityLogArgs(): CreateActivityLogArgs | null { + // Skip when the frontend executes the action itself (non-automatic mode). + // The front logs on its side via the standard agent activity flow. + if (this.context.stepDefinition.automaticExecution !== true) return null; + + return { + renderingId: this.context.user.renderingId, + action: 'action', + type: 'write', + collectionId: this.context.collectionId, + recordId: this.context.baseRecordRef.recordId[0], + }; + } + + protected override async checkIdempotency(): Promise { + const existing = await this.findPendingExecution( + 'trigger-action', + ); + + if (existing?.idempotencyPhase === 'done') { + return this.buildOutcomeResult({ status: 'success' }); + } + + if (existing?.idempotencyPhase === 'executing') { + throw new StepStateError('Step execution was interrupted. Please retry the step manually.'); + } + + return null; + } + protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( @@ -33,12 +69,23 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< pending, async exec => { const { selectedRecordRef, pendingData } = exec; + + // The frontend executes the action itself and posts the result back. + // A confirmed step without actionResult is a broken frontend contract. + if (!pendingData || !('actionResult' in pendingData)) { + throw new StepStateError( + `Frontend confirmed action but did not provide actionResult ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + ); + } + const target: ActionTarget = { selectedRecordRef, - ...(pendingData as ActionRef), + displayName: pendingData.displayName, + name: pendingData.name, }; - return this.resolveAndExecute(target, exec); + return this.saveFrontendResult(target, pendingData.actionResult, exec); }, ); } @@ -64,12 +111,24 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< const name = this.resolveActionName(schema, args.actionName); const target: ActionTarget = { selectedRecordRef, displayName: args.actionName, name }; - // Branch B -- automaticExecution + // Branch B -- automaticExecution: executor runs the action itself, so it cannot + // handle forms (no UI to fill them). Reject form-bearing actions here. When the + // frontend is in the loop (Branch C), it handles the form natively so no check. if (step.automaticExecution) { - return this.resolveAndExecute(target); + const { hasForm } = await this.agentPort.getActionFormInfo( + { + collection: selectedRecordRef.collectionName, + action: name, + id: selectedRecordRef.recordId, + }, + this.context.user, + ); + if (hasForm) throw new UnsupportedActionFormError(target.displayName); + + return this.executeOnExecutor(target); } - // Branch C -- Awaiting confirmation + // Branch C -- Awaiting confirmation (frontend executes the action, including forms) await this.context.runStore.saveStepExecution(this.context.runId, { type: 'trigger-action', stepIndex: this.context.stepIndex, @@ -80,17 +139,17 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< return this.buildOutcomeResult({ status: 'awaiting-input' }); } - /** - * Resolves the action name, calls executeAction, and persists execution data. - * When `existingExecution` is provided (confirmation flow), it is spread into the - * saved execution to preserve pendingData for traceability. - */ - private async resolveAndExecute( - target: ActionTarget, - existingExecution?: TriggerRecordActionStepExecutionData, - ): Promise { + /** Branch B — executor runs the action via agentPort, then persists the result. */ + private async executeOnExecutor(target: ActionTarget): Promise { const { selectedRecordRef, displayName, name } = target; + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'trigger-action', + stepIndex: this.context.stepIndex, + selectedRecordRef, + idempotencyPhase: 'executing', + }); + const actionResult = await this.agentPort.executeAction( { collection: selectedRecordRef.collectionName, @@ -100,22 +159,34 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< this.context.user, ); - try { - await this.context.runStore.saveStepExecution(this.context.runId, { - ...existingExecution, - type: 'trigger-action', - stepIndex: this.context.stepIndex, - executionParams: { displayName, name }, - executionResult: { success: true, actionResult }, - selectedRecordRef, - }); - } catch (cause) { - throw new StepPersistenceError( - `Action "${name}" executed but step state could not be persisted ` + - `(run "${this.context.runId}", step ${this.context.stepIndex})`, - cause, - ); - } + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'trigger-action', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { success: true, actionResult }, + selectedRecordRef, + idempotencyPhase: 'done', + }); + + return this.buildOutcomeResult({ status: 'success' }); + } + + /** Branch A — the frontend executed the action; executor only persists the result it sent. */ + private async saveFrontendResult( + target: ActionTarget, + actionResult: unknown, + existingExecution: TriggerRecordActionStepExecutionData, + ): Promise { + const { selectedRecordRef, displayName, name } = target; + + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'trigger-action', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { success: true, actionResult }, + selectedRecordRef, + }); return this.buildOutcomeResult({ status: 'success' }); } diff --git a/packages/workflow-executor/src/executors/update-record-step-executor.ts b/packages/workflow-executor/src/executors/update-record-step-executor.ts index 2f8d94a08e..f27b17783b 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -1,7 +1,8 @@ -import type { StepExecutionResult } from '../types/execution'; -import type { CollectionSchema, RecordRef } from '../types/record'; -import type { UpdateRecordStepDefinition } from '../types/step-definition'; +import type { CreateActivityLogArgs } from '../ports/activity-log-port'; +import type { StepExecutionResult } from '../types/execution-context'; import type { FieldRef, UpdateRecordStepExecutionData } from '../types/step-execution-data'; +import type { CollectionSchema, FieldSchema, RecordRef } from '../types/validated/collection'; +import type { UpdateRecordStepDefinition } from '../types/validated/step-definition'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; @@ -10,7 +11,7 @@ import { FieldNotFoundError, InvalidPreRecordedArgsError, NoWritableFieldsError, - StepPersistenceError, + StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; @@ -22,12 +23,105 @@ Important rules: - Final answer is definitive, you won't receive any other input from the user. - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; +const jsonStringSchema = z + .string() + .refine( + val => { + try { + JSON.parse(val); + + return true; + } catch { + return false; + } + }, + { message: 'Must be a valid JSON string' }, + ) + .describe('JSON content as a valid JSON string'); + +function buildZodSchemaForPrimitive(type: string, enumValues?: string[]): z.ZodTypeAny { + switch (type) { + case 'Boolean': + return z.preprocess(val => { + if (typeof val !== 'string') return val; + if (val === 'true') return true; + if (val === 'false') return false; + + return val; + }, z.boolean()); + case 'Date': + return z.iso.datetime().describe('ISO 8601 datetime, e.g. 2024-06-01T00:00:00Z'); + case 'Dateonly': + return z.iso.date().describe('ISO 8601 date, e.g. 2024-06-01'); + case 'Number': + return z.coerce.number(); + case 'Enum': + if (enumValues && enumValues.length >= 2) { + return z.enum(enumValues as [string, string, ...string[]]); + } + + if (enumValues?.length === 1) return z.literal(enumValues[0]); + + return z.string(); + case 'Json': + return jsonStringSchema; + case 'Point': + return z.array(z.number()).length(2).describe('[longitude, latitude]'); + // String, Uuid, Time, Binary, Timeonly, File → plain string + default: + return z.string(); + } +} + +function buildZodSchemaForField(field: FieldSchema): z.ZodTypeAny { + const { type, enumValues } = field; + + if (Array.isArray(type)) { + // Nested array (e.g. [['String']]) → treat as opaque JSON. + if (Array.isArray(type[0])) return z.array(jsonStringSchema); + + return z.array(buildZodSchemaForPrimitive(type[0] as string, enumValues)); + } + + if (typeof type === 'object' && type !== null) { + return jsonStringSchema; + } + + return buildZodSchemaForPrimitive(type as string, enumValues); +} + interface UpdateTarget extends FieldRef { selectedRecordRef: RecordRef; - value: string; + value: unknown; } export default class UpdateRecordStepExecutor extends RecordStepExecutor { + protected override buildActivityLogArgs(): CreateActivityLogArgs | null { + return { + renderingId: this.context.user.renderingId, + action: 'update', + type: 'write', + collectionId: this.context.collectionId, + recordId: this.context.baseRecordRef.recordId[0], + }; + } + + protected override async checkIdempotency(): Promise { + const existing = await this.findPendingExecution( + 'update-record', + ); + + if (existing?.idempotencyPhase === 'done') { + return this.buildOutcomeResult({ status: 'success' }); + } + + if (existing?.idempotencyPhase === 'executing') { + throw new StepStateError('Step execution was interrupted. Please retry the step manually.'); + } + + return null; + } + protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( @@ -39,7 +133,7 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { const { selectedRecordRef, displayName, name, value } = target; + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'update-record', + stepIndex: this.context.stepIndex, + selectedRecordRef, + idempotencyPhase: 'executing', + }); + const updated = await this.agentPort.updateRecord( { collection: selectedRecordRef.collectionName, @@ -126,22 +224,15 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { + ): Promise<{ fieldName: string; value: unknown; reasoning: string }> { const tool = this.buildUpdateFieldTool(schema); const messages = [ this.buildContextMessage(), @@ -161,10 +252,11 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor( - messages, - tool, - ); + const { input } = await this.invokeWithTool<{ + input: { fieldName: string; value: unknown; reasoning: string }; + }>(messages, tool); + + return input; } private buildUpdateFieldTool(schema: CollectionSchema): DynamicStructuredTool { @@ -174,18 +266,29 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor f.displayName) as [string, ...string[]]; + type FieldObject = z.ZodObject<{ + fieldName: z.ZodLiteral; + value: z.ZodNullable; + reasoning: z.ZodString; + }>; + + const fieldObjects = nonRelationFields.map(f => + z.object({ + fieldName: z.literal(f.displayName), + value: buildZodSchemaForField(f).nullable(), + reasoning: z.string().describe('Why this field and value were chosen'), + }), + ) as FieldObject[]; + + const inputSchema = + fieldObjects.length === 1 + ? fieldObjects[0] + : z.union(fieldObjects as [FieldObject, FieldObject, ...FieldObject[]]); return new DynamicStructuredTool({ name: 'update-record-field', description: 'Update a field on the selected record.', - schema: z.object({ - fieldName: z.enum(displayNames), - // z.string() intentionally: the value is always transmitted as string - // to updateRecord; data typing is handled by the agent/datasource layer. - value: z.string().describe('The new value for the field'), - reasoning: z.string().describe('Why this field and value were chosen'), - }), + schema: z.object({ input: inputSchema }), func: undefined, }); } diff --git a/packages/workflow-executor/src/http/executor-http-server.ts b/packages/workflow-executor/src/http/executor-http-server.ts index add56f3b3d..5438cb6da8 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -1,7 +1,7 @@ import type { Logger } from '../ports/logger-port'; import type { WorkflowPort } from '../ports/workflow-port'; import type Runner from '../runner'; -import type { StepUser } from '../types/execution'; +import type { StepUser } from '../types/execution-context'; import type { Server } from 'http'; import bodyParser from '@koa/bodyparser'; @@ -11,7 +11,12 @@ import Koa from 'koa'; import koaJwt from 'koa-jwt'; import ConsoleLogger from '../adapters/console-logger'; -import { RunNotFoundError, UserMismatchError } from '../errors'; +import { + RunNotFoundError, + UserMismatchError, + WorkflowExecutorError, + extractErrorMessage, +} from '../errors'; export interface ExecutorHttpServerOptions { port: number; @@ -49,7 +54,7 @@ export default class ExecutorHttpServer { this.logger.error('Unhandled HTTP error', { method: ctx.method, path: ctx.path, - error: err instanceof Error ? err.message : String(err), + error: extractErrorMessage(err), stack: err instanceof Error ? err.stack : undefined, }); ctx.status = 500; @@ -141,7 +146,7 @@ export default class ExecutorHttpServer { runId: ctx.params.runId, method: ctx.method, path: ctx.path, - error: err instanceof Error ? err.message : String(err), + error: extractErrorMessage(err), stack: err instanceof Error ? err.stack : undefined, }); ctx.status = 503; @@ -193,6 +198,19 @@ export default class ExecutorHttpServer { return; } + if (err instanceof WorkflowExecutorError) { + this.logger.error('Malformed run on trigger', { + runId, + bearerUserId, + error: extractErrorMessage(err), + stack: err.stack, + }); + ctx.status = 400; + ctx.body = { error: err.userMessage }; + + return; + } + throw err; } diff --git a/packages/workflow-executor/src/pending-data-validators.ts b/packages/workflow-executor/src/http/pending-data-validators.ts similarity index 56% rename from packages/workflow-executor/src/pending-data-validators.ts rename to packages/workflow-executor/src/http/pending-data-validators.ts index cb3957918b..95f3376c1d 100644 --- a/packages/workflow-executor/src/pending-data-validators.ts +++ b/packages/workflow-executor/src/http/pending-data-validators.ts @@ -1,10 +1,10 @@ -import type { StepExecutionData } from './types/step-execution-data'; +import type { StepExecutionData } from '../types/step-execution-data'; import { z } from 'zod'; -// Per-step-type body schemas for PATCH /runs/:runId/steps/:stepIndex/pending-data. -// Only step types that support the confirmation flow are listed here — others return 404. -// Schemas use .strict() to reject unknown fields from the client. +// Per-step-type schemas for the `pendingData` payload sent by the front via +// POST /runs/:runId/trigger. Consumed by step executors to validate `incomingPendingData` +// before applying user confirmation or override. Schemas use .strict() to reject unknown fields. const patchBodySchemas: Partial> = { 'update-record': z .object({ @@ -13,7 +13,15 @@ const patchBodySchemas: Partial> }) .strict(), - 'trigger-action': z.object({ userConfirmed: z.boolean() }).strict(), + 'trigger-action': z + .object({ + userConfirmed: z.boolean(), + // Opaque action result from the frontend. Required when userConfirmed=true; the + // presence check lives in the step-executor so a descriptive StepStateError can + // name the runId/stepIndex — not achievable from inside a zod schema. + actionResult: z.unknown().optional(), + }) + .strict(), mcp: z.object({ userConfirmed: z.boolean() }).strict(), @@ -35,9 +43,11 @@ const patchBodySchemas: Partial> guidance: z .object({ - userInput: z.string().min(1), + userInput: z.string().optional(), }) .strict(), + + condition: z.object({ selectedOption: z.string() }).strict(), }; export default patchBodySchemas; diff --git a/packages/workflow-executor/src/in-flight-run-registry.ts b/packages/workflow-executor/src/in-flight-run-registry.ts new file mode 100644 index 0000000000..3e38a4b6a6 --- /dev/null +++ b/packages/workflow-executor/src/in-flight-run-registry.ts @@ -0,0 +1,30 @@ +// Tracks promises for runs currently executing (including their full auto-chain). +// Keyed by runId — a run has one available step at a time, and a chain advances the stepId +// between iterations. Keying by runId keeps the dedup guarantee across the whole chain. +export default class InFlightRunRegistry { + private readonly runs = new Map>(); + + get size() { + return this.runs.size; + } + + keys() { + return [...this.runs.keys()]; + } + + has(runId: string) { + return this.runs.has(runId); + } + + // Registers the promise and automatically removes the entry when it settles. + track(runId: string, promise: Promise): Promise { + const tracked = promise.finally(() => this.runs.delete(runId)); + this.runs.set(runId, tracked); + + return tracked; + } + + drain(): Promise { + return Promise.allSettled(this.runs.values()).then(() => {}); + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 4c4b9ae243..607cf2fe9c 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -1,4 +1,4 @@ -export { StepType } from './types/step-definition'; +export { StepType } from './types/validated/step-definition'; export type { ConditionStepDefinition, ReadRecordStepDefinition, @@ -9,7 +9,7 @@ export type { McpStepDefinition, GuidanceStepDefinition, StepDefinition, -} from './types/step-definition'; +} from './types/validated/step-definition'; export type { StepStatus, @@ -18,7 +18,7 @@ export type { McpStepOutcome, GuidanceStepOutcome, StepOutcome, -} from './types/step-outcome'; +} from './types/validated/step-outcome'; export type { FieldReadSuccess, @@ -48,19 +48,20 @@ export type { CollectionSchema, RecordRef, RecordData, -} from './types/record'; +} from './types/validated/collection'; export type { StepUser, Step, - PendingStepExecution, + AvailableStepExecution, StepExecutionResult, ExecutionContext, -} from './types/execution'; +} from './types/execution-context'; export type { AgentPort, ExecuteActionQuery, + GetActionFormInfoQuery, GetRecordQuery, GetRelatedDataQuery, Id, @@ -82,7 +83,6 @@ export { NoResolvedFieldsError, NoWritableFieldsError, NoActionsError, - StepPersistenceError, NoRelationshipFieldsError, RelatedRecordNotFoundError, InvalidAIResponseError, @@ -94,8 +94,16 @@ export { McpToolNotFoundError, McpToolInvocationError, AgentPortError, + WorkflowPortError, + RunStorePortError, + AiModelPortError, + AgentProbeError, ConfigurationError, InvalidPreRecordedArgsError, + UnsupportedStepTypeError, + UnsupportedActionFormError, + InvalidStepDefinitionError, + DomainValidationError, } from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; @@ -118,6 +126,8 @@ export { default as DatabaseStore } from './stores/database-store'; export type { DatabaseStoreOptions } from './stores/database-store'; export { buildDatabaseRunStore, buildInMemoryRunStore } from './stores/build-run-store'; export { buildInMemoryExecutor, buildDatabaseExecutor } from './build-workflow-executor'; +export { runCli } from './cli-core'; +export { default as PrettyLogger } from './adapters/pretty-logger'; export type { WorkflowExecutor, ExecutorOptions, diff --git a/packages/workflow-executor/src/ports/activity-log-port.ts b/packages/workflow-executor/src/ports/activity-log-port.ts new file mode 100644 index 0000000000..15ce3f4801 --- /dev/null +++ b/packages/workflow-executor/src/ports/activity-log-port.ts @@ -0,0 +1,29 @@ +export interface CreateActivityLogArgs { + renderingId: number; + action: string; + type: 'read' | 'write'; + collectionId?: string; + recordId?: string | number; + label?: string; +} + +export interface ActivityLogHandle { + id: string; + index: string; +} + +// Per-run scoped port: token baked into the adapter's constructor. markSucceeded/markFailed +// retry transient failures internally and are invoked with `void` from base-step-executor. +export interface ActivityLogPort { + createPending(args: CreateActivityLogArgs): Promise; + markSucceeded(handle: ActivityLogHandle): Promise; + markFailed(handle: ActivityLogHandle, errorMessage: string): Promise; +} + +// Produces per-run ActivityLogPort instances and exposes drain() at the process level so the +// Runner can wait for in-flight fire-and-forget transitions before shutting down. +export interface ActivityLogPortFactory { + forRun(forestServerToken: string): ActivityLogPort; + // Never rejects — individual transition failures are logged by the adapter. + drain(): Promise; +} diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 9b809cc6db..4ccb652409 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -1,7 +1,7 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { StepUser } from '../types/execution'; -import type { RecordData } from '../types/record'; +import type { StepUser } from '../types/execution-context'; +import type { RecordData } from '../types/validated/collection'; export type Id = string | number; @@ -20,9 +20,17 @@ export type GetRelatedDataQuery = { export type ExecuteActionQuery = { collection: string; action: string; id?: Id[] }; +export type GetActionFormInfoQuery = { collection: string; action: string; id: Id[] }; + export interface AgentPort { getRecord(query: GetRecordQuery, user: StepUser): Promise; updateRecord(query: UpdateRecordQuery, user: StepUser): Promise; getRelatedData(query: GetRelatedDataQuery, user: StepUser): Promise; executeAction(query: ExecuteActionQuery, user: StepUser): Promise; + // Old Ruby agents with hooks.load=false return 404; agent-client falls back to the fields + // passed via ActionEndpointsByCollection (populated from the orchestrator's schema). + getActionFormInfo(query: GetActionFormInfoQuery, user: StepUser): Promise<{ hasForm: boolean }>; + // Startup healthcheck. Throws AgentProbeError on network error, timeout, or non-2xx. + // JWT is not verified here — it's validated naturally when the first step runs. + probe(): Promise; } diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index 74274e7288..7eee6419b5 100644 --- a/packages/workflow-executor/src/ports/workflow-port.ts +++ b/packages/workflow-executor/src/ports/workflow-port.ts @@ -1,17 +1,44 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { PendingStepExecution, StepUser } from '../types/execution'; -import type { CollectionSchema } from '../types/record'; -import type { StepOutcome } from '../types/step-outcome'; +import type { AvailableStepExecution, StepUser } from '../types/execution-context'; +import type { CollectionSchema } from '../types/validated/collection'; +import type { StepOutcome } from '../types/validated/step-outcome'; import type { McpConfiguration } from '@forestadmin/ai-proxy'; export type { McpConfiguration }; +export interface MalformedRunInfo { + runId: string; + // null when workflowHistory has no identifiable available step. + stepId: string | null; + stepIndex: number | null; + // userMessage surfaces in the Forest Admin UI / audit trail; technicalMessage in ops logs. + userMessage: string; + technicalMessage: string; +} + +// step = domain payload, auth = adapter metadata. Split so secrets don't leak into the domain. +export interface AvailableRunDispatch { + step: AvailableStepExecution; + auth: { forestServerToken: string }; +} + +export interface AvailableRunsBatch { + pending: AvailableRunDispatch[]; + malformed: MalformedRunInfo[]; +} + export interface WorkflowPort { - getPendingStepExecutions(): Promise; - getPendingStepExecutionsForRun(runId: string): Promise; - updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise; - getCollectionSchema(collectionName: string): Promise; + getAvailableRuns(): Promise; + // Throws MalformedRunError on mapping failure. + getAvailableRun(runId: string): Promise; + // Returns the next step to chain when the orchestrator has one ready, or null when the run is + // awaiting-input / finished / errored. Lets the executor skip a poll cycle for auto workflows. + updateStepExecution( + runId: string, + stepOutcome: StepOutcome, + ): Promise; + getCollectionSchema(collectionName: string, runId: string): Promise; getMcpServerConfigs(): Promise; hasRunAccess(runId: string, user: StepUser): Promise; } diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 41fadee427..d43df7562a 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -1,21 +1,41 @@ import type { StepContextConfig } from './executors/step-executor-factory'; +import type { ActivityLogPortFactory } from './ports/activity-log-port'; import type { AgentPort } from './ports/agent-port'; import type { AiModelPort } from './ports/ai-model-port'; import type { Logger } from './ports/logger-port'; import type { RunStore } from './ports/run-store'; -import type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; +import type { + AvailableRunDispatch, + MalformedRunInfo, + McpConfiguration, + WorkflowPort, +} from './ports/workflow-port'; import type SchemaCache from './schema-cache'; -import type { PendingStepExecution, StepExecutionResult } from './types/execution'; +import type { AvailableStepExecution, StepExecutionResult } from './types/execution-context'; import type { StepExecutionData } from './types/step-execution-data'; +import type { StepOutcome } from './types/validated/step-outcome'; import type { RemoteTool } from '@forestadmin/ai-proxy'; import ConsoleLogger from './adapters/console-logger'; -import { RunNotFoundError, UserMismatchError, causeMessage } from './errors'; +import { + MalformedRunError, + RunNotFoundError, + UserMismatchError, + causeMessage, + extractErrorMessage, +} from './errors'; import StepExecutorFactory from './executors/step-executor-factory'; +import InFlightRunRegistry from './in-flight-run-registry'; +import { stepTypeToOutcomeType } from './types/validated/step-outcome'; import validateSecrets from './validate-secrets'; export type RunnerState = 'idle' | 'running' | 'draining' | 'stopped'; +// Default cap on auto-chained steps per entry (initial step + chained). High enough to cover +// realistic auto workflows; low enough to fail loud if a workflow misbehaves. +const DEFAULT_MAX_CHAIN_DEPTH = 50; +const DEFAULT_STOP_TIMEOUT_MS = 30_000; + export interface RunnerConfig { agentPort: AgentPort; workflowPort: WorkflowPort; @@ -23,26 +43,26 @@ export interface RunnerConfig { schemaCache: SchemaCache; pollingIntervalMs: number; aiModelPort: AiModelPort; + activityLogPortFactory: ActivityLogPortFactory; envSecret: string; authSecret: string; logger?: Logger; stopTimeoutMs?: number; + // On timeout the step reports status:error; the underlying work is not aborted (Promise.race + // limitation). Late rejections are caught and logged; late resolutions are silently discarded. + stepTimeoutMs?: number; + // Max number of ADDITIONAL steps auto-chained via /update-step response before yielding to the + // next poll cycle (counted after the initial step). 0 disables chaining entirely. Default 50. + maxChainDepth?: number; } -const DEFAULT_STOP_TIMEOUT_MS = 30_000; - export default class Runner { private readonly config: RunnerConfig; private pollingTimer: NodeJS.Timeout | null = null; - private readonly inFlightSteps = new Map>(); - private isRunning = false; + private readonly inFlightRuns = new InFlightRunRegistry(); private readonly logger: Logger; private _state: RunnerState = 'idle'; - private static stepKey(step: PendingStepExecution): string { - return `${step.runId}:${step.stepId}`; - } - constructor(config: RunnerConfig) { this.config = config; this.logger = config.logger ?? new ConsoleLogger(); @@ -57,20 +77,16 @@ export default class Runner { throw new Error('Runner has been stopped and cannot be restarted'); } - if (this.isRunning) return; + if (this._state === 'running') return; validateSecrets({ envSecret: this.config.envSecret, authSecret: this.config.authSecret }); - this.isRunning = true; - this._state = 'running'; + // Probe the agent first so we fail fast without opening DB connections when unreachable. + await this.config.agentPort.probe(); + this.logger.info('Agent probe passed', {}); + await this.config.runStore.init(this.logger); - try { - await this.config.runStore.init(this.logger); - } catch (error) { - this.isRunning = false; - this._state = 'idle'; - throw error; - } + this._state = 'running'; this.schedulePoll(); } @@ -79,7 +95,7 @@ export default class Runner { if (this._state === 'idle' || this._state === 'stopped' || this._state === 'draining') return; this._state = 'draining'; - this.isRunning = false; + this.logger.info('Graceful shutdown initiated', { inFlightRuns: this.inFlightRuns.size }); if (this.pollingTimer !== null) { clearTimeout(this.pollingTimer); @@ -87,17 +103,17 @@ export default class Runner { } try { - // Drain in-flight steps - if (this.inFlightSteps.size > 0) { - this.logger.info?.('Draining in-flight steps', { - count: this.inFlightSteps.size, - steps: [...this.inFlightSteps.keys()], + // Drain in-flight runs (each entry may cover a whole auto-chain). + if (this.inFlightRuns.size > 0) { + this.logger.info('Draining in-flight runs', { + count: this.inFlightRuns.size, + runs: [...this.inFlightRuns.keys()], }); const timeout = this.config.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; let drainTimer: NodeJS.Timeout | undefined; const drainResult = await Promise.race([ - Promise.allSettled(this.inFlightSteps.values()).then(() => { + this.inFlightRuns.drain().then(() => { if (drainTimer) clearTimeout(drainTimer); return 'drained' as const; @@ -108,16 +124,19 @@ export default class Runner { ]); if (drainResult === 'timeout') { - this.logger.error('Drain timeout — steps still in flight', { - remainingSteps: [...this.inFlightSteps.keys()], + this.logger.error('Drain timeout — runs still in flight', { + remainingRuns: [...this.inFlightRuns.keys()], timeoutMs: timeout, }); } else { - this.logger.info?.('All in-flight steps drained', {}); + this.logger.info('All in-flight runs drained', {}); } } - // Close resources — log failures instead of silently swallowing + // Wait for fire-and-forget activity-log transitions to settle before closing resources — + // otherwise audit-trail rows can be left stuck in Pending. + await this.config.activityLogPortFactory.drain(); + const results = await Promise.allSettled([ this.config.aiModelPort.closeConnections(), this.config.runStore.close(this.logger), @@ -132,6 +151,7 @@ export default class Runner { } } finally { this._state = 'stopped'; + this.logger.info('Workflow executor stopped', {}); } } @@ -143,32 +163,61 @@ export default class Runner { runId: string, options?: { pendingData?: unknown; bearerUserId?: number }, ): Promise { - const step = await this.config.workflowPort.getPendingStepExecutionsForRun(runId); + let dispatch: AvailableRunDispatch | null; + + try { + dispatch = await this.config.workflowPort.getAvailableRun(runId); + } catch (err) { + if (err instanceof MalformedRunError) { + await this.reportMalformedRun(err.info); + } - if (!step) throw new RunNotFoundError(runId); + throw err; + } + + if (!dispatch) throw new RunNotFoundError(runId); + + const { step, auth } = dispatch; if (options?.bearerUserId !== undefined && step.user.id !== options.bearerUserId) { throw new UserMismatchError(runId); } - if (this.inFlightSteps.has(Runner.stepKey(step))) return; + if (this.inFlightRuns.has(step.runId)) { + this.logger.info('Trigger ignored — run already in flight', { + runId: step.runId, + stepIndex: step.stepIndex, + }); + + return; + } - await this.executeStep(step, options?.pendingData); + await this.executeStep(step, auth.forestServerToken, options?.pendingData); } private schedulePoll(): void { - if (!this.isRunning) return; + if (this._state !== 'running') return; this.pollingTimer = setTimeout(() => this.runPollCycle(), this.config.pollingIntervalMs); } private async runPollCycle(): Promise { try { - const steps = await this.config.workflowPort.getPendingStepExecutions(); - const pending = steps.filter(s => !this.inFlightSteps.has(Runner.stepKey(s))); - await Promise.allSettled(pending.map(s => this.executeStep(s))); + const { pending, malformed } = await this.config.workflowPort.getAvailableRuns(); + // Each reportMalformedRun has its own try/catch, no individual failure poisons the cycle. + await Promise.allSettled(malformed.map(info => this.reportMalformedRun(info))); + + const dispatchable = pending.filter(d => !this.inFlightRuns.has(d.step.runId)); + this.logger.info('Poll cycle completed', { + fetched: pending.length, + dispatching: dispatchable.length, + malformed: malformed.length, + }); + await Promise.allSettled( + dispatchable.map(d => this.executeStep(d.step, d.auth.forestServerToken)), + ); } catch (error) { this.logger.error('Poll cycle failed', { - error: error instanceof Error ? error.message : String(error), + error: extractErrorMessage(error), stack: error instanceof Error ? error.stack : undefined, }); } finally { @@ -176,6 +225,41 @@ export default class Runner { } } + // Posts an error outcome so the orchestrator marks the run failed and stops re-dispatching it. + // Idempotent server-side. If stepIndex is null (empty/corrupt history), log loudly and skip — + // ops has to clean up manually. + private async reportMalformedRun(info: MalformedRunInfo): Promise { + if (info.stepId === null || info.stepIndex === null) { + this.logger.error('Malformed run cannot be reported — no available step identified', { + runId: info.runId, + error: info.technicalMessage, + }); + + return; + } + + try { + await this.config.workflowPort.updateStepExecution(info.runId, { + type: 'record', + stepId: info.stepId, + stepIndex: info.stepIndex, + status: 'error', + error: info.userMessage, + }); + this.logger.error('Malformed run reported as error', { + runId: info.runId, + stepIndex: info.stepIndex, + error: info.technicalMessage, + }); + } catch (reportErr) { + this.logger.error('Malformed run — also failed to report', { + runId: info.runId, + mappingError: info.technicalMessage, + reportError: extractErrorMessage(reportErr), + }); + } + } + private async fetchRemoteTools(): Promise { const configs = await this.config.workflowPort.getMcpServerConfigs(); if (configs.length === 0) return []; @@ -188,53 +272,153 @@ export default class Runner { return this.config.aiModelPort.loadRemoteTools(mergedConfig); } - private executeStep(step: PendingStepExecution, incomingPendingData?: unknown): Promise { - const key = Runner.stepKey(step); - const promise = this.doExecuteStep(step, key, incomingPendingData); - this.inFlightSteps.set(key, promise); - - return promise; + private executeStep( + step: AvailableStepExecution, + forestServerToken: string, + incomingPendingData?: unknown, + ): Promise { + // The tracked promise covers the entire auto-chain for this run plus the map cleanup — + // register it once, clean up once. Storing per-step entries (or Promise.resolve()) would + // break drain: Promise.allSettled would see already-resolved entries and stop waiting while + // the chain is still running. + return this.inFlightRuns.track( + step.runId, + this.doExecuteStep(step, forestServerToken, incomingPendingData), + ); } private async doExecuteStep( - step: PendingStepExecution, - key: string, + step: AvailableStepExecution, + forestServerToken: string, incomingPendingData?: unknown, ): Promise { - let result: StepExecutionResult; + let currentStep = step; + let currentToken = forestServerToken; + let currentIncomingData = incomingPendingData; + let chainedCount = 0; // additional steps chained after the initial one + const maxDepth = this.config.maxChainDepth ?? DEFAULT_MAX_CHAIN_DEPTH; + + // Sequential by design: each step's outcome drives the next dispatch; steps within one run + // cannot overlap. The no-await-in-loop rule doesn't apply here. + /* eslint-disable no-await-in-loop, no-constant-condition */ + while (true) { + let result: StepExecutionResult; + + try { + const executor = await StepExecutorFactory.create( + currentStep, + this.contextConfig, + this.config.activityLogPortFactory.forRun(currentToken), + () => this.fetchRemoteTools(), + currentIncomingData, + ); + result = await executor.execute(); + } catch (error) { + this.logger.error('FATAL: executor contract violated — reporting synthetic error outcome', { + runId: currentStep.runId, + stepId: currentStep.stepId, + stepIndex: currentStep.stepIndex, + error: extractErrorMessage(error), + }); - try { - const executor = await StepExecutorFactory.create( - step, - this.contextConfig, - () => this.fetchRemoteTools(), - incomingPendingData, - ); - result = await executor.execute(); - } catch (error) { - this.logger.error('FATAL: executor contract violated — step outcome not reported', { - runId: step.runId, - stepId: step.stepId, - error: error instanceof Error ? error.message : String(error), - }); + // Report a synthetic error outcome so the orchestrator marks the run failed and stops + // re-dispatching — without this, the contract-violating step loops forever. + const syntheticOutcome: StepOutcome = { + type: stepTypeToOutcomeType(currentStep.stepDefinition.type), + stepId: currentStep.stepId, + stepIndex: currentStep.stepIndex, + status: 'error', + error: 'An unexpected error occurred.', + }; + + try { + await this.config.workflowPort.updateStepExecution(currentStep.runId, syntheticOutcome); + } catch (reportErr) { + this.logger.error('FATAL: also failed to report synthetic error outcome', { + runId: currentStep.runId, + stepId: currentStep.stepId, + reportError: extractErrorMessage(reportErr), + }); + } - return; - } finally { - this.inFlightSteps.delete(key); - } + return; + } - try { - await this.config.workflowPort.updateStepExecution(step.runId, result.stepOutcome); - } catch (error) { - this.logger.error('Failed to report step outcome', { - runId: step.runId, - stepId: step.stepId, - stepIndex: step.stepIndex, - error: error instanceof Error ? error.message : String(error), - cause: causeMessage(error), - stack: error instanceof Error ? error.stack : undefined, - }); + let nextDispatch: AvailableRunDispatch | null; + + try { + nextDispatch = await this.config.workflowPort.updateStepExecution( + currentStep.runId, + result.stepOutcome, + ); + } catch (error) { + this.logger.error('Failed to report step outcome', { + runId: currentStep.runId, + stepId: currentStep.stepId, + stepIndex: currentStep.stepIndex, + error: extractErrorMessage(error), + cause: causeMessage(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + return; + } + + if (nextDispatch === null) { + this.logger.info('Chain completed — orchestrator returned no further step', { + runId: currentStep.runId, + stepIndex: currentStep.stepIndex, + }); + + return; + } + + // Progression safety: the server must advance the workflow within the same run. A cross-run + // dispatch would execute under the initial run's inFlightRuns key (and leak the map entry + // on cleanup); a non-progressing stepIndex would loop forever. Both are server bugs — + // exit the chain and let the next poll re-fetch authoritative state. + if ( + nextDispatch.step.runId !== currentStep.runId || + nextDispatch.step.stepIndex <= currentStep.stepIndex + ) { + this.logger.error('Server returned non-progressing next step — exiting chain', { + runId: currentStep.runId, + currentStepIndex: currentStep.stepIndex, + returnedRunId: nextDispatch.step.runId, + returnedStepIndex: nextDispatch.step.stepIndex, + }); + + return; + } + + // Cap check BEFORE incrementing: chainedCount counts chained steps we've already executed. + // maxDepth=2 means "run up to 2 chained steps after the initial one" (3 total). + if (chainedCount >= maxDepth) { + this.logger.info('Chain depth cap reached — yielding to next poll', { + runId: currentStep.runId, + stepIndex: currentStep.stepIndex, + maxDepth, + }); + + return; + } + + // Graceful stop: finish the current step, then yield instead of chaining further. + if (this._state === 'draining') { + this.logger.info('Chain interrupted by stop() — yielding', { + runId: currentStep.runId, + stepIndex: currentStep.stepIndex, + }); + + return; + } + + chainedCount += 1; + currentStep = nextDispatch.step; + currentToken = nextDispatch.auth.forestServerToken; + currentIncomingData = undefined; // chained steps never carry pending data } + /* eslint-enable no-await-in-loop, no-constant-condition */ } private get contextConfig(): StepContextConfig { @@ -245,6 +429,7 @@ export default class Runner { runStore: this.config.runStore, schemaCache: this.config.schemaCache, logger: this.logger, + stepTimeoutMs: this.config.stepTimeoutMs, }; } } diff --git a/packages/workflow-executor/src/schema-cache.ts b/packages/workflow-executor/src/schema-cache.ts index bba308a4ab..68b1a3db0b 100644 --- a/packages/workflow-executor/src/schema-cache.ts +++ b/packages/workflow-executor/src/schema-cache.ts @@ -1,4 +1,4 @@ -import type { CollectionSchema } from './types/record'; +import type { CollectionSchema } from './types/validated/collection'; const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes @@ -30,7 +30,7 @@ export default class SchemaCache { this.store.set(collectionName, { schema, fetchedAt: this.now() }); } - /** Iterates over non-expired entries, removing stale ones. */ + // Yields non-expired entries; deletes stale ones along the way. *[Symbol.iterator](): IterableIterator<[string, CollectionSchema]> { const now = this.now(); diff --git a/packages/workflow-executor/src/stores/database-store.ts b/packages/workflow-executor/src/stores/database-store.ts index 1a28015b69..7eb587be88 100644 --- a/packages/workflow-executor/src/stores/database-store.ts +++ b/packages/workflow-executor/src/stores/database-store.ts @@ -6,6 +6,8 @@ import type { QueryInterface, Sequelize } from 'sequelize'; import { DataTypes } from 'sequelize'; import { SequelizeStorage, Umzug } from 'umzug'; +import { RunStorePortError, WorkflowExecutorError, extractErrorMessage } from '../errors'; + const TABLE_NAME = 'workflow_step_executions'; export interface DatabaseStoreOptions { @@ -75,52 +77,69 @@ export default class DatabaseStore implements RunStore { logger: undefined, }); - try { - await umzug.up(); - } catch (error) { - logger?.error('Database migration failed', { - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } + return this.callPort('init', async () => { + try { + await umzug.up(); + } catch (error) { + logger?.error('Database migration failed', { + error: extractErrorMessage(error), + }); + throw error; + } + }); } async getStepExecutions(runId: string): Promise { - const [rows] = await this.sequelize.query( - `SELECT data FROM ${TABLE_NAME} WHERE run_id = :runId ORDER BY step_index ASC`, - { replacements: { runId } }, - ); + return this.callPort('getStepExecutions', async () => { + const [rows] = await this.sequelize.query( + `SELECT data FROM ${TABLE_NAME} WHERE run_id = :runId ORDER BY step_index ASC`, + { replacements: { runId } }, + ); - return (rows as Array<{ data: string | StepExecutionData }>).map(row => - typeof row.data === 'string' ? JSON.parse(row.data) : row.data, - ); + return (rows as Array<{ data: string | StepExecutionData }>).map(row => + typeof row.data === 'string' ? JSON.parse(row.data) : row.data, + ); + }); } async saveStepExecution(runId: string, stepExecution: StepExecutionData): Promise { - await this.sequelize.transaction(async transaction => { - const now = new Date(); - const data = JSON.stringify(stepExecution); - const replacements = { runId, stepIndex: stepExecution.stepIndex, data, now }; + return this.callPort('saveStepExecution', async () => { + await this.sequelize.transaction(async transaction => { + const now = new Date(); + const data = JSON.stringify(stepExecution); + const replacements = { runId, stepIndex: stepExecution.stepIndex, data, now }; - // Delete + insert in transaction: dialect-agnostic upsert (avoids ON CONFLICT / ON DUPLICATE) - await this.sequelize.query( - `DELETE FROM ${TABLE_NAME} WHERE run_id = :runId AND step_index = :stepIndex`, - { replacements, transaction }, - ); - await this.sequelize.query( - `INSERT INTO ${TABLE_NAME} (run_id, step_index, data, created_at, updated_at) VALUES (:runId, :stepIndex, :data, :now, :now)`, - { replacements, transaction }, - ); + // Delete + insert in transaction: dialect-agnostic upsert (avoids ON CONFLICT / ON DUPLICATE) + await this.sequelize.query( + `DELETE FROM ${TABLE_NAME} WHERE run_id = :runId AND step_index = :stepIndex`, + { replacements, transaction }, + ); + await this.sequelize.query( + `INSERT INTO ${TABLE_NAME} (run_id, step_index, data, created_at, updated_at) VALUES (:runId, :stepIndex, :data, :now, :now)`, + { replacements, transaction }, + ); + }); }); } async close(logger?: Logger): Promise { + return this.callPort('close', async () => { + try { + await this.sequelize.close(); + } catch (error) { + logger?.error('Failed to close database connection', { + error: extractErrorMessage(error), + }); + } + }); + } + + private async callPort(operation: string, fn: () => Promise): Promise { try { - await this.sequelize.close(); - } catch (error) { - logger?.error('Failed to close database connection', { - error: error instanceof Error ? error.message : String(error), - }); + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new RunStorePortError(operation, cause); } } } diff --git a/packages/workflow-executor/src/stores/in-memory-store.ts b/packages/workflow-executor/src/stores/in-memory-store.ts index 8d8aab5714..90117d70aa 100644 --- a/packages/workflow-executor/src/stores/in-memory-store.ts +++ b/packages/workflow-executor/src/stores/in-memory-store.ts @@ -1,33 +1,52 @@ import type { RunStore } from '../ports/run-store'; import type { StepExecutionData } from '../types/step-execution-data'; +import { RunStorePortError, WorkflowExecutorError } from '../errors'; + export default class InMemoryStore implements RunStore { private readonly data = new Map>(); async init(): Promise { - // No-op: in-memory store requires no initialization + return this.callPort('init', async () => { + // No-op: in-memory store requires no initialization + }); } async close(): Promise { - // No-op: nothing to clean up + return this.callPort('close', async () => { + // No-op: nothing to clean up + }); } async getStepExecutions(runId: string): Promise { - const runData = this.data.get(runId); + return this.callPort('getStepExecutions', async () => { + const runData = this.data.get(runId); - if (!runData) return []; + if (!runData) return []; - return [...runData.values()].sort((a, b) => a.stepIndex - b.stepIndex); + return [...runData.values()].sort((a, b) => a.stepIndex - b.stepIndex); + }); } async saveStepExecution(runId: string, stepExecution: StepExecutionData): Promise { - let runData = this.data.get(runId); + return this.callPort('saveStepExecution', async () => { + let runData = this.data.get(runId); - if (!runData) { - runData = new Map(); - this.data.set(runId, runData); - } + if (!runData) { + runData = new Map(); + this.data.set(runId, runData); + } - runData.set(stepExecution.stepIndex, stepExecution); + runData.set(stepExecution.stepIndex, stepExecution); + }); + } + + private async callPort(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new RunStorePortError(operation, cause); + } } } diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution-context.ts similarity index 60% rename from packages/workflow-executor/src/types/execution.ts rename to packages/workflow-executor/src/types/execution-context.ts index 96e92635f4..94ea4b3a6d 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution-context.ts @@ -1,41 +1,19 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordRef } from './record'; -import type SchemaCache from '../schema-cache'; -import type { StepDefinition } from './step-definition'; -import type { StepOutcome } from './step-outcome'; +import type { ActivityLogPort } from '../ports/activity-log-port'; import type { AgentPort } from '../ports/agent-port'; import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type { WorkflowPort } from '../ports/workflow-port'; +import type SchemaCache from '../schema-cache'; +import type { RecordRef } from './validated/collection'; +import type { AvailableStepExecution, Step, StepUser } from './validated/execution'; +import type { StepDefinition } from './validated/step-definition'; +import type { StepOutcome } from './validated/step-outcome'; import type { BaseChatModel } from '@forestadmin/ai-proxy'; -export interface StepUser { - id: number; - email: string; - firstName: string; - lastName: string; - team: string; - renderingId: number; - role: string; - permissionLevel: string; - tags: Record; -} - -export interface Step { - stepDefinition: StepDefinition; - stepOutcome: StepOutcome; -} - -export interface PendingStepExecution { - readonly runId: string; - readonly stepId: string; - readonly stepIndex: number; - readonly baseRecordRef: RecordRef; - readonly stepDefinition: StepDefinition; - readonly previousSteps: ReadonlyArray; - readonly user: StepUser; -} +// Re-export the runtime result types alongside the context they flow with. +export type { AvailableStepExecution, Step, StepUser }; export interface StepExecutionResult { stepOutcome: StepOutcome; @@ -45,10 +23,12 @@ export interface IStepExecutor { execute(): Promise; } +// ExecutionContext holds port instances (with methods) — not zod-validatable. export interface ExecutionContext { readonly runId: string; readonly stepId: string; readonly stepIndex: number; + readonly collectionId: string; readonly baseRecordRef: RecordRef; readonly stepDefinition: TStep; readonly model: BaseChatModel; @@ -60,4 +40,6 @@ export interface ExecutionContext readonly previousSteps: ReadonlyArray>; readonly logger: Logger; readonly incomingPendingData?: unknown; + readonly stepTimeoutMs?: number; + readonly activityLogPort: ActivityLogPort; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts deleted file mode 100644 index c79cac76a5..0000000000 --- a/packages/workflow-executor/src/types/record.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @draft Types derived from the workflow-executor spec -- subject to change. */ - -// -- Schema types (structure of a collection — source: WorkflowPort) -- - -export interface FieldSchema { - fieldName: string; - displayName: string; - isRelationship: boolean; - /** Cardinality of the relation. Absent for non-relationship fields. */ - relationType?: 'BelongsTo' | 'HasMany' | 'HasOne'; - /** Target collection name; only meaningful for relationship fields. */ - relatedCollectionName?: string; -} - -export interface ActionSchema { - name: string; - displayName: string; - endpoint: string; -} - -export interface CollectionSchema { - collectionName: string; - collectionDisplayName: string; - primaryKeyFields: string[]; - fields: FieldSchema[]; - actions: ActionSchema[]; -} - -// -- Record types (data — source: AgentPort/RunStore) -- - -/** Lightweight pointer to a specific record. */ -export interface RecordRef { - collectionName: string; - recordId: Array; - /** Index of the workflow step that loaded this record. */ - stepIndex: number; -} - -/** A record with its loaded field values — no stepIndex (agent doesn't know about steps). */ -export type RecordData = Omit & { values: Record }; diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts deleted file mode 100644 index 14a27516e4..0000000000 --- a/packages/workflow-executor/src/types/step-definition.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** @draft Types derived from the workflow-executor spec -- subject to change. */ - -export enum StepType { - Condition = 'condition', - ReadRecord = 'read-record', - UpdateRecord = 'update-record', - TriggerAction = 'trigger-action', - LoadRelatedRecord = 'load-related-record', - Mcp = 'mcp', - Guidance = 'guidance', -} - -interface BaseStepDefinition { - type: StepType; - prompt?: string; - aiConfigName?: string; -} - -interface BaseRecordStepDefinition extends BaseStepDefinition { - automaticExecution?: boolean; -} - -export interface ConditionStepDefinition extends BaseStepDefinition { - type: StepType.Condition; - options: [string, ...string[]]; -} - -export interface ReadRecordStepDefinition extends BaseRecordStepDefinition { - type: StepType.ReadRecord; - preRecordedArgs?: { - selectedRecordStepIndex?: number; - /** Display names of the fields to read */ - fieldDisplayNames?: string[]; - }; -} - -export interface UpdateRecordStepDefinition extends BaseRecordStepDefinition { - type: StepType.UpdateRecord; - preRecordedArgs?: { - selectedRecordStepIndex?: number; - /** Display name of the field to update */ - fieldDisplayName?: string; - value?: string; - }; -} - -export interface TriggerActionStepDefinition extends BaseRecordStepDefinition { - type: StepType.TriggerAction; - preRecordedArgs?: { - selectedRecordStepIndex?: number; - /** Display name of the action to trigger */ - actionDisplayName?: string; - }; -} - -export interface LoadRelatedRecordStepDefinition extends BaseRecordStepDefinition { - type: StepType.LoadRelatedRecord; - preRecordedArgs?: { - selectedRecordStepIndex?: number; - /** Display name of the relation to follow */ - relationDisplayName?: string; - selectedRecordIndex?: number; - }; -} - -export interface McpStepDefinition extends BaseStepDefinition { - type: StepType.Mcp; - mcpServerId?: string; - automaticExecution?: boolean; -} - -export interface GuidanceStepDefinition extends BaseStepDefinition { - type: StepType.Guidance; -} - -export type RecordStepDefinition = - | ReadRecordStepDefinition - | UpdateRecordStepDefinition - | TriggerActionStepDefinition - | LoadRelatedRecordStepDefinition; - -export type StepDefinition = - | ConditionStepDefinition - | RecordStepDefinition - | McpStepDefinition - | GuidanceStepDefinition; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 31ec78ec4e..da1bce172c 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordRef } from './record'; +import type { RecordRef } from './validated/collection'; // -- Base -- @@ -8,6 +8,12 @@ interface BaseStepExecutionData { stepIndex: number; } +// Extended by executors that write a side effect (update-record, trigger-action, mcp). +// Write-ahead log: 'executing' = side effect may have fired; 'done' = completed, safe to replay. +interface MutatingStepExecutionData extends BaseStepExecutionData { + idempotencyPhase?: 'executing' | 'done'; +} + // -- Condition -- export interface ConditionStepExecutionData extends BaseStepExecutionData { @@ -44,13 +50,12 @@ export interface ReadRecordStepExecutionData extends BaseStepExecutionData { // -- Update Record -- -export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { +export interface UpdateRecordStepExecutionData extends MutatingStepExecutionData { type: 'update-record'; - executionParams?: FieldRef & { value: string }; - /** User confirmed → values returned by updateRecord. User rejected → skipped. */ + executionParams?: FieldRef & { value: unknown }; + // User confirmed → values returned by updateRecord. User rejected → skipped. executionResult?: { updatedValues: Record } | { skipped: true }; - /** AI-selected field and value awaiting user confirmation. Used in the confirmation flow only. */ - pendingData?: FieldRef & { value: string; userConfirmed?: boolean }; + pendingData?: FieldRef & { value: unknown; userConfirmed?: boolean }; selectedRecordRef: RecordRef; } @@ -68,30 +73,29 @@ export interface RelationRef { displayName: string; } -export interface TriggerRecordActionStepExecutionData extends BaseStepExecutionData { +export interface TriggerRecordActionStepExecutionData extends MutatingStepExecutionData { type: 'trigger-action'; - /** Display name and technical name of the executed action. */ executionParams?: ActionRef; executionResult?: { success: true; actionResult: unknown } | { skipped: true }; - /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ - pendingData?: ActionRef & { userConfirmed?: boolean }; + // When userConfirmed=true, actionResult is required: the frontend executes the action and + // posts the result back (the executor never re-executes on confirmation). + pendingData?: ActionRef & { userConfirmed?: boolean; actionResult?: unknown }; selectedRecordRef: RecordRef; } // -- Mcp -- -/** Reference to an MCP tool by its sanitized name (OpenAI-safe, alphanumeric + underscores/hyphens). */ +// `name` is the OpenAI-safe sanitized MCP tool name (alphanumeric + underscores/hyphens). export interface McpToolRef { name: string; sourceId: string; } -/** A resolved tool call: sanitized tool name + input parameters sent to the tool. */ export interface McpToolCall extends McpToolRef { input: Record; } -export interface McpStepExecutionData extends BaseStepExecutionData { +export interface McpStepExecutionData extends MutatingStepExecutionData { type: 'mcp'; executionParams?: McpToolCall; executionResult?: @@ -112,28 +116,19 @@ export interface RecordStepExecutionData extends BaseStepExecutionData { // -- Load Related Record -- export interface LoadRelatedRecordPendingData extends RelationRef { - /** AI-selected fields suggested for display on the frontend. undefined = not computed (no non-relation fields). */ + // undefined when not computed (record has no non-relation fields). suggestedFields?: string[]; - /** - * The record id to load. Initially set by the AI. Can be overridden by the frontend - * via PATCH /runs/:runId/steps/:stepIndex/pending-data. - */ + // AI-selected initially; can be overridden by the frontend via PATCH .../pending-data. selectedRecordId: Array; - /** Set by the frontend via PATCH /runs/:runId/steps/:stepIndex/pending-data. */ userConfirmed?: boolean; } export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { type: 'load-related-record'; - /** AI-selected relation with pre-fetched candidates awaiting user confirmation. */ pendingData?: LoadRelatedRecordPendingData; - /** The record ref used to load the relation. Required for handleConfirmationFlow. */ selectedRecordRef: RecordRef; executionParams?: RelationRef; - /** - * Navigation path captured at execution time — used by StepSummaryBuilder for AI context. - * Source is not repeated here — it is always selectedRecordRef, consistent with other step types. - */ + // Source is always selectedRecordRef, not repeated here (consistent with other step types). executionResult?: { relation: RelationRef; record: RecordRef } | { skipped: true }; } @@ -142,7 +137,7 @@ export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionDat export interface GuidanceStepExecutionData extends BaseStepExecutionData { type: 'guidance'; pendingData?: { userInput?: string }; - executionResult?: { userInput: string }; + executionResult?: { userInput?: string }; } // -- Union -- @@ -157,5 +152,4 @@ export type StepExecutionData = | McpStepExecutionData | GuidanceStepExecutionData; -/** Alias for StepExecutionData — kept for backwards-compatible consumption at the call sites. */ export type ExecutedStepExecutionData = StepExecutionData; diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts deleted file mode 100644 index 151582b101..0000000000 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** @draft Types derived from the workflow-executor spec -- subject to change. */ - -import { StepType } from './step-definition'; - -export type BaseStepStatus = 'success' | 'error'; - -/** AI steps can pause mid-execution to await user input (e.g. awaiting-input). */ -export type RecordStepStatus = BaseStepStatus | 'awaiting-input'; - -/** Union of all step statuses. */ -export type StepStatus = BaseStepStatus | RecordStepStatus; - -/** - * StepOutcome is sent to the orchestrator — it must NEVER contain client data. - * Any privacy-sensitive information (e.g. AI reasoning) must stay in - * StepExecutionData (persisted in the RunStore, client-side only). - */ -interface BaseStepOutcome { - stepId: string; - stepIndex: number; - /** Present when status is 'error'. */ - error?: string; -} - -export interface ConditionStepOutcome extends BaseStepOutcome { - type: 'condition'; - status: BaseStepStatus; - /** Present when status is 'success'. */ - selectedOption?: string; -} - -export interface RecordStepOutcome extends BaseStepOutcome { - type: 'record'; - status: RecordStepStatus; -} - -export interface McpStepOutcome extends BaseStepOutcome { - type: 'mcp'; - status: RecordStepStatus; -} - -export interface GuidanceStepOutcome extends BaseStepOutcome { - type: 'guidance'; - status: BaseStepStatus; -} - -export type StepOutcome = - | ConditionStepOutcome - | RecordStepOutcome - | McpStepOutcome - | GuidanceStepOutcome; - -export function stepTypeToOutcomeType(type: StepType): 'condition' | 'record' | 'mcp' | 'guidance' { - if (type === StepType.Condition) return 'condition'; - if (type === StepType.Mcp) return 'mcp'; - if (type === StepType.Guidance) return 'guidance'; - - return 'record'; -} diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts new file mode 100644 index 0000000000..e488f599cf --- /dev/null +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -0,0 +1,107 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import { z } from 'zod'; + +// -- Schema types (structure of a collection — source: WorkflowPort) -- + +// Mirrors PrimitiveTypes from @forestadmin/datasource-toolkit — kept local to avoid +// adding a hard dependency on datasource-toolkit from the executor package. +export const PRIMITIVE_TYPES = [ + 'Boolean', + 'Binary', + 'Date', + 'Dateonly', + 'Enum', + 'File', + 'Json', + 'Number', + 'Point', + 'String', + 'Time', + 'Timeonly', + 'Uuid', +] as const; +export type PrimitiveType = (typeof PRIMITIVE_TYPES)[number]; + +// Mirrors ColumnType = PrimitiveTypes | [ColumnType] | { [key: string]: ColumnType } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ColumnTypeSchema: z.ZodType = z.lazy(() => + z.union([ + z.enum(PRIMITIVE_TYPES), + z.tuple([ColumnTypeSchema]), + z.record(z.string(), ColumnTypeSchema), + ]), +); + +export const FieldSchemaSchema = z + .object({ + fieldName: z.string().min(1), + displayName: z.string().min(1), + isRelationship: z.boolean(), + /** Cardinality of the relation. Absent for non-relationship fields. */ + relationType: z.enum(['BelongsTo', 'HasMany', 'HasOne', 'BelongsToMany']).optional(), + /** Target collection name; only meaningful for relationship fields. */ + relatedCollectionName: z.string().optional(), + /** Column type — null for relationship fields. */ + type: ColumnTypeSchema.nullable(), + /** Allowed values for Enum fields. */ + enumValues: z.array(z.string()).min(1).optional(), + }) + .strict(); +export type FieldSchema = z.infer; + +// ActionSchema.fields / hooks content is a discriminated union owned by the upstream +// `@forestadmin/forestadmin-client` lib and consumed downstream by `@forestadmin/agent-client`. +// We validate the envelope shape only — detail re-validation would duplicate the lib's job. +const ActionFieldsSchema = z.array(z.looseObject({})).optional(); +const ActionHooksSchema = z + .object({ + load: z.boolean(), + change: z.array(z.unknown()), + }) + .strict() + .optional(); + +export const ActionSchemaSchema = z + .object({ + name: z.string().min(1), + displayName: z.string().min(1), + endpoint: z.string().min(1), + /** Static form fields. Used as fallback when the agent's /hooks/load route 404s (old Ruby agents). */ + fields: ActionFieldsSchema, + /** Action lifecycle hooks. Drives agent-client's dynamic form loading. */ + hooks: ActionHooksSchema, + }) + .strict(); +export type ActionSchema = z.infer; + +export const CollectionSchemaSchema = z + .object({ + collectionName: z.string().min(1), + // null when the rendering has no explicit displayName configured — normalized to collectionName. + collectionDisplayName: z.string().nullable(), + primaryKeyFields: z.array(z.string().min(1)).min(1), + fields: z.array(FieldSchemaSchema), + actions: z.array(ActionSchemaSchema), + }) + .strict() + .transform(data => ({ + ...data, + collectionDisplayName: data.collectionDisplayName || data.collectionName, + })); +export type CollectionSchema = z.infer; + +// -- Record types (data — source: AgentPort/RunStore) -- + +export const RecordRefSchema = z + .object({ + collectionName: z.string().min(1), + recordId: z.array(z.union([z.string(), z.number()])).min(1), + // Index of the workflow step that loaded this record. + stepIndex: z.number().int().nonnegative(), + }) + .strict(); +export type RecordRef = z.infer; + +// No stepIndex — the agent doesn't know about steps. +export type RecordData = Omit & { values: Record }; diff --git a/packages/workflow-executor/src/types/validated/execution.ts b/packages/workflow-executor/src/types/validated/execution.ts new file mode 100644 index 0000000000..43b5c0c06a --- /dev/null +++ b/packages/workflow-executor/src/types/validated/execution.ts @@ -0,0 +1,44 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import { z } from 'zod'; + +import { RecordRefSchema } from './collection'; +import { StepDefinitionSchema } from './step-definition'; +import { StepOutcomeSchema } from './step-outcome'; + +export const StepUserSchema = z + .object({ + id: z.number(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + team: z.string(), + renderingId: z.number().int().nonnegative(), + role: z.string(), + permissionLevel: z.string(), + tags: z.record(z.string(), z.string()), + }) + .strict(); +export type StepUser = z.infer; + +export const StepSchema = z + .object({ + stepDefinition: StepDefinitionSchema, + stepOutcome: StepOutcomeSchema, + }) + .strict(); +export type Step = z.infer; + +export const AvailableStepExecutionSchema = z + .object({ + runId: z.string().min(1), + stepId: z.string().min(1), + stepIndex: z.number().int().nonnegative(), + collectionId: z.string().min(1), + baseRecordRef: RecordRefSchema, + stepDefinition: StepDefinitionSchema, + previousSteps: z.array(StepSchema), + user: StepUserSchema, + }) + .strict(); +export type AvailableStepExecution = z.infer; diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts new file mode 100644 index 0000000000..9ad308a738 --- /dev/null +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -0,0 +1,117 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import { z } from 'zod'; + +export enum StepType { + Condition = 'condition', + ReadRecord = 'read-record', + UpdateRecord = 'update-record', + TriggerAction = 'trigger-action', + LoadRelatedRecord = 'load-related-record', + Mcp = 'mcp', + Guidance = 'guidance', +} + +const baseFields = { + prompt: z.string().optional(), + aiConfigName: z.string().optional(), +}; + +const baseRecordFields = { + ...baseFields, + automaticExecution: z.boolean().optional(), +}; + +export const ConditionStepDefinitionSchema = z.object({ + ...baseFields, + type: z.literal(StepType.Condition), + options: z.array(z.string()).min(2), +}); +export type ConditionStepDefinition = z.infer; + +export const ReadRecordStepDefinitionSchema = z.object({ + ...baseRecordFields, + type: z.literal(StepType.ReadRecord), + preRecordedArgs: z + .object({ + selectedRecordStepIndex: z.number().int().optional(), + /** Display names of the fields to read */ + fieldDisplayNames: z.array(z.string()).optional(), + }) + .optional(), +}); +export type ReadRecordStepDefinition = z.infer; + +export const UpdateRecordStepDefinitionSchema = z.object({ + ...baseRecordFields, + type: z.literal(StepType.UpdateRecord), + preRecordedArgs: z + .object({ + selectedRecordStepIndex: z.number().int().optional(), + /** Display name of the field to update */ + fieldDisplayName: z.string().optional(), + value: z.unknown().optional(), + }) + .optional(), +}); +export type UpdateRecordStepDefinition = z.infer; + +export const TriggerActionStepDefinitionSchema = z.object({ + ...baseRecordFields, + type: z.literal(StepType.TriggerAction), + preRecordedArgs: z + .object({ + selectedRecordStepIndex: z.number().int().optional(), + /** Display name of the action to trigger */ + actionDisplayName: z.string().optional(), + }) + .optional(), +}); +export type TriggerActionStepDefinition = z.infer; + +export const LoadRelatedRecordStepDefinitionSchema = z.object({ + ...baseRecordFields, + type: z.literal(StepType.LoadRelatedRecord), + preRecordedArgs: z + .object({ + selectedRecordStepIndex: z.number().int().optional(), + /** Display name of the relation to follow */ + relationDisplayName: z.string().optional(), + selectedRecordIndex: z.number().int().optional(), + }) + .optional(), +}); +export type LoadRelatedRecordStepDefinition = z.infer; + +export const McpStepDefinitionSchema = z.object({ + ...baseFields, + type: z.literal(StepType.Mcp), + mcpServerId: z.string().optional(), + automaticExecution: z.boolean().optional(), +}); +export type McpStepDefinition = z.infer; + +export const GuidanceStepDefinitionSchema = z.object({ + ...baseFields, + type: z.literal(StepType.Guidance), +}); +export type GuidanceStepDefinition = z.infer; + +export const RecordStepDefinitionSchema = z.discriminatedUnion('type', [ + ReadRecordStepDefinitionSchema, + UpdateRecordStepDefinitionSchema, + TriggerActionStepDefinitionSchema, + LoadRelatedRecordStepDefinitionSchema, +]); +export type RecordStepDefinition = z.infer; + +export const StepDefinitionSchema = z.discriminatedUnion('type', [ + ConditionStepDefinitionSchema, + ReadRecordStepDefinitionSchema, + UpdateRecordStepDefinitionSchema, + TriggerActionStepDefinitionSchema, + LoadRelatedRecordStepDefinitionSchema, + McpStepDefinitionSchema, + GuidanceStepDefinitionSchema, +]); +export type StepDefinition = z.infer; diff --git a/packages/workflow-executor/src/types/validated/step-outcome.ts b/packages/workflow-executor/src/types/validated/step-outcome.ts new file mode 100644 index 0000000000..89ec606939 --- /dev/null +++ b/packages/workflow-executor/src/types/validated/step-outcome.ts @@ -0,0 +1,80 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import { z } from 'zod'; + +import { StepType } from './step-definition'; + +export const BaseStepStatusSchema = z.enum(['success', 'error']); +export type BaseStepStatus = z.infer; + +// AI steps can pause mid-execution to await user input (awaiting-input). +export const RecordStepStatusSchema = z.enum(['success', 'error', 'awaiting-input']); +export type RecordStepStatus = z.infer; + +export type StepStatus = BaseStepStatus | RecordStepStatus; + +/** + * StepOutcome is sent to the orchestrator — it must NEVER contain client data. + * Any privacy-sensitive information (e.g. AI reasoning) must stay in + * StepExecutionData (persisted in the RunStore, client-side only). + */ +const baseOutcomeFields = { + stepId: z.string().min(1), + stepIndex: z.number().int().nonnegative(), + /** Present when status is 'error'. */ + error: z.string().optional(), +}; + +export const ConditionStepOutcomeSchema = z + .object({ + ...baseOutcomeFields, + type: z.literal('condition'), + status: BaseStepStatusSchema, + /** Present when status is 'success'. */ + selectedOption: z.string().optional(), + }) + .strict(); +export type ConditionStepOutcome = z.infer; + +export const RecordStepOutcomeSchema = z + .object({ + ...baseOutcomeFields, + type: z.literal('record'), + status: RecordStepStatusSchema, + }) + .strict(); +export type RecordStepOutcome = z.infer; + +export const McpStepOutcomeSchema = z + .object({ + ...baseOutcomeFields, + type: z.literal('mcp'), + status: RecordStepStatusSchema, + }) + .strict(); +export type McpStepOutcome = z.infer; + +export const GuidanceStepOutcomeSchema = z + .object({ + ...baseOutcomeFields, + type: z.literal('guidance'), + status: RecordStepStatusSchema, + }) + .strict(); +export type GuidanceStepOutcome = z.infer; + +export const StepOutcomeSchema = z.discriminatedUnion('type', [ + ConditionStepOutcomeSchema, + RecordStepOutcomeSchema, + McpStepOutcomeSchema, + GuidanceStepOutcomeSchema, +]); +export type StepOutcome = z.infer; + +export function stepTypeToOutcomeType(type: StepType): 'condition' | 'record' | 'mcp' | 'guidance' { + if (type === StepType.Condition) return 'condition'; + if (type === StepType.Mcp) return 'mcp'; + if (type === StepType.Guidance) return 'guidance'; + + return 'record'; +} diff --git a/packages/workflow-executor/test/adapters/activity-log-drainer.test.ts b/packages/workflow-executor/test/adapters/activity-log-drainer.test.ts new file mode 100644 index 0000000000..134a3086be --- /dev/null +++ b/packages/workflow-executor/test/adapters/activity-log-drainer.test.ts @@ -0,0 +1,62 @@ +import ActivityLogDrainer from '../../src/adapters/activity-log-drainer'; + +describe('ActivityLogDrainer', () => { + it('drain() resolves immediately when nothing is in flight', async () => { + const drainer = new ActivityLogDrainer(); + + await expect(drainer.drain()).resolves.toBeUndefined(); + }); + + it('drain() awaits all tracked promises before resolving', async () => { + const drainer = new ActivityLogDrainer(); + let resolveWork!: () => void; + + drainer.track( + () => + new Promise(resolve => { + resolveWork = resolve; + }), + ); + + let drainResolved = false; + const drainPromise = drainer.drain().then(() => { + drainResolved = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(drainResolved).toBe(false); + + resolveWork(); + await drainPromise; + expect(drainResolved).toBe(true); + }); + + it('removes promises from the in-flight set after they settle', async () => { + const drainer = new ActivityLogDrainer(); + + await drainer.track(async () => 'done'); + + // If the promise stayed tracked, a second drain would wait forever; here it resolves instantly. + await expect(drainer.drain()).resolves.toBeUndefined(); + }); + + it('drain() resolves even when a tracked promise rejects (allSettled)', async () => { + const drainer = new ActivityLogDrainer(); + + // Attach a .catch() so the rejection is handled (no UnhandledPromiseRejection). + drainer.track(async () => Promise.reject(new Error('boom'))).catch(() => {}); + + await expect(drainer.drain()).resolves.toBeUndefined(); + }); + + it('track() still rejects on the returned promise when the tracked fn throws', async () => { + const drainer = new ActivityLogDrainer(); + + await expect( + drainer.track(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + }); +}); diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index 38ed081d41..aacaff0652 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -1,9 +1,9 @@ -import type { StepUser } from '../../src/types/execution'; +import type { StepUser } from '../../src/types/execution-context'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; -import { RecordNotFoundError } from '../../src/errors'; +import { AgentProbeError, RecordNotFoundError } from '../../src/errors'; import SchemaCache from '../../src/schema-cache'; jest.mock('@forestadmin/agent-client', () => ({ @@ -15,7 +15,7 @@ const mockedCreateRemoteAgentClient = createRemoteAgentClient as jest.MockedFunc >; function createMockClient() { - const mockAction = { execute: jest.fn() }; + const mockAction = { execute: jest.fn(), getFields: jest.fn().mockReturnValue([]) }; const mockRelation = { list: jest.fn() }; const mockCollection = { list: jest.fn(), @@ -164,6 +164,19 @@ describe('AgentClientAgentPort', () => { }); }); + it('should restore snake_case field names when agent returns camelCase keys', async () => { + // The agent-client HTTP layer deserializes JSON:API responses with camelCase keys. + // restoreFieldNames must map them back to the original snake_case names. + mockCollection.list.mockResolvedValue([{ cardNumber: '4111', isActive: true }]); + + const result = await port.getRecord( + { collection: 'users', id: [42], fields: ['card_number', 'is_active'] }, + user, + ); + + expect(result.values).toEqual({ card_number: '4111', is_active: true }); + }); + it('should not pass fields to list when fields is undefined', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); @@ -190,7 +203,7 @@ describe('AgentClientAgentPort', () => { }); describe('updateRecord', () => { - it('should call update with pipe-encoded id and return a RecordData', async () => { + it('should forward the RecordId array to agent-client and return a RecordData', async () => { mockCollection.update.mockResolvedValue({ id: 42, name: 'Bob' }); const result = await port.updateRecord( @@ -202,7 +215,7 @@ describe('AgentClientAgentPort', () => { user, ); - expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); + expect(mockCollection.update).toHaveBeenCalledWith([42], { name: 'Bob' }); expect(result).toEqual({ collectionName: 'users', recordId: [42], @@ -210,7 +223,7 @@ describe('AgentClientAgentPort', () => { }); }); - it('should encode composite PK to pipe format for update', async () => { + it('should forward composite PKs as arrays (agent-client handles pipe encoding)', async () => { mockCollection.update.mockResolvedValue({ tenantId: 1, orderId: 2 }); await port.updateRecord( @@ -218,7 +231,18 @@ describe('AgentClientAgentPort', () => { user, ); - expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); + expect(mockCollection.update).toHaveBeenCalledWith([1, 2], { status: 'done' }); + }); + + it('should restore snake_case field names when agent returns camelCase keys', async () => { + mockCollection.update.mockResolvedValue({ cardNumber: '4111', isActive: true }); + + const result = await port.updateRecord( + { collection: 'users', id: [42], values: { card_number: '4111', is_active: true } }, + user, + ); + + expect(result.values).toEqual({ card_number: '4111', is_active: true }); }); }); @@ -239,7 +263,7 @@ describe('AgentClientAgentPort', () => { user, ); - expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); + expect(mockCollection.relation).toHaveBeenCalledWith('posts', [42]); expect(result).toEqual([ { collectionName: 'posts', @@ -342,10 +366,55 @@ describe('AgentClientAgentPort', () => { expect.not.objectContaining({ fields: expect.anything() }), ); }); + + it('should restore snake_case field names in recordId and values when agent returns camelCase keys', async () => { + const cache = new SchemaCache(); + cache.set('users', { + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'posts', + displayName: 'Posts', + isRelationship: true, + relatedCollectionName: 'posts', + }, + ], + actions: [], + }); + cache.set('posts', { + collectionName: 'posts', + collectionDisplayName: 'Posts', + primaryKeyFields: ['post_id'], + fields: [], + actions: [], + }); + const localPort = new AgentClientAgentPort({ + agentUrl: 'http://agent', + authSecret: 'secret', + schemaCache: cache, + }); + mockRelation.list.mockResolvedValue([{ postId: 99, createdAt: '2024-01-01' }]); + + const result = await localPort.getRelatedData( + { + collection: 'users', + id: [42], + relation: 'posts', + limit: null, + fields: ['post_id', 'created_at'], + }, + user, + ); + + expect(result[0].recordId).toEqual([99]); + expect(result[0].values).toEqual({ post_id: 99, created_at: '2024-01-01' }); + }); }); describe('executeAction', () => { - it('should encode ids to pipe format and call execute', async () => { + it('should forward the RecordId array to agent-client and call execute', async () => { mockAction.execute.mockResolvedValue({ success: 'done' }); const result = await port.executeAction( @@ -357,7 +426,7 @@ describe('AgentClientAgentPort', () => { user, ); - expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1'] }); + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: [[1]] }); expect(result).toEqual({ success: 'done' }); }); @@ -378,4 +447,183 @@ describe('AgentClientAgentPort', () => { ).rejects.toThrow('Action failed'); }); }); + + describe('getActionFormInfo', () => { + it('returns hasForm:false when agent-client reports no fields', async () => { + mockAction.getFields.mockReturnValue([]); + + const result = await port.getActionFormInfo( + { collection: 'users', action: 'sendEmail', id: [1] }, + user, + ); + + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: [[1]] }); + expect(result).toEqual({ hasForm: false }); + }); + + it('returns hasForm:true when agent-client reports at least one field', async () => { + mockAction.getFields.mockReturnValue([{ getName: () => 'reason' }]); + + const result = await port.getActionFormInfo( + { collection: 'users', action: 'sendEmail', id: [1] }, + user, + ); + + expect(result).toEqual({ hasForm: true }); + }); + + it('forwards composite ids as arrays (agent-client handles pipe encoding)', async () => { + mockAction.getFields.mockReturnValue([]); + + await port.getActionFormInfo( + { collection: 'users', action: 'sendEmail', id: [1, 'abc'] }, + user, + ); + + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: [[1, 'abc']] }); + }); + }); + + describe('buildActionEndpoints', () => { + it('passes fields and hooks from schema to agent-client (supports Ruby agent fallback)', async () => { + const schemaCache = new SchemaCache(); + schemaCache.set('users', { + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [{ fieldName: 'id', displayName: 'id', isRelationship: false }], + actions: [ + { + name: 'refund', + displayName: 'Refund', + endpoint: '/forest/actions/refund', + hooks: { load: true, change: ['amount'] }, + fields: [{ field: 'amount', type: 'Number', isRequired: true }], + }, + ], + }); + const customPort = new AgentClientAgentPort({ + agentUrl: 'http://localhost:3310', + authSecret: 'secret', + schemaCache, + }); + + await customPort.executeAction({ collection: 'users', action: 'refund', id: [1] }, user); + + expect(mockedCreateRemoteAgentClient).toHaveBeenCalledWith( + expect.objectContaining({ + actionEndpoints: { + users: { + refund: expect.objectContaining({ + name: 'refund', + endpoint: '/forest/actions/refund', + hooks: { load: true, change: ['amount'] }, + fields: [{ field: 'amount', type: 'Number', isRequired: true }], + }), + }, + }, + }), + ); + }); + + it('falls back to neutral hooks/fields when the schema omits them', async () => { + // Default schema in beforeEach has no hooks/fields on actions. + await port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }, user); + + expect(mockedCreateRemoteAgentClient).toHaveBeenCalledWith( + expect.objectContaining({ + actionEndpoints: expect.objectContaining({ + users: expect.objectContaining({ + sendEmail: expect.objectContaining({ + hooks: { load: false, change: [] }, + fields: [], + }), + }), + }), + }), + ); + }); + }); + + describe('probe', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(jest.fn()); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('resolves when the agent returns 200 at GET /forest/', async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 200 })); + + await expect(port.probe()).resolves.toBeUndefined(); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:3310/forest/', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('throws when the agent responds with 404 (wrong URL / not a Forest agent)', async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 404, statusText: 'Not Found' })); + + await expect(port.probe()).rejects.toThrow(AgentProbeError); + await expect(port.probe()).rejects.toThrow(/404.*Not Found/); + }); + + it('throws when the agent responds with 401 (reverse proxy auth / wrong host)', async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 401, statusText: 'Unauthorized' })); + + await expect(port.probe()).rejects.toThrow(AgentProbeError); + await expect(port.probe()).rejects.toThrow(/401.*Unauthorized/); + }); + + it('throws AgentProbeError with status when the agent responds with 5xx', async () => { + fetchSpy.mockResolvedValue( + new Response(null, { status: 503, statusText: 'Service Unavailable' }), + ); + + await expect(port.probe()).rejects.toThrow(AgentProbeError); + await expect(port.probe()).rejects.toThrow(/503.*Service Unavailable/); + }); + + it('throws AgentProbeError with "cannot reach" when fetch throws and chains the cause', async () => { + const underlying = new TypeError('fetch failed'); + fetchSpy.mockRejectedValue(underlying); + + await expect(port.probe()).rejects.toThrow(AgentProbeError); + await expect(port.probe()).rejects.toThrow(/cannot reach.*fetch failed/); + + let caughtCause: unknown; + + try { + await port.probe(); + } catch (error) { + caughtCause = (error as AgentProbeError).cause; + } + + expect(caughtCause).toBe(underlying); + }); + + it('throws AgentProbeError with "timeout" when fetch is aborted by the signal', async () => { + const abortError = new Error('This operation was aborted'); + abortError.name = 'TimeoutError'; + fetchSpy.mockRejectedValue(abortError); + + await expect(port.probe()).rejects.toThrow(AgentProbeError); + await expect(port.probe()).rejects.toThrow(/timeout after 5000ms/); + }); + + it('passes an AbortSignal with 5s timeout to fetch', async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 200 })); + + await port.probe(); + + const fetchCall = fetchSpy.mock.calls[0]; + expect(fetchCall[1]?.signal).toBeInstanceOf(AbortSignal); + }); + }); }); diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index 725a98807d..dc4c27f1f7 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -1,10 +1,11 @@ -import type { PendingStepExecution } from '../../src/types/execution'; -import type { CollectionSchema } from '../../src/types/record'; -import type { StepOutcome } from '../../src/types/step-outcome'; +import type { ServerHydratedWorkflowRun, ServerUserProfile } from '../../src/adapters/server-types'; +import type { CollectionSchema } from '../../src/types/validated/collection'; +import type { StepOutcome } from '../../src/types/validated/step-outcome'; import { ServerUtils } from '@forestadmin/forestadmin-client'; import ForestServerWorkflowPort from '../../src/adapters/forest-server-workflow-port'; +import { MalformedRunError } from '../../src/errors'; jest.mock('@forestadmin/forestadmin-client', () => ({ ServerUtils: { query: jest.fn() }, @@ -14,6 +15,51 @@ const mockQuery = ServerUtils.query as jest.Mock; const options = { envSecret: 'env-secret-123', forestServerUrl: 'https://api.forestadmin.com' }; +function makeRun(overrides: Partial = {}): ServerHydratedWorkflowRun { + return { + id: 42, + workflowId: 'wf-1', + collectionId: 'col-1', + collectionName: 'users', + selectedRecordId: '7', + bpmnVersion: '1.0', + runState: 'started', + workflowHistory: [ + { + stepName: 'step-1', + stepIndex: 0, + done: false, + stepDefinition: { + type: 'condition', + title: 'Decide', + prompt: 'pick one', + outgoing: [ + { stepId: 'next-a', buttonText: 'A', answer: 'Yes' }, + { stepId: 'next-b', buttonText: 'B', answer: 'No' }, + ], + }, + }, + ], + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + userId: 1, + renderingId: 1, + userProfile: { + id: 1, + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + team: 'admin', + renderingId: 1, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + serverToken: 'test-forest-token', + }, + ...overrides, + }; +} + describe('ForestServerWorkflowPort', () => { let port: ForestServerWorkflowPort; @@ -22,52 +68,316 @@ describe('ForestServerWorkflowPort', () => { port = new ForestServerWorkflowPort(options); }); - describe('getPendingStepExecutions', () => { - it('should call the pending step executions route', async () => { - const pending: PendingStepExecution[] = []; - mockQuery.mockResolvedValue(pending); + describe('getAvailableRuns', () => { + it('calls the pending-run route and returns pending + malformed buckets', async () => { + mockQuery.mockResolvedValue([makeRun()]); - const result = await port.getPendingStepExecutions(); + const result = await port.getAvailableRuns(); expect(mockQuery).toHaveBeenCalledWith( options, 'get', - '/liana/v1/workflow-step-executions/pending', + '/api/workflow-orchestrator/pending-run', + ); + expect(result.pending).toHaveLength(1); + expect(result.pending[0].step.runId).toBe('42'); + expect(result.pending[0].step.stepId).toBe('step-1'); + expect(result.pending[0].auth.forestServerToken).toBe('test-forest-token'); + expect(result.malformed).toEqual([]); + }); + + it('filters out runs with no available step', async () => { + const terminalRun = makeRun({ + workflowHistory: [ + { + stepName: 'step-1', + stepIndex: 0, + done: true, + stepDefinition: { + type: 'condition', + title: 'Done', + prompt: '', + outgoing: [{ stepId: 'next', buttonText: 'ok', answer: 'ok' }], + }, + }, + ], + }); + mockQuery.mockResolvedValue([terminalRun]); + + const result = await port.getAvailableRuns(); + + expect(result.pending).toEqual([]); + expect(result.malformed).toEqual([]); + }); + + it('bucketizes malformed runs and keeps valid ones in the pending bucket', async () => { + const validRun = makeRun({ id: 42 }); + const malformedRun = makeRun({ id: 99, collectionName: null }); + mockQuery.mockResolvedValue([malformedRun, validRun]); + + const result = await port.getAvailableRuns(); + + expect(result.pending).toHaveLength(1); + expect(result.pending[0].step.runId).toBe('42'); + expect(result.malformed).toEqual([ + { + runId: '99', + stepId: 'step-1', + stepIndex: 0, + userMessage: + 'The workflow step configuration is invalid. Please check the workflow designer.', + technicalMessage: expect.stringContaining('collectionName'), + }, + ]); + + // Port must not POST — that's the Runner's job now. + expect(mockQuery).toHaveBeenCalledTimes(1); + }); + + it('extracts stepId/stepIndex from workflowHistory (first non-done step)', async () => { + const malformedRun = makeRun({ + id: 77, + collectionName: null, + workflowHistory: [ + { + stepName: 'done-step', + stepIndex: 0, + done: true, + stepDefinition: { + type: 'condition', + title: 'x', + prompt: 'x', + outgoing: [{ stepId: 'n', buttonText: 'ok', answer: 'ok' }], + }, + }, + { + stepName: 'pending-step', + stepIndex: 1, + done: false, + stepDefinition: { + type: 'condition', + title: 'y', + prompt: 'y', + outgoing: [{ stepId: 'm', buttonText: 'ok', answer: 'ok' }], + }, + }, + ], + }); + mockQuery.mockResolvedValue([malformedRun]); + + const result = await port.getAvailableRuns(); + + expect(result.malformed[0]).toEqual( + expect.objectContaining({ stepId: 'pending-step', stepIndex: 1 }), + ); + }); + + it('returns null stepId/stepIndex when workflowHistory has no available step', async () => { + const malformedRun = makeRun({ id: 88, collectionName: null, workflowHistory: [] }); + mockQuery.mockResolvedValue([malformedRun]); + + const result = await port.getAvailableRuns(); + + expect(result.malformed[0]).toEqual( + expect.objectContaining({ runId: '88', stepId: null, stepIndex: null }), + ); + }); + + it('bucketizes UnsupportedStepTypeError the same way as InvalidStepDefinitionError', async () => { + const unsupportedRun = makeRun({ + id: 33, + workflowHistory: [ + { + stepName: 'esc-step', + stepIndex: 0, + done: false, + stepDefinition: { + type: 'escalation', + title: 'x', + prompt: 'x', + outgoing: { stepId: 'n', buttonText: null }, + inboxId: null, + }, + }, + ], + }); + mockQuery.mockResolvedValue([unsupportedRun]); + + const result = await port.getAvailableRuns(); + + expect(result.pending).toEqual([]); + expect(result.malformed).toHaveLength(1); + expect(result.malformed[0]).toEqual( + expect.objectContaining({ runId: '33', stepId: 'esc-step', stepIndex: 0 }), + ); + }); + + it('bucketizes runs missing serverToken as malformed (token validated at the adapter)', async () => { + const malformedRun = makeRun({ + id: 44, + userProfile: { + id: 1, + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + team: 'admin', + renderingId: 1, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + serverToken: '', + }, + }); + mockQuery.mockResolvedValue([malformedRun]); + + const result = await port.getAvailableRuns(); + + expect(result.pending).toEqual([]); + expect(result.malformed[0]).toEqual( + expect.objectContaining({ + runId: '44', + technicalMessage: expect.stringContaining('userProfile is missing serverToken'), + }), + ); + }); + + it('bucketizes runs missing userProfile as malformed with a distinct error', async () => { + const malformedRun = makeRun({ + id: 45, + userProfile: undefined as unknown as ServerUserProfile, + }); + mockQuery.mockResolvedValue([malformedRun]); + + const result = await port.getAvailableRuns(); + + expect(result.pending).toEqual([]); + expect(result.malformed[0]).toEqual( + expect.objectContaining({ + runId: '45', + technicalMessage: expect.stringContaining('missing required field userProfile'), + }), + ); + }); + + it('bucketizes DomainValidationError (zod parse failure in mapper) as malformed', async () => { + // Wire guards pass but the available step has an empty stepName → zod parse rejects via + // AvailableStepExecutionSchema.stepId.min(1). Proves DomainValidationError flows through the + // malformed pathway just like InvalidStepDefinitionError. + const malformedRun = makeRun({ + id: 46, + workflowHistory: [ + { + stepName: '', + stepIndex: 0, + done: false, + stepDefinition: { + type: 'condition', + title: 'Decide', + prompt: 'pick one', + outgoing: [ + { stepId: 'a', buttonText: 'A', answer: 'Yes' }, + { stepId: 'b', buttonText: 'B', answer: 'No' }, + ], + }, + }, + ], + }); + mockQuery.mockResolvedValue([malformedRun]); + + const result = await port.getAvailableRuns(); + + expect(result.pending).toEqual([]); + expect(result.malformed[0]).toEqual( + expect.objectContaining({ + runId: '46', + technicalMessage: expect.stringContaining('invalid AvailableStepExecution'), + }), + ); + }); + + it('logs and skips when the mapping throws a non-WorkflowExecutorError', async () => { + const logger = { error: jest.fn(), info: jest.fn() }; + const portWithLogger = new ForestServerWorkflowPort({ ...options, logger }); + // Simulate a non-domain error by passing a run whose workflowHistory will + // blow up a pure JS operation inside the mapper (missing `find` on non-array). + const brokenRun = { ...makeRun({ id: 111 }), workflowHistory: null as never }; + mockQuery.mockResolvedValue([brokenRun]); + + const result = await portWithLogger.getAvailableRuns(); + + expect(result.pending).toEqual([]); + expect(result.malformed).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to hydrate pending run — unexpected error', + expect.objectContaining({ runId: 111 }), ); - expect(result).toBe(pending); }); }); - describe('getPendingStepExecutionsForRun', () => { - it('calls the pending step execution route with the runId query param', async () => { - const step = { runId: 'run-42' } as PendingStepExecution; - mockQuery.mockResolvedValue(step); + describe('getAvailableRun', () => { + it('calls the available-run route with the encoded runId', async () => { + mockQuery.mockResolvedValue(makeRun({ id: 42 })); - const result = await port.getPendingStepExecutionsForRun('run-42'); + const result = await port.getAvailableRun('run-42'); expect(mockQuery).toHaveBeenCalledWith( options, 'get', - '/liana/v1/workflow-step-executions/pending?runId=run-42', + '/api/workflow-orchestrator/available-run/run-42', ); - expect(result).toBe(step); + expect(result?.step.runId).toBe('42'); + expect(result?.auth.forestServerToken).toBe('test-forest-token'); }); it('encodes special characters in the runId', async () => { - mockQuery.mockResolvedValue({} as PendingStepExecution); + mockQuery.mockResolvedValue(makeRun()); - await port.getPendingStepExecutionsForRun('run/42 special'); + await port.getAvailableRun('run/42 special'); expect(mockQuery).toHaveBeenCalledWith( options, 'get', - '/liana/v1/workflow-step-executions/pending?runId=run%2F42%20special', + '/api/workflow-orchestrator/available-run/run%2F42%20special', ); }); + + it('returns null when the server returns null (no pending run)', async () => { + mockQuery.mockResolvedValue(null); + + const result = await port.getAvailableRun('run-42'); + + expect(result).toBeNull(); + }); + + it('throws MalformedRunError carrying MalformedRunInfo when mapping fails', async () => { + const malformedRun = makeRun({ id: 66, collectionName: null }); + mockQuery.mockResolvedValue(malformedRun); + + await expect(port.getAvailableRun('66')).rejects.toMatchObject({ + name: 'MalformedRunError', + info: { + runId: '66', + stepId: 'step-1', + stepIndex: 0, + userMessage: + 'The workflow step configuration is invalid. Please check the workflow designer.', + technicalMessage: expect.stringContaining('collectionName'), + }, + }); + // Port must not POST — the Runner catches this error and reports. + expect(mockQuery).toHaveBeenCalledTimes(1); + }); + + it('MalformedRunError is a WorkflowExecutorError (HTTP layer can catch polymorphically)', async () => { + const malformedRun = makeRun({ id: 66, collectionName: null }); + mockQuery.mockResolvedValue(malformedRun); + + await expect(port.getAvailableRun('66')).rejects.toBeInstanceOf(MalformedRunError); + }); }); describe('updateStepExecution', () => { - it('should post step outcome to the complete route', async () => { + it('posts the mapped body for a condition success outcome', async () => { mockQuery.mockResolvedValue(undefined); const stepOutcome: StepOutcome = { type: 'condition', @@ -77,38 +387,352 @@ describe('ForestServerWorkflowPort', () => { selectedOption: 'optionA', }; - await port.updateStepExecution('run-42', stepOutcome); + await port.updateStepExecution('42', stepOutcome); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'post', + '/api/workflow-orchestrator/update-step', + {}, + { + runId: 42, + stepUpdate: { + stepIndex: 0, + attributes: { + done: true, + context: { status: 'success', selectedOption: 'optionA' }, + }, + }, + executionStatus: { type: 'success' }, + }, + ); + }); + + it('posts the mapped body for an error outcome', async () => { + mockQuery.mockResolvedValue(undefined); + const stepOutcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 1, + status: 'error', + error: 'boom', + }; + + await port.updateStepExecution('42', stepOutcome); expect(mockQuery).toHaveBeenCalledWith( options, 'post', - '/liana/v1/workflow-step-executions/run-42/complete', + '/api/workflow-orchestrator/update-step', {}, - stepOutcome, + { + runId: 42, + stepUpdate: { + stepIndex: 1, + attributes: { + done: true, + context: { status: 'error', error: 'boom' }, + }, + }, + executionStatus: { type: 'error', message: 'boom' }, + }, + ); + }); + + it('posts the mapped body for an awaiting-input outcome', async () => { + mockQuery.mockResolvedValue(undefined); + const stepOutcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 2, + status: 'awaiting-input', + }; + + await port.updateStepExecution('42', stepOutcome); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'post', + '/api/workflow-orchestrator/update-step', + {}, + { + runId: 42, + stepUpdate: { + stepIndex: 2, + attributes: { + done: false, + context: { status: 'awaiting-input' }, + }, + }, + executionStatus: { type: 'awaiting-input' }, + }, + ); + }); + + it('returns null when the server returns null (no chain)', async () => { + mockQuery.mockResolvedValue(null); + const stepOutcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + selectedOption: 'optionA', + }; + + const result = await port.updateStepExecution('42', stepOutcome); + + expect(result).toBeNull(); + }); + + it('returns null when the server returns undefined (legacy contract)', async () => { + mockQuery.mockResolvedValue(undefined); + const stepOutcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + selectedOption: 'optionA', + }; + + const result = await port.updateStepExecution('42', stepOutcome); + + expect(result).toBeNull(); + }); + + it('returns the next dispatch parsed from the /update-step response', async () => { + mockQuery.mockResolvedValue( + makeRun({ + id: 42, + workflowHistory: [ + { + stepName: 'step-1', + stepIndex: 0, + done: true, + stepDefinition: { + type: 'condition', + title: 'Decide', + prompt: 'pick one', + outgoing: [ + { stepId: 'step-2', buttonText: 'Yes', answer: 'Yes' }, + { stepId: 'step-end', buttonText: 'No', answer: 'No' }, + ], + }, + }, + { + stepName: 'step-2', + stepIndex: 1, + done: false, + stepDefinition: { + type: 'condition', + title: 'Next', + prompt: 'choose', + outgoing: [ + { stepId: 'end-a', buttonText: 'A', answer: 'Yes' }, + { stepId: 'end-b', buttonText: 'B', answer: 'No' }, + ], + }, + }, + ], + }), ); + const stepOutcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + selectedOption: 'Yes', + }; + + const result = await port.updateStepExecution('42', stepOutcome); + + expect(result).not.toBeNull(); + expect(result?.step.runId).toBe('42'); + expect(result?.step.stepId).toBe('step-2'); + expect(result?.step.stepIndex).toBe(1); + expect(result?.auth.forestServerToken).toBe('test-forest-token'); + }); + + it('returns null (and does not throw) when the chain response is malformed', async () => { + // Run returned by /update-step is missing its userProfile — toDispatch throws + // InvalidStepDefinitionError. The outcome was already recorded server-side, so we + // gracefully yield to the next poll rather than propagate the parse failure. + mockQuery.mockResolvedValue( + makeRun({ userProfile: undefined as unknown as ServerUserProfile }), + ); + const stepOutcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + selectedOption: 'optionA', + }; + + const result = await port.updateStepExecution('42', stepOutcome); + + expect(result).toBeNull(); }); }); describe('getCollectionSchema', () => { - it('should fetch the collection schema by name', async () => { - const collectionSchema: CollectionSchema = { + const collectionSchema: CollectionSchema = { + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [], + actions: [], + }; + + it('fetches the collection schema with runId as query param', async () => { + mockQuery.mockResolvedValue(collectionSchema); + + const result = await port.getCollectionSchema('users', '42'); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/api/workflow-orchestrator/collection-schema/users?runId=42', + ); + expect(result).toEqual(collectionSchema); + }); + + it('encodes special characters in collectionName and runId', async () => { + mockQuery.mockResolvedValue(collectionSchema); + + await port.getCollectionSchema('users/admin', 'run/42'); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/api/workflow-orchestrator/collection-schema/users%2Fadmin?runId=run%2F42', + ); + }); + + it('throws WorkflowPortError wrapping DomainValidationError when the wire response does not match CollectionSchema', async () => { + // Shape invalide : fields[0] manque fieldName (violation FieldSchema.fieldName.min(1)). + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [{ displayName: 'Email', isRelationship: false }], + actions: [], + }); + + await expect(port.getCollectionSchema('users', '42')).rejects.toThrow(/invalid/i); + }); + + it('rejects unknown extra fields on the wire (strict mode)', async () => { + mockQuery.mockResolvedValue({ collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], fields: [], actions: [], - }; - mockQuery.mockResolvedValue(collectionSchema); + unexpectedNewField: 'oops', + }); - const result = await port.getCollectionSchema('users'); + await expect(port.getCollectionSchema('users', '42')).rejects.toThrow(); + }); - expect(mockQuery).toHaveBeenCalledWith(options, 'get', '/liana/v1/collections/users'); - expect(result).toEqual(collectionSchema); + it.each([null, ''])( + 'normalizes collectionDisplayName %p to collectionName', + async displayName => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: displayName, + primaryKeyFields: ['id'], + fields: [], + actions: [], + }); + + const result = await port.getCollectionSchema('users', '42'); + + expect(result.collectionDisplayName).toBe('users'); + }, + ); + + it('accepts relationType BelongsToMany (many-to-many relation)', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'tags', + displayName: 'Tags', + isRelationship: true, + relationType: 'BelongsToMany', + relatedCollectionName: 'tags', + type: null, + }, + ], + actions: [], + }); + + await expect(port.getCollectionSchema('users', '42')).resolves.toMatchObject({ + collectionName: 'users', + }); + }); + + it('accepts type File (Forest Admin extension)', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'avatar', displayName: 'Avatar', isRelationship: false, type: 'File' }, + ], + actions: [], + }); + + await expect(port.getCollectionSchema('users', '42')).resolves.toMatchObject({ + collectionName: 'users', + }); + }); + + it('accepts type [File] (array of files)', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'attachments', + displayName: 'Attachments', + isRelationship: false, + type: ['File'], + }, + ], + actions: [], + }); + + await expect(port.getCollectionSchema('users', '42')).resolves.toMatchObject({ + collectionName: 'users', + }); + }); + + it('rejects enumValues: [] (empty enum is invalid)', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'status', + displayName: 'Status', + isRelationship: false, + type: 'Enum', + enumValues: [], + }, + ], + actions: [], + }); + + await expect(port.getCollectionSchema('users', '42')).rejects.toThrow(); }); }); describe('getMcpServerConfigs', () => { - it('should fetch mcp server configs', async () => { + it('fetches mcp server configs', async () => { const configs = [{ name: 'mcp-1' }]; mockQuery.mockResolvedValue(configs); @@ -124,35 +748,216 @@ describe('ForestServerWorkflowPort', () => { }); describe('hasRunAccess', () => { - it('always returns true (stub until orchestrator endpoint is available)', async () => { - const result = await port.hasRunAccess('run-42', { - id: 1, - email: 'test@example.com', - firstName: 'Test', - lastName: 'User', - team: 'admin', - renderingId: 1, - role: 'admin', - permissionLevel: 'admin', - tags: {}, - }); + const user = { + id: 1, + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + team: 'admin', + renderingId: 1, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + }; + + it('calls the access-check route with runId in the path and userId in the query', async () => { + mockQuery.mockResolvedValue({ hasAccess: true }); + + await port.hasRunAccess('run-42', user); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/api/workflow-orchestrator/run/run-42/access-check?userId=1', + ); + }); + + it('returns true when the server responds with hasAccess: true', async () => { + mockQuery.mockResolvedValue({ hasAccess: true }); + + const result = await port.hasRunAccess('run-42', user); expect(result).toBe(true); - expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('returns false when the server responds with hasAccess: false', async () => { + mockQuery.mockResolvedValue({ hasAccess: false }); + + const result = await port.hasRunAccess('run-42', user); + + expect(result).toBe(false); + }); + + it('returns false (fail-secure) when the server responds with a malformed body', async () => { + mockQuery.mockResolvedValue({}); + + const result = await port.hasRunAccess('run-42', user); + + expect(result).toBe(false); + }); + + it('encodes special characters in the runId', async () => { + mockQuery.mockResolvedValue({ hasAccess: true }); + + await port.hasRunAccess('run/42 special', user); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/api/workflow-orchestrator/run/run%2F42%20special/access-check?userId=1', + ); }); }); describe('error propagation', () => { - it('should propagate errors from ServerUtils.query', async () => { + it('propagates errors from ServerUtils.query on getAvailableRuns', async () => { mockQuery.mockRejectedValue(new Error('Network error')); - await expect(port.getPendingStepExecutions()).rejects.toThrow('Network error'); + await expect(port.getAvailableRuns()).rejects.toThrow('Network error'); }); - it('should propagate errors from getPendingStepExecutionsForRun', async () => { + it('propagates errors from getAvailableRun', async () => { mockQuery.mockRejectedValue(new Error('Network error')); - await expect(port.getPendingStepExecutionsForRun('run-1')).rejects.toThrow('Network error'); + await expect(port.getAvailableRun('run-1')).rejects.toThrow('Network error'); + }); + + it('propagates errors from hasRunAccess', async () => { + mockQuery.mockRejectedValue(new Error('Network error')); + + await expect( + port.hasRunAccess('run-42', { + id: 1, + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + team: 'admin', + renderingId: 1, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + }), + ).rejects.toThrow('Network error'); + }); + + it('propagates errors from updateStepExecution', async () => { + mockQuery.mockRejectedValue(new Error('Network error')); + const outcome: StepOutcome = { + type: 'guidance', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + }; + + await expect(port.updateStepExecution('42', outcome)).rejects.toThrow('Network error'); + }); + }); + + describe('retry on transient failures', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const makeHttpError = (status: number) => { + const err = new Error(`HTTP ${status}`); + (err as Error & { status: number }).status = status; + + return err; + }; + + it('updateStepExecution retries on HTTP 503 and succeeds on the second attempt', async () => { + mockQuery.mockRejectedValueOnce(makeHttpError(503)).mockResolvedValueOnce(null); + const outcome: StepOutcome = { + type: 'guidance', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + }; + + const promise = port.updateStepExecution('42', outcome); + await jest.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toBeNull(); + expect(mockQuery).toHaveBeenCalledTimes(2); + expect(mockQuery).toHaveBeenNthCalledWith( + 1, + options, + 'post', + '/api/workflow-orchestrator/update-step', + {}, + expect.any(Object), + ); + }); + + it('updateStepExecution retries multiple times and succeeds on the third attempt', async () => { + mockQuery + .mockRejectedValueOnce(makeHttpError(503)) + .mockRejectedValueOnce(makeHttpError(503)) + .mockResolvedValueOnce(null); + const outcome: StepOutcome = { + type: 'guidance', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + }; + + const promise = port.updateStepExecution('42', outcome); + await jest.advanceTimersByTimeAsync(100 + 500); + + await expect(promise).resolves.toBeNull(); + expect(mockQuery).toHaveBeenCalledTimes(3); + }); + + it('getCollectionSchema retries on HTTP 408 (timeout)', async () => { + const validSchema: CollectionSchema = { + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'id', + displayName: 'Id', + isRelationship: false, + type: 'String', + }, + ], + actions: [], + }; + + mockQuery.mockRejectedValueOnce(makeHttpError(408)).mockResolvedValueOnce(validSchema); + + const promise = port.getCollectionSchema('users', '42'); + await jest.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toMatchObject({ collectionName: 'users' }); + expect(mockQuery).toHaveBeenCalledTimes(2); + }); + + it('getMcpServerConfigs retries on HTTP 502', async () => { + mockQuery.mockRejectedValueOnce(makeHttpError(502)).mockResolvedValueOnce([]); + + const promise = port.getMcpServerConfigs(); + await jest.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toEqual([]); + expect(mockQuery).toHaveBeenCalledTimes(2); + }); + + it('does not retry on non-retryable HTTP errors (4xx)', async () => { + mockQuery.mockRejectedValue(makeHttpError(400)); + const outcome: StepOutcome = { + type: 'guidance', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + }; + + await expect(port.updateStepExecution('42', outcome)).rejects.toThrow(); + expect(mockQuery).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts new file mode 100644 index 0000000000..2ff6b0bd07 --- /dev/null +++ b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts @@ -0,0 +1,93 @@ +import type { ActivityLogsServiceInterface } from '@forestadmin/forestadmin-client'; + +import ForestadminClientActivityLogPort from '../../src/adapters/forestadmin-client-activity-log-port'; +import ForestadminClientActivityLogPortFactory from '../../src/adapters/forestadmin-client-activity-log-port-factory'; + +function makeLogger() { + return { info: jest.fn(), error: jest.fn() }; +} + +function makeService(): jest.Mocked { + return { + createActivityLog: jest.fn().mockResolvedValue({ id: 'log-1', attributes: { index: '0' } }), + createMcpActivityLog: jest.fn().mockResolvedValue({ id: 'log-1', attributes: { index: '0' } }), + updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), + }; +} + +describe('ForestadminClientActivityLogPortFactory', () => { + it('forRun() returns a ForestadminClientActivityLogPort instance bound to the given token', async () => { + const service = makeService(); + const factory = new ForestadminClientActivityLogPortFactory(service, makeLogger()); + + const port = factory.forRun('token-42'); + await port.createPending({ renderingId: 1, action: 'update', type: 'write' }); + + expect(port).toBeInstanceOf(ForestadminClientActivityLogPort); + expect(service.createActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ forestServerToken: 'token-42' }), + ); + }); + + it('shares a single drainer across every port instance it produces', async () => { + const service = makeService(); + const resolvers: Array<() => void> = []; + service.updateActivityLogStatus.mockImplementation( + () => + new Promise(resolve => { + resolvers.push(resolve); + }), + ); + const factory = new ForestadminClientActivityLogPortFactory(service, makeLogger()); + + const portA = factory.forRun('token-a'); + const portB = factory.forRun('token-b'); + + const handle = { id: 'log-1', index: '0' }; + const pendingA = portA.markSucceeded(handle); + const pendingB = portB.markSucceeded(handle); + + let drainResolved = false; + const drainPromise = factory.drain().then(() => { + drainResolved = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(drainResolved).toBe(false); + + resolvers.forEach(resolve => resolve()); + await pendingA; + await pendingB; + await drainPromise; + + expect(drainResolved).toBe(true); + expect(service.updateActivityLogStatus).toHaveBeenCalledTimes(2); + }); + + it('forRun() binds the token on each produced port independently', async () => { + const service = makeService(); + const factory = new ForestadminClientActivityLogPortFactory(service, makeLogger()); + + const portA = factory.forRun('token-a'); + const portB = factory.forRun('token-b'); + + await portA.markSucceeded({ id: 'log-1', index: '0' }); + await portB.markSucceeded({ id: 'log-2', index: '0' }); + + expect(service.updateActivityLogStatus).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ forestServerToken: 'token-a' }), + ); + expect(service.updateActivityLogStatus).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ forestServerToken: 'token-b' }), + ); + }); + + it('drain() resolves immediately when no ports have in-flight transitions', async () => { + const factory = new ForestadminClientActivityLogPortFactory(makeService(), makeLogger()); + + await expect(factory.drain()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts new file mode 100644 index 0000000000..526f6b696b --- /dev/null +++ b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts @@ -0,0 +1,291 @@ +import type { ActivityLogsServiceInterface } from '@forestadmin/forestadmin-client'; + +import ActivityLogDrainer from '../../src/adapters/activity-log-drainer'; +import ForestadminClientActivityLogPort from '../../src/adapters/forestadmin-client-activity-log-port'; +import { ActivityLogCreationError } from '../../src/errors'; + +function makeLogger() { + return { info: jest.fn(), error: jest.fn() }; +} + +function makeService(): jest.Mocked { + return { + createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), + updateActivityLogStatus: jest.fn(), + }; +} + +function makeHttpError(status: number): Error { + return Object.assign(new Error(`HTTP ${status}`), { status }); +} + +function makePort( + service: ActivityLogsServiceInterface, + overrides: { + logger?: ReturnType; + token?: string; + drainer?: ActivityLogDrainer; + } = {}, +) { + return new ForestadminClientActivityLogPort( + service, + overrides.logger ?? makeLogger(), + overrides.token ?? 'tok', + overrides.drainer ?? new ActivityLogDrainer(), + ); +} + +describe('ForestadminClientActivityLogPort', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('createPending', () => { + it('returns handle on first-attempt success without retry', async () => { + const service = makeService(); + service.createActivityLog.mockResolvedValue({ + id: 'log-1', + attributes: { index: '0' }, + }); + const port = makePort(service); + + const handle = await port.createPending({ renderingId: 5, action: 'update', type: 'write' }); + + expect(handle).toEqual({ id: 'log-1', index: '0' }); + expect(service.createActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ forestServerToken: 'tok', renderingId: '5', action: 'update' }), + ); + expect(service.createActivityLog).toHaveBeenCalledTimes(1); + }); + + it('retries on 503 and succeeds on the second attempt', async () => { + const service = makeService(); + service.createActivityLog + .mockRejectedValueOnce(makeHttpError(503)) + .mockResolvedValueOnce({ id: 'log-2', attributes: { index: '1' } }); + const logger = makeLogger(); + const port = makePort(service, { logger }); + + const promise = port.createPending({ renderingId: 5, action: 'update', type: 'write' }); + await jest.advanceTimersByTimeAsync(100); + const handle = await promise; + + expect(handle).toEqual({ id: 'log-2', index: '1' }); + expect(service.createActivityLog).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('createPending'), + expect.objectContaining({ attempt: 1 }), + ); + }); + + it('throws ActivityLogCreationError after all retries are exhausted', async () => { + const service = makeService(); + service.createActivityLog.mockRejectedValue(makeHttpError(502)); + const port = makePort(service); + + const promise = port.createPending({ renderingId: 5, action: 'update', type: 'write' }); + const settled = promise.catch(err => err); + await jest.advanceTimersByTimeAsync(2_600); + const err = await settled; + + expect(err).toBeInstanceOf(ActivityLogCreationError); + expect(service.createActivityLog).toHaveBeenCalledTimes(4); + }); + + it('does not retry on 401 (not a transient error)', async () => { + const service = makeService(); + service.createActivityLog.mockRejectedValue(makeHttpError(401)); + const port = makePort(service); + + await expect( + port.createPending({ renderingId: 5, action: 'update', type: 'write' }), + ).rejects.toBeInstanceOf(ActivityLogCreationError); + expect(service.createActivityLog).toHaveBeenCalledTimes(1); + }); + + it('retries on retryable HTTP status (503)', async () => { + const service = makeService(); + const httpErr = Object.assign(new Error('maintenance'), { status: 503 }); + service.createActivityLog + .mockRejectedValueOnce(httpErr) + .mockResolvedValueOnce({ id: 'log-3', attributes: { index: '2' } }); + const port = makePort(service); + + const promise = port.createPending({ renderingId: 5, action: 'update', type: 'write' }); + await jest.advanceTimersByTimeAsync(100); + await expect(promise).resolves.toEqual({ id: 'log-3', index: '2' }); + }); + + it('converts numeric renderingId to string for the Forest API', async () => { + const service = makeService(); + service.createActivityLog.mockResolvedValue({ + id: 'log-4', + attributes: { index: '3' }, + }); + const port = makePort(service); + + await port.createPending({ renderingId: 42, action: 'update', type: 'write' }); + + expect(service.createActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ renderingId: '42' }), + ); + }); + + it('feeds args.collectionId into the lib collectionName slot (JSON:API relationship id)', async () => { + const service = makeService(); + service.createActivityLog.mockResolvedValue({ + id: 'log-5', + attributes: { index: '4' }, + }); + const port = makePort(service); + + await port.createPending({ + renderingId: 5, + action: 'update', + type: 'write', + collectionId: '11', + }); + + expect(service.createActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ collectionName: '11' }), + ); + }); + }); + + describe('markSucceeded', () => { + it('retries on 503 and eventually resolves without rethrowing', async () => { + const service = makeService(); + service.updateActivityLogStatus + .mockRejectedValueOnce(makeHttpError(503)) + .mockResolvedValueOnce(undefined); + const port = makePort(service); + + const promise = port.markSucceeded({ id: 'log-1', index: '0' }); + await jest.advanceTimersByTimeAsync(100); + await expect(promise).resolves.toBeUndefined(); + expect(service.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed', forestServerToken: 'tok' }), + ); + }); + + it('swallows errors after retries are exhausted (fire-and-forget)', async () => { + const service = makeService(); + service.updateActivityLogStatus.mockRejectedValue(makeHttpError(503)); + const logger = makeLogger(); + const port = makePort(service, { logger }); + + const promise = port.markSucceeded({ id: 'log-1', index: '0' }); + await jest.advanceTimersByTimeAsync(2_600); + await expect(promise).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('markSucceeded failed'), + expect.objectContaining({ handleId: 'log-1' }), + ); + }); + }); + + describe('markFailed', () => { + it('sends status: failed (no errorMessage — server schema rejects unknown fields) and retries on 503', async () => { + const service = makeService(); + service.updateActivityLogStatus + .mockRejectedValueOnce(makeHttpError(503)) + .mockResolvedValueOnce(undefined); + const port = makePort(service); + + const promise = port.markFailed({ id: 'log-1', index: '0' }, 'boom'); + await jest.advanceTimersByTimeAsync(100); + await promise; + + expect(service.updateActivityLogStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'failed', + forestServerToken: 'tok', + }), + ); + expect(service.updateActivityLogStatus).toHaveBeenLastCalledWith( + expect.not.objectContaining({ errorMessage: expect.anything() }), + ); + }); + + it('swallows errors after retries are exhausted (fire-and-forget) and logs with stepErrorMessage', async () => { + const service = makeService(); + service.updateActivityLogStatus.mockRejectedValue(makeHttpError(503)); + const logger = makeLogger(); + const port = makePort(service, { logger }); + + const promise = port.markFailed({ id: 'log-1', index: '0' }, 'step-error-msg'); + await jest.advanceTimersByTimeAsync(2_600); + await expect(promise).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('markFailed failed'), + expect.objectContaining({ + handleId: 'log-1', + stepErrorMessage: 'step-error-msg', + }), + ); + }); + }); + + describe('drainer integration', () => { + it('registers markSucceeded in the shared drainer for drain() to await', async () => { + const service = makeService(); + let resolveUpdate!: () => void; + service.updateActivityLogStatus.mockImplementation( + () => + new Promise(resolve => { + resolveUpdate = resolve; + }), + ); + const drainer = new ActivityLogDrainer(); + const port = makePort(service, { drainer }); + + const markPromise = port.markSucceeded({ id: 'log-1', index: '0' }); + + let drainResolved = false; + const drainPromise = drainer.drain().then(() => { + drainResolved = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(drainResolved).toBe(false); + + resolveUpdate(); + await markPromise; + await drainPromise; + expect(drainResolved).toBe(true); + }); + + it('registers markFailed in the shared drainer for drain() to await', async () => { + const service = makeService(); + let resolveUpdate!: () => void; + service.updateActivityLogStatus.mockImplementation( + () => + new Promise(resolve => { + resolveUpdate = resolve; + }), + ); + const drainer = new ActivityLogDrainer(); + const port = makePort(service, { drainer }); + + const markPromise = port.markFailed({ id: 'log-1', index: '0' }, 'boom'); + + let drainResolved = false; + const drainPromise = drainer.drain().then(() => { + drainResolved = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(drainResolved).toBe(false); + + resolveUpdate(); + await markPromise; + await drainPromise; + expect(drainResolved).toBe(true); + }); + }); +}); diff --git a/packages/workflow-executor/test/adapters/pretty-logger.test.ts b/packages/workflow-executor/test/adapters/pretty-logger.test.ts new file mode 100644 index 0000000000..6ec6858d10 --- /dev/null +++ b/packages/workflow-executor/test/adapters/pretty-logger.test.ts @@ -0,0 +1,67 @@ +import PrettyLogger from '../../src/adapters/pretty-logger'; + +// eslint-disable-next-line no-control-regex +const ANSI_PATTERN = /\x1B\[[0-9;]*m/g; +const stripAnsi = (s: string): string => s.replace(ANSI_PATTERN, ''); + +describe('PrettyLogger', () => { + let logger: PrettyLogger; + let infoSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + logger = new PrettyLogger(); + infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + infoSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + describe('info', () => { + it('prints the timestamp, level, message and context', () => { + logger.info('Poll cycle completed', { fetched: 0, dispatching: 0 }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const output = stripAnsi(infoSpy.mock.calls[0][0] as string); + expect(output).toMatch( + /^\d{2}:\d{2}:\d{2} info {2}Poll cycle completed fetched=0 dispatching=0$/, + ); + }); + + it('omits the context chunk when empty', () => { + logger.info('Ready', {}); + + const output = stripAnsi(infoSpy.mock.calls[0][0] as string); + expect(output).toMatch(/^\d{2}:\d{2}:\d{2} info {2}Ready$/); + }); + + it('JSON-quotes string values in context', () => { + logger.info('Step execution started', { runId: '42', stepIndex: 2 }); + + const output = stripAnsi(infoSpy.mock.calls[0][0] as string); + expect(output).toContain('runId="42"'); + expect(output).toContain('stepIndex=2'); + }); + + it('preserves context insertion order', () => { + logger.info('ordered', { a: 1, b: 2, c: 3 }); + + const output = stripAnsi(infoSpy.mock.calls[0][0] as string); + expect(output).toMatch(/a=1 b=2 c=3$/); + }); + }); + + describe('error', () => { + it('prints on console.error with "error" level', () => { + logger.error('Poll cycle failed', { error: 'timeout' }); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).not.toHaveBeenCalled(); + const output = stripAnsi(errorSpy.mock.calls[0][0] as string); + expect(output).toMatch(/^\d{2}:\d{2}:\d{2} error Poll cycle failed error="timeout"$/); + }); + }); +}); diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts new file mode 100644 index 0000000000..f84edcf5a7 --- /dev/null +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -0,0 +1,632 @@ +import type { + ServerHydratedWorkflowRun, + ServerStepHistory, + ServerUserProfile, +} from '../../src/adapters/server-types'; + +import { z } from 'zod'; + +import toAvailableStepExecution from '../../src/adapters/run-to-available-step-mapper'; +import { DomainValidationError, InvalidStepDefinitionError } from '../../src/errors'; +import { StepType } from '../../src/types/validated/step-definition'; + +function makeStepHistory(overrides: Partial = {}): ServerStepHistory { + return { + stepName: 'step-a', + stepIndex: 0, + done: false, + stepDefinition: { + type: 'task', + taskType: 'get-data', + title: 'Task', + prompt: 'prompt', + outgoing: { stepId: 'next', buttonText: null }, + }, + ...overrides, + }; +} + +function makeRun(overrides: Partial = {}): ServerHydratedWorkflowRun { + return { + id: 42, + workflowId: 'wf-1', + collectionId: '11', + collectionName: 'customers', + selectedRecordId: '123', + bpmnVersion: '1.0', + userId: 7, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + renderingId: 3, + runState: 'started', + workflowHistory: [makeStepHistory()], + userProfile: { + id: 7, + email: 'alban@forestadmin.com', + firstName: 'Alban', + lastName: 'Bertolini', + team: 'team-a', + renderingId: 3, + role: 'admin', + permissionLevel: 'admin', + tags: { env: 'prod' }, + serverToken: 'test-forest-token', + }, + ...overrides, + }; +} + +describe('toAvailableStepExecution', () => { + it('should map a run with a available step to a AvailableStepExecution', () => { + const run = makeRun(); + + const result = toAvailableStepExecution(run); + + expect(result).toEqual({ + runId: '42', + stepId: 'step-a', + stepIndex: 0, + collectionId: '11', + baseRecordRef: { + collectionName: 'customers', + recordId: ['123'], + stepIndex: 0, + }, + stepDefinition: { type: StepType.ReadRecord, prompt: 'prompt' }, + previousSteps: [], + user: expect.objectContaining({ id: 7, email: 'alban@forestadmin.com' }), + }); + }); + + it('should stringify the numeric run id', () => { + const run = makeRun({ id: 999 }); + + const result = toAvailableStepExecution(run); + + expect(result?.runId).toBe('999'); + }); + + it('should wrap selectedRecordId in an array for baseRecordRef', () => { + const run = makeRun({ selectedRecordId: 'rec-abc' }); + + const result = toAvailableStepExecution(run); + + expect(result?.baseRecordRef.recordId).toEqual(['rec-abc']); + }); + + it('should return null when workflowHistory is empty', () => { + const run = makeRun({ workflowHistory: [] }); + + expect(toAvailableStepExecution(run)).toBeNull(); + }); + + it('picks the last step — orchestrator is the source of truth for which step to execute', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ stepName: 's0', stepIndex: 0, done: true }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: true }), + makeStepHistory({ stepName: 's2', stepIndex: 2, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.stepId).toBe('s2'); + expect(result?.stepIndex).toBe(2); + }); + + it('should strip unknown server keys (e.g. automaticExecution) from guidance step without throwing', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepDefinition: { + type: 'task', + taskType: 'guideline', + title: 'guidance', + prompt: 'follow the guide', + automaticExecution: true, + outgoing: { stepId: 'next', buttonText: null }, + }, + }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.stepDefinition).toEqual({ type: StepType.Guidance, prompt: 'follow the guide' }); + expect(result?.stepDefinition).not.toHaveProperty('automaticExecution'); + }); + + describe('previousSteps', () => { + it('should include done steps preceding the available step', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { status: 'success' }, + stepDefinition: { + type: 'task', + taskType: 'update-data', + title: 't', + prompt: 'p', + outgoing: { stepId: 'x', buttonText: null }, + }, + }), + makeStepHistory({ + stepName: 's1', + stepIndex: 1, + done: true, + context: { status: 'success', selectedOption: 'Yes' }, + stepDefinition: { + type: 'condition', + title: 'c', + prompt: 'p', + outgoing: [ + { stepId: 'a', buttonText: null, answer: 'Yes' }, + { stepId: 'b', buttonText: null, answer: 'No' }, + ], + }, + }), + makeStepHistory({ stepName: 's2', stepIndex: 2, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps).toHaveLength(2); + expect(result?.previousSteps[1].stepOutcome).toEqual({ + type: 'condition', + status: 'success', + selectedOption: 'Yes', + stepId: 's1', + stepIndex: 1, + }); + }); + + it('should default to success status when context is empty (legacy frontend data)', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { legacyData: 'from-frontend' }, + stepDefinition: { + type: 'task', + taskType: 'update-data', + title: 't', + prompt: 'p', + outgoing: { stepId: 'x', buttonText: null }, + }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps[0].stepOutcome).toEqual({ + type: 'record', + stepId: 's0', + stepIndex: 0, + status: 'success', + }); + }); + + it('should not leak arbitrary context fields into the step outcome', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { + status: 'success', + aiReasoning: 'SECRET', + clientData: { foo: 'bar' }, + }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps[0].stepOutcome).not.toHaveProperty('aiReasoning'); + expect(result?.previousSteps[0].stepOutcome).not.toHaveProperty('clientData'); + }); + + it('should propagate error status and message', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { status: 'error', error: 'Something failed' }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps[0].stepOutcome).toEqual({ + type: 'record', + stepId: 's0', + stepIndex: 0, + status: 'error', + error: 'Something failed', + }); + }); + + it('should map guidance step outcome with success status', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { status: 'success' }, + stepDefinition: { + type: 'task', + taskType: 'guideline', + title: 'Guide', + prompt: 'Please review', + outgoing: { stepId: 'next', buttonText: null }, + }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps[0].stepOutcome).toEqual({ + type: 'guidance', + stepId: 's0', + stepIndex: 0, + status: 'success', + }); + }); + + it('should map guidance step outcome with error status', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { status: 'error', error: 'Guide failed' }, + stepDefinition: { + type: 'task', + taskType: 'guideline', + title: 'Guide', + prompt: 'Please review', + outgoing: { stepId: 'next', buttonText: null }, + }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps[0].stepOutcome).toEqual({ + type: 'guidance', + stepId: 's0', + stepIndex: 0, + status: 'error', + error: 'Guide failed', + }); + }); + + it('should map mcp step outcome', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + context: { status: 'success' }, + stepDefinition: { + type: 'task', + taskType: 'mcp-server', + title: 'MCP', + prompt: 'Run tool', + mcpServerId: 'srv-1', + outgoing: { stepId: 'next', buttonText: null }, + }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.previousSteps[0].stepOutcome).toEqual({ + type: 'mcp', + stepId: 's0', + stepIndex: 0, + status: 'success', + }); + }); + + it('should not include the pending step itself in previousSteps', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ stepName: 's0', stepIndex: 0, done: true }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.stepId).toBe('s1'); + expect(result?.previousSteps).toHaveLength(1); + expect(result?.previousSteps[0].stepOutcome.stepId).toBe('s0'); + }); + + it.each([ + [ + 'start-sub-workflow', + { + type: 'start-sub-workflow', + title: 't', + prompt: 'p', + outgoing: { stepId: 'x', buttonText: null }, + workflowId: 'wf-2', + }, + ], + [ + 'close-sub-workflow', + { + type: 'close-sub-workflow', + outgoing: { stepId: 'x', buttonText: null }, + parentWorkflowId: null, + }, + ], + ['end', { type: 'end', title: 'End' }], + [ + 'escalation', + { + type: 'escalation', + title: 'Escalation', + prompt: 'p', + outgoing: { stepId: 'x', buttonText: null }, + inboxId: null, + }, + ], + ])('should silently skip %s steps in history and not throw', (_, subWorkflowStep) => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + stepDefinition: subWorkflowStep as never, + }), + makeStepHistory({ + stepName: 's1', + stepIndex: 1, + done: true, + context: { status: 'success' }, + stepDefinition: { + type: 'task', + taskType: 'guideline', + title: 't', + prompt: 'p', + outgoing: { stepId: 'x', buttonText: null }, + }, + }), + makeStepHistory({ stepName: 's2', stepIndex: 2, done: false }), + ], + }); + + const result = toAvailableStepExecution(run); + + expect(result?.stepId).toBe('s2'); + expect(result?.previousSteps).toHaveLength(1); + expect(result?.previousSteps[0].stepDefinition.type).toBe(StepType.Guidance); + }); + + it('should propagate InvalidStepDefinitionError thrown by a done history step with unknown taskType', () => { + const run = makeRun({ + workflowHistory: [ + makeStepHistory({ + stepName: 's0', + stepIndex: 0, + done: true, + stepDefinition: { + type: 'task', + taskType: 'unknown-future-type' as never, + title: 't', + prompt: 'p', + outgoing: { stepId: 'x', buttonText: null }, + }, + }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), + ], + }); + + expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + }); + }); + + describe('user mapping', () => { + it('should map server userProfile to StepUser with null → empty string', () => { + const profile: ServerUserProfile = { + id: 5, + email: 'nulls@test.com', + firstName: null, + lastName: null, + team: null, + renderingId: 2, + role: null, + permissionLevel: null, + tags: {}, + serverToken: 'test-forest-token', + }; + const run = makeRun({ userProfile: profile }); + + const result = toAvailableStepExecution(run); + + expect(result?.user).toEqual({ + id: 5, + email: 'nulls@test.com', + firstName: '', + lastName: '', + team: '', + renderingId: 2, + role: '', + permissionLevel: '', + tags: {}, + }); + }); + + it.each([ + ['undefined', undefined], + ['null', null], + ['NaN', Number.NaN], + ['string', '3' as unknown as number], + ])('should throw InvalidStepDefinitionError when renderingId is %s', (_label, badValue) => { + const run = makeRun({ + userProfile: { + id: 7, + email: 'alban@forestadmin.com', + firstName: 'Alban', + lastName: 'Bertolini', + team: 'team-a', + renderingId: badValue as unknown as number, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + serverToken: 'test-forest-token', + }, + }); + + expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run)).toThrow(/renderingId/); + }); + + it('should accept renderingId = 0 (valid finite number)', () => { + const run = makeRun({ + userProfile: { + id: 7, + email: 'alban@forestadmin.com', + firstName: 'Alban', + lastName: 'Bertolini', + team: 'team-a', + renderingId: 0, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + serverToken: 'test-forest-token', + }, + }); + + const result = toAvailableStepExecution(run); + + expect(result?.user.renderingId).toBe(0); + }); + }); + + describe('error cases', () => { + it('should throw InvalidStepDefinitionError when collectionName is null', () => { + const run = makeRun({ collectionName: null }); + + expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run)).toThrow( + 'Run 42 has no collectionName — cannot build baseRecordRef', + ); + }); + + it('should throw InvalidStepDefinitionError when collectionId is empty', () => { + const run = makeRun({ collectionId: '' }); + + expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run)).toThrow( + 'Run 42 has no collectionId — cannot build baseRecordRef', + ); + }); + + it('should propagate mapper errors from toStepDefinition', () => { + const run = makeRun({ + workflowHistory: [makeStepHistory({ stepDefinition: { type: 'end', title: 'End' } })], + }); + + expect(() => toAvailableStepExecution(run)).toThrow(); + }); + + it('should throw DomainValidationError when the mapper output violates a zod invariant (empty stepId)', () => { + // Wire guards don't validate pending.stepName, but AvailableStepExecutionSchema requires + // stepId.min(1). This exercises the actual parse path in the mapper. + const run = makeRun({ + workflowHistory: [makeStepHistory({ stepName: '' })], + }); + + let caught: unknown; + + try { + toAvailableStepExecution(run); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(DomainValidationError); + const domainErr = caught as DomainValidationError; + expect(domainErr.issues.some(i => i.path === 'stepId')).toBe(true); + expect(domainErr.userMessage).toMatch(/Internal validation error/); + }); + + it('should throw DomainValidationError when renderingId is not an integer (zod catches what wire finite check misses)', () => { + // profile.renderingId = 0.5 passes toStepUser's finite() guard but fails zod's int() check. + const run = makeRun({ + userProfile: { + id: 7, + email: 'a@b.c', + firstName: 'A', + lastName: 'B', + team: 't', + renderingId: 0.5, + role: 'admin', + permissionLevel: 'admin', + tags: {}, + serverToken: 'tok', + }, + }); + + expect(() => toAvailableStepExecution(run)).toThrow(DomainValidationError); + }); + + it('should structure DomainValidationError.issues as { path, message } objects', () => { + const zodError = new z.ZodError([ + { + code: 'invalid_type', + path: ['runId'], + message: 'Expected string, received number', + expected: 'string', + input: 42, + }, + { + code: 'custom', + path: ['user', 'renderingId'], + message: 'Number must be finite', + input: Number.POSITIVE_INFINITY, + }, + ]); + const err = new DomainValidationError(42, zodError); + + expect(err.issues).toHaveLength(2); + expect(err.issues[0]).toEqual({ path: 'runId', message: 'Expected string, received number' }); + expect(err.issues[1]).toEqual({ + path: 'user.renderingId', + message: 'Number must be finite', + }); + expect(err.message).toContain('runId: Expected string, received number'); + expect(err.userMessage).toMatch(/Internal validation error/); + }); + }); +}); diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts new file mode 100644 index 0000000000..fa9790c500 --- /dev/null +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -0,0 +1,254 @@ +import type { + ServerCloseSubWorkflow, + ServerStartSubWorkflow, + ServerWorkflowCondition, + ServerWorkflowEnd, + ServerWorkflowEscalation, + ServerWorkflowStep, + ServerWorkflowTask, +} from '../../src/adapters/server-types'; + +import toStepDefinition from '../../src/adapters/step-definition-mapper'; +import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../../src/errors'; +import { StepType } from '../../src/types/validated/step-definition'; + +function makeTask(overrides: Partial = {}): ServerWorkflowTask { + return { + type: 'task', + taskType: 'get-data', + title: 'Test task', + prompt: 'Do something', + outgoing: { stepId: 'next', buttonText: null }, + ...overrides, + }; +} + +function makeCondition( + outgoing: ServerWorkflowCondition['outgoing'], + overrides: Partial = {}, +): ServerWorkflowCondition { + return { + type: 'condition', + title: 'Test condition', + prompt: 'Choose one', + outgoing, + ...overrides, + }; +} + +describe('toStepDefinition', () => { + describe('task mapping', () => { + it('should map task with get-data taskType to read-record', () => { + const task = makeTask({ taskType: 'get-data', prompt: 'read it' }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.ReadRecord, + prompt: 'read it', + }); + }); + + it('should map task with update-data taskType to update-record', () => { + const task = makeTask({ taskType: 'update-data', prompt: 'update it' }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.UpdateRecord, + prompt: 'update it', + }); + }); + + it('should map task with trigger-action taskType to trigger-action', () => { + const task = makeTask({ taskType: 'trigger-action', prompt: 'trigger it' }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.TriggerAction, + prompt: 'trigger it', + }); + }); + + it('should map task with load-related-record taskType to load-related-record', () => { + const task = makeTask({ taskType: 'load-related-record', prompt: 'load it' }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.LoadRelatedRecord, + prompt: 'load it', + }); + }); + + it('should map task with mcp-server taskType to mcp and include mcpServerId', () => { + const task = makeTask({ + taskType: 'mcp-server', + prompt: 'run mcp', + mcpServerId: 'mcp-abc', + }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.Mcp, + prompt: 'run mcp', + mcpServerId: 'mcp-abc', + }); + }); + + it('should map task with mcp-server taskType without mcpServerId', () => { + const task = makeTask({ taskType: 'mcp-server', prompt: 'run mcp' }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.Mcp, + prompt: 'run mcp', + }); + }); + + it('should map task with guideline taskType to guidance', () => { + const task = makeTask({ taskType: 'guideline', prompt: 'guide them' }); + + expect(toStepDefinition(task)).toEqual({ + type: StepType.Guidance, + prompt: 'guide them', + }); + }); + + it('should preserve automaticExecution when true', () => { + const task = makeTask({ taskType: 'get-data', automaticExecution: true }); + + expect(toStepDefinition(task)).toMatchObject({ automaticExecution: true }); + }); + + it('should preserve automaticExecution when false', () => { + const task = makeTask({ taskType: 'get-data', automaticExecution: false }); + + expect(toStepDefinition(task)).toMatchObject({ automaticExecution: false }); + }); + + it('should omit automaticExecution when undefined on the server step', () => { + const task = makeTask({ taskType: 'get-data' }); + + expect(toStepDefinition(task)).not.toHaveProperty('automaticExecution'); + }); + + it('should throw InvalidStepDefinitionError for unknown taskType', () => { + const task = makeTask({ taskType: 'unknown-task' as ServerWorkflowTask['taskType'] }); + + expect(() => toStepDefinition(task)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(task)).toThrow('Unknown taskType: "unknown-task"'); + }); + }); + + describe('condition mapping', () => { + it('should map condition with answer transitions to options', () => { + const condition = makeCondition([ + { stepId: 's1', buttonText: null, answer: 'Yes' }, + { stepId: 's2', buttonText: null, answer: 'No' }, + ]); + + expect(toStepDefinition(condition)).toEqual({ + type: StepType.Condition, + prompt: 'Choose one', + options: ['Yes', 'No'], + }); + }); + + it('should fall back to buttonText when answer is absent', () => { + const condition = makeCondition([ + { stepId: 's1', buttonText: 'Approve' }, + { stepId: 's2', buttonText: 'Reject' }, + ]); + + expect(toStepDefinition(condition)).toEqual({ + type: StepType.Condition, + prompt: 'Choose one', + options: ['Approve', 'Reject'], + }); + }); + + it('should prefer answer over buttonText when both are present', () => { + const condition = makeCondition([ + { stepId: 's1', buttonText: 'Btn1', answer: 'Answer1' }, + { stepId: 's2', buttonText: 'Btn2', answer: 'Answer2' }, + ]); + + expect(toStepDefinition(condition)).toMatchObject({ + options: ['Answer1', 'Answer2'], + }); + }); + + it('should throw InvalidStepDefinitionError when fewer than 2 options', () => { + const condition = makeCondition([{ stepId: 's1', buttonText: 'Only' }]); + + expect(() => toStepDefinition(condition)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(condition)).toThrow( + 'Condition step requires at least 2 options, got 1', + ); + }); + + it('should throw InvalidStepDefinitionError when outgoing is empty', () => { + const condition = makeCondition([]); + + expect(() => toStepDefinition(condition)).toThrow(InvalidStepDefinitionError); + }); + + it('should filter out transitions with no answer and no buttonText', () => { + const condition = makeCondition([ + { stepId: 's1', buttonText: null }, + { stepId: 's2', buttonText: 'Valid' }, + { stepId: 's3', buttonText: null, answer: 'AlsoValid' }, + ]); + + expect(toStepDefinition(condition)).toMatchObject({ + options: ['Valid', 'AlsoValid'], + }); + }); + }); + + describe('unsupported step types', () => { + it('should throw UnsupportedStepTypeError for end', () => { + const step: ServerWorkflowEnd = { type: 'end', title: 'End', prompt: 'Done' }; + + expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step)).toThrow( + 'Step type "end" is not supported by the executor', + ); + }); + + it('should throw UnsupportedStepTypeError for escalation', () => { + const step: ServerWorkflowEscalation = { + type: 'escalation', + title: 'Escalate', + prompt: 'To whom', + outgoing: { stepId: 'next', buttonText: null }, + inboxId: null, + }; + + expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + }); + + it('should throw UnsupportedStepTypeError for start-sub-workflow', () => { + const step: ServerStartSubWorkflow = { + type: 'start-sub-workflow', + title: 'Start sub', + prompt: 'Run sub', + outgoing: { stepId: 'next', buttonText: null }, + workflowId: 'sub-wf', + }; + + expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + }); + + it('should throw UnsupportedStepTypeError for close-sub-workflow', () => { + const step: ServerCloseSubWorkflow = { + type: 'close-sub-workflow', + outgoing: { stepId: 'next', buttonText: null }, + parentWorkflowId: null, + }; + + expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + }); + }); + + describe('unknown step types', () => { + it('should throw InvalidStepDefinitionError for unknown type', () => { + const step = { type: 'mystery', title: 'x' } as unknown as ServerWorkflowStep; + + expect(() => toStepDefinition(step)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(step)).toThrow('Unknown server step type: "mystery"'); + }); + }); +}); diff --git a/packages/workflow-executor/test/adapters/step-outcome-to-update-step-mapper.test.ts b/packages/workflow-executor/test/adapters/step-outcome-to-update-step-mapper.test.ts new file mode 100644 index 0000000000..cdef5e425f --- /dev/null +++ b/packages/workflow-executor/test/adapters/step-outcome-to-update-step-mapper.test.ts @@ -0,0 +1,182 @@ +import type { StepOutcome } from '../../src/types/validated/step-outcome'; + +import toUpdateStepRequest from '../../src/adapters/step-outcome-to-update-step-mapper'; + +describe('toUpdateStepRequest', () => { + it('maps a condition success outcome with selectedOption', () => { + const outcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 2, + status: 'success', + selectedOption: 'optionA', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body).toEqual({ + runId: 42, + stepUpdate: { + stepIndex: 2, + attributes: { + done: true, + context: { status: 'success', selectedOption: 'optionA' }, + }, + }, + executionStatus: { type: 'success' }, + }); + }); + + it('maps a condition error outcome with an error message', () => { + const outcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'error', + error: 'AI gateway unreachable', + }; + + const body = toUpdateStepRequest('7', outcome); + + expect(body).toEqual({ + runId: 7, + stepUpdate: { + stepIndex: 0, + attributes: { + done: true, + context: { status: 'error', error: 'AI gateway unreachable' }, + }, + }, + executionStatus: { type: 'error', message: 'AI gateway unreachable' }, + }); + }); + + it('falls back to "Unknown error" when an error outcome has no error message', () => { + const outcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'error', + }; + + const body = toUpdateStepRequest('7', outcome); + + expect(body.executionStatus).toEqual({ type: 'error', message: 'Unknown error' }); + expect(body.stepUpdate.attributes.context).toEqual({ status: 'error' }); + }); + + it('falls back to "Unknown error" when an error outcome has an empty string message', () => { + const outcome: StepOutcome = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'error', + error: '', + }; + + const body = toUpdateStepRequest('7', outcome); + + expect(body.executionStatus).toEqual({ type: 'error', message: 'Unknown error' }); + }); + + it('maps a record awaiting-input outcome (done=false, no selectedOption)', () => { + const outcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 3, + status: 'awaiting-input', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body).toEqual({ + runId: 42, + stepUpdate: { + stepIndex: 3, + attributes: { + done: false, + context: { status: 'awaiting-input' }, + }, + }, + executionStatus: { type: 'awaiting-input' }, + }); + }); + + it('maps a record success outcome (done=true, no selectedOption in context)', () => { + const outcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 1, + status: 'success', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body.stepUpdate.attributes).toEqual({ + done: true, + context: { status: 'success' }, + }); + expect(body.executionStatus).toEqual({ type: 'success' }); + }); + + it('maps an mcp awaiting-input outcome like a record', () => { + const outcome: StepOutcome = { + type: 'mcp', + stepId: 'step-1', + stepIndex: 0, + status: 'awaiting-input', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body.stepUpdate.attributes).toEqual({ + done: false, + context: { status: 'awaiting-input' }, + }); + expect(body.executionStatus).toEqual({ type: 'awaiting-input' }); + }); + + it('maps a guidance success outcome (done=true)', () => { + const outcome: StepOutcome = { + type: 'guidance', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body.stepUpdate.attributes).toEqual({ + done: true, + context: { status: 'success' }, + }); + expect(body.executionStatus).toEqual({ type: 'success' }); + }); + + it('converts the runId string to a number', () => { + const outcome: StepOutcome = { + type: 'guidance', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + }; + + const body = toUpdateStepRequest('1337', outcome); + + expect(body.runId).toBe(1337); + expect(typeof body.runId).toBe('number'); + }); + + it('preserves stepIndex in the stepUpdate', () => { + const outcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 7, + status: 'success', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body.stepUpdate.stepIndex).toBe(7); + }); +}); diff --git a/packages/workflow-executor/test/adapters/with-retry.test.ts b/packages/workflow-executor/test/adapters/with-retry.test.ts new file mode 100644 index 0000000000..0b039b0722 --- /dev/null +++ b/packages/workflow-executor/test/adapters/with-retry.test.ts @@ -0,0 +1,132 @@ +import type { Logger } from '../../src/ports/logger-port'; + +import withRetry from '../../src/adapters/with-retry'; + +const makeLogger = (): jest.Mocked => ({ + info: jest.fn(), + error: jest.fn(), +}); + +const makeHttpError = (status: number) => { + const err = new Error(`HTTP ${status}`); + (err as Error & { status: number }).status = status; + + return err; +}; + +describe('withRetry', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns immediately when the call succeeds on first attempt', async () => { + const logger = makeLogger(); + const fn = jest.fn().mockResolvedValue('ok'); + + const result = await withRetry('test', fn, { logger }); + + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('retries on retryable HTTP status codes (503)', async () => { + const logger = makeLogger(); + const fn = jest.fn().mockRejectedValueOnce(makeHttpError(503)).mockResolvedValueOnce('ok'); + + const promise = withRetry('test', fn, { logger }); + await jest.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + '"test" failed, retrying', + expect.objectContaining({ attempt: 1 }), + ); + }); + + it('retries on status 408 (request timeout)', async () => { + const logger = makeLogger(); + const fn = jest.fn().mockRejectedValueOnce(makeHttpError(408)).mockResolvedValueOnce('ok'); + + const promise = withRetry('test', fn, { logger }); + await jest.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toBe('ok'); + }); + + it('retries on status 429 (rate limit)', async () => { + const logger = makeLogger(); + const fn = jest.fn().mockRejectedValueOnce(makeHttpError(429)).mockResolvedValueOnce('ok'); + + const promise = withRetry('test', fn, { logger }); + await jest.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toBe('ok'); + }); + + it('honors the 100/500/2000 ms backoff', async () => { + const logger = makeLogger(); + const fn = jest + .fn() + .mockRejectedValueOnce(makeHttpError(503)) + .mockRejectedValueOnce(makeHttpError(503)) + .mockRejectedValueOnce(makeHttpError(503)) + .mockResolvedValueOnce('ok'); + + const promise = withRetry('test', fn, { logger }); + + await jest.advanceTimersByTimeAsync(100); + expect(fn).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(500); + expect(fn).toHaveBeenCalledTimes(3); + + await jest.advanceTimersByTimeAsync(2000); + expect(fn).toHaveBeenCalledTimes(4); + + await expect(promise).resolves.toBe('ok'); + }); + + it('rethrows the original error after exhausting all 4 attempts', async () => { + const logger = makeLogger(); + const finalErr = makeHttpError(500); + const fn = jest + .fn() + .mockRejectedValueOnce(makeHttpError(500)) + .mockRejectedValueOnce(makeHttpError(500)) + .mockRejectedValueOnce(makeHttpError(500)) + .mockRejectedValueOnce(finalErr); + + let caught: unknown; + const promise = withRetry('test', fn, { logger }).catch(err => { + caught = err; + }); + await jest.advanceTimersByTimeAsync(100 + 500 + 2000); + await promise; + + expect(caught).toBe(finalErr); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('throws immediately on non-retryable errors (4xx)', async () => { + const logger = makeLogger(); + const fn = jest.fn().mockRejectedValue(makeHttpError(400)); + + await expect(withRetry('test', fn, { logger })).rejects.toMatchObject({ status: 400 }); + expect(fn).toHaveBeenCalledTimes(1); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('throws immediately on errors with no status', async () => { + const logger = makeLogger(); + const fn = jest.fn().mockRejectedValue(new Error('plain error')); + + await expect(withRetry('test', fn, { logger })).rejects.toThrow('plain error'); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/workflow-executor/test/build-workflow-executor.test.ts b/packages/workflow-executor/test/build-workflow-executor.test.ts index 41f2ca04ed..fda4b83943 100644 --- a/packages/workflow-executor/test/build-workflow-executor.test.ts +++ b/packages/workflow-executor/test/build-workflow-executor.test.ts @@ -55,19 +55,23 @@ describe('buildInMemoryExecutor', () => { it('creates ForestServerWorkflowPort with default forestServerUrl', () => { buildInMemoryExecutor(BASE_OPTIONS); - expect(ForestServerWorkflowPort).toHaveBeenCalledWith({ - envSecret: BASE_OPTIONS.envSecret, - forestServerUrl: 'https://api.forestadmin.com', - }); + expect(ForestServerWorkflowPort).toHaveBeenCalledWith( + expect.objectContaining({ + envSecret: BASE_OPTIONS.envSecret, + forestServerUrl: 'https://api.forestadmin.com', + }), + ); }); it('creates ForestServerWorkflowPort with custom forestServerUrl', () => { buildInMemoryExecutor({ ...BASE_OPTIONS, forestServerUrl: 'https://custom.example.com' }); - expect(ForestServerWorkflowPort).toHaveBeenCalledWith({ - envSecret: BASE_OPTIONS.envSecret, - forestServerUrl: 'https://custom.example.com', - }); + expect(ForestServerWorkflowPort).toHaveBeenCalledWith( + expect.objectContaining({ + envSecret: BASE_OPTIONS.envSecret, + forestServerUrl: 'https://custom.example.com', + }), + ); }); it('creates AgentClientAgentPort with agentUrl and authSecret as agentPort singleton', () => { @@ -125,6 +129,20 @@ describe('buildInMemoryExecutor', () => { expect(MockedRunner).toHaveBeenCalledWith(expect.objectContaining({ pollingIntervalMs: 1000 })); }); + it('applies a 5-minute default when stepTimeoutMs is not configured', () => { + buildInMemoryExecutor(BASE_OPTIONS); + + expect(MockedRunner).toHaveBeenCalledWith( + expect.objectContaining({ stepTimeoutMs: 5 * 60_000 }), + ); + }); + + it('respects a caller-provided stepTimeoutMs over the default', () => { + buildInMemoryExecutor({ ...BASE_OPTIONS, stepTimeoutMs: 30_000 }); + + expect(MockedRunner).toHaveBeenCalledWith(expect.objectContaining({ stepTimeoutMs: 30_000 })); + }); + it('passes secrets to Runner config', () => { buildInMemoryExecutor(BASE_OPTIONS); @@ -174,6 +192,7 @@ describe('buildDatabaseExecutor', () => { expect(MockedSequelize).toHaveBeenCalledWith('postgres://localhost/mydb', { dialect: 'postgres', + logging: false, }); }); @@ -195,6 +214,31 @@ describe('buildDatabaseExecutor', () => { }); }); + it('forwards a caller-provided logging function to Sequelize', () => { + const customLogger = jest.fn(); + buildDatabaseExecutor({ + ...BASE_OPTIONS, + database: { uri: 'postgres://localhost/mydb', logging: customLogger }, + }); + + expect(MockedSequelize).toHaveBeenCalledWith( + 'postgres://localhost/mydb', + expect.objectContaining({ logging: customLogger }), + ); + }); + + it('keeps logging disabled when the caller passes logging: undefined', () => { + buildDatabaseExecutor({ + ...BASE_OPTIONS, + database: { uri: 'postgres://localhost/mydb', logging: undefined }, + }); + + expect(MockedSequelize).toHaveBeenCalledWith( + 'postgres://localhost/mydb', + expect.objectContaining({ logging: false }), + ); + }); + it('creates Sequelize with options only when no uri is provided', () => { buildDatabaseExecutor({ ...BASE_OPTIONS, @@ -206,16 +250,19 @@ describe('buildDatabaseExecutor', () => { host: 'db.example.com', port: 5432, database: 'mydb', + logging: false, }); }); it('shares the same common dependencies as buildInMemoryExecutor', () => { buildDatabaseExecutor(DB_OPTIONS); - expect(ForestServerWorkflowPort).toHaveBeenCalledWith({ - envSecret: BASE_OPTIONS.envSecret, - forestServerUrl: 'https://api.forestadmin.com', - }); + expect(ForestServerWorkflowPort).toHaveBeenCalledWith( + expect.objectContaining({ + envSecret: BASE_OPTIONS.envSecret, + forestServerUrl: 'https://api.forestadmin.com', + }), + ); expect(MockedRunner).toHaveBeenCalledWith( expect.objectContaining({ agentPort: expect.any(Object) }), ); @@ -307,4 +354,58 @@ describe('WorkflowExecutor lifecycle', () => { it('state getter returns runner state', () => { expect(executor.state).toBe('running'); }); + + it('SIGTERM handler calls shutdown and sets exitCode=0 on success', async () => { + jest.useFakeTimers(); + const originalExitCode = process.exitCode; + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + try { + await executor.start(); + + const sigtermCall = onSpy.mock.calls.find(([sig]) => sig === 'SIGTERM'); + if (!sigtermCall) throw new Error('SIGTERM handler not registered'); + const handler = sigtermCall[1] as () => Promise; + + await handler(); + + expect(process.exitCode).toBe(0); + expect(MockedRunner.prototype.stop).toHaveBeenCalled(); + + // Verify safety-net timer is scheduled and triggers process.exit when fired + jest.runAllTimers(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + process.exitCode = originalExitCode; + exitSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + it('SIGTERM handler sets exitCode=1 when shutdown throws', async () => { + jest.useFakeTimers(); + const originalExitCode = process.exitCode; + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); + MockedRunner.prototype.stop = jest.fn().mockRejectedValue(new Error('stop failed')); + + try { + const exec = buildInMemoryExecutor(BASE_OPTIONS); + await exec.start(); + + const sigtermCall = onSpy.mock.calls.find(([sig]) => sig === 'SIGTERM'); + if (!sigtermCall) throw new Error('SIGTERM handler not registered'); + const handler = sigtermCall[1] as () => Promise; + + await handler(); + + expect(process.exitCode).toBe(1); + + jest.runAllTimers(); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + process.exitCode = originalExitCode; + exitSpy.mockRestore(); + jest.useRealTimers(); + } + }); }); diff --git a/packages/workflow-executor/test/cli.test.ts b/packages/workflow-executor/test/cli.test.ts new file mode 100644 index 0000000000..ee9e8b0173 --- /dev/null +++ b/packages/workflow-executor/test/cli.test.ts @@ -0,0 +1,395 @@ +import type { WorkflowExecutor } from '../src/build-workflow-executor'; +import type { CliFactories } from '../src/cli-core'; + +import ConsoleLogger from '../src/adapters/console-logger'; +import PrettyLogger from '../src/adapters/pretty-logger'; +import { + parseArgs, + pickLogger, + printHelp, + printVersion, + readEnvConfig, + runCli, +} from '../src/cli-core'; + +const baseEnv: NodeJS.ProcessEnv = { + FOREST_ENV_SECRET: 'env-secret', + FOREST_AUTH_SECRET: 'auth-secret', + AGENT_URL: 'http://localhost:3351', + DATABASE_URL: 'postgres://u:p@localhost:5432/wfe', +}; + +function makeFakeExecutor(): WorkflowExecutor { + return { + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + state: 'running', + } as unknown as WorkflowExecutor; +} + +function makeFactories() { + const executor = makeFakeExecutor(); + const factories: CliFactories = { + buildDatabase: jest.fn().mockReturnValue(executor), + buildInMemory: jest.fn().mockReturnValue(executor), + }; + + return { factories, executor }; +} + +const fakeStream = (isTTY: boolean) => ({ isTTY } as unknown as NodeJS.WriteStream); + +describe('parseArgs', () => { + it('returns all false for empty argv', () => { + expect(parseArgs([])).toEqual({ + help: false, + version: false, + inMemory: false, + pretty: false, + json: false, + }); + }); + + it('parses --help and -h', () => { + expect(parseArgs(['--help']).help).toBe(true); + expect(parseArgs(['-h']).help).toBe(true); + }); + + it('parses --version and -v', () => { + expect(parseArgs(['--version']).version).toBe(true); + expect(parseArgs(['-v']).version).toBe(true); + }); + + it('parses --in-memory', () => { + expect(parseArgs(['--in-memory']).inMemory).toBe(true); + }); + + it('parses --pretty', () => { + expect(parseArgs(['--pretty']).pretty).toBe(true); + }); + + it('parses --json', () => { + expect(parseArgs(['--json']).json).toBe(true); + }); + + it('throws on unknown argument', () => { + expect(() => parseArgs(['--nope'])).toThrow('Unknown argument: --nope'); + }); +}); + +describe('pickLogger', () => { + const baseArgs = { help: false, version: false, inMemory: false, pretty: false, json: false }; + + it('returns PrettyLogger when stdout is a TTY', () => { + expect(pickLogger(baseArgs, fakeStream(true))).toBeInstanceOf(PrettyLogger); + }); + + it('returns ConsoleLogger when stdout is not a TTY', () => { + expect(pickLogger(baseArgs, fakeStream(false))).toBeInstanceOf(ConsoleLogger); + }); + + it('forces PrettyLogger with --pretty even when stdout is not a TTY', () => { + expect(pickLogger({ ...baseArgs, pretty: true }, fakeStream(false))).toBeInstanceOf( + PrettyLogger, + ); + }); + + it('forces ConsoleLogger with --json even when stdout is a TTY', () => { + expect(pickLogger({ ...baseArgs, json: true }, fakeStream(true))).toBeInstanceOf(ConsoleLogger); + }); + + it('gives --json precedence when both flags are set', () => { + expect(pickLogger({ ...baseArgs, pretty: true, json: true }, fakeStream(true))).toBeInstanceOf( + ConsoleLogger, + ); + }); +}); + +describe('readEnvConfig', () => { + const args = { help: false, version: false, inMemory: false, pretty: false, json: false }; + + it('returns a full config when all required vars are present', () => { + const config = readEnvConfig(baseEnv, args); + + expect(config.mode).toBe('database'); + expect(config.databaseUrl).toBe('postgres://u:p@localhost:5432/wfe'); + expect(config.executorOptions).toEqual( + expect.objectContaining({ + envSecret: 'env-secret', + authSecret: 'auth-secret', + agentUrl: 'http://localhost:3351', + httpPort: 3400, + }), + ); + }); + + it('parses numeric env vars as numbers', () => { + const config = readEnvConfig( + { + ...baseEnv, + HTTP_PORT: '5000', + POLLING_INTERVAL_MS: '1000', + STOP_TIMEOUT_MS: '10000', + STEP_TIMEOUT_MS: '60000', + MAX_CHAIN_DEPTH: '10', + }, + args, + ); + + expect(config.executorOptions.httpPort).toBe(5000); + expect(config.executorOptions.pollingIntervalMs).toBe(1000); + expect(config.executorOptions.stopTimeoutMs).toBe(10000); + expect(config.executorOptions.stepTimeoutMs).toBe(60000); + expect(config.executorOptions.maxChainDepth).toBe(10); + }); + + it('leaves stepTimeoutMs undefined when STEP_TIMEOU_MS is unset (default applied downstream in build)', () => { + const config = readEnvConfig(baseEnv, args); + + expect(config.executorOptions.stepTimeoutMs).toBeUndefined(); + }); + + it('leaves maxChainDepth undefined when MAX_CHAIN_DEPTH is unset (default applied downstream in build)', () => { + const config = readEnvConfig(baseEnv, args); + + expect(config.executorOptions.maxChainDepth).toBeUndefined(); + }); + + it.each(['abc', '30s', '1_000', 'NaN'])( + 'throws ConfigurationError when STEP_TIMEOUT_MS is non-numeric (%s)', + value => { + expect(() => readEnvConfig({ ...baseEnv, STEP_TIMEOUT_MS: value }, args)).toThrow( + /STEP_TIMEOUT_MS must be a positive integer/, + ); + }, + ); + + it('throws ConfigurationError when STEP_TIMEOUT_MS is 0', () => { + expect(() => readEnvConfig({ ...baseEnv, STEP_TIMEOUT_MS: '0' }, args)).toThrow( + /STEP_TIMEOUT_MS must be a positive integer/, + ); + }); + + it('throws ConfigurationError when STEP_TIMEOUT_MS is negative', () => { + expect(() => readEnvConfig({ ...baseEnv, STEP_TIMEOUT_MS: '-100' }, args)).toThrow( + /STEP_TIMEOUT_MS must be a positive integer/, + ); + }); + + it('throws ConfigurationError when STEP_TIMEOUT_MS is a float', () => { + expect(() => readEnvConfig({ ...baseEnv, STEP_TIMEOUT_MS: '1.5' }, args)).toThrow( + /STEP_TIMEOUT_MS must be a positive integer/, + ); + }); + + it('aggregates all missing required env vars in a single error', () => { + expect(() => readEnvConfig({}, args)).toThrow( + /FOREST_ENV_SECRET[\s\S]*FOREST_AUTH_SECRET[\s\S]*AGENT_URL[\s\S]*DATABASE_URL/, + ); + }); + + it('does not require DATABASE_URL in --in-memory mode', () => { + const envWithoutDb = { ...baseEnv }; + delete envWithoutDb.DATABASE_URL; + const config = readEnvConfig(envWithoutDb, { ...args, inMemory: true }); + + expect(config.mode).toBe('in-memory'); + expect(config.databaseUrl).toBeUndefined(); + }); + + it('still requires FOREST_ENV_SECRET etc. in --in-memory mode', () => { + expect(() => readEnvConfig({}, { ...args, inMemory: true })).toThrow(/FOREST_ENV_SECRET/); + }); + + it('builds aiConfigurations when AI vars are set', () => { + const config = readEnvConfig( + { ...baseEnv, AI_PROVIDER: 'anthropic', AI_MODEL: 'claude', AI_API_KEY: 'sk-xxx' }, + args, + ); + + expect(config.executorOptions.aiConfigurations).toEqual([ + { name: 'default', provider: 'anthropic', model: 'claude', apiKey: 'sk-xxx' }, + ]); + }); + + it('omits aiConfigurations when no AI vars are set', () => { + const config = readEnvConfig(baseEnv, args); + + expect(config.executorOptions.aiConfigurations).toBeUndefined(); + }); + + it('throws when AI config is partially set', () => { + expect(() => + readEnvConfig({ ...baseEnv, AI_PROVIDER: 'anthropic', AI_MODEL: 'claude' }, args), + ).toThrow('AI config must be all-or-nothing'); + }); + + it('throws on invalid AI_PROVIDER', () => { + expect(() => + readEnvConfig({ ...baseEnv, AI_PROVIDER: 'bogus', AI_MODEL: 'm', AI_API_KEY: 'k' }, args), + ).toThrow('AI_PROVIDER must be "anthropic" or "openai"'); + }); +}); + +describe('printHelp / printVersion', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('printHelp prints usage with env vars and flags', () => { + printHelp(); + const output = logSpy.mock.calls.map(call => call[0]).join('\n'); + + expect(output).toContain('Usage: forest-workflow-executor'); + expect(output).toContain('--in-memory'); + expect(output).toContain('--pretty'); + expect(output).toContain('--json'); + expect(output).toContain('FOREST_ENV_SECRET'); + expect(output).toContain('NO_COLOR'); + expect(output).toContain('SIGTERM'); + }); + + it('printVersion prints a version string', () => { + printVersion(); + + expect(logSpy).toHaveBeenCalled(); + expect(logSpy.mock.calls[0][0]).toMatch(/^\d+\.\d+\.\d+/); + }); +}); + +describe('runCli', () => { + let infoSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + infoSpy.mockRestore(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('returns null and prints help on --help without building an executor', async () => { + const { factories } = makeFactories(); + const result = await runCli(['--help'], baseEnv, factories); + + expect(result).toBeNull(); + expect(factories.buildDatabase).not.toHaveBeenCalled(); + expect(factories.buildInMemory).not.toHaveBeenCalled(); + }); + + it('returns null and prints version on --version', async () => { + const { factories } = makeFactories(); + const result = await runCli(['--version'], baseEnv, factories); + + expect(result).toBeNull(); + expect(factories.buildDatabase).not.toHaveBeenCalled(); + }); + + it('throws before building the executor when env is invalid', async () => { + const { factories } = makeFactories(); + + await expect(runCli([], {}, factories)).rejects.toThrow( + /Missing required environment variables/, + ); + expect(factories.buildDatabase).not.toHaveBeenCalled(); + expect(factories.buildInMemory).not.toHaveBeenCalled(); + }); + + it('builds a database executor in default mode and starts it', async () => { + const { factories, executor } = makeFactories(); + await runCli([], baseEnv, factories); + + expect(factories.buildDatabase).toHaveBeenCalledWith( + expect.objectContaining({ + envSecret: 'env-secret', + authSecret: 'auth-secret', + agentUrl: 'http://localhost:3351', + database: { uri: 'postgres://u:p@localhost:5432/wfe' }, + }), + ); + expect(factories.buildInMemory).not.toHaveBeenCalled(); + expect(executor.start).toHaveBeenCalled(); + }); + + it('injects the picked logger into executorOptions', async () => { + const { factories } = makeFactories(); + await runCli(['--json'], baseEnv, factories); + + const call = (factories.buildDatabase as jest.Mock).mock.calls[0][0]; + expect(call.logger).toBeInstanceOf(ConsoleLogger); + }); + + it('injects a PrettyLogger when --pretty is set', async () => { + const { factories } = makeFactories(); + await runCli(['--pretty'], baseEnv, factories); + + const call = (factories.buildDatabase as jest.Mock).mock.calls[0][0]; + expect(call.logger).toBeInstanceOf(PrettyLogger); + }); + + it('builds an in-memory executor with --in-memory', async () => { + const env = { ...baseEnv }; + delete env.DATABASE_URL; + const { factories, executor } = makeFactories(); + await runCli(['--in-memory'], env, factories); + + expect(factories.buildInMemory).toHaveBeenCalledWith( + expect.objectContaining({ + envSecret: 'env-secret', + agentUrl: 'http://localhost:3351', + }), + ); + expect(factories.buildDatabase).not.toHaveBeenCalled(); + expect(executor.start).toHaveBeenCalled(); + }); + + it('does not log any secret during startup', async () => { + const { factories } = makeFactories(); + await runCli([], baseEnv, factories); + const output = [...logSpy.mock.calls, ...infoSpy.mock.calls, ...errorSpy.mock.calls] + .map(call => call.join(' ')) + .join('\n'); + + expect(output).not.toContain('env-secret'); + expect(output).not.toContain('auth-secret'); + }); + + it('emits a startup info log via the injected logger', async () => { + const { factories } = makeFactories(); + await runCli(['--json'], baseEnv, factories); + + const output = infoSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(output).toContain('Workflow executor starting'); + expect(output).toContain('Workflow executor ready'); + }); + + it('logs a structured error when executor.start() fails and rethrows', async () => { + const failingExecutor = { + start: jest.fn().mockRejectedValue(new Error('db unreachable')), + stop: jest.fn(), + state: 'idle', + } as unknown as WorkflowExecutor; + const factories: CliFactories = { + buildDatabase: jest.fn().mockReturnValue(failingExecutor), + buildInMemory: jest.fn().mockReturnValue(failingExecutor), + }; + + await expect(runCli(['--json'], baseEnv, factories)).rejects.toThrow('db unreachable'); + + const errorOutput = errorSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(errorOutput).toContain('Workflow executor failed to start'); + expect(errorOutput).toContain('db unreachable'); + }); +}); diff --git a/packages/workflow-executor/test/errors.test.ts b/packages/workflow-executor/test/errors.test.ts new file mode 100644 index 0000000000..a936567012 --- /dev/null +++ b/packages/workflow-executor/test/errors.test.ts @@ -0,0 +1,60 @@ +import { extractErrorMessage } from '../src/errors'; + +describe('extractErrorMessage', () => { + it('returns err.message when non-empty', () => { + expect(extractErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('falls back to err.parent.message when err.message is empty (Sequelize pattern)', () => { + const err = new Error(''); + (err as Error & { parent?: Error }).parent = new Error('connect ECONNREFUSED 127.0.0.1:5459'); + + expect(extractErrorMessage(err)).toBe('connect ECONNREFUSED 127.0.0.1:5459'); + }); + + it('falls back to err.cause.message when err.message is empty (Error.cause pattern)', () => { + const err = new Error(''); + (err as Error & { cause?: Error }).cause = new Error('downstream failed'); + + expect(extractErrorMessage(err)).toBe('downstream failed'); + }); + + it('prefers err.parent over err.cause when both are set', () => { + const err = new Error(''); + (err as Error & { cause?: Error }).cause = new Error('from cause'); + (err as Error & { parent?: Error }).parent = new Error('from parent'); + + expect(extractErrorMessage(err)).toBe('from parent'); + }); + + it('falls back to err.name when message/parent/cause are absent', () => { + class MyError extends Error { + override name = 'MyError'; + } + + expect(extractErrorMessage(new MyError(''))).toBe('MyError'); + }); + + it('returns "Unknown error" when a custom Error overrides .name to empty string', () => { + const err = new Error(''); + // Force-empty name to exercise the final fallback branch + Object.defineProperty(err, 'name', { value: '' }); + + expect(extractErrorMessage(err)).toBe('Unknown error'); + }); + + it('converts non-Error values to string', () => { + expect(extractErrorMessage('plain string')).toBe('plain string'); + expect(extractErrorMessage(42)).toBe('42'); + expect(extractErrorMessage(null)).toBe('null'); + expect(extractErrorMessage(undefined)).toBe('undefined'); + }); + + it('ignores a non-Error .parent (Sequelize-like shape but wrong type)', () => { + const err = new Error(''); + (err as Error & { parent?: unknown }).parent = 'not an error'; + (err as Error & { cause?: unknown }).cause = new Error('from cause'); + + expect(extractErrorMessage(err)).toBe('from cause'); + }); +}); diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts index 222fdfbb78..1b0f16bb1e 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -1,11 +1,11 @@ /* eslint-disable max-classes-per-file */ import type { Logger } from '../../src/ports/logger-port'; import type { RunStore } from '../../src/ports/run-store'; -import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution'; -import type { RecordRef } from '../../src/types/record'; -import type { StepDefinition } from '../../src/types/step-definition'; +import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution-context'; import type { StepExecutionData } from '../../src/types/step-execution-data'; -import type { BaseStepStatus, StepOutcome } from '../../src/types/step-outcome'; +import type { RecordRef } from '../../src/types/validated/collection'; +import type { StepDefinition } from '../../src/types/validated/step-definition'; +import type { BaseStepStatus, StepOutcome } from '../../src/types/validated/step-outcome'; import type { BaseMessage, DynamicStructuredTool } from '@forestadmin/ai-proxy'; import { SystemMessage } from '@forestadmin/ai-proxy'; @@ -14,11 +14,12 @@ import { MalformedToolCallError, MissingToolCallError, NoRecordsError, - StepPersistenceError, + RunStorePortError, + WorkflowExecutorError, } from '../../src/errors'; import BaseStepExecutor from '../../src/executors/base-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; /** Concrete subclass that exposes protected methods for testing. */ class TestableExecutor extends BaseStepExecutor { @@ -90,11 +91,20 @@ function makeMockLogger(): Logger { return { info: jest.fn(), error: jest.fn() }; } +function makeMockActivityLogPort(): ExecutionContext['activityLogPort'] { + return { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; +} + function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', stepId: 'step-0', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: { collectionName: 'customers', recordId: [1], @@ -123,6 +133,8 @@ function makeContext(overrides: Partial = {}): ExecutionContex schemaCache: new SchemaCache(), previousSteps: [], logger: makeMockLogger(), + + activityLogPort: makeMockActivityLogPort(), ...overrides, }; } @@ -263,11 +275,11 @@ describe('BaseStepExecutor', () => { it('logs cause when WorkflowExecutorError has a cause', async () => { const logger = makeMockLogger(); const cause = new Error('db timeout'); - const error = new StepPersistenceError('write failed', cause); + const error = new RunStorePortError('saveStepExecution', cause); const executor = new TestableExecutor(makeContext({ logger }), error); await executor.execute(); expect(logger.error).toHaveBeenCalledWith( - 'write failed', + 'Run store "saveStepExecution" failed: db timeout', expect.objectContaining({ cause: 'db timeout', stack: cause.stack, @@ -283,6 +295,341 @@ describe('BaseStepExecutor', () => { }); }); + describe('step execution timeout', () => { + class SlowExecutor extends BaseStepExecutor { + constructor(context: ExecutionContext, private readonly delayMs: number) { + super(context); + } + + protected async doExecute(): Promise { + await new Promise(resolve => { + setTimeout(resolve, this.delayMs); + }); + + return this.buildOutcomeResult({ status: 'success' }); + } + + protected buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: outcome.status, + ...(outcome.error !== undefined && { error: outcome.error }), + }, + }; + } + } + + it('returns error outcome with timeout userMessage when step exceeds stepTimeoutMs', async () => { + jest.useFakeTimers(); + + try { + const executor = new SlowExecutor(makeContext({ stepTimeoutMs: 50 }), 10_000); + const resultPromise = executor.execute(); + await Promise.resolve(); // flush checkIdempotency microtask so timers are registered + jest.advanceTimersByTime(60); + const result = await resultPromise; + + expect(result.stepOutcome.status).toBe('error'); + expect((result.stepOutcome as { error?: string }).error).toBe( + 'The step took too long to complete. Please try again, or contact your administrator if the problem persists.', + ); + } finally { + jest.useRealTimers(); + } + }); + + it('returns success when step finishes before stepTimeoutMs', async () => { + const executor = new SlowExecutor(makeContext({ stepTimeoutMs: 5_000 }), 5); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + }); + + it('does not apply a timeout when stepTimeoutMs is unset', async () => { + jest.useFakeTimers(); + + try { + const executor = new SlowExecutor(makeContext({ stepTimeoutMs: undefined }), 1_000); + const resultPromise = executor.execute(); + // Advance past a hypothetical default; no timeout should fire + jest.advanceTimersByTime(10_000); + jest.useRealTimers(); + const result = await resultPromise; + expect(result.stepOutcome.status).toBe('success'); + } finally { + jest.useRealTimers(); + } + }); + + it('ignores stepTimeoutMs <= 0 (treated as disabled)', async () => { + const executor = new SlowExecutor(makeContext({ stepTimeoutMs: 0 }), 5); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + }); + + it('logs structured context (runId/stepId/timeoutMs/stepType) when a step times out', async () => { + jest.useFakeTimers(); + + try { + const logger = makeMockLogger(); + const executor = new SlowExecutor(makeContext({ stepTimeoutMs: 50, logger }), 10_000); + const resultPromise = executor.execute(); + await Promise.resolve(); // flush checkIdempotency microtask so timers are registered + jest.advanceTimersByTime(60); + await resultPromise; + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution exceeded timeout of 50ms', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-0', + stepIndex: 0, + stepType: StepType.Condition, + timeoutMs: 50, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('logs a late rejection of the losing promise as info (no UnhandledPromiseRejection)', async () => { + class FailingAfterTimeoutExecutor extends BaseStepExecutor { + protected async doExecute(): Promise { + await new Promise(resolve => { + setTimeout(resolve, 1_000); + }); + throw new Error('late agent failure'); + } + + protected buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: outcome.status, + ...(outcome.error !== undefined && { error: outcome.error }), + }, + }; + } + } + + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + + try { + const logger = makeMockLogger(); + const executor = new FailingAfterTimeoutExecutor( + makeContext({ stepTimeoutMs: 10, logger }), + ); + + await executor.execute(); + // Let the underlying doExecute() reject after the timeout + await new Promise(resolve => { + setTimeout(resolve, 1_100); + }); + + expect(logger.info).toHaveBeenCalledWith( + 'Step work rejected after timeout — result discarded', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-0', + error: 'late agent failure', + }), + ); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }, 5_000); + }); + + describe('activity log lifecycle', () => { + class LoggedExecutor extends BaseStepExecutor { + constructor(context: ExecutionContext, private readonly errorToThrow?: unknown) { + super(context); + } + + protected override buildActivityLogArgs() { + return { + renderingId: 1, + action: 'update', + type: 'write' as const, + collectionId: 'col-1', + recordId: 42, + }; + } + + protected async doExecute(): Promise { + if (this.errorToThrow !== undefined) throw this.errorToThrow; + + return this.buildOutcomeResult({ status: 'success' }); + } + + protected buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: outcome.status, + ...(outcome.error !== undefined && { error: outcome.error }), + }, + }; + } + } + + it('creates pending log, runs doExecute, then marks succeeded on success', async () => { + const context = makeContext(); + const executor = new LoggedExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(context.activityLogPort.createPending).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'update', + type: 'write', + collectionId: 'col-1', + }), + ); + expect(context.activityLogPort.markSucceeded).toHaveBeenCalledWith({ + id: 'log-1', + index: '0', + }); + expect(context.activityLogPort.markFailed).not.toHaveBeenCalled(); + }); + + it('marks failed when doExecute throws a WorkflowExecutorError', async () => { + const context = makeContext(); + const executor = new LoggedExecutor(context, new NoRecordsError()); + + await executor.execute(); + + expect(context.activityLogPort.markFailed).toHaveBeenCalledWith( + { id: 'log-1', index: '0' }, + 'No records available', + ); + expect(context.activityLogPort.markSucceeded).not.toHaveBeenCalled(); + }); + + it('fails the step and does NOT run doExecute when createPending throws ActivityLogCreationError', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const { ActivityLogCreationError } = require('../../src/errors'); + const context = makeContext(); + (context.activityLogPort.createPending as jest.Mock).mockRejectedValue( + new ActivityLogCreationError(new Error('net')), + ); + const doExecuteSpy = jest.fn().mockResolvedValue({ + stepOutcome: { type: 'record', stepId: 'x', stepIndex: 0, status: 'success' }, + }); + + class NeverRunExecutor extends LoggedExecutor { + protected override async doExecute(): Promise { + return doExecuteSpy(); + } + } + + const executor = new NeverRunExecutor(context); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'Could not record this step in the audit log. Please try again, or contact your administrator if the problem persists.', + ); + expect(doExecuteSpy).not.toHaveBeenCalled(); + }); + + it('does NOT create pending log when buildActivityLogArgs returns null (default)', async () => { + const context = makeContext(); + const executor = new TestableExecutor(context); + + await executor.execute(); + + expect(context.activityLogPort.createPending).not.toHaveBeenCalled(); + expect(context.activityLogPort.markSucceeded).not.toHaveBeenCalled(); + }); + + it('calls markFailed with userMessage (not the technical message) on WorkflowExecutorError', async () => { + class DualMessageError extends WorkflowExecutorError { + constructor() { + super( + 'Internal: datasource "customers" returned no record for pk=42', + 'The record no longer exists.', + ); + } + } + const context = makeContext(); + const executor = new LoggedExecutor(context, new DualMessageError()); + + await executor.execute(); + + expect(context.activityLogPort.markFailed).toHaveBeenCalledWith( + { id: 'log-1', index: '0' }, + 'The record no longer exists.', + ); + }); + + it('marks failed when doExecute returns an error outcome without throwing', async () => { + class ErrorOutcomeExecutor extends BaseStepExecutor { + protected override buildActivityLogArgs() { + return { + renderingId: 1, + action: 'update', + type: 'write' as const, + collectionName: 'customers', + recordId: 42, + }; + } + + protected async doExecute(): Promise { + return this.buildOutcomeResult({ status: 'error', error: 'soft failure' }); + } + + protected buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: outcome.status, + ...(outcome.error !== undefined && { error: outcome.error }), + }, + }; + } + } + + const context = makeContext(); + const executor = new ErrorOutcomeExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(context.activityLogPort.markFailed).toHaveBeenCalledWith( + { id: 'log-1', index: '0' }, + 'soft failure', + ); + expect(context.activityLogPort.markSucceeded).not.toHaveBeenCalled(); + }); + }); + describe('invokeWithTool', () => { function makeMockModel(response: unknown) { const invoke = jest.fn().mockResolvedValue(response); diff --git a/packages/workflow-executor/test/executors/condition-step-executor.test.ts b/packages/workflow-executor/test/executors/condition-step-executor.test.ts index 944e331c98..98761d6400 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -1,12 +1,13 @@ import type { RunStore } from '../../src/ports/run-store'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { RecordRef } from '../../src/types/record'; -import type { ConditionStepDefinition } from '../../src/types/step-definition'; -import type { ConditionStepOutcome } from '../../src/types/step-outcome'; +import type { ExecutionContext } from '../../src/types/execution-context'; +import type { RecordRef } from '../../src/types/validated/collection'; +import type { ConditionStepDefinition } from '../../src/types/validated/step-definition'; +import type { ConditionStepOutcome } from '../../src/types/validated/step-outcome'; +import { RunStorePortError } from '../../src/errors'; import ConditionStepExecutor from '../../src/executors/condition-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeStep(overrides: Partial = {}): ConditionStepDefinition { return { @@ -46,6 +47,7 @@ function makeContext( runId: 'run-1', stepId: 'cond-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: { collectionName: 'customers', recordId: [1], @@ -70,6 +72,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -303,14 +311,79 @@ describe('ConditionStepExecutor', () => { question: 'Approve?', }); const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Storage full'))), }); const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model, runStore })); const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); + }); + }); + + describe('user override via incomingPendingData', () => { + it('bypasses AI and persists the user-selected option', async () => { + const mockModel = makeMockModel(); + const runStore = makeMockRunStore(); + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + runStore, + incomingPendingData: { selectedOption: 'Approve' }, + }), + ); + + const result = await executor.execute(); + + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(mockModel.invoke).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Approve', reasoning: 'Selected by user' }, + executionResult: { answer: 'Approve' }, + }); + expect(result.stepOutcome.status).toBe('success'); + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Approve'); + }); + + it('returns error outcome when the selected option is not in step.options', async () => { + const mockModel = makeMockModel(); + const runStore = makeMockRunStore(); + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + runStore, + incomingPendingData: { selectedOption: 'Maybe' }, + }), + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when incomingPendingData has unexpected fields', async () => { + const mockModel = makeMockModel(); + const runStore = makeMockRunStore(); + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + runStore, + incomingPendingData: { selectedOption: 'Approve', extraField: 'x' }, + }), + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts index 50a1c25f81..eddde2a98f 100644 --- a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts @@ -1,12 +1,12 @@ import type { RunStore } from '../../src/ports/run-store'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { RecordRef } from '../../src/types/record'; -import type { GuidanceStepDefinition } from '../../src/types/step-definition'; -import type { GuidanceStepOutcome } from '../../src/types/step-outcome'; +import type { ExecutionContext } from '../../src/types/execution-context'; +import type { RecordRef } from '../../src/types/validated/collection'; +import type { GuidanceStepDefinition } from '../../src/types/validated/step-definition'; +import type { GuidanceStepOutcome } from '../../src/types/validated/step-outcome'; import GuidanceStepExecutor from '../../src/executors/guidance-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeMockRunStore(overrides: Partial = {}): RunStore { return { @@ -25,6 +25,7 @@ function makeContext( runId: 'run-1', stepId: 'guidance-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: { collectionName: 'customers', recordId: [1], @@ -49,6 +50,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -75,7 +82,7 @@ describe('GuidanceStepExecutor', () => { }); }); - it('returns error outcome when incomingPendingData is undefined', async () => { + it('returns awaiting-input when incomingPendingData is absent', async () => { const runStore = makeMockRunStore(); const executor = new GuidanceStepExecutor(makeContext({ runStore })); @@ -83,12 +90,11 @@ describe('GuidanceStepExecutor', () => { const outcome = result.stepOutcome as GuidanceStepOutcome; expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('error'); - expect(outcome.error).toBe('An unexpected error occurred while processing this step.'); + expect(outcome.status).toBe('awaiting-input'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('returns error outcome when incomingPendingData has empty userInput', async () => { + it('saves empty string and returns success when userInput is empty', async () => { const runStore = makeMockRunStore(); const executor = new GuidanceStepExecutor( @@ -98,12 +104,15 @@ describe('GuidanceStepExecutor', () => { const outcome = result.stepOutcome as GuidanceStepOutcome; expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('error'); - expect(outcome.error).toBe('An unexpected error occurred while processing this step.'); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(outcome.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: '' }, + }); }); - it('returns error outcome when incomingPendingData has no userInput field', async () => { + it('saves empty string and returns success when userInput is absent', async () => { const runStore = makeMockRunStore(); const executor = new GuidanceStepExecutor(makeContext({ runStore, incomingPendingData: {} })); @@ -111,7 +120,11 @@ describe('GuidanceStepExecutor', () => { const outcome = result.stepOutcome as GuidanceStepOutcome; expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('error'); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(outcome.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: '' }, + }); }); }); diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index 05c77587cd..e7fdab82ef 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -1,14 +1,15 @@ import type { AgentPort } from '../../src/ports/agent-port'; import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/record'; -import type { LoadRelatedRecordStepDefinition } from '../../src/types/step-definition'; +import type { ExecutionContext } from '../../src/types/execution-context'; import type { LoadRelatedRecordStepExecutionData } from '../../src/types/step-execution-data'; +import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/validated/collection'; +import type { LoadRelatedRecordStepDefinition } from '../../src/types/validated/step-definition'; +import { AgentPortError, RunStorePortError } from '../../src/errors'; import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeStep( overrides: Partial = {}, @@ -91,8 +92,8 @@ function makeMockWorkflowPort( }, ): WorkflowPort { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() @@ -123,6 +124,7 @@ function makeContext( runId: 'run-1', stepId: 'load-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), model: makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }).model, @@ -143,6 +145,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -967,7 +975,10 @@ describe('LoadRelatedRecordStepExecutor', () => { await executor.execute(); - expect(workflowPort.getCollectionSchema).toHaveBeenCalledWith('customers'); + expect(workflowPort.getCollectionSchema).toHaveBeenCalledWith( + 'customers', + expect.any(String), + ); }); }); @@ -1140,10 +1151,12 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('StepPersistenceError post-load', () => { + describe('RunStorePortError post-load', () => { it('returns error outcome when saveStepExecution fails after load (Branch B)', async () => { const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ runId: 'run-1', @@ -1156,7 +1169,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); }); it('returns error outcome when saveStepExecution fails after load (Branch A confirmed)', async () => { @@ -1171,7 +1184,9 @@ describe('LoadRelatedRecordStepExecutor', () => { }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ runId: 'run-1', stepIndex: 0, runStore }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1179,7 +1194,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); }); }); @@ -1299,7 +1314,9 @@ describe('LoadRelatedRecordStepExecutor', () => { it('returns user message and logs cause when agentPort.getRelatedData throws an infra error', async () => { const logger = { info: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); - (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue( + new AgentPortError('getRelatedData', new Error('DB connection lost')), + ); const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); const context = makeContext({ model: mockModel.model, diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index a7a023a212..bd52a2e751 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -1,15 +1,15 @@ import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { McpStepDefinition } from '../../src/types/step-definition'; +import type { ExecutionContext } from '../../src/types/execution-context'; import type { McpStepExecutionData } from '../../src/types/step-execution-data'; +import type { McpStepDefinition } from '../../src/types/validated/step-definition'; import RemoteTool from '@forestadmin/ai-proxy/src/remote-tool'; -import { StepStateError } from '../../src/errors'; +import { RunStorePortError, StepStateError } from '../../src/errors'; import McpStepExecutor from '../../src/executors/mcp-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; // --------------------------------------------------------------------------- // Helpers @@ -51,8 +51,8 @@ function makeMockRunStore(overrides: Partial = {}): RunStore { function makeMockWorkflowPort(): WorkflowPort { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest.fn().mockResolvedValue({ collectionName: 'customers', @@ -83,6 +83,7 @@ function makeContext( runId: 'run-1', stepId: 'mcp-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, stepDefinition: makeStep(), model: makeMockModel('send_notification', { message: 'Hello' }).model, @@ -108,6 +109,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -190,17 +197,24 @@ describe('McpStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(modelInvoke).toHaveBeenCalledTimes(2); - // First save: raw result only + // First save: executing marker (before tool call) expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( 1, 'run-1', + expect.objectContaining({ idempotencyPhase: 'executing' }), + ); + // Second save: raw result with done marker + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 2, + 'run-1', expect.objectContaining({ executionResult: { success: true, toolResult }, + idempotencyPhase: 'done', }), ); - // Second save: raw result + formattedResponse + // Third save: raw result + formattedResponse expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( - 2, + 3, 'run-1', expect.objectContaining({ executionResult: { success: true, toolResult, formattedResponse: 'Found 3 results.' }, @@ -235,9 +249,10 @@ describe('McpStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - // Only the first save (raw result) — no second save since formatting failed - expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); - expect(runStore.saveStepExecution).toHaveBeenCalledWith( + // Two saves: executing marker, then raw result with done marker (no third save since formatting failed) + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(2); + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 2, 'run-1', expect.objectContaining({ executionResult: { success: true, toolResult: { result: 'ok' } }, @@ -270,8 +285,10 @@ describe('McpStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); // Model called only once (tool selection) — no formatting call for null result expect(modelInvoke).toHaveBeenCalledTimes(1); - expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); - expect(runStore.saveStepExecution).toHaveBeenCalledWith( + // Two saves: executing marker, then raw result with done marker + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(2); + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 2, 'run-1', expect.objectContaining({ executionResult: { success: true, toolResult: null }, @@ -309,7 +326,11 @@ describe('McpStepExecutor', () => { const { model } = makeMockModel('send_notification', { message: 'Hello' }); const logger = { info: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(new Error('DB unavailable')), + saveStepExecution: jest + .fn() + .mockRejectedValue( + new RunStorePortError('saveStepExecution', new Error('DB unavailable')), + ), }); const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); const context = makeContext({ model, runStore, logger }); @@ -318,9 +339,9 @@ describe('McpStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); expect(logger.error).toHaveBeenCalledWith( - 'MCP task step state could not be persisted (run "run-1", step 0)', + 'Run store "saveStepExecution" failed: DB unavailable', expect.objectContaining({ cause: 'DB unavailable', stepId: 'mcp-1' }), ); }); @@ -503,7 +524,7 @@ describe('McpStepExecutor', () => { }); }); - describe('StepPersistenceError', () => { + describe('RunStorePortError propagation', () => { it('returns error and logs cause when saveStepExecution fails after tool invocation (Branch B)', async () => { const invokeFn = jest.fn().mockResolvedValue('ok'); const tool = new MockRemoteTool({ @@ -514,7 +535,9 @@ describe('McpStepExecutor', () => { const { model } = makeMockModel('send_notification', { message: 'Hello' }); const logger = { info: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ model, @@ -527,9 +550,9 @@ describe('McpStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); expect(logger.error).toHaveBeenCalledWith( - 'MCP tool "send_notification" executed but step state could not be persisted (run "run-1", step 0)', + 'Run store "saveStepExecution" failed: Disk full', expect.objectContaining({ cause: 'Disk full', stepId: 'mcp-1' }), ); }); @@ -554,7 +577,9 @@ describe('McpStepExecutor', () => { const logger = { info: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ runStore, logger }); const executor = new McpStepExecutor(context, [tool]); @@ -562,9 +587,9 @@ describe('McpStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); expect(logger.error).toHaveBeenCalledWith( - 'MCP tool "send_notification" executed but step state could not be persisted (run "run-1", step 0)', + 'Run store "saveStepExecution" failed: Disk full', expect.objectContaining({ cause: 'Disk full', stepId: 'mcp-1' }), ); }); @@ -649,7 +674,11 @@ describe('McpStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(mockRunStore.saveStepExecution).not.toHaveBeenCalled(); + expect(mockRunStore.saveStepExecution).toHaveBeenCalledTimes(1); + expect(mockRunStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ idempotencyPhase: 'executing' }), + ); }); it('returns error and logs when tool invocation throws an infrastructure error', async () => { @@ -782,4 +811,94 @@ describe('McpStepExecutor', () => { expect(messages[1].content).toContain('Should we send a notification?'); }); }); + + describe('idempotency', () => { + it('returns success without re-executing or emitting activity log when idempotencyPhase is done', async () => { + const toolInvoke = jest.fn().mockResolvedValue('tool-result'); + const tool = new MockRemoteTool({ name: 'send_notification', invoke: toolInvoke }); + const activityLogPort = { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; + const doneExecution: McpStepExecutionData = { + type: 'mcp', + stepIndex: 0, + executionParams: { name: 'send_notification', sourceId: 'mcp-server-1', input: {} }, + executionResult: { success: true, toolResult: 'tool-result' }, + idempotencyPhase: 'done', + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([doneExecution]), + }); + const context = makeContext({ runStore, activityLogPort }); + const executor = new McpStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(toolInvoke).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + + it('returns error without activity log when idempotencyPhase is executing', async () => { + const toolInvoke = jest.fn().mockResolvedValue('tool-result'); + const tool = new MockRemoteTool({ name: 'send_notification', invoke: toolInvoke }); + const activityLogPort = { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; + const executingExecution: McpStepExecutionData = { + type: 'mcp', + stepIndex: 0, + idempotencyPhase: 'executing', + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([executingExecution]), + }); + const context = makeContext({ runStore, activityLogPort }); + const executor = new McpStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(toolInvoke).not.toHaveBeenCalled(); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + + it('saves executing marker before side effect and done marker with executionResult after', async () => { + const toolInvoke = jest.fn().mockResolvedValue('tool-result'); + const tool = new MockRemoteTool({ name: 'send_notification', invoke: toolInvoke }); + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpStepExecutor(context, [tool]); + + await executor.execute(); + + const { calls } = (runStore.saveStepExecution as jest.Mock).mock; + // First: 'executing'; Second: 'done' with executionResult (no formattedResponse model call) + expect(calls[0][1]).toMatchObject({ + type: 'mcp', + stepIndex: 0, + idempotencyPhase: 'executing', + }); + expect(calls[0][1]).not.toHaveProperty('executionResult'); + expect(calls[1][1]).toMatchObject({ + type: 'mcp', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { success: true, toolResult: 'tool-result' }, + }); + }); + }); }); diff --git a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts index e63884fd93..03e25f4a5b 100644 --- a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts @@ -1,14 +1,14 @@ import type { AgentPort } from '../../src/ports/agent-port'; import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { CollectionSchema, RecordRef } from '../../src/types/record'; -import type { ReadRecordStepDefinition } from '../../src/types/step-definition'; +import type { ExecutionContext } from '../../src/types/execution-context'; +import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; +import type { ReadRecordStepDefinition } from '../../src/types/validated/step-definition'; -import { NoRecordsError, RecordNotFoundError } from '../../src/errors'; +import { AgentPortError, NoRecordsError, RecordNotFoundError } from '../../src/errors'; import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeStep(overrides: Partial = {}): ReadRecordStepDefinition { return { @@ -75,8 +75,8 @@ function makeMockWorkflowPort( }, ): WorkflowPort { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() @@ -110,6 +110,7 @@ function makeContext( runId: 'run-1', stepId: 'read-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), model: makeMockModel({ fieldNames: ['email'] }).model, @@ -130,6 +131,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -671,7 +678,10 @@ describe('ReadRecordStepExecutor', () => { it('returns user message and logs cause when agentPort.getRecord throws an infra error', async () => { const logger = { info: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); - (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + // Prod adapter normalizes infra errors into AgentPortError — simulate here. + (agentPort.getRecord as jest.Mock).mockRejectedValue( + new AgentPortError('getRecord', new Error('DB connection lost')), + ); const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model, agentPort, logger }); const executor = new ReadRecordStepExecutor(context); diff --git a/packages/workflow-executor/test/executors/safe-agent-port.test.ts b/packages/workflow-executor/test/executors/safe-agent-port.test.ts deleted file mode 100644 index 2458801304..0000000000 --- a/packages/workflow-executor/test/executors/safe-agent-port.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { AgentPort } from '../../src/ports/agent-port'; -import type { StepUser } from '../../src/types/execution'; - -import { AgentPortError, StepStateError, WorkflowExecutorError } from '../../src/errors'; -import SafeAgentPort from '../../src/executors/safe-agent-port'; - -const dummyUser: StepUser = { - id: 1, - email: 'test@example.com', - firstName: 'Test', - lastName: 'User', - team: 'admin', - renderingId: 1, - role: 'admin', - permissionLevel: 'admin', - tags: {}, -}; - -function makeMockPort(overrides: Partial = {}): AgentPort { - return { - getRecord: jest - .fn() - .mockResolvedValue({ collectionName: 'customers', recordId: [1], values: {} }), - updateRecord: jest - .fn() - .mockResolvedValue({ collectionName: 'customers', recordId: [1], values: {} }), - getRelatedData: jest.fn().mockResolvedValue([]), - executeAction: jest.fn().mockResolvedValue(undefined), - ...overrides, - } as unknown as AgentPort; -} - -describe('SafeAgentPort', () => { - describe('returns result when port call succeeds', () => { - it('getRecord returns the port result', async () => { - const expected = { collectionName: 'customers', recordId: [1], values: { email: 'a@b.com' } }; - const port = makeMockPort({ getRecord: jest.fn().mockResolvedValue(expected) }); - const safe = new SafeAgentPort(port); - - const result = await safe.getRecord({ collection: 'customers', id: [1] }, dummyUser); - - expect(result).toBe(expected); - }); - - it('updateRecord returns the port result', async () => { - const expected = { collectionName: 'customers', recordId: [1], values: { status: 'active' } }; - const port = makeMockPort({ updateRecord: jest.fn().mockResolvedValue(expected) }); - const safe = new SafeAgentPort(port); - - const result = await safe.updateRecord( - { - collection: 'customers', - id: [1], - values: { status: 'active' }, - }, - dummyUser, - ); - - expect(result).toBe(expected); - }); - - it('getRelatedData returns the port result', async () => { - const expected = [{ collectionName: 'orders', recordId: [10], values: {} }]; - const port = makeMockPort({ getRelatedData: jest.fn().mockResolvedValue(expected) }); - const safe = new SafeAgentPort(port); - - const result = await safe.getRelatedData( - { - collection: 'customers', - id: [1], - relation: 'orders', - limit: 10, - }, - dummyUser, - ); - - expect(result).toBe(expected); - }); - - it('executeAction returns the port result', async () => { - const expected = { success: true }; - const port = makeMockPort({ executeAction: jest.fn().mockResolvedValue(expected) }); - const safe = new SafeAgentPort(port); - - const result = await safe.executeAction( - { collection: 'customers', action: 'send-email' }, - dummyUser, - ); - - expect(result).toBe(expected); - }); - }); - - describe('wraps infra Error in AgentPortError', () => { - it('wraps getRecord infra error with correct operation name', async () => { - const port = makeMockPort({ - getRecord: jest.fn().mockRejectedValue(new Error('DB connection lost')), - }); - const safe = new SafeAgentPort(port); - - await expect(safe.getRecord({ collection: 'customers', id: [1] }, dummyUser)).rejects.toThrow( - AgentPortError, - ); - }); - - it('includes cause message in AgentPortError.message for getRecord', async () => { - const port = makeMockPort({ - getRecord: jest.fn().mockRejectedValue(new Error('DB connection lost')), - }); - const safe = new SafeAgentPort(port); - - await expect(safe.getRecord({ collection: 'customers', id: [1] }, dummyUser)).rejects.toThrow( - 'Agent port "getRecord" failed: DB connection lost', - ); - }); - - it('wraps updateRecord infra error with correct operation name', async () => { - const port = makeMockPort({ - updateRecord: jest.fn().mockRejectedValue(new Error('Timeout')), - }); - const safe = new SafeAgentPort(port); - - await expect( - safe.updateRecord({ collection: 'customers', id: [1], values: {} }, dummyUser), - ).rejects.toThrow('Agent port "updateRecord" failed: Timeout'); - }); - - it('wraps getRelatedData infra error with correct operation name', async () => { - const port = makeMockPort({ - getRelatedData: jest.fn().mockRejectedValue(new Error('Network error')), - }); - const safe = new SafeAgentPort(port); - - await expect( - safe.getRelatedData( - { collection: 'customers', id: [1], relation: 'orders', limit: 10 }, - dummyUser, - ), - ).rejects.toThrow('Agent port "getRelatedData" failed: Network error'); - }); - - it('wraps executeAction infra error with correct operation name', async () => { - const port = makeMockPort({ - executeAction: jest.fn().mockRejectedValue(new Error('Action failed')), - }); - const safe = new SafeAgentPort(port); - - await expect( - safe.executeAction({ collection: 'customers', action: 'send-email' }, dummyUser), - ).rejects.toThrow('Agent port "executeAction" failed: Action failed'); - }); - - it('sets cause on AgentPortError', async () => { - const infraError = new Error('DB connection lost'); - const port = makeMockPort({ getRecord: jest.fn().mockRejectedValue(infraError) }); - const safe = new SafeAgentPort(port); - - let thrown: unknown; - - try { - await safe.getRecord({ collection: 'customers', id: [1] }, dummyUser); - } catch (e) { - thrown = e; - } - - expect(thrown).toBeInstanceOf(AgentPortError); - expect((thrown as AgentPortError).cause).toBe(infraError); - }); - }); - - describe('does not re-wrap WorkflowExecutorError', () => { - it('rethrows WorkflowExecutorError as-is from getRecord', async () => { - const domainError = new StepStateError('invalid state'); - const port = makeMockPort({ getRecord: jest.fn().mockRejectedValue(domainError) }); - const safe = new SafeAgentPort(port); - - await expect(safe.getRecord({ collection: 'customers', id: [1] }, dummyUser)).rejects.toBe( - domainError, - ); - }); - - it('rethrows WorkflowExecutorError subclass without wrapping in AgentPortError', async () => { - const domainError = new StepStateError('invalid state'); - const port = makeMockPort({ executeAction: jest.fn().mockRejectedValue(domainError) }); - const safe = new SafeAgentPort(port); - - let thrown: unknown; - - try { - await safe.executeAction({ collection: 'customers', action: 'send-email' }, dummyUser); - } catch (e) { - thrown = e; - } - - expect(thrown).toBeInstanceOf(WorkflowExecutorError); - expect(thrown).not.toBeInstanceOf(AgentPortError); - expect(thrown).toBe(domainError); - }); - }); -}); diff --git a/packages/workflow-executor/test/executors/step-summary-builder.test.ts b/packages/workflow-executor/test/executors/step-summary-builder.test.ts index efd24443fc..3b1770eb37 100644 --- a/packages/workflow-executor/test/executors/step-summary-builder.test.ts +++ b/packages/workflow-executor/test/executors/step-summary-builder.test.ts @@ -1,9 +1,9 @@ -import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; -import type { StepOutcome } from '../../src/types/step-outcome'; +import type { StepDefinition } from '../../src/types/validated/step-definition'; +import type { StepOutcome } from '../../src/types/validated/step-outcome'; import StepSummaryBuilder from '../../src/executors/summary/step-summary-builder'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeConditionStep(prompt?: string): StepDefinition { return { type: StepType.Condition, options: ['A', 'B'], prompt }; @@ -265,6 +265,175 @@ describe('StepSummaryBuilder', () => { expect(result).not.toContain('Loaded:'); }); + describe('manually handled steps', () => { + it('signals manually handled update-record when pendingData exists and idempotencyPhase is undefined', () => { + const step: StepDefinition = { + type: StepType.UpdateRecord, + prompt: 'Set status to active', + }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'update-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Proposed:'); + expect(result).toContain('"displayName":"Status"'); + expect(result).toContain('handled this step manually'); + expect(result).not.toContain('Pending:'); + expect(result).not.toContain('Output:'); + }); + + it('signals manually handled trigger-action when pendingData exists and idempotencyPhase is undefined', () => { + const step: StepDefinition = { + type: StepType.TriggerAction, + prompt: 'Archive the customer', + }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'trigger-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { displayName: 'Archive Customer', name: 'archive' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Proposed:'); + expect(result).toContain('"displayName":"Archive Customer"'); + expect(result).toContain('handled this step manually'); + }); + + it('does NOT signal manually handled when idempotencyPhase is done (executor completed it)', () => { + const step: StepDefinition = { type: StepType.UpdateRecord, prompt: 'Set status' }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'update-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'update-record', + stepIndex: 0, + idempotencyPhase: 'done', + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + executionResult: { updatedValues: { status: 'active' } }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).not.toContain('handled this step manually'); + }); + + it('does NOT signal manually handled when status is awaiting-input (still pending)', () => { + const step: StepDefinition = { type: StepType.UpdateRecord, prompt: 'Set status' }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'update-1', + stepIndex: 0, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).not.toContain('handled this step manually'); + expect(result).toContain('Pending:'); + }); + + it('does NOT signal manually handled for trigger-action completed via saveFrontendResult (executionResult present)', () => { + const step: StepDefinition = { + type: StepType.TriggerAction, + prompt: 'Archive the customer', + }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'trigger-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { displayName: 'Archive Customer', name: 'archive' }, + executionResult: { success: true, actionResult: {} }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).not.toContain('handled this step manually'); + }); + + it('does NOT signal manually handled for update-record skipped (executionResult: skipped)', () => { + const step: StepDefinition = { type: StepType.UpdateRecord, prompt: 'Set status' }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'update-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + executionResult: { skipped: true }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).not.toContain('handled this step manually'); + }); + + it('does NOT signal manually handled for load-related-record success (executionResult present, no idempotencyPhase)', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record', + stepId: 'load-1', + stepIndex: 1, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + pendingData: { displayName: 'Address', name: 'address', selectedRecordId: [1] }, + executionResult: { + relation: { name: 'address', displayName: 'Address' }, + record: { collectionName: 'addresses', recordId: [1], stepIndex: 1 }, + }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).not.toContain('handled this step manually'); + }); + }); + it('shows "(no prompt)" when step has no prompt', () => { const step: StepDefinition = { type: StepType.Condition, options: ['A', 'B'] }; const outcome = makeConditionOutcome('cond-1', 0); diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index 63a1b3a030..f2c62ee988 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -1,15 +1,15 @@ import type { AgentPort } from '../../src/ports/agent-port'; import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { CollectionSchema, RecordRef } from '../../src/types/record'; -import type { TriggerActionStepDefinition } from '../../src/types/step-definition'; +import type { ExecutionContext } from '../../src/types/execution-context'; import type { TriggerRecordActionStepExecutionData } from '../../src/types/step-execution-data'; +import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; +import type { TriggerActionStepDefinition } from '../../src/types/validated/step-definition'; -import { StepStateError } from '../../src/errors'; +import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeStep( overrides: Partial = {}, @@ -36,6 +36,7 @@ function makeMockAgentPort(): AgentPort { updateRecord: jest.fn(), getRelatedData: jest.fn(), executeAction: jest.fn().mockResolvedValue(undefined), + getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), } as unknown as AgentPort; } @@ -76,8 +77,8 @@ function makeMockWorkflowPort( }, ): WorkflowPort { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() @@ -108,6 +109,7 @@ function makeContext( runId: 'run-1', stepId: 'trigger-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), model: makeMockModel({ @@ -131,6 +133,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -211,12 +219,29 @@ describe('TriggerRecordActionStepExecutor', () => { }), ); }); + + it('does NOT create an activity log (the frontend logs on its side)', async () => { + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ automaticExecution: false }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + expect(context.activityLogPort.createPending).not.toHaveBeenCalled(); + expect(context.activityLogPort.markSucceeded).not.toHaveBeenCalled(); + expect(context.activityLogPort.markFailed).not.toHaveBeenCalled(); + }); }); describe('confirmation accepted (Branch A)', () => { - it('triggers the action when user confirms and preserves pendingAction', async () => { + it('saves the frontend-provided actionResult without re-executing the action', async () => { const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, @@ -224,6 +249,7 @@ describe('TriggerRecordActionStepExecutor', () => { displayName: 'Send Welcome Email', name: 'send-welcome-email', userConfirmed: true, + actionResult: { success: 'ok', html: '

Email queued

' }, }, selectedRecordRef: makeRecordRef(), }; @@ -236,10 +262,8 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.executeAction).toHaveBeenCalledWith( - { collection: 'customers', action: 'send-welcome-email', id: [42] }, - expect.objectContaining({ id: 1 }), - ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(agentPort.getActionFormInfo).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ @@ -248,15 +272,78 @@ describe('TriggerRecordActionStepExecutor', () => { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, - executionResult: { success: true, actionResult: { message: 'Email sent' } }, + executionResult: { + success: true, + actionResult: { success: 'ok', html: '

Email queued

' }, + }, pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', userConfirmed: true, + actionResult: { success: 'ok', html: '

Email queued

' }, }, }), ); }); + + it('persists actionResult:null as a legitimate void-action result', async () => { + const agentPort = makeMockAgentPort(); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + userConfirmed: true, + actionResult: null, + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { success: true, actionResult: null }, + }), + ); + }); + + it('returns error when the frontend confirmed without providing actionResult', async () => { + const agentPort = makeMockAgentPort(); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + userConfirmed: true, + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); }); describe('confirmation rejected (Branch A)', () => { @@ -376,6 +463,71 @@ describe('TriggerRecordActionStepExecutor', () => { }); }); + describe('UnsupportedActionFormError (form detection)', () => { + it('throws when the action has a form and automaticExecution is true', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getActionFormInfo as jest.Mock).mockResolvedValue({ hasForm: true }); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'r', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'This action requires user input via a form, which is not yet supported in workflows.', + ); + // Form detection uses the resolved technical name, not the AI display name — + // passing "Send Welcome Email" would 404 against the agent. + expect(agentPort.getActionFormInfo).toHaveBeenCalledWith( + { collection: 'customers', action: 'send-welcome-email', id: [42] }, + expect.objectContaining({ id: 1 }), + ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('supports form-bearing actions when automaticExecution is false (frontend handles the form)', async () => { + const agentPort = makeMockAgentPort(); + // hasForm would return true if called — but it should not be called in this branch. + (agentPort.getActionFormInfo as jest.Mock).mockResolvedValue({ hasForm: true }); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'r', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // Form check is skipped when not automatic — the frontend will handle the form. + expect(agentPort.getActionFormInfo).not.toHaveBeenCalled(); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email' }, + }), + ); + }); + }); + describe('resolveActionName failure', () => { it('returns error when AI returns an action name not found in the schema', async () => { const agentPort = makeMockAgentPort(); @@ -438,42 +590,11 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.error).toBe( 'An unexpected error occurred while processing this step.', ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); - }); - }); - - describe('agentPort.executeAction WorkflowExecutorError (Branch A)', () => { - it('returns error when executeAction throws WorkflowExecutorError during confirmation', async () => { - const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockRejectedValue( - new StepStateError('Action not permitted'), - ); - const execution: TriggerRecordActionStepExecutionData = { - type: 'trigger-action', - stepIndex: 0, - pendingData: { - displayName: 'Send Welcome Email', - name: 'send-welcome-email', - userConfirmed: true, - }, - selectedRecordRef: makeRecordRef(), - }; - const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([execution]), - }); - const context = makeContext({ agentPort, runStore }); - const executor = new TriggerRecordActionStepExecutor(context); - - const result = await executor.execute(); - - expect(result.stepOutcome.type).toBe('record'); - expect(result.stepOutcome.stepId).toBe('trigger-1'); - expect(result.stepOutcome.stepIndex).toBe(0); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - 'An unexpected error occurred while processing this step.', + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ idempotencyPhase: 'executing' }), ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -496,33 +617,12 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); }); - it('returns error outcome for infrastructure errors (Branch A)', async () => { - const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const execution: TriggerRecordActionStepExecutionData = { - type: 'trigger-action', - stepIndex: 0, - pendingData: { - displayName: 'Send Welcome Email', - name: 'send-welcome-email', - userConfirmed: true, - }, - selectedRecordRef: makeRecordRef(), - }; - const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([execution]), - }); - const context = makeContext({ agentPort, runStore }); - const executor = new TriggerRecordActionStepExecutor(context); - - const result = await executor.execute(); - expect(result.stepOutcome.status).toBe('error'); - }); - it('returns user message and logs cause when agentPort.executeAction throws an infra error', async () => { const logger = { info: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new AgentPortError('executeAction', new Error('DB connection lost')), + ); const mockModel = makeMockModel({ actionName: 'Send Welcome Email', reasoning: 'User requested welcome email', @@ -822,7 +922,9 @@ describe('TriggerRecordActionStepExecutor', () => { it('returns error outcome after successful executeAction when saveStepExecution fails (Branch B)', async () => { const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ runStore, @@ -833,10 +935,10 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); }); - it('returns error outcome after successful executeAction when saveStepExecution fails (Branch A confirmed)', async () => { + it('returns error outcome when saveStepExecution fails saving the frontend result (Branch A confirmed)', async () => { const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, @@ -844,12 +946,15 @@ describe('TriggerRecordActionStepExecutor', () => { displayName: 'Send Welcome Email', name: 'send-welcome-email', userConfirmed: true, + actionResult: { success: 'ok' }, }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ runStore }); const executor = new TriggerRecordActionStepExecutor(context); @@ -857,7 +962,7 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); }); }); @@ -988,4 +1093,97 @@ describe('TriggerRecordActionStepExecutor', () => { expect(mockModel.bindTools).toHaveBeenCalledTimes(1); }); }); + + describe('idempotency', () => { + it('returns success without re-executing or emitting activity log when idempotencyPhase is done', async () => { + const agentPort = makeMockAgentPort(); + const activityLogPort = { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; + const doneExecution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + executionParams: { displayName: 'Send Welcome Email', name: 'send-welcome-email' }, + executionResult: { success: true, actionResult: undefined }, + selectedRecordRef: makeRecordRef(), + idempotencyPhase: 'done', + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([doneExecution]), + }); + const context = makeContext({ agentPort, runStore, activityLogPort }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + + it('returns error without activity log when idempotencyPhase is executing', async () => { + const agentPort = makeMockAgentPort(); + const activityLogPort = { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; + const executingExecution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + idempotencyPhase: 'executing', + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([executingExecution]), + }); + const context = makeContext({ agentPort, runStore, activityLogPort }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + + it('saves executing marker before side effect and done marker with executionResult after', async () => { + const agentPort = makeMockAgentPort(); + const runStore = makeMockRunStore(); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + const { calls } = (runStore.saveStepExecution as jest.Mock).mock; + expect(calls).toHaveLength(2); + expect(calls[0][1]).toMatchObject({ + type: 'trigger-action', + stepIndex: 0, + idempotencyPhase: 'executing', + }); + expect(calls[0][1]).not.toHaveProperty('executionResult'); + expect(calls[1][1]).toMatchObject({ + type: 'trigger-action', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { success: true }, + }); + }); + }); }); diff --git a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts index a298d546d2..9c1866e092 100644 --- a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -1,15 +1,15 @@ import type { AgentPort } from '../../src/ports/agent-port'; import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; -import type { ExecutionContext } from '../../src/types/execution'; -import type { CollectionSchema, RecordRef } from '../../src/types/record'; -import type { UpdateRecordStepDefinition } from '../../src/types/step-definition'; +import type { ExecutionContext } from '../../src/types/execution-context'; import type { UpdateRecordStepExecutionData } from '../../src/types/step-execution-data'; +import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; +import type { UpdateRecordStepDefinition } from '../../src/types/validated/step-definition'; -import { StepStateError } from '../../src/errors'; +import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; function makeStep(overrides: Partial = {}): UpdateRecordStepDefinition { return { @@ -49,10 +49,10 @@ function makeCollectionSchema(overrides: Partial = {}): Collec collectionDisplayName: 'Customers', primaryKeyFields: ['id'], fields: [ - { fieldName: 'email', displayName: 'Email', isRelationship: false }, - { fieldName: 'status', displayName: 'Status', isRelationship: false }, - { fieldName: 'name', displayName: 'Full Name', isRelationship: false }, - { fieldName: 'orders', displayName: 'Orders', isRelationship: true }, + { fieldName: 'email', displayName: 'Email', isRelationship: false, type: 'String' }, + { fieldName: 'status', displayName: 'Status', isRelationship: false, type: 'String' }, + { fieldName: 'name', displayName: 'Full Name', isRelationship: false, type: 'String' }, + { fieldName: 'orders', displayName: 'Orders', isRelationship: true, type: null }, ], actions: [], ...overrides, @@ -75,8 +75,8 @@ function makeMockWorkflowPort( }, ): WorkflowPort { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() @@ -107,12 +107,11 @@ function makeContext( runId: 'run-1', stepId: 'update-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), model: makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'User requested status change', + input: { fieldName: 'Status', value: 'active', reasoning: 'User requested status change' }, }).model, agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), @@ -131,6 +130,12 @@ function makeContext( schemaCache: new SchemaCache(), previousSteps: [], logger: { info: jest.fn(), error: jest.fn() }, + + activityLogPort: { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }, ...overrides, }; } @@ -141,9 +146,7 @@ describe('UpdateRecordStepExecutor', () => { const updatedValues = { status: 'active', name: 'John Doe' }; const agentPort = makeMockAgentPort(updatedValues); const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'User requested status change', + input: { fieldName: 'Status', value: 'active', reasoning: 'User requested status change' }, }); const runStore = makeMockRunStore(); const context = makeContext({ @@ -180,9 +183,7 @@ describe('UpdateRecordStepExecutor', () => { describe('without automaticExecution: awaiting-input (Branch C)', () => { it('saves execution and returns awaiting-input', async () => { const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'User requested status change', + input: { fieldName: 'Status', value: 'active', reasoning: 'User requested status change' }, }); const runStore = makeMockRunStore(); const context = makeContext({ @@ -366,8 +367,13 @@ describe('UpdateRecordStepExecutor', () => { collectionName: 'orders', collectionDisplayName: 'Orders', fields: [ - { fieldName: 'total', displayName: 'Total', isRelationship: false }, - { fieldName: 'status', displayName: 'Order Status', isRelationship: false }, + { fieldName: 'total', displayName: 'Total', isRelationship: false, type: 'Number' }, + { + fieldName: 'status', + displayName: 'Order Status', + isRelationship: false, + type: 'String', + }, ], }); @@ -387,7 +393,13 @@ describe('UpdateRecordStepExecutor', () => { tool_calls: [ { name: 'update-record-field', - args: { fieldName: 'Order Status', value: 'shipped', reasoning: 'Mark as shipped' }, + args: { + input: { + fieldName: 'Order Status', + value: 'shipped', + reasoning: 'Mark as shipped', + }, + }, id: 'call_2', }, ], @@ -446,12 +458,10 @@ describe('UpdateRecordStepExecutor', () => { describe('NoWritableFieldsError', () => { it('returns error when all fields are relationships', async () => { const schema = makeCollectionSchema({ - fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], + fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true, type: null }], }); const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const runStore = makeMockRunStore(); const workflowPort = makeMockWorkflowPort({ customers: schema }); @@ -472,9 +482,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when field is not found during automaticExecution (Branch B)', async () => { // AI returns a display name that doesn't match any field in the schema const mockModel = makeMockModel({ - fieldName: 'NonExistentField', - value: 'test', - reasoning: 'test', + input: { fieldName: 'NonExistentField', value: 'test', reasoning: 'test' }, }); const context = makeContext({ model: mockModel.model, @@ -494,9 +502,7 @@ describe('UpdateRecordStepExecutor', () => { describe('relationship fields excluded from update tool', () => { it('excludes relationship fields from the tool schema', async () => { const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const context = makeContext({ model: mockModel.model }); const executor = new UpdateRecordStepExecutor(context); @@ -508,16 +514,20 @@ describe('UpdateRecordStepExecutor', () => { const tool = lastCall[0][0]; expect(tool.name).toBe('update-record-field'); - // Non-relationship display names should be accepted - expect(tool.schema.parse({ fieldName: 'Email', value: 'x', reasoning: 'r' })).toBeTruthy(); - expect(tool.schema.parse({ fieldName: 'Status', value: 'x', reasoning: 'r' })).toBeTruthy(); + // Each non-relationship field is a literal in the union — exact displayName required + expect( + tool.schema.parse({ input: { fieldName: 'Email', value: 'x', reasoning: 'r' } }), + ).toBeTruthy(); expect( - tool.schema.parse({ fieldName: 'Full Name', value: 'x', reasoning: 'r' }), + tool.schema.parse({ input: { fieldName: 'Status', value: 'x', reasoning: 'r' } }), + ).toBeTruthy(); + expect( + tool.schema.parse({ input: { fieldName: 'Full Name', value: 'x', reasoning: 'r' } }), ).toBeTruthy(); - // Relationship display name should be rejected + // Relationship display name rejected — no union variant has fieldName 'Orders' expect(() => - tool.schema.parse({ fieldName: 'Orders', value: 'x', reasoning: 'r' }), + tool.schema.parse({ input: { fieldName: 'Orders', value: 'x', reasoning: 'r' } }), ).toThrow(); }); }); @@ -578,9 +588,7 @@ describe('UpdateRecordStepExecutor', () => { const agentPort = makeMockAgentPort(); (agentPort.updateRecord as jest.Mock).mockRejectedValue(new StepStateError('Record locked')); const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const runStore = makeMockRunStore(); const context = makeContext({ @@ -641,9 +649,7 @@ describe('UpdateRecordStepExecutor', () => { const agentPort = makeMockAgentPort(); (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const context = makeContext({ model: mockModel.model, @@ -683,11 +689,11 @@ describe('UpdateRecordStepExecutor', () => { it('returns user message and logs cause when agentPort.updateRecord throws an infra error', async () => { const logger = { info: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); - (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + (agentPort.updateRecord as jest.Mock).mockRejectedValue( + new AgentPortError('updateRecord', new Error('DB connection lost')), + ); const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const context = makeContext({ model: mockModel.model, @@ -730,7 +736,9 @@ describe('UpdateRecordStepExecutor', () => { it('resolves update when AI returns raw fieldName instead of displayName', async () => { const agentPort = makeMockAgentPort(); // AI returns 'status' (fieldName) instead of 'Status' (displayName) - const mockModel = makeMockModel({ fieldName: 'status', value: 'active', reasoning: 'test' }); + const mockModel = makeMockModel({ + input: { fieldName: 'status', value: 'active', reasoning: 'test' }, + }); const context = makeContext({ model: mockModel.model, agentPort, @@ -812,7 +820,9 @@ describe('UpdateRecordStepExecutor', () => { it('returns error outcome after successful updateRecord when saveStepExecution fails (Branch B)', async () => { const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + saveStepExecution: jest + .fn() + .mockRejectedValue(new RunStorePortError('saveStepExecution', new Error('Disk full'))), }); const context = makeContext({ runStore, @@ -823,16 +833,14 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(result.stepOutcome.error).toBe('The step state could not be accessed. Please retry.'); }); }); describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const context = makeContext({ model: mockModel.model, @@ -851,9 +859,7 @@ describe('UpdateRecordStepExecutor', () => { describe('previous steps context', () => { it('includes previous steps summary in update-field messages', async () => { const mockModel = makeMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'test', + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ @@ -944,7 +950,9 @@ describe('UpdateRecordStepExecutor', () => { }); it('falls back to AI when preRecordedArgs has no fieldDisplayName', async () => { - const mockModel = makeMockModel({ fieldName: 'Status', value: 'active', reasoning: 'r' }); + const mockModel = makeMockModel({ + input: { fieldName: 'Status', value: 'active', reasoning: 'r' }, + }); const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ @@ -986,6 +994,305 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); }); + + it('accepts non-string pre-recorded value (Number) and passes it through to updateRecord', async () => { + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema({ + fields: [{ fieldName: 'age', displayName: 'Age', isRelationship: false, type: 'Number' }], + }), + }); + const context = makeContext({ + runStore, + workflowPort, + stepDefinition: makeStep({ + automaticExecution: true, + preRecordedArgs: { fieldDisplayName: 'Age', value: 42 }, + }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(context.agentPort.updateRecord).toHaveBeenCalledWith( + expect.objectContaining({ values: { age: 42 } }), + context.user, + ); + }); + }); + + describe('buildUpdateFieldTool — type-specific schemas', () => { + async function getToolSchema(fields: CollectionSchema['fields']) { + const mockModel = makeMockModel({ + input: { fieldName: fields[0].displayName, value: null, reasoning: 'r' }, + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema({ fields }), + }); + const context = makeContext({ model: mockModel.model, workflowPort }); + const executor = new UpdateRecordStepExecutor(context); + await executor.execute(); + const lastCall = mockModel.bindTools.mock.calls[mockModel.bindTools.mock.calls.length - 1]; + + return lastCall[0][0].schema; + } + + it('Boolean: accepts true/false and coerces string "true"/"false"', async () => { + const schema = await getToolSchema([ + { fieldName: 'active', displayName: 'Active', isRelationship: false, type: 'Boolean' }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Active', value: true, reasoning: 'r' } }).input.value, + ).toBe(true); + expect( + schema.parse({ input: { fieldName: 'Active', value: 'true', reasoning: 'r' } }).input.value, + ).toBe(true); + expect( + schema.parse({ input: { fieldName: 'Active', value: false, reasoning: 'r' } }).input.value, + ).toBe(false); + expect(() => + schema.parse({ input: { fieldName: 'Active', value: 'maybe', reasoning: 'r' } }), + ).toThrow(); + }); + + it('Date: accepts ISO 8601 datetime, rejects date-only string', async () => { + const schema = await getToolSchema([ + { fieldName: 'created_at', displayName: 'Created At', isRelationship: false, type: 'Date' }, + ]); + + expect( + schema.parse({ + input: { fieldName: 'Created At', value: '2024-06-01T00:00:00Z', reasoning: 'r' }, + }).input.value, + ).toBe('2024-06-01T00:00:00Z'); + expect(() => + schema.parse({ input: { fieldName: 'Created At', value: '2024-06-01', reasoning: 'r' } }), + ).toThrow(); + expect(() => + schema.parse({ input: { fieldName: 'Created At', value: 'not-a-date', reasoning: 'r' } }), + ).toThrow(); + }); + + it('Dateonly: accepts ISO 8601 date, rejects datetime and free text', async () => { + const schema = await getToolSchema([ + { + fieldName: 'birth_date', + displayName: 'Birth Date', + isRelationship: false, + type: 'Dateonly', + }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Birth Date', value: '2024-06-01', reasoning: 'r' } }) + .input.value, + ).toBe('2024-06-01'); + expect(() => + schema.parse({ input: { fieldName: 'Birth Date', value: 'not-a-date', reasoning: 'r' } }), + ).toThrow(); + // datetime string must be rejected — Dateonly only accepts date-only format + expect(() => + schema.parse({ + input: { fieldName: 'Birth Date', value: '2024-06-01T00:00:00Z', reasoning: 'r' }, + }), + ).toThrow(); + }); + + it('Number: coerces string "42" to 42', async () => { + const schema = await getToolSchema([ + { fieldName: 'age', displayName: 'Age', isRelationship: false, type: 'Number' }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Age', value: 42, reasoning: 'r' } }).input.value, + ).toBe(42); + expect( + schema.parse({ input: { fieldName: 'Age', value: '42', reasoning: 'r' } }).input.value, + ).toBe(42); + expect(() => + schema.parse({ input: { fieldName: 'Age', value: 'not-a-number', reasoning: 'r' } }), + ).toThrow(); + }); + + it('Enum: accepts valid enum values, rejects unknown ones', async () => { + const schema = await getToolSchema([ + { + fieldName: 'status', + displayName: 'Status', + isRelationship: false, + type: 'Enum', + enumValues: ['active', 'inactive', 'pending'], + }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Status', value: 'active', reasoning: 'r' } }).input + .value, + ).toBe('active'); + expect(() => + schema.parse({ input: { fieldName: 'Status', value: 'unknown', reasoning: 'r' } }), + ).toThrow(); + }); + + it('Enum with single enumValue: only accepts the one literal', async () => { + const schema = await getToolSchema([ + { + fieldName: 'flag', + displayName: 'Flag', + isRelationship: false, + type: 'Enum', + enumValues: ['only'], + }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Flag', value: 'only', reasoning: 'r' } }).input.value, + ).toBe('only'); + expect(() => + schema.parse({ input: { fieldName: 'Flag', value: 'other', reasoning: 'r' } }), + ).toThrow(); + }); + + it('Enum with no enumValues: falls back to any string', async () => { + const schema = await getToolSchema([ + { + fieldName: 'tag', + displayName: 'Tag', + isRelationship: false, + type: 'Enum', + enumValues: [], + }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Tag', value: 'anything', reasoning: 'r' } }).input + .value, + ).toBe('anything'); + }); + + it('Json: accepts valid JSON string, rejects non-JSON', async () => { + const schema = await getToolSchema([ + { fieldName: 'metadata', displayName: 'Metadata', isRelationship: false, type: 'Json' }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Metadata', value: '{"key":"val"}', reasoning: 'r' } }) + .input.value, + ).toBe('{"key":"val"}'); + expect(() => + schema.parse({ input: { fieldName: 'Metadata', value: 'not json', reasoning: 'r' } }), + ).toThrow(); + }); + + it('Point: accepts [longitude, latitude] array, rejects wrong length', async () => { + const schema = await getToolSchema([ + { fieldName: 'location', displayName: 'Location', isRelationship: false, type: 'Point' }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Location', value: [-0.5, 44.8], reasoning: 'r' } }) + .input.value, + ).toEqual([-0.5, 44.8]); + expect(() => + schema.parse({ input: { fieldName: 'Location', value: [1], reasoning: 'r' } }), + ).toThrow(); + }); + + it('String/Uuid/Time/File (default): accepts any string', async () => { + const schemas = await Promise.all( + (['String', 'Uuid', 'Time', 'File'] as const).map(type => + getToolSchema([{ fieldName: 'f', displayName: 'F', isRelationship: false, type }]), + ), + ); + + for (const schema of schemas) { + expect( + schema.parse({ input: { fieldName: 'F', value: 'anything', reasoning: 'r' } }).input + .value, + ).toBe('anything'); + } + }); + + it('type [File]: accepts array of strings', async () => { + const schema = await getToolSchema([ + { + fieldName: 'attachments', + displayName: 'Attachments', + isRelationship: false, + type: ['File'], + }, + ]); + + expect( + schema.parse({ + input: { fieldName: 'Attachments', value: ['file1.pdf', 'file2.pdf'], reasoning: 'r' }, + }).input.value, + ).toEqual(['file1.pdf', 'file2.pdf']); + expect(() => + schema.parse({ + input: { fieldName: 'Attachments', value: 'not-an-array', reasoning: 'r' }, + }), + ).toThrow(); + }); + + it('any field: accepts null value', async () => { + const schema = await getToolSchema([ + { fieldName: 'name', displayName: 'Name', isRelationship: false, type: 'String' }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Name', value: null, reasoning: 'r' } }).input.value, + ).toBeNull(); + }); + + it('type [[String]] (nested array): treats as array of JSON strings', async () => { + const schema = await getToolSchema([ + { + fieldName: 'data', + displayName: 'Data', + isRelationship: false, + type: [['String']] as unknown as ['String'], + }, + ]); + + expect( + schema.parse({ + input: { fieldName: 'Data', value: ['{"a":1}', '{"b":2}'], reasoning: 'r' }, + }).input.value, + ).toEqual(['{"a":1}', '{"b":2}']); + expect(() => + schema.parse({ input: { fieldName: 'Data', value: ['not json'], reasoning: 'r' } }), + ).toThrow(); + }); + + it('root schema is ZodObject (not union) — satisfies OpenAI type:object requirement', async () => { + const schema = await getToolSchema([ + { fieldName: 'status', displayName: 'Status', isRelationship: false, type: 'String' }, + { fieldName: 'name', displayName: 'Name', isRelationship: false, type: 'String' }, + ]); + + expect(schema.constructor.name).toBe('ZodObject'); + }); + + it('multi-field: both variants accepted under input wrapper, flat payload rejected', async () => { + const schema = await getToolSchema([ + { fieldName: 'status', displayName: 'Status', isRelationship: false, type: 'String' }, + { fieldName: 'count', displayName: 'Count', isRelationship: false, type: 'Number' }, + ]); + + expect( + schema.parse({ input: { fieldName: 'Status', value: 'active', reasoning: 'r' } }).input + .value, + ).toBe('active'); + expect( + schema.parse({ input: { fieldName: 'Count', value: '5', reasoning: 'r' } }).input.value, + ).toBe(5); + expect(() => + schema.parse({ fieldName: 'Status', value: 'active', reasoning: 'r' }), + ).toThrow(); + }); }); describe('patchAndReloadPendingData validation', () => { @@ -1012,4 +1319,97 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); }); }); + + describe('idempotency', () => { + it('returns success without re-executing or emitting activity log when idempotencyPhase is done', async () => { + const agentPort = makeMockAgentPort(); + const activityLogPort = { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; + const doneExecution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + executionParams: { displayName: 'Status', name: 'status', value: 'active' }, + executionResult: { updatedValues: { status: 'active' } }, + selectedRecordRef: makeRecordRef(), + idempotencyPhase: 'done', + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([doneExecution]), + }); + const context = makeContext({ agentPort, runStore, activityLogPort }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + + it('returns error without activity log when idempotencyPhase is executing', async () => { + const agentPort = makeMockAgentPort(); + const activityLogPort = { + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }; + const executingExecution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + idempotencyPhase: 'executing', + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([executingExecution]), + }); + const context = makeContext({ agentPort, runStore, activityLogPort }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + + it('saves executing marker before side effect and done marker with executionResult after', async () => { + const updatedValues = { status: 'active' }; + const agentPort = makeMockAgentPort(updatedValues); + const runStore = makeMockRunStore(); + const mockModel = makeMockModel({ + input: { fieldName: 'Status', value: 'active', reasoning: 'test' }, + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + const { calls } = (runStore.saveStepExecution as jest.Mock).mock; + expect(calls).toHaveLength(2); + expect(calls[0][1]).toMatchObject({ + type: 'update-record', + stepIndex: 0, + idempotencyPhase: 'executing', + }); + expect(calls[0][1]).not.toHaveProperty('executionResult'); + expect(calls[1][1]).toMatchObject({ + type: 'update-record', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { updatedValues }, + }); + }); + }); }); diff --git a/packages/workflow-executor/test/http/executor-http-server.test.ts b/packages/workflow-executor/test/http/executor-http-server.test.ts index 97c2341218..d27d9a4e17 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -4,7 +4,7 @@ import type Runner from '../../src/runner'; import jsonwebtoken from 'jsonwebtoken'; import request from 'supertest'; -import { RunNotFoundError, UserMismatchError } from '../../src/errors'; +import { MalformedRunError, RunNotFoundError, UserMismatchError } from '../../src/errors'; import ExecutorHttpServer from '../../src/http/executor-http-server'; const AUTH_SECRET = 'test-auth-secret'; @@ -26,8 +26,8 @@ function createMockRunner(overrides: Partial = {}): Runner { function createMockWorkflowPort(overrides: Partial = {}): WorkflowPort { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn(), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn(), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest.fn(), getMcpServerConfigs: jest.fn().mockResolvedValue([]), @@ -413,6 +413,33 @@ describe('ExecutorHttpServer', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ error: 'Internal server error' }); }); + + it('returns 400 with userMessage when triggerPoll rejects with MalformedRunError', async () => { + const runner = createMockRunner({ + triggerPoll: jest.fn().mockRejectedValue( + new MalformedRunError({ + runId: '1', + stepId: 'step-1', + stepIndex: 0, + userMessage: + 'The workflow step configuration is invalid. Please check the workflow designer.', + technicalMessage: 'Run 1 has no collectionName', + }), + ), + }); + + const server = createServer({ runner }); + const token = signToken({ id: 1 }); + + const response = await request(server.callback) + .post('/runs/run-1/trigger') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'The workflow step configuration is invalid. Please check the workflow designer.', + }); + }); }); describe('start / stop', () => { diff --git a/packages/workflow-executor/test/http/pending-data-validators.test.ts b/packages/workflow-executor/test/http/pending-data-validators.test.ts new file mode 100644 index 0000000000..a1c3634e2f --- /dev/null +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -0,0 +1,67 @@ +import patchBodySchemas from '../../src/http/pending-data-validators'; + +describe('patchBodySchemas', () => { + describe('guidance', () => { + const schema = patchBodySchemas.guidance; + if (!schema) throw new Error('guidance schema not registered'); + + it('accepts { userInput: "text" }', () => { + expect(schema.parse({ userInput: 'some text' })).toEqual({ userInput: 'some text' }); + }); + + it('accepts {} (userInput absent — user submitted without input)', () => { + expect(schema.parse({})).toEqual({}); + }); + + it('accepts { userInput: "" } (empty string)', () => { + expect(schema.parse({ userInput: '' })).toEqual({ userInput: '' }); + }); + + it('rejects unknown fields (strict schema)', () => { + expect(() => schema.parse({ userInput: 'text', extra: 'leak' })).toThrow(); + }); + }); + + describe('trigger-action', () => { + const schema = patchBodySchemas['trigger-action']; + if (!schema) throw new Error('trigger-action schema not registered'); + + it('accepts { userConfirmed: true, actionResult: }', () => { + const parsed = schema.parse({ + userConfirmed: true, + actionResult: { success: 'ok', html: '

done

' }, + }); + + expect(parsed).toEqual({ + userConfirmed: true, + actionResult: { success: 'ok', html: '

done

' }, + }); + }); + + it('accepts { userConfirmed: true, actionResult: null } (void action)', () => { + const parsed = schema.parse({ userConfirmed: true, actionResult: null }); + + expect(parsed).toEqual({ userConfirmed: true, actionResult: null }); + }); + + it('accepts { userConfirmed: false } without actionResult (skip flow)', () => { + const parsed = schema.parse({ userConfirmed: false }); + + expect(parsed).toEqual({ userConfirmed: false }); + }); + + it('rejects unknown fields (strict schema)', () => { + expect(() => + schema.parse({ userConfirmed: true, actionResult: {}, extra: 'leak' }), + ).toThrow(); + }); + + it('rejects missing userConfirmed', () => { + expect(() => schema.parse({ actionResult: {} })).toThrow(); + }); + + it('rejects non-boolean userConfirmed', () => { + expect(() => schema.parse({ userConfirmed: 'yes' })).toThrow(); + }); + }); +}); diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index 9de94e0e84..103f25c14c 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -1,8 +1,8 @@ import type { AgentPort } from '../../src/ports/agent-port'; import type { AiModelPort } from '../../src/ports/ai-model-port'; import type { WorkflowPort } from '../../src/ports/workflow-port'; -import type { PendingStepExecution, StepUser } from '../../src/types/execution'; -import type { CollectionSchema } from '../../src/types/record'; +import type { AvailableStepExecution, StepUser } from '../../src/types/execution-context'; +import type { CollectionSchema } from '../../src/types/validated/collection'; import type { BaseChatModel, RemoteTool } from '@forestadmin/ai-proxy'; import jsonwebtoken from 'jsonwebtoken'; @@ -13,7 +13,7 @@ import ExecutorHttpServer from '../../src/http/executor-http-server'; import Runner from '../../src/runner'; import SchemaCache from '../../src/schema-cache'; import InMemoryStore from '../../src/stores/in-memory-store'; -import { StepType } from '../../src/types/step-definition'; +import { StepType } from '../../src/types/validated/step-definition'; // --------------------------------------------------------------------------- // Constants @@ -135,9 +135,9 @@ function createMockAiClient(model: BaseChatModel): AiModelPort { function createMockWorkflowPort(overrides: Partial = {}): jest.Mocked { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), - updateStepExecution: jest.fn().mockResolvedValue(undefined), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn().mockResolvedValue(null), + updateStepExecution: jest.fn().mockResolvedValue(null), getCollectionSchema: jest.fn().mockResolvedValue(COLLECTION_SCHEMA), getMcpServerConfigs: jest.fn().mockResolvedValue([]), hasRunAccess: jest.fn().mockResolvedValue(true), @@ -159,6 +159,8 @@ function createMockAgentPort(): jest.Mocked { }), getRelatedData: jest.fn().mockResolvedValue([]), executeAction: jest.fn().mockResolvedValue(undefined), + getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), + probe: jest.fn().mockResolvedValue(undefined), } as jest.Mocked; } @@ -186,6 +188,14 @@ function createIntegrationSetup(overrides?: { runStore, schemaCache, aiModelPort: aiClient, + activityLogPortFactory: { + forRun: jest.fn().mockReturnValue({ + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }), + drain: jest.fn().mockResolvedValue(undefined), + }, pollingIntervalMs: overrides?.pollingIntervalMs ?? 60_000, envSecret: ENV_SECRET, authSecret: AUTH_SECRET, @@ -202,15 +212,17 @@ function createIntegrationSetup(overrides?: { } function buildPendingStep( - overrides: Partial & Pick, -): PendingStepExecution { + overrides: Partial & Pick, +): AvailableStepExecution { return { runId: 'run-1', stepId: 'step-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: BASE_RECORD_REF, previousSteps: [], user: STEP_USER, + ...overrides, }; } @@ -222,14 +234,18 @@ function buildPendingStep( describe('workflow execution (integration)', () => { it('read-record happy path: trigger → AI selects field → read record → success', async () => { const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue({ - runId: 'run-1', - stepId: 'step-1', - stepIndex: 0, - baseRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, - stepDefinition: { type: StepType.ReadRecord, prompt: 'Read the customer email' }, - previousSteps: [], - user: STEP_USER, + getAvailableRun: jest.fn().mockResolvedValue({ + step: { + runId: 'run-1', + stepId: 'step-1', + stepIndex: 0, + collectionId: 'col-1', + baseRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + stepDefinition: { type: StepType.ReadRecord, prompt: 'Read the customer email' }, + previousSteps: [], + user: STEP_USER, + }, + auth: { forestServerToken: 'test-forest-token' }, }), }); @@ -303,7 +319,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), }); const { server, runStore } = createIntegrationSetup({ workflowPort, model }); @@ -336,9 +354,7 @@ describe('workflow execution (integration)', () => { it('update-record: awaiting-input → confirm → success', async () => { const model = createMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'update status', + input: { fieldName: 'Status', value: 'active', reasoning: 'update status' }, }); const step = buildPendingStep({ @@ -346,7 +362,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), getCollectionSchema: jest.fn().mockResolvedValue(COLLECTION_SCHEMA_WITH_STATUS), }); @@ -403,7 +421,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), getCollectionSchema: jest.fn().mockResolvedValue(COLLECTION_SCHEMA_WITH_ACTIONS), }); @@ -424,21 +444,20 @@ describe('workflow execution (integration)', () => { expect.objectContaining({ type: 'record', status: 'awaiting-input' }), ); - // 2nd trigger with userConfirmed: true → success + // 2nd trigger with userConfirmed: true + actionResult (frontend executed the action itself) const res2 = await request(server.callback) .post('/runs/run-1/trigger') .set('Authorization', `Bearer ${token}`) - .send({ pendingData: { userConfirmed: true } }); + .send({ + pendingData: { + userConfirmed: true, + actionResult: { success: 'Email sent' }, + }, + }); expect(res2.status).toBe(200); - expect(agentPort.executeAction).toHaveBeenCalledWith( - expect.objectContaining({ - collection: 'customers', - action: 'send_email', - id: [42], - }), - expect.objectContaining({ id: STEP_USER.id }), - ); + // Executor no longer re-runs the action — the frontend is the one that executed it. + expect(agentPort.executeAction).not.toHaveBeenCalled(); expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ type: 'record', status: 'success' }), @@ -460,7 +479,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), getCollectionSchema: jest.fn().mockImplementation(async (collectionName: string) => { if (collectionName === 'orders') return ORDERS_SCHEMA; @@ -549,7 +570,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), getMcpServerConfigs: jest .fn() .mockResolvedValue([{ type: 'sse', configs: { 'mcp-1': { url: 'http://fake' } } }]), @@ -601,7 +624,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), }); const { server, runStore } = createIntegrationSetup({ workflowPort }); @@ -630,7 +655,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), }); const { server, runStore } = createIntegrationSetup({ workflowPort }); @@ -665,7 +692,7 @@ describe('workflow execution (integration)', () => { // ------------------------------------------------------------------------- it('run not found → HTTP 404', async () => { - // Default mock returns null for getPendingStepExecutionsForRun + // Default mock returns null for getAvailableRun const { server, runStore, workflowPort } = createIntegrationSetup(); await runStore.init(); @@ -687,9 +714,7 @@ describe('workflow execution (integration)', () => { it('skip step (userConfirmed: false) → success without executing action', async () => { const model = createMockModel({ - fieldName: 'Status', - value: 'active', - reasoning: 'update status', + input: { fieldName: 'Status', value: 'active', reasoning: 'update status' }, }); const step = buildPendingStep({ @@ -697,7 +722,9 @@ describe('workflow execution (integration)', () => { }); const workflowPort = createMockWorkflowPort({ - getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(step), + getAvailableRun: jest + .fn() + .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), getCollectionSchema: jest.fn().mockResolvedValue(COLLECTION_SCHEMA_WITH_STATUS), }); @@ -742,10 +769,13 @@ describe('workflow execution (integration)', () => { const workflowPort = createMockWorkflowPort({ // Return the step only on the first poll, then empty (to avoid re-execution loops) - getPendingStepExecutions: jest + getAvailableRuns: jest .fn() - .mockResolvedValueOnce([pendingStep]) - .mockResolvedValue([]), + .mockResolvedValueOnce({ + pending: [{ step: pendingStep, auth: { forestServerToken: 'test-forest-token' } }], + malformed: [], + }) + .mockResolvedValue({ pending: [], malformed: [] }), }); const { runner, runStore } = createIntegrationSetup({ diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 92d8975855..f1bc218aa0 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -4,11 +4,16 @@ import type { AiModelPort } from '../src/ports/ai-model-port'; import type { Logger } from '../src/ports/logger-port'; import type { RunStore } from '../src/ports/run-store'; import type { WorkflowPort } from '../src/ports/workflow-port'; -import type { PendingStepExecution } from '../src/types/execution'; -import type { StepDefinition } from '../src/types/step-definition'; +import type { AvailableStepExecution } from '../src/types/execution-context'; +import type { StepDefinition } from '../src/types/validated/step-definition'; import type { BaseChatModel } from '@forestadmin/ai-proxy'; -import { ConfigurationError, RunNotFoundError, UserMismatchError } from '../src/errors'; +import { + ConfigurationError, + MalformedRunError, + RunNotFoundError, + UserMismatchError, +} from '../src/errors'; import BaseStepExecutor from '../src/executors/base-step-executor'; import ConditionStepExecutor from '../src/executors/condition-step-executor'; import GuidanceStepExecutor from '../src/executors/guidance-step-executor'; @@ -20,7 +25,7 @@ import TriggerRecordActionStepExecutor from '../src/executors/trigger-record-act import UpdateRecordStepExecutor from '../src/executors/update-record-step-executor'; import Runner from '../src/runner'; import SchemaCache from '../src/schema-cache'; -import { StepType } from '../src/types/step-definition'; +import { StepType } from '../src/types/validated/step-definition'; // --------------------------------------------------------------------------- // Helpers @@ -36,9 +41,9 @@ const flushPromises = async () => { function createMockWorkflowPort(): jest.Mocked { return { - getPendingStepExecutions: jest.fn().mockResolvedValue([]), - getPendingStepExecutionsForRun: jest.fn(), - updateStepExecution: jest.fn().mockResolvedValue(undefined), + getAvailableRuns: jest.fn().mockResolvedValue({ pending: [], malformed: [] }), + getAvailableRun: jest.fn(), + updateStepExecution: jest.fn().mockResolvedValue(null), getCollectionSchema: jest.fn(), getMcpServerConfigs: jest.fn().mockResolvedValue([]), hasRunAccess: jest.fn().mockResolvedValue(true), @@ -72,6 +77,7 @@ function createMockRunStore(overrides: Partial = {}): jest.Mocked = {}, ) { return { - agentPort: {} as AgentPort, + agentPort: { probe: jest.fn().mockResolvedValue(undefined) } as unknown as AgentPort, workflowPort: createMockWorkflowPort(), runStore: { init: jest.fn().mockResolvedValue(undefined), @@ -93,6 +100,14 @@ function createRunnerConfig( } as unknown as RunStore, pollingIntervalMs: POLLING_INTERVAL_MS, aiModelPort: createMockAiClient() as unknown as AiModelPort, + activityLogPortFactory: { + forRun: jest.fn().mockReturnValue({ + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }), + drain: jest.fn().mockResolvedValue(undefined), + }, logger: createMockLogger(), schemaCache: new SchemaCache(), envSecret: VALID_ENV_SECRET, @@ -120,14 +135,15 @@ function makeStepDefinition(stepType: StepType): StepDefinition { } function makePendingStep( - overrides: Partial & { stepType?: StepType } = {}, -): PendingStepExecution { + overrides: Partial & { stepType?: StepType } = {}, +): AvailableStepExecution { const { stepType = StepType.ReadRecord, ...rest } = overrides; return { runId: 'run-1', stepId: 'step-1', stepIndex: 0, + collectionId: 'col-1', baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, stepDefinition: makeStepDefinition(stepType), previousSteps: [], @@ -146,6 +162,13 @@ function makePendingStep( }; } +function makePendingDispatch( + overrides: Partial & { stepType?: StepType } = {}, + forestServerToken = 'test-forest-token', +) { + return { step: makePendingStep(overrides), auth: { forestServerToken } }; +} + // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -217,6 +240,30 @@ describe('start', () => { await expect(runner.start()).rejects.toThrow(ConfigurationError); await expect(runner.start()).rejects.toThrow('authSecret must be a non-empty string'); }); + + it('probes the agent before initialising the run store', async () => { + const agentPort = { probe: jest.fn().mockResolvedValue(undefined) } as unknown as AgentPort; + const config = createRunnerConfig({ agentPort }); + runner = new Runner(config); + + await runner.start(); + + const probeOrder = (agentPort.probe as jest.Mock).mock.invocationCallOrder[0]; + const initOrder = (config.runStore.init as jest.Mock).mock.invocationCallOrder[0]; + expect(probeOrder).toBeLessThan(initOrder); + }); + + it('does not init the run store when agent probe fails', async () => { + const agentPort = { + probe: jest.fn().mockRejectedValue(new Error('cannot reach agent')), + } as unknown as AgentPort; + const config = createRunnerConfig({ agentPort }); + runner = new Runner(config); + + await expect(runner.start()).rejects.toThrow('cannot reach agent'); + expect(config.runStore.init).not.toHaveBeenCalled(); + expect(runner.state).toBe('idle'); + }); }); describe('stop', () => { @@ -310,9 +357,10 @@ describe('graceful shutdown', () => { }); const workflowPort = createMockWorkflowPort(); - workflowPort.getPendingStepExecutions.mockResolvedValueOnce([ - makePendingStep({ runId: 'run-1', stepId: 'step-1' }), - ]); + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [makePendingDispatch({ runId: 'run-1', stepId: 'step-1' })], + malformed: [], + }); jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ execute: () => @@ -345,9 +393,10 @@ describe('graceful shutdown', () => { it('stop() resolves after timeout when step is stuck', async () => { const workflowPort = createMockWorkflowPort(); const logger = createMockLogger(); - workflowPort.getPendingStepExecutions.mockResolvedValueOnce([ - makePendingStep({ runId: 'run-1', stepId: 'stuck-step' }), - ]); + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [makePendingDispatch({ runId: 'run-1', stepId: 'stuck-step' })], + malformed: [], + }); jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ execute: () => new Promise(() => {}), // never resolves @@ -364,9 +413,9 @@ describe('graceful shutdown', () => { jest.useFakeTimers(); expect(logger.error).toHaveBeenCalledWith( - 'Drain timeout — steps still in flight', + 'Drain timeout — runs still in flight', expect.objectContaining({ - remainingSteps: ['run-1:stuck-step'], + remainingRuns: ['run-1'], timeoutMs: 50, }), ); @@ -379,10 +428,46 @@ describe('graceful shutdown', () => { await runner.start(); await runner.stop(); - expect(logger.info).not.toHaveBeenCalledWith('Draining in-flight steps', expect.anything()); + expect(logger.info).not.toHaveBeenCalledWith('Draining in-flight runs', expect.anything()); expect(runner.state).toBe('stopped'); }); + it('stop() awaits activityLogPortFactory.drain() before closing resources', async () => { + const config = createRunnerConfig(); + const callOrder: string[] = []; + let releaseDrain!: () => void; + + (config.activityLogPortFactory.drain as jest.Mock).mockImplementation( + () => + new Promise(resolve => { + releaseDrain = () => { + callOrder.push('activityLogDrain'); + resolve(); + }; + }), + ); + (config.aiModelPort.closeConnections as jest.Mock).mockImplementation(async () => { + callOrder.push('aiClose'); + }); + (config.runStore.close as jest.Mock).mockImplementation(async () => { + callOrder.push('runStoreClose'); + }); + + runner = new Runner(config); + await runner.start(); + const stopPromise = runner.stop(); + + await Promise.resolve(); + await Promise.resolve(); + expect(callOrder).toEqual([]); + + releaseDrain(); + await stopPromise; + + expect(callOrder[0]).toBe('activityLogDrain'); + expect(callOrder.slice(1).sort()).toEqual(['aiClose', 'runStoreClose']); + }); + it('logs drain info when steps are in flight', async () => { let resolveStep!: () => void; const stepPromise = new Promise(resolve => { @@ -391,9 +476,10 @@ describe('graceful shutdown', () => { const workflowPort = createMockWorkflowPort(); const logger = createMockLogger(); - workflowPort.getPendingStepExecutions.mockResolvedValueOnce([ - makePendingStep({ runId: 'run-1', stepId: 'step-1' }), - ]); + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [makePendingDispatch({ runId: 'run-1', stepId: 'step-1' })], + malformed: [], + }); jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ execute: () => @@ -411,11 +497,11 @@ describe('graceful shutdown', () => { resolveStep(); await runner.stop(); - expect(logger.info).toHaveBeenCalledWith('Draining in-flight steps', { + expect(logger.info).toHaveBeenCalledWith('Draining in-flight runs', { count: 1, - steps: ['run-1:step-1'], + runs: ['run-1'], }); - expect(logger.info).toHaveBeenCalledWith('All in-flight steps drained', {}); + expect(logger.info).toHaveBeenCalledWith('All in-flight runs drained', {}); }); }); @@ -429,12 +515,12 @@ describe('polling loop', () => { runner = new Runner(createRunnerConfig({ workflowPort })); await runner.start(); - expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + expect(workflowPort.getAvailableRuns).not.toHaveBeenCalled(); jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(1); }); it('reschedules automatically after each cycle', async () => { @@ -444,11 +530,11 @@ describe('polling loop', () => { jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(2); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(2); }); it('stop() prevents scheduling a new cycle', async () => { @@ -458,14 +544,14 @@ describe('polling loop', () => { jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(1); await runner.stop(); jest.advanceTimersByTime(POLLING_INTERVAL_MS * 3); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(1); }); it('stop() clears the pending timer', async () => { @@ -478,7 +564,7 @@ describe('polling loop', () => { jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + expect(workflowPort.getAvailableRuns).not.toHaveBeenCalled(); }); it('calling start() twice does not schedule two timers', async () => { @@ -491,7 +577,7 @@ describe('polling loop', () => { jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(1); }); }); @@ -500,10 +586,13 @@ describe('polling loop', () => { // --------------------------------------------------------------------------- describe('deduplication', () => { - it('skips a step whose key is already in inFlightSteps', async () => { + it('skips a run already tracked in inFlightRuns', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'inflight-step' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); // Block the first execution so the key stays in-flight const unblockRef = { fn: (): void => {} }; @@ -524,7 +613,7 @@ describe('deduplication', () => { runner = new Runner(createRunnerConfig({ workflowPort })); const poll1 = runner.triggerPoll('run-1'); - await Promise.resolve(); // let getPendingStepExecutionsForRun resolve and step key get added + await Promise.resolve(); // let getAvailableRun resolve and step key get added // Second poll: step is in-flight → should be skipped await runner.triggerPoll('run-1'); @@ -535,10 +624,13 @@ describe('deduplication', () => { await poll1; }); - it('removes the step key after successful execution', async () => { + it('removes the run entry after successful execution', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-dedup' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner(createRunnerConfig({ workflowPort })); @@ -548,11 +640,14 @@ describe('deduplication', () => { expect(executeSpy).toHaveBeenCalledTimes(2); }); - it('removes the step key even when executor construction fails', async () => { + it('removes the run entry even when executor construction fails', async () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-throws' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); aiClient.getModel.mockImplementationOnce(() => { throw new Error('construction error'); }); @@ -580,16 +675,19 @@ describe('deduplication', () => { // --------------------------------------------------------------------------- describe('triggerPoll', () => { - it('calls getPendingStepExecutionsForRun with the given runId and executes the step', async () => { + it('calls getAvailableRun with the given runId and executes the step', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-A', stepId: 'step-a' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner(createRunnerConfig({ workflowPort })); await runner.triggerPoll('run-A'); - expect(workflowPort.getPendingStepExecutionsForRun).toHaveBeenCalledWith('run-A'); - expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + expect(workflowPort.getAvailableRun).toHaveBeenCalledWith('run-A'); + expect(workflowPort.getAvailableRuns).not.toHaveBeenCalled(); expect(executeSpy).toHaveBeenCalledTimes(1); expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-A', expect.anything()); }); @@ -597,7 +695,10 @@ describe('triggerPoll', () => { it('skips in-flight steps', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-inflight' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); const unblockRef = { fn: (): void => {} }; executeSpy.mockReturnValueOnce( @@ -630,7 +731,10 @@ describe('triggerPoll', () => { it('resolves after the step has settled', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-a' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner(createRunnerConfig({ workflowPort })); @@ -638,18 +742,18 @@ describe('triggerPoll', () => { expect(executeSpy).toHaveBeenCalledTimes(1); }); - it('rejects with RunNotFoundError when getPendingStepExecutionsForRun returns null', async () => { + it('rejects with RunNotFoundError when getAvailableRun returns null', async () => { const workflowPort = createMockWorkflowPort(); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(null); + workflowPort.getAvailableRun.mockResolvedValue(null); runner = new Runner(createRunnerConfig({ workflowPort })); await expect(runner.triggerPoll('run-1')).rejects.toThrow(RunNotFoundError); }); - it('propagates errors from getPendingStepExecutionsForRun as-is', async () => { + it('propagates errors from getAvailableRun as-is', async () => { const workflowPort = createMockWorkflowPort(); - workflowPort.getPendingStepExecutionsForRun.mockRejectedValue(new Error('Network error')); + workflowPort.getAvailableRun.mockRejectedValue(new Error('Network error')); runner = new Runner(createRunnerConfig({ workflowPort })); @@ -657,6 +761,372 @@ describe('triggerPoll', () => { }); }); +// --------------------------------------------------------------------------- +// Chain (auto-chained steps from /update-step response) +// --------------------------------------------------------------------------- + +describe('chain', () => { + it('chains the next step dispatched by updateStepExecution', async () => { + const workflowPort = createMockWorkflowPort(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution + .mockResolvedValueOnce({ step: chained, auth: { forestServerToken: 'token-1' } }) + .mockResolvedValueOnce(null); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(2); + expect(workflowPort.updateStepExecution).toHaveBeenCalledTimes(2); + expect(workflowPort.updateStepExecution).toHaveBeenNthCalledWith(1, 'run-1', expect.anything()); + expect(workflowPort.updateStepExecution).toHaveBeenNthCalledWith(2, 'run-1', expect.anything()); + }); + + it('uses the forestServerToken from each dispatch when calling activityLogPortFactory.forRun', async () => { + const workflowPort = createMockWorkflowPort(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-initial' }, + }); + workflowPort.updateStepExecution + .mockResolvedValueOnce({ step: chained, auth: { forestServerToken: 'token-chained' } }) + .mockResolvedValueOnce(null); + + const config = createRunnerConfig({ workflowPort }); + runner = new Runner(config); + await runner.triggerPoll('run-1'); + + expect(config.activityLogPortFactory.forRun).toHaveBeenNthCalledWith(1, 'token-initial'); + expect(config.activityLogPortFactory.forRun).toHaveBeenNthCalledWith(2, 'token-chained'); + }); + + it('exits the chain when the server returns a non-progressing next step', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 5 }); + // Same stepIndex → must exit the chain without executing the regression dispatch. + const regression = makePendingStep({ runId: 'run-1', stepId: 'step-loop', stepIndex: 5 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution.mockResolvedValueOnce({ + step: regression, + auth: { forestServerToken: 'token-regression' }, + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(workflowPort.updateStepExecution).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Server returned non-progressing next step — exiting chain', + expect.objectContaining({ + runId: 'run-1', + currentStepIndex: 5, + returnedRunId: 'run-1', + returnedStepIndex: 5, + }), + ); + }); + + it('exits the chain when the server returns a dispatch for a different runId', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + // Cross-run dispatch — server contract violation. Chain must exit to avoid leaking the + // initial run's inFlightRuns entry. + const foreign = makePendingStep({ runId: 'run-other', stepId: 'step-x', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution.mockResolvedValueOnce({ + step: foreign, + auth: { forestServerToken: 'token-foreign' }, + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Server returned non-progressing next step — exiting chain', + expect.objectContaining({ runId: 'run-1', returnedRunId: 'run-other' }), + ); + }); + + it('yields after maxChainDepth chained steps', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + // Always return a progressing next step — the cap must stop us. + let i = 0; + workflowPort.updateStepExecution.mockImplementation(async () => { + i += 1; + + return { + step: makePendingStep({ runId: 'run-1', stepId: `step-${i}`, stepIndex: i }), + auth: { forestServerToken: `token-${i}` }, + }; + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger, maxChainDepth: 2 })); + await runner.triggerPoll('run-1'); + + // initial + 2 chained = 3 total executions; the 3rd update returns a next we don't chain. + expect(executeSpy).toHaveBeenCalledTimes(3); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Chain depth cap reached — yielding to next poll', + expect.objectContaining({ runId: 'run-1', maxDepth: 2 }), + ); + }); + + it('disables chaining when maxChainDepth is 0', async () => { + const workflowPort = createMockWorkflowPort(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution.mockResolvedValueOnce({ + step: chained, + auth: { forestServerToken: 'token-1' }, + }); + + runner = new Runner(createRunnerConfig({ maxChainDepth: 0, workflowPort })); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + }); + + it('dedups by runId — a concurrent triggerPoll during a chain is skipped', async () => { + const workflowPort = createMockWorkflowPort(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValue({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + + // Block the first step so the chain is in progress when the second triggerPoll arrives. + const unblockRef = { fn: (): void => {} }; + executeSpy.mockImplementationOnce( + () => + new Promise(resolve => { + unblockRef.fn = () => + resolve({ + stepOutcome: { type: 'record', stepId: 'step-0', stepIndex: 0, status: 'success' }, + }); + }), + ); + workflowPort.updateStepExecution + .mockResolvedValueOnce({ step: chained, auth: { forestServerToken: 'token-1' } }) + .mockResolvedValueOnce(null); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + const first = runner.triggerPoll('run-1'); + await Promise.resolve(); + // Concurrent trigger arrives — even though the chain will advance stepId, dedup is by runId. + await runner.triggerPoll('run-1'); + + // Only the initial step ran so far; the concurrent trigger was dropped. + expect(executeSpy).toHaveBeenCalledTimes(1); + + unblockRef.fn(); + await first; + + // After the chain completes, the run entry is released. executeSpy ran once for initial, + // once for chained — that's 2 total. The concurrent trigger never added a third. + expect(executeSpy).toHaveBeenCalledTimes(2); + }); + + it('exits the chain and releases the run entry when updateStepExecution throws mid-chain', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + // First triggerPoll: initial + 1 chained, then update #2 explodes. + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + // A second triggerPoll will re-dispatch the same initial — we expect it to actually run, + // proving the run entry was released (not leaked in inFlightRuns). + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution + .mockResolvedValueOnce({ step: chained, auth: { forestServerToken: 'token-1' } }) + .mockRejectedValueOnce(new Error('orchestrator down')) + // Second triggerPoll's update returns null (end of chain). + .mockResolvedValueOnce(null); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(2); // initial + 1 chained before the throw + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to report step outcome', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-1', + stepIndex: 1, + error: 'orchestrator down', + }), + ); + + // Run entry released — a subsequent triggerPoll executes rather than being deduped. + await runner.triggerPoll('run-1'); + expect(executeSpy).toHaveBeenCalledTimes(3); + }); + + it('logs FATAL and exits the chain when a chained executor violates the never-throw contract', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-chained-fatal', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution.mockResolvedValueOnce({ + step: chained, + auth: { forestServerToken: 'token-1' }, + }); + + // Initial step executes normally via BaseStepExecutor.execute mock (see beforeEach). The + // chained step gets a factory that returns an executor which rejects — violating contract. + jest + .spyOn(StepExecutorFactory, 'create') + .mockImplementationOnce(async () => ({ + execute: jest.fn().mockResolvedValue({ + stepOutcome: { type: 'record', stepId: 'step-0', stepIndex: 0, status: 'success' }, + }), + })) + .mockImplementationOnce(async () => ({ + execute: jest.fn().mockRejectedValue(new Error('chained contract violated')), + })); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-1'); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'FATAL: executor contract violated — reporting synthetic error outcome', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-chained-fatal', + error: 'chained contract violated', + }), + ); + // 2 calls: initial success outcome (step-0) + synthetic error outcome (step-chained-fatal). + expect(workflowPort.updateStepExecution).toHaveBeenCalledTimes(2); + expect(workflowPort.updateStepExecution).toHaveBeenNthCalledWith( + 2, + 'run-1', + expect.objectContaining({ + stepId: 'step-chained-fatal', + stepIndex: 1, + status: 'error', + error: 'An unexpected error occurred.', + }), + ); + }); + + it('passes undefined incomingPendingData to the factory for chained steps', async () => { + const workflowPort = createMockWorkflowPort(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution + .mockResolvedValueOnce({ step: chained, auth: { forestServerToken: 'token-1' } }) + .mockResolvedValueOnce(null); + + const createSpy = jest.spyOn(StepExecutorFactory, 'create'); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.triggerPoll('run-1', { pendingData: { userConfirmed: true } }); + + // Initial dispatch carries pendingData; chained dispatch must NOT — pending data only flows + // via the triggerPoll PATCH endpoint, never inline through the auto-chain. + expect(createSpy).toHaveBeenNthCalledWith( + 1, + initial, + expect.anything(), + expect.anything(), + expect.any(Function), + { userConfirmed: true }, + ); + expect(createSpy).toHaveBeenNthCalledWith( + 2, + chained, + expect.anything(), + expect.anything(), + expect.any(Function), + undefined, + ); + + createSpy.mockRestore(); + }); + + it('finishes the current step then yields when stop() is called mid-chain', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const initial = makePendingStep({ runId: 'run-1', stepId: 'step-0', stepIndex: 0 }); + const chained = makePendingStep({ runId: 'run-1', stepId: 'step-1', stepIndex: 1 }); + + // Poll cycle dispatches the initial step. + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [{ step: initial, auth: { forestServerToken: 'token-0' } }], + malformed: [], + }); + + // updateStepExecution for the initial step triggers stop() WITHOUT awaiting — stop() drains + // by awaiting the in-flight run promise, and awaiting it here would deadlock (the chain is + // the in-flight run). stop() sets `_state='draining'` synchronously before suspending, so + // the chain's next iteration observes it. + workflowPort.updateStepExecution.mockImplementationOnce(async () => { + void runner.stop(); + + return { step: chained, auth: { forestServerToken: 'token-1' } }; + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + // Let the chain reach the draining check, log, exit, and release the inFlightRuns entry so + // stop()'s drain settles too. + await flushPromises(); + + // Only the initial step executed — the draining check prevented chaining. + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Chain interrupted by stop() — yielding', + expect.objectContaining({ runId: 'run-1' }), + ); + }); +}); + // --------------------------------------------------------------------------- // MCP lazy loading // --------------------------------------------------------------------------- @@ -666,7 +1136,10 @@ describe('MCP lazy loading (via once thunk)', () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepType: StepType.ReadRecord }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner( createRunnerConfig({ workflowPort, aiModelPort: aiClient as unknown as AiModelPort }), @@ -685,7 +1158,10 @@ describe('MCP lazy loading (via once thunk)', () => { stepId: 'step-mcp-1', stepType: StepType.Mcp, }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); // Provide a non-empty config so fetchRemoteTools actually calls loadRemoteTools workflowPort.getMcpServerConfigs.mockResolvedValue([{ configs: {} }] as never); @@ -708,54 +1184,95 @@ describe('StepExecutorFactory.create — factory', () => { aiModelPort: { getModel: jest.fn().mockReturnValue({} as BaseChatModel), } as unknown as AiModelPort, - agentPort: {} as AgentPort, + agentPort: { probe: jest.fn().mockResolvedValue(undefined) } as unknown as AgentPort, workflowPort: {} as WorkflowPort, runStore: {} as RunStore, schemaCache: new SchemaCache(), logger: { info: jest.fn(), error: jest.fn() }, }); + const makeRunLogger = () => ({ + createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markFailed: jest.fn().mockResolvedValue(undefined), + }); + it('dispatches Condition steps to ConditionStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.Condition }); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); expect(executor).toBeInstanceOf(ConditionStepExecutor); }); it('dispatches ReadRecord steps to ReadRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.ReadRecord }); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); expect(executor).toBeInstanceOf(ReadRecordStepExecutor); }); it('dispatches UpdateRecord steps to UpdateRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.UpdateRecord }); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); expect(executor).toBeInstanceOf(UpdateRecordStepExecutor); }); it('dispatches TriggerAction steps to TriggerRecordActionStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.TriggerAction }); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); expect(executor).toBeInstanceOf(TriggerRecordActionStepExecutor); }); it('dispatches LoadRelatedRecord steps to LoadRelatedRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.LoadRelatedRecord }); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); expect(executor).toBeInstanceOf(LoadRelatedRecordStepExecutor); }); it('dispatches McpTask steps to McpStepExecutor and calls loadTools', async () => { const step = makePendingStep({ stepType: StepType.Mcp }); const loadTools = jest.fn().mockResolvedValue([]); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + loadTools, + ); expect(executor).toBeInstanceOf(McpStepExecutor); expect(loadTools).toHaveBeenCalledTimes(1); }); it('dispatches Guidance steps to GuidanceStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.Guidance }); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); expect(executor).toBeInstanceOf(GuidanceStepExecutor); }); @@ -763,8 +1280,13 @@ describe('StepExecutorFactory.create — factory', () => { const step = { ...makePendingStep(), stepDefinition: { type: 'unknown-type' as StepType }, - } as unknown as PendingStepExecution; - const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + } as unknown as AvailableStepExecution; + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + jest.fn(), + ); const { stepOutcome } = await executor.execute(); expect(stepOutcome.status).toBe('error'); expect(stepOutcome.error).toBe('An unexpected error occurred.'); @@ -773,7 +1295,12 @@ describe('StepExecutorFactory.create — factory', () => { it('returns an executor with an error outcome when loadTools rejects for a McpTask step', async () => { const step = makePendingStep({ stepType: StepType.Mcp }); const loadTools = jest.fn().mockRejectedValueOnce(new Error('MCP server down')); - const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); + const executor = await StepExecutorFactory.create( + step, + makeContextConfig(), + makeRunLogger(), + loadTools, + ); const { stepOutcome } = await executor.execute(); expect(stepOutcome.status).toBe('error'); expect(stepOutcome.type).toBe('mcp'); @@ -795,7 +1322,7 @@ describe('StepExecutorFactory.create — factory', () => { logger, }; - await StepExecutorFactory.create(makePendingStep(), contextConfig, jest.fn()); + await StepExecutorFactory.create(makePendingStep(), contextConfig, makeRunLogger(), jest.fn()); expect(logger.error).toHaveBeenCalledWith( 'Step execution failed unexpectedly', @@ -817,7 +1344,7 @@ describe('StepExecutorFactory.create — factory', () => { logger, }; - await StepExecutorFactory.create(makePendingStep(), contextConfig, jest.fn()); + await StepExecutorFactory.create(makePendingStep(), contextConfig, makeRunLogger(), jest.fn()); expect(logger.error).toHaveBeenCalledWith( 'Step execution failed unexpectedly', @@ -836,7 +1363,10 @@ describe('error handling', () => { const mockLogger = createMockLogger(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-err' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); aiClient.getModel.mockImplementationOnce(() => { throw new Error('AI not configured'); }); @@ -876,7 +1406,10 @@ describe('error handling', () => { stepId: 'step-mcp-err', stepType: StepType.Mcp, }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); aiClient.getModel.mockImplementationOnce(() => { throw new Error('AI not configured'); }); @@ -898,7 +1431,10 @@ describe('error handling', () => { const aiClient = createMockAiClient(); const error = new Error('something blew up'); const step = makePendingStep({ runId: 'run-2', stepId: 'step-log' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); aiClient.getModel.mockImplementationOnce(() => { throw error; }); @@ -928,7 +1464,10 @@ describe('error handling', () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-fallback' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); aiClient.getModel.mockImplementationOnce(() => { throw new Error('construction error'); }); @@ -941,11 +1480,18 @@ describe('error handling', () => { await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); }); - it('logs FATAL and does not call updateStepExecution if executor.execute() rejects', async () => { + it('logs FATAL and posts a synthetic error outcome if executor.execute() rejects', async () => { const workflowPort = createMockWorkflowPort(); const mockLogger = createMockLogger(); - const step = makePendingStep({ runId: 'run-1', stepId: 'step-fatal' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-fatal', + stepType: StepType.ReadRecord, + }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); // Simulate a broken executor that violates the never-throw contract jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ @@ -956,21 +1502,110 @@ describe('error handling', () => { await runner.triggerPoll('run-1'); expect(mockLogger.error).toHaveBeenCalledWith( - 'FATAL: executor contract violated — step outcome not reported', + 'FATAL: executor contract violated — reporting synthetic error outcome', expect.objectContaining({ runId: 'run-1', stepId: 'step-fatal', + stepIndex: 0, error: 'contract violated', }), ); - expect(workflowPort.updateStepExecution).not.toHaveBeenCalled(); + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-1', { + type: 'record', + stepId: 'step-fatal', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred.', + }); + }); + + it.each([ + [StepType.Condition, 'condition'], + [StepType.Mcp, 'mcp'], + [StepType.Guidance, 'guidance'], + [StepType.ReadRecord, 'record'], + [StepType.UpdateRecord, 'record'], + [StepType.TriggerAction, 'record'], + [StepType.LoadRelatedRecord, 'record'], + ])( + 'synthetic error outcome uses the %s-matching outcome type', + async (stepType, expectedOutcomeType) => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-f', stepType }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ + execute: jest.fn().mockRejectedValueOnce(new Error('boom')), + }); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.triggerPoll('run-1'); + + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ type: expectedOutcomeType, status: 'error' }), + ); + }, + ); + + it('logs a second FATAL (without rethrowing) when the synthetic error POST also fails', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-double' }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ + execute: jest.fn().mockRejectedValueOnce(new Error('contract violated')), + }); + workflowPort.updateStepExecution.mockRejectedValueOnce(new Error('orchestrator unreachable')); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'FATAL: also failed to report synthetic error outcome', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-double', + reportError: 'orchestrator unreachable', + }), + ); + }); + + it('releases the inFlightRuns entry after a FATAL so a subsequent triggerPoll executes', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-release' }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + // Only the FIRST call fails the contract — second call runs normally via BaseStepExecutor + // mock from beforeEach. + jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ + execute: jest.fn().mockRejectedValueOnce(new Error('contract violated')), + }); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.triggerPoll('run-1'); + await runner.triggerPoll('run-1'); + + // 2 POSTs: 1st is the synthetic error for the failed attempt, 2nd is the success outcome + // produced by the default BaseStepExecutor.execute mock on the 2nd trigger. + expect(workflowPort.updateStepExecution).toHaveBeenCalledTimes(2); }); it('reports an outcome when getModel throws a non-Error throwable', async () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-string-throw' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); aiClient.getModel.mockImplementationOnce(() => { // eslint-disable-next-line @typescript-eslint/no-throw-literal throw 'plain string error'; @@ -987,12 +1622,30 @@ describe('error handling', () => { ); }); - it('catches getPendingStepExecutions failure, logs it, and reschedules', async () => { + it('emits Poll cycle completed with fetched/dispatching counts on each cycle', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + workflowPort.getAvailableRuns.mockResolvedValue({ pending: [], malformed: [] }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(mockLogger.info).toHaveBeenCalledWith('Poll cycle completed', { + fetched: 0, + dispatching: 0, + malformed: 0, + }); + }); + + it('catches getAvailableRuns failure, logs it, and reschedules', async () => { const workflowPort = createMockWorkflowPort(); const mockLogger = createMockLogger(); - workflowPort.getPendingStepExecutions + workflowPort.getAvailableRuns .mockRejectedValueOnce(new Error('network error')) - .mockResolvedValue([]); + .mockResolvedValue({ pending: [], malformed: [] }); runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); await runner.start(); @@ -1009,7 +1662,99 @@ describe('error handling', () => { jest.advanceTimersByTime(POLLING_INTERVAL_MS); await flushPromises(); - expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(2); + expect(workflowPort.getAvailableRuns).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// malformed run reporting +// --------------------------------------------------------------------------- + +describe('malformed run reporting', () => { + const malformedInfo = { + runId: '99', + stepId: 'pending-step', + stepIndex: 2, + userMessage: 'The workflow step configuration is invalid. Please check the workflow designer.', + technicalMessage: 'Invalid step definition: some detail', + }; + + it('runPollCycle reports each malformed run via updateStepExecution', async () => { + const workflowPort = createMockWorkflowPort(); + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [], + malformed: [malformedInfo], + }); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('99', { + type: 'record', + stepId: 'pending-step', + stepIndex: 2, + status: 'error', + error: malformedInfo.userMessage, + }); + }); + + it('runPollCycle skips updateStepExecution and logs when stepIndex is null', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [], + malformed: [{ ...malformedInfo, stepId: null, stepIndex: null }], + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.updateStepExecution).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Malformed run cannot be reported — no available step identified', + expect.objectContaining({ runId: '99' }), + ); + }); + + it('runPollCycle logs when updateStepExecution itself fails but keeps running', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + workflowPort.updateStepExecution.mockRejectedValueOnce(new Error('orchestrator unreachable')); + workflowPort.getAvailableRuns.mockResolvedValueOnce({ + pending: [], + malformed: [malformedInfo], + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Malformed run — also failed to report', + expect.objectContaining({ runId: '99', reportError: 'orchestrator unreachable' }), + ); + }); + + it('triggerPoll reports the malformed run via updateStepExecution before rethrowing', async () => { + const workflowPort = createMockWorkflowPort(); + workflowPort.getAvailableRun.mockRejectedValue(new MalformedRunError(malformedInfo)); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('99')).rejects.toBeInstanceOf(MalformedRunError); + + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + '99', + expect.objectContaining({ status: 'error', stepIndex: 2 }), + ); }); }); @@ -1040,7 +1785,10 @@ describe('triggerPoll with options', () => { it('succeeds when bearerUserId matches step.user.id', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1' }); // user.id = 1 - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner(createRunnerConfig({ workflowPort })); await expect(runner.triggerPoll('run-1', { bearerUserId: 1 })).resolves.toBeUndefined(); @@ -1051,7 +1799,10 @@ describe('triggerPoll with options', () => { it('throws UserMismatchError when bearerUserId does not match step.user.id', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1' }); // user.id = 1 - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner(createRunnerConfig({ workflowPort })); @@ -1064,7 +1815,10 @@ describe('triggerPoll with options', () => { it('skips user check when bearerUserId is undefined', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1' }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); runner = new Runner(createRunnerConfig({ workflowPort })); await expect(runner.triggerPoll('run-1', {})).resolves.toBeUndefined(); @@ -1075,7 +1829,10 @@ describe('triggerPoll with options', () => { it('passes pendingData through to executor via context when provided', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepIndex: 0 }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); const createSpy = jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ execute: jest.fn().mockResolvedValue({ @@ -1087,10 +1844,13 @@ describe('triggerPoll with options', () => { await runner.triggerPoll('run-1', { pendingData: { userConfirmed: true, value: 'new' } }); - expect(createSpy).toHaveBeenCalledWith(step, expect.anything(), expect.any(Function), { - userConfirmed: true, - value: 'new', - }); + expect(createSpy).toHaveBeenCalledWith( + step, + expect.anything(), + expect.anything(), + expect.any(Function), + { userConfirmed: true, value: 'new' }, + ); createSpy.mockRestore(); }); @@ -1098,7 +1858,10 @@ describe('triggerPoll with options', () => { it('passes undefined incomingPendingData when no pendingData option is provided', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepIndex: 0 }); - workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); const createSpy = jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ execute: jest.fn().mockResolvedValue({ @@ -1113,6 +1876,7 @@ describe('triggerPoll with options', () => { expect(createSpy).toHaveBeenCalledWith( step, expect.anything(), + expect.anything(), expect.any(Function), undefined, ); diff --git a/packages/workflow-executor/test/schema-cache.test.ts b/packages/workflow-executor/test/schema-cache.test.ts index e90a3815c4..216721640d 100644 --- a/packages/workflow-executor/test/schema-cache.test.ts +++ b/packages/workflow-executor/test/schema-cache.test.ts @@ -1,4 +1,4 @@ -import type { CollectionSchema } from '../src/types/record'; +import type { CollectionSchema } from '../src/types/validated/collection'; import SchemaCache from '../src/schema-cache'; diff --git a/packages/workflow-executor/test/types/step-outcome.test.ts b/packages/workflow-executor/test/types/step-outcome.test.ts index 7046a79e0c..b137ea0bc6 100644 --- a/packages/workflow-executor/test/types/step-outcome.test.ts +++ b/packages/workflow-executor/test/types/step-outcome.test.ts @@ -1,5 +1,5 @@ -import { StepType } from '../../src/types/step-definition'; -import { stepTypeToOutcomeType } from '../../src/types/step-outcome'; +import { StepType } from '../../src/types/validated/step-definition'; +import { stepTypeToOutcomeType } from '../../src/types/validated/step-outcome'; describe('stepTypeToOutcomeType', () => { it('maps Condition to condition', () => { diff --git a/yarn.lock b/yarn.lock index f485bd5167..36bce105d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,33 +7,33 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@actions/core@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@actions/core/-/core-2.0.1.tgz#fc4961acb04f6253bcdf83ad356e013ba29fc218" - integrity sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg== +"@actions/core@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-3.0.1.tgz#0f4d8b14527ee51e0db061eedc24a206c9f98c23" + integrity sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA== dependencies: - "@actions/exec" "^2.0.0" - "@actions/http-client" "^3.0.0" + "@actions/exec" "^3.0.0" + "@actions/http-client" "^4.0.0" -"@actions/exec@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@actions/exec/-/exec-2.0.0.tgz#35e829723389f80e362ec2cc415697ec74362ad8" - integrity sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw== +"@actions/exec@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@actions/exec/-/exec-3.0.0.tgz#8c3464d20f0aa4068707757021d7e3c01a7ee203" + integrity sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw== dependencies: - "@actions/io" "^2.0.0" + "@actions/io" "^3.0.2" -"@actions/http-client@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-3.0.0.tgz#6c6058bef29c0580d6683a08c5bf0362c90c2e6e" - integrity sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ== +"@actions/http-client@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-4.0.1.tgz#22a23a7625ba1326d9ba154012ca8301a27f88a3" + integrity sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg== dependencies: tunnel "^0.0.6" - undici "^5.28.5" + undici "^6.23.0" -"@actions/io@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@actions/io/-/io-2.0.0.tgz#3ad1271ba3cd515324f2215e8d4c1c0c3864d65b" - integrity sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg== +"@actions/io@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@actions/io/-/io-3.0.2.tgz#6f89b27a159d109836d983efa283997c23b92284" + integrity sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw== "@ampproject/remapping@^2.2.0": version "2.2.1" @@ -919,16 +919,7 @@ "@babel/highlight" "^7.22.13" chalk "^2.4.2" -"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" - integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== - dependencies: - "@babel/helper-validator-identifier" "^7.27.1" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/code-frame@^7.28.6": +"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.28.6": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== @@ -937,6 +928,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.3.tgz#3febd552541e62b5e883a25eb3effd7c7379db11" @@ -1571,6 +1571,136 @@ dependencies: tslib "^2.4.0" +"@esbuild/aix-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" + integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== + +"@esbuild/android-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" + integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== + +"@esbuild/android-arm@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" + integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== + +"@esbuild/android-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" + integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== + +"@esbuild/darwin-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" + integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== + +"@esbuild/darwin-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" + integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== + +"@esbuild/freebsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" + integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== + +"@esbuild/freebsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" + integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== + +"@esbuild/linux-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" + integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== + +"@esbuild/linux-arm@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" + integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== + +"@esbuild/linux-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" + integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== + +"@esbuild/linux-loong64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" + integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== + +"@esbuild/linux-mips64el@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" + integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== + +"@esbuild/linux-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" + integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== + +"@esbuild/linux-riscv64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" + integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== + +"@esbuild/linux-s390x@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" + integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== + +"@esbuild/linux-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" + integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== + +"@esbuild/netbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" + integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== + +"@esbuild/netbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" + integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== + +"@esbuild/openbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" + integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== + +"@esbuild/openbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" + integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== + +"@esbuild/openharmony-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" + integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== + +"@esbuild/sunos-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" + integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== + +"@esbuild/win32-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" + integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== + +"@esbuild/win32-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" + integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== + +"@esbuild/win32-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" + integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1643,11 +1773,6 @@ ajv-formats "^2.1.1" fast-uri "^2.0.0" -"@fastify/busboy@^2.0.0": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" - integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== - "@fastify/cors@9.0.1": version "9.0.1" resolved "https://registry.yarnpkg.com/@fastify/cors/-/cors-9.0.1.tgz#9ddb61b4a61e02749c5c54ca29f1c646794145be" @@ -1745,6 +1870,11 @@ object-hash "^3.0.0" uuid "^9.0.0" +"@gar/promise-retry@^1.0.0", "@gar/promise-retry@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@gar/promise-retry/-/promise-retry-1.0.3.tgz#65e726428e794bc4453948e0a41e6de4215ce8b0" + integrity sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA== + "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -1778,10 +1908,10 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@hono/node-server@^1.19.9": - version "1.19.12" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.12.tgz#dae075247959b6d7d2dba4c8bdc8c452ca0c7b40" - integrity sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw== +"@hono/node-server@^1.19.13", "@hono/node-server@^1.19.9": + version "1.19.14" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.14.tgz#e30f844bc77e3ce7be442aac3b1f73ad8b58d181" + integrity sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw== "@humanwhocodes/config-array@^0.11.13": version "0.11.13" @@ -2686,11 +2816,12 @@ treeverse "^3.0.0" walk-up-path "^3.0.1" -"@npmcli/arborist@^9.1.9": - version "9.1.9" - resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-9.1.9.tgz#1458850184fa97967263c67c6f34a052ac632b46" - integrity sha512-O/rLeBo64mkUn1zU+1tFDWXvbAA9UXe9eUldwTwRLxOLFx9obqjNoozW65LmYqgWb0DG40i9lNZSv78VX2GKhw== +"@npmcli/arborist@^9.4.3": + version "9.4.3" + resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-9.4.3.tgz#50be500c61927a73c8df364b4dde057627b3b9c0" + integrity sha512-YhkR7XFdO7OBr8U1qs7DA7PmhSJXg59rLqd53jmeJ4pYe8WTCAsUZsKqxX7KKPEgAO5K7D/SjbyPUrBes9aP6Q== dependencies: + "@gar/promise-retry" "^1.0.0" "@isaacs/string-locale-compare" "^1.1.0" "@npmcli/fs" "^5.0.0" "@npmcli/installed-package-contents" "^4.0.0" @@ -2704,7 +2835,7 @@ "@npmcli/run-script" "^10.0.0" bin-links "^6.0.0" cacache "^20.0.1" - common-ancestor-path "^1.0.1" + common-ancestor-path "^2.0.0" hosted-git-info "^9.0.0" json-stringify-nice "^1.1.4" lru-cache "^11.2.1" @@ -2725,10 +2856,10 @@ treeverse "^3.0.0" walk-up-path "^4.0.0" -"@npmcli/config@^10.4.5": - version "10.4.5" - resolved "https://registry.yarnpkg.com/@npmcli/config/-/config-10.4.5.tgz#6b5bfe6326d8ffe0c53998ea59b3b338a972a058" - integrity sha512-i3d+ysO0ix+2YGXLxKu44cEe9z47dtUPKbiPLFklDZvp/rJAsLmeWG2Bf6YKuqR8jEhMl/pHw1pGOquJBxvKIA== +"@npmcli/config@^10.8.1": + version "10.8.1" + resolved "https://registry.yarnpkg.com/@npmcli/config/-/config-10.8.1.tgz#36dd459a03cda0fa9211df9f669bd1b2ac46497b" + integrity sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ== dependencies: "@npmcli/map-workspaces" "^5.0.0" "@npmcli/package-json" "^7.0.0" @@ -2784,16 +2915,16 @@ which "^4.0.0" "@npmcli/git@^7.0.0": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-7.0.1.tgz#d1f6462af0e9901536e447beea922bc20dcc5762" - integrity sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA== + version "7.0.2" + resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-7.0.2.tgz#680c3271fe51401c07ee41076be678851e600ff0" + integrity sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg== dependencies: + "@gar/promise-retry" "^1.0.0" "@npmcli/promise-spawn" "^9.0.0" ini "^6.0.0" lru-cache "^11.2.1" npm-pick-manifest "^11.0.1" proc-log "^6.0.0" - promise-retry "^2.0.1" semver "^7.3.5" which "^6.0.0" @@ -2917,10 +3048,10 @@ proc-log "^4.0.0" semver "^7.5.3" -"@npmcli/package-json@^7.0.0", "@npmcli/package-json@^7.0.4": - version "7.0.4" - resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-7.0.4.tgz#f4178e5d90b888f3bdf666915706f613c2d870d7" - integrity sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ== +"@npmcli/package-json@^7.0.0", "@npmcli/package-json@^7.0.5": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-7.0.5.tgz#e29481dfc586d1625a6553799e6bec52ae0487a5" + integrity sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ== dependencies: "@npmcli/git" "^7.0.0" glob "^13.0.0" @@ -2928,7 +3059,7 @@ json-parse-even-better-errors "^5.0.0" proc-log "^6.0.0" semver "^7.5.3" - validate-npm-package-license "^3.0.4" + spdx-expression-parse "^4.0.0" "@npmcli/promise-spawn@^7.0.0": version "7.0.2" @@ -2980,17 +3111,16 @@ proc-log "^4.0.0" which "^4.0.0" -"@npmcli/run-script@^10.0.0", "@npmcli/run-script@^10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-10.0.3.tgz#85c16cd893e44cad5edded441b002d8a1d3a8a8e" - integrity sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw== +"@npmcli/run-script@^10.0.0", "@npmcli/run-script@^10.0.4": + version "10.0.4" + resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-10.0.4.tgz#99cddae483ce3dbf1a10f5683a4e6aaa02345ac0" + integrity sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg== dependencies: "@npmcli/node-gyp" "^5.0.0" "@npmcli/package-json" "^7.0.0" "@npmcli/promise-spawn" "^9.0.0" node-gyp "^12.1.0" proc-log "^6.0.0" - which "^6.0.0" "@nuxtjs/opencollective@0.3.2": version "0.3.2" @@ -3187,10 +3317,10 @@ before-after-hook "^4.0.0" universal-user-agent "^7.0.0" -"@octokit/endpoint@^11.0.2": - version "11.0.2" - resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.2.tgz#a8d955e053a244938b81d86cd73efd2dcb5ef5af" - integrity sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ== +"@octokit/endpoint@^11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.3.tgz#acf5f7feddde4e12185d5312ee38ff77235d8205" + integrity sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag== dependencies: "@octokit/types" "^16.0.0" universal-user-agent "^7.0.2" @@ -3263,9 +3393,9 @@ "@octokit/types" "^13.8.0" "@octokit/plugin-retry@^8.0.0": - version "8.0.3" - resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-8.0.3.tgz#8b7af9700272df724d12fd6333ead98961d135c6" - integrity sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA== + version "8.1.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz#e25c2fb5e0a09cfe674ef9df75d7ca4fafa16c11" + integrity sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw== dependencies: "@octokit/request-error" "^7.0.2" "@octokit/types" "^16.0.0" @@ -3296,14 +3426,15 @@ "@octokit/types" "^16.0.0" "@octokit/request@^10.0.6": - version "10.0.7" - resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.7.tgz#93f619914c523750a85e7888de983e1009eb03f6" - integrity sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA== + version "10.0.8" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.8.tgz#6609a5a38ad6f8ee203d9eb8ac9361d906a4414e" + integrity sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw== dependencies: - "@octokit/endpoint" "^11.0.2" + "@octokit/endpoint" "^11.0.3" "@octokit/request-error" "^7.0.2" "@octokit/types" "^16.0.0" fast-content-type-parse "^3.0.0" + json-with-bigint "^3.5.3" universal-user-agent "^7.0.2" "@octokit/request@^8.4.1": @@ -3487,9 +3618,9 @@ p-reduce "^2.0.0" "@semantic-release/github@^12.0.0": - version "12.0.2" - resolved "https://registry.yarnpkg.com/@semantic-release/github/-/github-12.0.2.tgz#bc1f76e9cd386c5b01a20c3f0606e8eec6b1b93a" - integrity sha512-qyqLS+aSGH1SfXIooBKjs7mvrv0deg8v+jemegfJg1kq6ji+GJV8CO08VJDEsvjp3O8XJmTTIAjjZbMzagzsdw== + version "12.0.6" + resolved "https://registry.yarnpkg.com/@semantic-release/github/-/github-12.0.6.tgz#c60c556e7087938be988d0be3de6d70e8cbaced8" + integrity sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA== dependencies: "@octokit/core" "^7.0.0" "@octokit/plugin-paginate-rest" "^14.0.0" @@ -3510,11 +3641,11 @@ url-join "^5.0.0" "@semantic-release/npm@^13.1.1": - version "13.1.3" - resolved "https://registry.yarnpkg.com/@semantic-release/npm/-/npm-13.1.3.tgz#f75bc82e005fcb859932461bfc5583746a31f6c1" - integrity sha512-q7zreY8n9V0FIP1Cbu63D+lXtRAVAIWb30MH5U3TdrfXt6r2MIrWCY0whAImN53qNvSGp0Zt07U95K+Qp9GpEg== + version "13.1.5" + resolved "https://registry.yarnpkg.com/@semantic-release/npm/-/npm-13.1.5.tgz#99178d57ca8f68fb4ea2aa2d388052ec3f397498" + integrity sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg== dependencies: - "@actions/core" "^2.0.0" + "@actions/core" "^3.0.0" "@semantic-release/error" "^4.0.0" aggregate-error "^5.0.0" env-ci "^11.2.0" @@ -3522,7 +3653,7 @@ fs-extra "^11.0.0" lodash-es "^4.17.21" nerf-dart "^1.0.0" - normalize-url "^8.0.0" + normalize-url "^9.0.0" npm "^11.6.2" rc "^1.2.8" read-pkg "^10.0.0" @@ -3634,10 +3765,10 @@ resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-1.1.0.tgz#5583d8f7ffe599fa0a89f2bf289301a5af262380" integrity sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg== -"@sigstore/core@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-3.0.0.tgz#42f42f733596f26eb055348635098fa28676f117" - integrity sha512-NgbJ+aW9gQl/25+GIEGYcCyi8M+ng2/5X04BMuIgoDfgvp18vDcoNHOQjQsG9418HGNYRxG3vfEXaR1ayD37gg== +"@sigstore/core@^3.1.0", "@sigstore/core@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-3.2.0.tgz#beaea6ea4d7d4caadadb7453168e35636b78830e" + integrity sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA== "@sigstore/protobuf-specs@^0.3.2": version "0.3.3" @@ -3645,9 +3776,9 @@ integrity sha512-RpacQhBlwpBWd7KEJsRKcBQalbV28fvkxwTOJIqhIuDysMMaJW47V4OqW30iJB9uRpqOSxxEAQFdr8tTattReQ== "@sigstore/protobuf-specs@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz#e5f029edcb3a4329853a09b603011e61043eb005" - integrity sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA== + version "0.5.1" + resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz#5401e444b6ab0db7d1969c91c43e7954927a52fe" + integrity sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g== "@sigstore/sign@^2.3.2": version "2.3.2" @@ -3661,17 +3792,17 @@ proc-log "^4.2.0" promise-retry "^2.0.1" -"@sigstore/sign@^4.0.0": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-4.0.1.tgz#36ed397d0528e4da880b9060e26234098de5d35b" - integrity sha512-KFNGy01gx9Y3IBPG/CergxR9RZpN43N+lt3EozEfeoyqm8vEiLxwRl3ZO5sPx3Obv1ix/p7FWOlPc2Jgwfp9PA== +"@sigstore/sign@^4.1.0": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-4.1.1.tgz#34765fe4a190d693340c0771a3d150a397bcfc55" + integrity sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ== dependencies: + "@gar/promise-retry" "^1.0.2" "@sigstore/bundle" "^4.0.0" - "@sigstore/core" "^3.0.0" + "@sigstore/core" "^3.2.0" "@sigstore/protobuf-specs" "^0.5.0" - make-fetch-happen "^15.0.2" - proc-log "^5.0.0" - promise-retry "^2.0.1" + make-fetch-happen "^15.0.4" + proc-log "^6.1.0" "@sigstore/tuf@^2.3.4": version "2.3.4" @@ -3681,13 +3812,13 @@ "@sigstore/protobuf-specs" "^0.3.2" tuf-js "^2.2.1" -"@sigstore/tuf@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-4.0.0.tgz#8b3ae2bd09e401386d5b6842a46839e8ff484e6c" - integrity sha512-0QFuWDHOQmz7t66gfpfNO6aEjoFrdhkJaej/AOqb4kqWZVbPWFZifXZzkxyQBB1OwTbkhdT3LNpMFxwkTvf+2w== +"@sigstore/tuf@^4.0.1", "@sigstore/tuf@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-4.0.2.tgz#7d2fa2abcd5afa5baf752671d14a1c6ed0ed3196" + integrity sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ== dependencies: "@sigstore/protobuf-specs" "^0.5.0" - tuf-js "^4.0.0" + tuf-js "^4.1.0" "@sigstore/verify@^1.2.1": version "1.2.1" @@ -3698,15 +3829,20 @@ "@sigstore/core" "^1.1.0" "@sigstore/protobuf-specs" "^0.3.2" -"@sigstore/verify@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-3.0.0.tgz#59a1ffa98246f8b3f91a17459e3532095ee7fbb7" - integrity sha512-moXtHH33AobOhTZF8xcX1MpOFqdvfCk7v6+teJL8zymBiDXwEsQH6XG9HGx2VIxnJZNm4cNSzflTLDnQLmIdmw== +"@sigstore/verify@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-3.1.0.tgz#4046d4186421db779501fe87fa5acaa5d4d21b08" + integrity sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag== dependencies: "@sigstore/bundle" "^4.0.0" - "@sigstore/core" "^3.0.0" + "@sigstore/core" "^3.1.0" "@sigstore/protobuf-specs" "^0.5.0" +"@simple-libs/stream-utils@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz#5af724b826f1ab4d7f2826d31d3efccec124102b" + integrity sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -4248,11 +4384,6 @@ resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== -"@tokenizer/token@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" - integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== - "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -4296,13 +4427,13 @@ "@tufjs/canonical-json" "2.0.0" minimatch "^9.0.4" -"@tufjs/models@4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-4.0.0.tgz#91fa6608413bb2d593c87d8aaf8bfbf7f7a79cb8" - integrity sha512-h5x5ga/hh82COe+GoD4+gKUeV4T3iaYOxqLt41GRKApinPI7DMidhCmNVTjKfhCWFJIGXaFJee07XczdT4jdZQ== +"@tufjs/models@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-4.1.0.tgz#494b39cf5e2f6855d80031246dd236d8086069b3" + integrity sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww== dependencies: "@tufjs/canonical-json" "2.0.0" - minimatch "^9.0.5" + minimatch "^10.1.1" "@tybys/wasm-util@^0.9.0": version "0.9.0" @@ -5172,9 +5303,9 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.2: type-fest "^0.21.3" ansi-escapes@^7.0.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.2.0.tgz#31b25afa3edd3efc09d98c2fee831d460ff06b49" - integrity sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw== + version "7.3.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" + integrity sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== dependencies: environment "^1.0.0" @@ -5198,12 +5329,7 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - -ansi-regex@^6.1.0: +ansi-regex@^6.1.0, ansi-regex@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== @@ -5597,14 +5723,14 @@ avvio@^8.3.0: "@fastify/error" "^3.3.0" fastq "^1.17.1" -axios@^1.13.5, axios@^1.8.3: - version "1.13.5" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43" - integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== +axios@^1.15.0, axios@^1.8.3: + version "1.15.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" + integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== dependencies: follow-redirects "^1.15.11" form-data "^4.0.5" - proxy-from-env "^1.1.0" + proxy-from-env "^2.1.0" babel-jest@^29.7.0: version "29.7.0" @@ -5873,6 +5999,13 @@ brace-expansion@^5.0.2: dependencies: balanced-match "^4.0.2" +brace-expansion@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -6034,7 +6167,23 @@ cacache@^18.0.0, cacache@^18.0.3: tar "^6.1.11" unique-filename "^3.0.0" -cacache@^20.0.0, cacache@^20.0.1, cacache@^20.0.3: +cacache@^20.0.0, cacache@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-20.0.4.tgz#9b547dc3db0c1f87cba6dbbff91fb17181b4bbb1" + integrity sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA== + dependencies: + "@npmcli/fs" "^5.0.0" + fs-minipass "^3.0.0" + glob "^13.0.0" + lru-cache "^11.1.0" + minipass "^7.0.3" + minipass-collect "^2.0.1" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + p-map "^7.0.2" + ssri "^13.0.0" + +cacache@^20.0.1: version "20.0.3" resolved "https://registry.yarnpkg.com/cacache/-/cacache-20.0.3.tgz#bd65205d5e6d86e02bbfaf8e4ce6008f1b81d119" integrity sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw== @@ -6288,17 +6437,15 @@ ci-info@^4.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.2.0.tgz#cbd21386152ebfe1d56f280a3b5feccbd96764c7" integrity sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg== -ci-info@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa" - integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA== +ci-info@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.4.0.tgz#7d54eff9f54b45b62401c26032696eb59c8bd18c" + integrity sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg== -cidr-regex@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-5.0.1.tgz#4b3972457b06445832929f6f268b477fe0372c1f" - integrity sha512-2Apfc6qH9uwF3QHmlYBA8ExB9VHq+1/Doj9sEMY55TVBcpQ3y/+gmMpcNIBBtfb5k54Vphmta+1IxjMqPlWWAA== - dependencies: - ip-regex "5.0.0" +cidr-regex@^5.0.4: + version "5.0.5" + resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-5.0.5.tgz#4f3ef4fd123f602481df6e6baf3e5a53b534a046" + integrity sha512-59tdLZcC+BJXa4C5rOmVSuJTy/UneqfJJtCraqwdx5BDHTkGrBtKCUl3u2uiCFvXu+wk0kVuX8axX7yHCZOI9w== cjs-module-lexer@^1.0.0: version "1.2.3" @@ -6335,14 +6482,6 @@ cli-color@^2.0.0: memoizee "^0.4.15" timers-ext "^0.1.7" -cli-columns@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cli-columns/-/cli-columns-4.0.0.tgz#9fe4d65975238d55218c41bd2ed296a7fa555646" - integrity sha512-XW2Vg+w+L9on9wtwKpyzluIPCWXjaBahI7mTcYjx+BVIYD9c3yqcv/yKC7CmdCZat4rq2yiE1UMSJC5ivKfMtQ== - dependencies: - string-width "^4.2.3" - strip-ansi "^6.0.1" - cli-cursor@3.1.0, cli-cursor@^3.0.0, cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -6590,6 +6729,11 @@ common-ancestor-path@^1.0.1: resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7" integrity sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w== +common-ancestor-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz#f1d361aea9236aad5b92a0ff5b9df1422dd360ff" + integrity sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng== + common-tags@^1.4.0: version "1.8.2" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" @@ -6623,6 +6767,18 @@ concat-stream@^2.0.0: readable-stream "^3.0.2" typedarray "^0.0.6" +concurrently@^9.0.0: + version "9.2.1" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.2.1.tgz#248ea21b95754947be2dad9c3e4b60f18ca4e44f" + integrity sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng== + dependencies: + chalk "4.1.2" + rxjs "7.8.2" + shell-quote "1.8.3" + supports-color "8.1.1" + tree-kill "1.2.2" + yargs "17.7.2" + config-chain@^1.1.11: version "1.1.13" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" @@ -6646,13 +6802,6 @@ console-control-strings@^1.0.0, console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== -console-table-printer@^2.12.1: - version "2.15.0" - resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.15.0.tgz#5c808204640b8f024d545bde8aabe5d344dfadc1" - integrity sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw== - dependencies: - simple-wcswidth "^1.1.2" - content-disposition@0.5.4, content-disposition@^0.5.3, content-disposition@~0.5.2, content-disposition@~0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -6685,9 +6834,9 @@ conventional-changelog-angular@^6.0.0: compare-func "^2.0.0" conventional-changelog-angular@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz#06223a40f818c5618982fdb92d2b2aac5e24d33e" - integrity sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w== + version "8.3.1" + resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz#0b015e25ca7f2766a8c5352ab6488dcb76a9d881" + integrity sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg== dependencies: compare-func "^2.0.0" @@ -6734,10 +6883,11 @@ conventional-changelog-writer@^6.0.0: split "^1.0.1" conventional-changelog-writer@^8.0.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-writer/-/conventional-changelog-writer-8.2.0.tgz#1b77ef8e45ccc4559e02a23a34d50c15d2051e5a" - integrity sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw== + version "8.4.0" + resolved "https://registry.yarnpkg.com/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz#600bfb4c98ccf0a31baddf8a1305f229072faf1f" + integrity sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g== dependencies: + "@simple-libs/stream-utils" "^1.2.0" conventional-commits-filter "^5.0.0" handlebars "^4.7.7" meow "^13.0.0" @@ -6767,10 +6917,11 @@ conventional-commits-parser@^4.0.0: split2 "^3.2.2" conventional-commits-parser@^6.0.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-6.2.1.tgz#855e53c4792b1feaf93649eff5d75e0dbc2c63ad" - integrity sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA== + version "6.4.0" + resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz#8ac1c12ec467354ed4d73ec940efe380e1e83686" + integrity sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw== dependencies: + "@simple-libs/stream-utils" "^1.2.0" meow "^13.0.0" conventional-recommended-bump@7.0.1: @@ -6867,7 +7018,7 @@ cosmiconfig-typescript-loader@^4.0.0: resolved "https://registry.yarnpkg.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.4.0.tgz#f3feae459ea090f131df5474ce4b1222912319f9" integrity sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw== -cosmiconfig@9.0.0, cosmiconfig@^9.0.0: +cosmiconfig@9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz#34c3fc58287b915f3ae905ab6dc3de258b55ad9d" integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg== @@ -6887,6 +7038,16 @@ cosmiconfig@^8.0.0, cosmiconfig@^8.3.6: parse-json "^5.2.0" path-type "^4.0.0" +cosmiconfig@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz#df110631a8547b5d1a98915271986f06e3011379" + integrity sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ== + dependencies: + env-paths "^2.2.1" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + cpu-features@~0.0.8, cpu-features@~0.0.9: version "0.0.9" resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.9.tgz#5226b92f0f1c63122b0a3eb84cb8335a4de499fc" @@ -7039,7 +7200,7 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.1, debug@^4.4.1, debug@^4.4.3: +debug@^4.3.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -7228,9 +7389,9 @@ diff@^4.0.1: integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== diff@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae" - integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg== + version "8.0.4" + resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.4.tgz#4f5baf3188b9b2431117b962eb20ba330fadf696" + integrity sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw== dir-glob@^3.0.0, dir-glob@^3.0.1: version "3.0.1" @@ -7685,6 +7846,38 @@ es6-weak-map@^2.0.3: es6-iterator "^2.0.3" es6-symbol "^3.1.1" +esbuild@~0.27.0: + version "0.27.7" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" + integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.7" + "@esbuild/android-arm" "0.27.7" + "@esbuild/android-arm64" "0.27.7" + "@esbuild/android-x64" "0.27.7" + "@esbuild/darwin-arm64" "0.27.7" + "@esbuild/darwin-x64" "0.27.7" + "@esbuild/freebsd-arm64" "0.27.7" + "@esbuild/freebsd-x64" "0.27.7" + "@esbuild/linux-arm" "0.27.7" + "@esbuild/linux-arm64" "0.27.7" + "@esbuild/linux-ia32" "0.27.7" + "@esbuild/linux-loong64" "0.27.7" + "@esbuild/linux-mips64el" "0.27.7" + "@esbuild/linux-ppc64" "0.27.7" + "@esbuild/linux-riscv64" "0.27.7" + "@esbuild/linux-s390x" "0.27.7" + "@esbuild/linux-x64" "0.27.7" + "@esbuild/netbsd-arm64" "0.27.7" + "@esbuild/netbsd-x64" "0.27.7" + "@esbuild/openbsd-arm64" "0.27.7" + "@esbuild/openbsd-x64" "0.27.7" + "@esbuild/openharmony-arm64" "0.27.7" + "@esbuild/sunos-x64" "0.27.7" + "@esbuild/win32-arm64" "0.27.7" + "@esbuild/win32-ia32" "0.27.7" + "@esbuild/win32-x64" "0.27.7" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -8600,15 +8793,6 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@^16.5.4: - version "16.5.4" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" - integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== - dependencies: - readable-web-to-node-stream "^3.0.0" - strtok3 "^6.2.4" - token-types "^4.1.1" - file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -8761,10 +8945,10 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== -follow-redirects@^1.15.11: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== +follow-redirects@^1.15.11, follow-redirects@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3: version "0.3.3" @@ -8945,7 +9129,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -9039,9 +9223,9 @@ get-caller-file@^2.0.5: integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== get-east-asian-width@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz#9bc4caa131702b4b61729cb7e42735bc550c9ee6" - integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q== + version "1.5.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz#ce7008fe345edcf5497a6f557cfa54bc318a9ce7" + integrity sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA== get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2: version "1.2.2" @@ -9160,6 +9344,13 @@ get-symbol-description@^1.1.0: es-errors "^1.3.0" get-intrinsic "^1.2.6" +get-tsconfig@^4.7.5: + version "4.14.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz#985d85c52a9903864280ccc2448d413fbf1efed8" + integrity sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA== + dependencies: + resolve-pkg-maps "^1.0.0" + git-log-parser@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/git-log-parser/-/git-log-parser-1.2.0.tgz#2e6a4c1b13fc00028207ba795a7ac31667b9fd4a" @@ -9249,14 +9440,14 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@>=10.5.0, glob@^10.2.2, glob@^10.3.10, glob@^9.2.0: - version "13.0.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.2.tgz#74b28859255e319c84d1aed1a0a5b5248bfea227" - integrity sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ== +glob@>=10.5.0, glob@^10.2.2, glob@^10.3.10, glob@^13.0.6, glob@^9.2.0: + version "13.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== dependencies: - minimatch "^10.1.2" - minipass "^7.1.2" - path-scurry "^2.0.0" + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" glob@^13.0.0: version "13.0.0" @@ -9501,10 +9692,10 @@ highlight.js@^10.7.1: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== -hono@^4.11.4: - version "4.12.9" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.9.tgz#7cd59dec4abf02022f5baad87f6413a04081144c" - integrity sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA== +hono@^4.11.4, hono@^4.12.12: + version "4.12.15" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.15.tgz#50302aae9a2b8ae6e5a1bab62e722f2259f9d0fb" + integrity sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg== hook-std@^4.0.0: version "4.0.0" @@ -9888,17 +10079,16 @@ init-package-json@6.0.3: validate-npm-package-license "^3.0.4" validate-npm-package-name "^5.0.0" -init-package-json@^8.2.4: - version "8.2.4" - resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-8.2.4.tgz#dc3c1c13e6b2da9631acb5b4763f5d5523133647" - integrity sha512-SqX/+tPl3sZD+IY0EuMiM1kK1B45h+P6JQPo3Q9zlqNINX2XiX3x/WSbYGFqS6YCkODNbGb3L5RawMrYE/cfKw== +init-package-json@^8.2.5: + version "8.2.5" + resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-8.2.5.tgz#6e90972b632eb410637a5a532019240ee7227d62" + integrity sha512-IknQ+upLuJU6t3p0uo9wS3GjFD/1GtxIwcIGYOWR8zL2HxQeJwvxYTgZr9brJ8pyZ4kvpkebM8ZKcyqOeLOHSg== dependencies: "@npmcli/package-json" "^7.0.0" npm-package-arg "^13.0.0" promzard "^3.0.1" read "^5.0.1" semver "^7.7.2" - validate-npm-package-license "^3.0.4" validate-npm-package-name "^7.0.0" inquirer@6.2.0: @@ -9986,11 +10176,6 @@ ip-address@^5.8.9: lodash "^4.17.15" sprintf-js "1.1.2" -ip-regex@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632" - integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw== - ip6@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/ip6/-/ip6-0.0.4.tgz#44c5a9db79e39d405201b4d78d13b3870e48db31" @@ -10125,12 +10310,12 @@ is-ci@3.0.1: dependencies: ci-info "^3.2.0" -is-cidr@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-6.0.1.tgz#125e9dead938b6fa996aa500662a5e9f88f338f4" - integrity sha512-JIJlvXodfsoWFAvvjB7Elqu8qQcys2SZjkIJCLdk4XherUqZ6+zH7WIpXkp4B3ZxMH0Fz7zIsZwyvs6JfM0csw== +is-cidr@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-6.0.4.tgz#7dcbde8640cf00cddc38a3c159d937dc216deb5c" + integrity sha512-tOIBU3QiXy0W4LvHbcKWAWSuQfGwDiEILphFCAZtDqj7C57uv3ClO6K8aNEGV4VTA7bWJlpQ0suKQkUe6Rd6ag== dependencies: - cidr-regex "5.0.1" + cidr-regex "^5.0.4" is-core-module@^2.13.0, is-core-module@^2.5.0: version "2.13.1" @@ -10538,6 +10723,11 @@ isexe@^3.1.1: resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== +isexe@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca" + integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw== + isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" @@ -11041,10 +11231,10 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@4.1.0, js-yaml@4.1.1, js-yaml@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" - integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== +js-yaml@4.1.0, js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" @@ -11056,10 +11246,10 @@ js-yaml@^3.10.0, js-yaml@^3.13.1, js-yaml@^3.14.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" @@ -11174,6 +11364,11 @@ json-stringify-safe@^5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== +json-with-bigint@^3.5.3: + version "3.5.8" + resolved "https://registry.yarnpkg.com/json-with-bigint/-/json-with-bigint-3.5.8.tgz#1b1edb55a1bc4816ca87ac684297591acd822383" + integrity sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw== + json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -11426,17 +11621,12 @@ koa@^3.0.1: type-is "^2.0.1" vary "^1.1.2" -"langsmith@>=0.4.0 <1.0.0": - version "0.5.11" - resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.11.tgz#98994aaa051b0c807c31731ac3664f9415174f51" - integrity sha512-Yio502Ow2vbVt16P1sybNMNpMsr5BMqoeonoi4flrcDsP55No/aCe2zydtBNOv0+kjKQw4WSKAzTsNwenDeD5w== +"langsmith@>=0.4.0 <1.0.0", langsmith@^0.5.18: + version "0.5.26" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.26.tgz#49c720450991c81b1ddd86aec1aa633417753885" + integrity sha512-HmmFgeQR2n9x1Kq8NiVaNL/j72ta71qN11hYjbyePJ/QuYEnOMhQjbNv9KeyKB3bOetpIzNalQbhHm+RyKoPRQ== dependencies: - "@types/uuid" "^10.0.0" - chalk "^5.6.2" - console-table-printer "^2.12.1" - p-queue "^6.6.2" - semver "^7.6.3" - uuid "^10.0.0" + p-queue "6.6.2" lerna@^8.2.3: version "8.2.3" @@ -11553,12 +11743,12 @@ libnpmaccess@^10.0.3: npm-package-arg "^13.0.0" npm-registry-fetch "^19.0.0" -libnpmdiff@^8.0.12: - version "8.0.12" - resolved "https://registry.yarnpkg.com/libnpmdiff/-/libnpmdiff-8.0.12.tgz#c55c80e0cb196588174989f36c285750fe7de048" - integrity sha512-M33yWsbxCUv4fwquYNxdRl//mX8CcmY+pHhZZ+f8ihKh+yfcQw2jROv0sJQ3eX5FzRVJKdCdH7nM0cNlHy83DQ== +libnpmdiff@^8.1.6: + version "8.1.6" + resolved "https://registry.yarnpkg.com/libnpmdiff/-/libnpmdiff-8.1.6.tgz#02db3eb234b52838cc0c69a18cc77936b53a6898" + integrity sha512-nr6/MrxRnqMUoB9t0aHImBKArkJCU3YeaTyu817XYQXAQq9iWgX+ZVLgd+5wZVfoyemPdJj2LasXhFNyVk5GAA== dependencies: - "@npmcli/arborist" "^9.1.9" + "@npmcli/arborist" "^9.4.3" "@npmcli/installed-package-contents" "^4.0.0" binary-extensions "^3.0.0" diff "^8.0.2" @@ -11567,30 +11757,30 @@ libnpmdiff@^8.0.12: pacote "^21.0.2" tar "^7.5.1" -libnpmexec@^10.1.11: - version "10.1.11" - resolved "https://registry.yarnpkg.com/libnpmexec/-/libnpmexec-10.1.11.tgz#6ccc19f2d81c0eeb4f72f2fe09e8fc1637f5ec7f" - integrity sha512-228ZmYSfElpfywVFO3FMieLkFUDNknExXLLJoFcKJbyrucHc8KgDW4i9F4uJGNrbPvDqDtm7hcSEvrneN0Anqg== +libnpmexec@^10.2.6: + version "10.2.6" + resolved "https://registry.yarnpkg.com/libnpmexec/-/libnpmexec-10.2.6.tgz#b982a017650b986f4d7ee58756f0dff86a39e756" + integrity sha512-aUHRHUhoi98CW9x+0+RzOVvKvl4rvGgr6o7wnWfdyuvZtU5WXGStfuArN1wBANxEP50bLTocMJrEsBktEuiVqw== dependencies: - "@npmcli/arborist" "^9.1.9" + "@gar/promise-retry" "^1.0.0" + "@npmcli/arborist" "^9.4.3" "@npmcli/package-json" "^7.0.0" "@npmcli/run-script" "^10.0.0" ci-info "^4.0.0" npm-package-arg "^13.0.0" pacote "^21.0.2" proc-log "^6.0.0" - promise-retry "^2.0.1" read "^5.0.1" semver "^7.3.7" signal-exit "^4.1.0" walk-up-path "^4.0.0" -libnpmfund@^7.0.12: - version "7.0.12" - resolved "https://registry.yarnpkg.com/libnpmfund/-/libnpmfund-7.0.12.tgz#0a8afd552c0e9d56b8e5904599406d62f2a640be" - integrity sha512-Jg4zvboAkI35JFoywEleJa9eU0ZIkMOZH3gt16VoexaYV3yVTjjIr4ZVnPx+MfsLo28y6DHQ8RgN4PFuKt1bhg== +libnpmfund@^7.0.20: + version "7.0.20" + resolved "https://registry.yarnpkg.com/libnpmfund/-/libnpmfund-7.0.20.tgz#a8f2a79b3bed8d6578f416d67363ef62df011206" + integrity sha512-H1FvUdssvUlAfQJsNotf+DUetF2mS7d2sW8+MByLCMmgsZ+OkKbXgQit0PCjAwg8BD/Z/f8UO0FJT7bOYe73fQ== dependencies: - "@npmcli/arborist" "^9.1.9" + "@npmcli/arborist" "^9.4.3" libnpmorg@^8.0.1: version "8.0.1" @@ -11600,12 +11790,12 @@ libnpmorg@^8.0.1: aproba "^2.0.0" npm-registry-fetch "^19.0.0" -libnpmpack@^9.0.12: - version "9.0.12" - resolved "https://registry.yarnpkg.com/libnpmpack/-/libnpmpack-9.0.12.tgz#1514e3caa44f47896089bfa7f474beb8a10de21a" - integrity sha512-32j+CIrJhVngbqGUbhnpNFnPi6rkx6NP1lRO1OHf4aoZ57ad+mTkS788FfeAoXoiJDmfmAqgZejXRmEfy7s6Sg== +libnpmpack@^9.1.6: + version "9.1.6" + resolved "https://registry.yarnpkg.com/libnpmpack/-/libnpmpack-9.1.6.tgz#f72985464c2eac91e10549402572e25c6a3ee31e" + integrity sha512-Uov/MsMO+1MdJdT4PKdz6MiLNuZb73REKxbxKXKcNUaDkeBGNXxGB1GUxpdsvZlx1sos4MQDTYw34q4yw7hzHw== dependencies: - "@npmcli/arborist" "^9.1.9" + "@npmcli/arborist" "^9.4.3" "@npmcli/run-script" "^10.0.0" npm-package-arg "^13.0.0" pacote "^21.0.2" @@ -11783,10 +11973,10 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: - version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.23.tgz#58c4360fd1b5d33afc6c0bbd3d1149349b1138e0" - integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== +lodash-es@^4.17.21, lodash-es@^4.18.0: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash.camelcase@^4.3.0: version "4.3.0" @@ -11923,10 +12113,10 @@ lodash.upperfirst@^4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@4.17.23, lodash@^4.16.3, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4: - version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" - integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== +lodash@4.17.23, lodash@^4.16.3, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.0: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== log-symbols@^2.2.0: version "2.2.0" @@ -12024,14 +12214,19 @@ luxon@^3.2.1: resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== +magic-bytes.js@^1.13.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz#b86cc065639368599034ec67941da39d88d7795e" + integrity sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg== + make-asynchronous@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/make-asynchronous/-/make-asynchronous-1.0.1.tgz#5ff174bae4e4371746debff112103545037373ee" - integrity sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ== + version "1.1.0" + resolved "https://registry.yarnpkg.com/make-asynchronous/-/make-asynchronous-1.1.0.tgz#6225f7f1ccaab9acaac5e2fcd0b075afefff19aa" + integrity sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg== dependencies: p-event "^6.0.0" type-fest "^4.6.0" - web-worker "1.2.0" + web-worker "^1.5.0" make-dir@4.0.0, make-dir@^4.0.0: version "4.0.0" @@ -12078,7 +12273,7 @@ make-fetch-happen@^13.0.0, make-fetch-happen@^13.0.1: promise-retry "^2.0.1" ssri "^10.0.0" -make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.2, make-fetch-happen@^15.0.3: +make-fetch-happen@^15.0.0: version "15.0.3" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz#1578d72885f2b3f9e5daa120b36a14fc31a84610" integrity sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw== @@ -12095,6 +12290,24 @@ make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.2, make-fetch-happen@^15.0.3: promise-retry "^2.0.1" ssri "^13.0.0" +make-fetch-happen@^15.0.1, make-fetch-happen@^15.0.4, make-fetch-happen@^15.0.5: + version "15.0.5" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz#b0e3dd53d487b2733e4ea232c2bebf1bd16afb03" + integrity sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg== + dependencies: + "@gar/promise-retry" "^1.0.0" + "@npmcli/agent" "^4.0.0" + "@npmcli/redact" "^4.0.0" + cacache "^20.0.1" + http-cache-semantics "^4.1.1" + minipass "^7.0.2" + minipass-fetch "^5.0.0" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^1.0.0" + proc-log "^6.0.0" + ssri "^13.0.0" + make-fetch-happen@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz#53085a09e7971433e6765f7971bf63f4e05cb968" @@ -12548,7 +12761,14 @@ minimatch@9.0.3: dependencies: brace-expansion "^2.0.1" -minimatch@^10.0.3, minimatch@^10.1.1, minimatch@^10.1.2, minimatch@^10.2.4: +minimatch@^10.0.3, minimatch@^10.2.2, minimatch@^10.2.5: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minimatch@^10.1.1, minimatch@^10.2.4: version "10.2.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== @@ -12665,7 +12885,7 @@ minipass@^3.0.0, minipass@^3.1.0, minipass@^3.1.1, minipass@^3.1.3: dependencies: yallist "^4.0.0" -minipass@^7.0.2, minipass@^7.0.4, minipass@^7.1.1, minipass@^7.1.2: +minipass@^7.0.2, minipass@^7.0.4, minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== @@ -12675,6 +12895,11 @@ minipass@^7.0.3: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== +minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + minizlib@^2.0.0, minizlib@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -13083,20 +13308,20 @@ node-gyp@^10.0.0: tar "^6.2.1" which "^4.0.0" -node-gyp@^12.1.0: - version "12.1.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.1.0.tgz#302fc2d3fec36975cfb8bfee7a6bf6b7f0be9553" - integrity sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g== +node-gyp@^12.1.0, node-gyp@^12.3.0: + version "12.3.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.3.0.tgz#a0e0d9364779451eaf4148b6f9a7366f98000b3f" + integrity sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg== dependencies: env-paths "^2.2.0" exponential-backoff "^3.1.1" graceful-fs "^4.2.6" - make-fetch-happen "^15.0.0" nopt "^9.0.0" proc-log "^6.0.0" semver "^7.3.5" - tar "^7.5.2" + tar "^7.5.4" tinyglobby "^0.2.12" + undici "^6.25.0" which "^6.0.0" node-int64@^0.4.0: @@ -13229,10 +13454,10 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -normalize-url@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-8.1.0.tgz#d33504f67970decf612946fd4880bc8c0983486d" - integrity sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w== +normalize-url@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-9.0.0.tgz#9a2c3e23dcc3cb4c5be7d70c6377cddd76e57dc1" + integrity sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ== npm-audit-report@^7.0.0: version "7.0.0" @@ -13315,9 +13540,9 @@ npm-packlist@8.0.2, npm-packlist@^8.0.0: ignore-walk "^6.0.4" npm-packlist@^10.0.1: - version "10.0.3" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-10.0.3.tgz#e22c039357faf81a75d1b0cdf53dd113f2bed9c7" - integrity sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg== + version "10.0.4" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-10.0.4.tgz#aa2e0e4daf910eae8c5745c2645cf8bb8813de01" + integrity sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng== dependencies: ignore-walk "^8.0.0" proc-log "^6.0.0" @@ -13413,52 +13638,51 @@ npm-user-validate@^4.0.0: integrity sha512-TP+Ziq/qPi/JRdhaEhnaiMkqfMGjhDLoh/oRfW+t5aCuIfJxIUxvwk6Sg/6ZJ069N/Be6gs00r+aZeJTfS9uHQ== npm@^11.6.2: - version "11.7.0" - resolved "https://registry.yarnpkg.com/npm/-/npm-11.7.0.tgz#897fa4af764b64fa384b50e071636e7497d4f6de" - integrity sha512-wiCZpv/41bIobCoJ31NStIWKfAxxYyD1iYnWCtiyns8s5v3+l8y0HCP/sScuH6B5+GhIfda4HQKiqeGZwJWhFw== + version "11.13.0" + resolved "https://registry.yarnpkg.com/npm/-/npm-11.13.0.tgz#1af5ccf2fc595e4ede1f46f4e6cda78cee0d7458" + integrity sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg== dependencies: "@isaacs/string-locale-compare" "^1.1.0" - "@npmcli/arborist" "^9.1.9" - "@npmcli/config" "^10.4.5" + "@npmcli/arborist" "^9.4.3" + "@npmcli/config" "^10.8.1" "@npmcli/fs" "^5.0.0" "@npmcli/map-workspaces" "^5.0.3" "@npmcli/metavuln-calculator" "^9.0.3" - "@npmcli/package-json" "^7.0.4" + "@npmcli/package-json" "^7.0.5" "@npmcli/promise-spawn" "^9.0.1" "@npmcli/redact" "^4.0.0" - "@npmcli/run-script" "^10.0.3" - "@sigstore/tuf" "^4.0.0" + "@npmcli/run-script" "^10.0.4" + "@sigstore/tuf" "^4.0.2" abbrev "^4.0.0" archy "~1.0.0" - cacache "^20.0.3" + cacache "^20.0.4" chalk "^5.6.2" - ci-info "^4.3.1" - cli-columns "^4.0.0" + ci-info "^4.4.0" fastest-levenshtein "^1.0.16" fs-minipass "^3.0.3" - glob "^13.0.0" + glob "^13.0.6" graceful-fs "^4.2.11" hosted-git-info "^9.0.2" ini "^6.0.0" - init-package-json "^8.2.4" - is-cidr "^6.0.1" + init-package-json "^8.2.5" + is-cidr "^6.0.4" json-parse-even-better-errors "^5.0.0" libnpmaccess "^10.0.3" - libnpmdiff "^8.0.12" - libnpmexec "^10.1.11" - libnpmfund "^7.0.12" + libnpmdiff "^8.1.6" + libnpmexec "^10.2.6" + libnpmfund "^7.0.20" libnpmorg "^8.0.1" - libnpmpack "^9.0.12" + libnpmpack "^9.1.6" libnpmpublish "^11.1.3" libnpmsearch "^9.0.1" libnpmteam "^8.0.2" libnpmversion "^8.0.3" - make-fetch-happen "^15.0.3" - minimatch "^10.1.1" - minipass "^7.1.1" + make-fetch-happen "^15.0.5" + minimatch "^10.2.5" + minipass "^7.1.3" minipass-pipeline "^1.2.4" ms "^2.1.2" - node-gyp "^12.1.0" + node-gyp "^12.3.0" nopt "^9.0.0" npm-audit-report "^7.0.0" npm-install-checks "^8.0.0" @@ -13468,21 +13692,21 @@ npm@^11.6.2: npm-registry-fetch "^19.1.1" npm-user-validate "^4.0.0" p-map "^7.0.4" - pacote "^21.0.4" + pacote "^21.5.0" parse-conflict-json "^5.0.1" proc-log "^6.1.0" qrcode-terminal "^0.12.0" read "^5.0.1" - semver "^7.7.3" + semver "^7.7.4" spdx-expression-parse "^4.0.0" - ssri "^13.0.0" + ssri "^13.0.1" supports-color "^10.2.2" - tar "^7.5.2" + tar "^7.5.13" text-table "~0.2.0" tiny-relative-date "^2.0.2" treeverse "^3.0.0" - validate-npm-package-name "^7.0.0" - which "^6.0.0" + validate-npm-package-name "^7.0.2" + which "^6.0.1" npmlog@^5.0.1: version "5.0.1" @@ -14043,11 +14267,12 @@ pacote@^18.0.0, pacote@^18.0.6: ssri "^10.0.0" tar "^6.1.11" -pacote@^21.0.0, pacote@^21.0.2, pacote@^21.0.4: - version "21.0.4" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-21.0.4.tgz#59cd2a2b5a4c8c1b625f33991a96b136d1c05d95" - integrity sha512-RplP/pDW0NNNDh3pnaoIWYPvNenS7UqMbXyvMqJczosiFWTeGGwJC2NQBLqKf4rGLFfwCOnntw1aEp9Jiqm1MA== +pacote@^21.0.0, pacote@^21.0.2, pacote@^21.5.0: + version "21.5.0" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-21.5.0.tgz#475fe00db73585dec296590bec484109522e9e6f" + integrity sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ== dependencies: + "@gar/promise-retry" "^1.0.0" "@npmcli/git" "^7.0.0" "@npmcli/installed-package-contents" "^4.0.0" "@npmcli/package-json" "^7.0.0" @@ -14061,7 +14286,6 @@ pacote@^21.0.0, pacote@^21.0.2, pacote@^21.0.4: npm-pick-manifest "^11.0.1" npm-registry-fetch "^19.0.0" proc-log "^6.0.0" - promise-retry "^2.0.1" sigstore "^4.0.0" ssri "^13.0.0" tar "^7.4.3" @@ -14247,6 +14471,14 @@ path-scurry@^2.0.0: lru-cache "^11.0.0" minipass "^7.1.2" +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-to-regexp@0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" @@ -14289,11 +14521,6 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -peek-readable@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" - integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== - pg-cloudflare@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" @@ -14400,6 +14627,11 @@ picomatch@^4.0.2, picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + pify@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pify/-/pify-5.0.0.tgz#1f5eca3f5e87ebec28cc6d54a0e4aaf00acc127f" @@ -14646,11 +14878,6 @@ proc-log@^4.0.0, proc-log@^4.1.0, proc-log@^4.2.0: resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034" integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== -proc-log@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" - integrity sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ== - proc-log@^6.0.0, proc-log@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" @@ -14774,10 +15001,10 @@ proxy-addr@^2.0.6, proxy-addr@^2.0.7, proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== pstree.remy@^1.1.8: version "1.1.8" @@ -14813,9 +15040,9 @@ qrcode-terminal@^0.12.0: integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== qs@6.13.0, qs@>=6.14.1, qs@^6.11.2, qs@^6.14.0, qs@^6.14.1, qs@^6.5.2, qs@~6.14.0: - version "6.14.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" - integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== + version "6.15.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" + integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== dependencies: side-channel "^1.1.0" @@ -14958,15 +15185,15 @@ read-pkg-up@^7.0.1: type-fest "^0.8.1" read-pkg@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-10.0.0.tgz#06401f0331115e9fba9880cb3f2ae1efa3db00e4" - integrity sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A== + version "10.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-10.1.0.tgz#eff31c7e505a4995a85c5af017b3dc413745431c" + integrity sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg== dependencies: "@types/normalize-package-data" "^2.4.4" normalize-package-data "^8.0.0" parse-json "^8.3.0" - type-fest "^5.2.0" - unicorn-magic "^0.3.0" + type-fest "^5.4.4" + unicorn-magic "^0.4.0" read-pkg@^3.0.0: version "3.0.0" @@ -15045,13 +15272,6 @@ readable-stream@^4.2.0: process "^0.11.10" string_decoder "^1.3.0" -readable-web-to-node-stream@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" - integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== - dependencies: - readable-stream "^3.6.0" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -15199,6 +15419,11 @@ resolve-global@1.0.0, resolve-global@^1.0.0: dependencies: global-dirs "^0.1.1" +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + resolve.exports@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" @@ -15310,6 +15535,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rxjs@7.8.2: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + rxjs@^6.1.0: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" @@ -15433,9 +15665,9 @@ semantic-release-slack-bot@^4.0.2: slackify-markdown "^4.3.0" semantic-release@^21.0.5, semantic-release@^25.0.0: - version "25.0.2" - resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-25.0.2.tgz#efd4fa16ce3518a747e737baf3f69fd82979d98e" - integrity sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g== + version "25.0.3" + resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-25.0.3.tgz#77c2a7bfdcc63125fa2dea062d2cee28662ce224" + integrity sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA== dependencies: "@semantic-release/commit-analyzer" "^13.0.1" "@semantic-release/error" "^4.0.0" @@ -15463,17 +15695,9 @@ semantic-release@^21.0.5, semantic-release@^25.0.0: read-package-up "^12.0.0" resolve-from "^5.0.0" semver "^7.3.2" - semver-diff "^5.0.0" signale "^1.2.1" yargs "^18.0.0" -semver-diff@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-5.0.0.tgz#62a8396f44c11386c83d1e57caedc806c6c7755c" - integrity sha512-0HbGtOm+S7T6NGQ/pxJSJipJvc4DK3FcRVMRkhsIwJDJ4Jcz5DQC1cPPzB5GhzyHjwttW878HaWQq46CkL3cqg== - dependencies: - semver "^7.3.5" - semver-regex@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-4.0.5.tgz#fbfa36c7ba70461311f5debcb3928821eb4f9180" @@ -15501,10 +15725,10 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.2, semver@^7.7.2, semver@^7.7.3: - version "7.7.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" - integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== +semver@^7.5.2, semver@^7.7.2, semver@^7.7.4: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== semver@^7.6.3: version "7.7.1" @@ -15768,6 +15992,11 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shell-quote@1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + side-channel-list@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" @@ -15854,16 +16083,16 @@ sigstore@^2.2.0: "@sigstore/verify" "^1.2.1" sigstore@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-4.0.0.tgz#cc260814a95a6027c5da24b819d5c11334af60f9" - integrity sha512-Gw/FgHtrLM9WP8P5lLcSGh9OQcrTruWCELAiS48ik1QbL0cH+dfjomiRTUE9zzz+D1N6rOLkwXUvVmXZAsNE0Q== + version "4.1.0" + resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-4.1.0.tgz#d34b92a544a05e003a2430209d26d8dfafd805a0" + integrity sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA== dependencies: "@sigstore/bundle" "^4.0.0" - "@sigstore/core" "^3.0.0" + "@sigstore/core" "^3.1.0" "@sigstore/protobuf-specs" "^0.5.0" - "@sigstore/sign" "^4.0.0" - "@sigstore/tuf" "^4.0.0" - "@sigstore/verify" "^3.0.0" + "@sigstore/sign" "^4.1.0" + "@sigstore/tuf" "^4.0.1" + "@sigstore/verify" "^3.1.0" simple-concat@^1.0.0: version "1.0.1" @@ -15893,11 +16122,6 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" -simple-wcswidth@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b" - integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw== - sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -16190,6 +16414,13 @@ ssri@^13.0.0: dependencies: minipass "^7.0.3" +ssri@^13.0.1: + version "13.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-13.0.1.tgz#2d8946614d33f4d0c84946bb370dce7a9379fd18" + integrity sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ== + dependencies: + minipass "^7.0.3" + ssri@^8.0.0, ssri@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" @@ -16404,11 +16635,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: ansi-regex "^5.0.1" strip-ansi@^7.1.0: - version "7.1.2" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" - integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== dependencies: - ansi-regex "^6.0.1" + ansi-regex "^6.2.2" strip-bom@^3.0.0: version "3.0.0" @@ -16462,14 +16693,6 @@ strnum@^2.2.0: resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.2.tgz#f11fd94ab62b536ba2ecc615858f3747c2881b3f" integrity sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA== -strtok3@^6.2.4: - version "6.3.0" - resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" - integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== - dependencies: - "@tokenizer/token" "^0.3.0" - peek-readable "^4.1.0" - subscriptions-transport-ws@^0.9.19: version "0.9.19" resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.19.tgz#10ca32f7e291d5ee8eb728b9c02e43c52606cdcf" @@ -16528,6 +16751,13 @@ supertest@^7.1.3: methods "^1.1.2" superagent "^10.2.3" +supports-color@8.1.1, supports-color@^8, supports-color@^8.0.0, supports-color@^8.1.1, supports-color@~8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + supports-color@^10.2.2: version "10.2.2" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4" @@ -16552,13 +16782,6 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8, supports-color@^8.0.0, supports-color@^8.1.1, supports-color@~8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-hyperlinks@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz#3943544347c1ff90b15effb03fc14ae45ec10624" @@ -16611,7 +16834,7 @@ tar-stream@^2.1.4, tar-stream@~2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@6.2.1, tar@>=7.5.11, tar@^6.0.2, tar@^6.1.11, tar@^6.1.2, tar@^6.2.1, tar@^7.4.3, tar@^7.5.1, tar@^7.5.10, tar@^7.5.2, tar@^7.5.4: +tar@6.2.1, tar@>=7.5.11, tar@^6.0.2, tar@^6.1.11, tar@^6.1.2, tar@^6.2.1, tar@^7.4.3, tar@^7.5.1, tar@^7.5.10, tar@^7.5.13, tar@^7.5.4: version "7.5.13" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.13.tgz#0d214ed56781a26edc313581c0e2d929ceeb866d" integrity sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng== @@ -16756,7 +16979,7 @@ tinyglobby@0.2.12: fdir "^6.4.3" picomatch "^4.0.2" -tinyglobby@^0.2.12, tinyglobby@^0.2.14: +tinyglobby@^0.2.12: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -16764,6 +16987,14 @@ tinyglobby@^0.2.12, tinyglobby@^0.2.14: fdir "^6.5.0" picomatch "^4.0.3" +tinyglobby@^0.2.14: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -16805,14 +17036,6 @@ toidentifier@1.0.1, toidentifier@~1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -token-types@^4.1.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" - integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== - dependencies: - "@tokenizer/token" "^0.3.0" - ieee754 "^1.2.1" - toposort-class@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988" @@ -16854,6 +17077,11 @@ traverse@~0.6.6: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.7.tgz#46961cd2d57dd8706c36664acde06a248f1173fe" integrity sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg== +tree-kill@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + treeverse@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-3.0.0.tgz#dd82de9eb602115c6ebd77a574aae67003cb48c8" @@ -16974,6 +17202,16 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +tsx@^4.19.2: + version "4.21.0" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1" + integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== + dependencies: + esbuild "~0.27.0" + get-tsconfig "^4.7.5" + optionalDependencies: + fsevents "~2.3.3" + tuf-js@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-2.2.1.tgz#fdd8794b644af1a75c7aaa2b197ddffeb2911b56" @@ -16983,14 +17221,14 @@ tuf-js@^2.2.1: debug "^4.3.4" make-fetch-happen "^13.0.1" -tuf-js@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-4.0.0.tgz#dbfc7df8b4e04fd6a0c598678a8c789a3e5f9c27" - integrity sha512-Lq7ieeGvXDXwpoSmOSgLWVdsGGV9J4a77oDTAPe/Ltrqnnm/ETaRlBAQTH5JatEh8KXuE6sddf9qAv1Q2282Hg== +tuf-js@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-4.1.0.tgz#ae4ef9afa456fcb4af103dc50a43bc031f066603" + integrity sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ== dependencies: - "@tufjs/models" "4.0.0" - debug "^4.4.1" - make-fetch-happen "^15.0.0" + "@tufjs/models" "4.1.0" + debug "^4.4.3" + make-fetch-happen "^15.0.1" tunnel-agent@^0.6.0: version "0.6.0" @@ -17078,10 +17316,10 @@ type-fest@^4.0.0, type-fest@^4.39.1, type-fest@^4.6.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== -type-fest@^5.2.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.3.1.tgz#251b8d0a813c1dbccf1f9450ba5adcdf7072adc2" - integrity sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg== +type-fest@^5.2.0, type-fest@^5.4.4: + version "5.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.6.0.tgz#502f7a003b7309e96a7e17052cc2ab2c7e5c7a31" + integrity sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA== dependencies: tagged-tag "^1.0.0" @@ -17305,17 +17543,15 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== -undici@^5.28.5: - version "5.29.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" - integrity sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg== - dependencies: - "@fastify/busboy" "^2.0.0" +undici@^6.23.0, undici@^6.25.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.25.0.tgz#8c4efb8c998dc187fc1cfb5dde1ef19a211849fb" + integrity sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg== undici@^7.0.0: - version "7.16.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a" - integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== + version "7.25.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.25.0.tgz#7d72fc429a0421769ca2966fd07cac875c85b781" + integrity sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ== unicode-emoji-modifier-base@^1.0.0: version "1.0.0" @@ -17332,6 +17568,11 @@ unicorn-magic@^0.3.0: resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104" integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== +unicorn-magic@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.4.0.tgz#78c6a090fd6d07abd2468b83b385603e00dfdb24" + integrity sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw== + unified@^9.0.0: version "9.2.2" resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" @@ -17563,10 +17804,10 @@ validate-npm-package-name@^5.0.0: dependencies: builtins "^5.0.0" -validate-npm-package-name@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.0.tgz#3b4fe12b4abfb8b0be010d0e75b1fe2b52295bc6" - integrity sha512-bwVk/OK+Qu108aJcMAEiU4yavHUI7aN20TgZNBj9MR2iU1zPUl1Z1Otr7771ExfYTPTvfN8ZJ1pbr5Iklgt4xg== +validate-npm-package-name@^7.0.0, validate-npm-package-name@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz#e57c3d721a4c8bbff454a246e7f7da811559ea0d" + integrity sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A== validator@^13.9.0: version "13.15.26" @@ -17633,10 +17874,10 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -web-worker@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da" - integrity sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA== +web-worker@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5" + integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw== webidl-conversions@^3.0.0: version "3.0.1" @@ -17786,6 +18027,13 @@ which@^6.0.0: dependencies: isexe "^3.1.1" +which@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/which/-/which-6.0.1.tgz#021642443a198fb93b784a5606721cb18cfcbfce" + integrity sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg== + dependencies: + isexe "^4.0.0" + wide-align@1.1.5, wide-align@^1.1.2, wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" @@ -17870,11 +18118,10 @@ write-file-atomic@^4.0.2: signal-exit "^3.0.7" write-file-atomic@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-7.0.0.tgz#f89def4f223e9bf8b06cc6fdb12bda3a917505c7" - integrity sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg== + version "7.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-7.0.1.tgz#0e2a450ab5aa306bcfcd3aed61833b10cc4fb885" + integrity sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg== dependencies: - imurmurhash "^0.1.4" signal-exit "^4.0.1" write-json-file@^3.2.0: