Skip to content

feat(workflow-executor): scaffold @forestadmin/workflow-executor package#1493

Open
Scra3 wants to merge 19 commits intomainfrom
feat/prd-214-setup-workflow-executor-package
Open

feat(workflow-executor): scaffold @forestadmin/workflow-executor package#1493
Scra3 wants to merge 19 commits intomainfrom
feat/prd-214-setup-workflow-executor-package

Conversation

@Scra3
Copy link
Copy Markdown
Member

@Scra3 Scra3 commented Mar 17, 2026

image

Summary

  • Scaffold the new @forestadmin/workflow-executor package (tsconfig, jest, eslint, CI matrix)
  • CLAUDE.md with project overview and architecture principles (pull-based polling, atomic steps, privacy, port injection, AI integration)
  • No premature dependencies — they'll be added as needed (YAGNI)
  • Smoke test so CI lint+test passes

fixes PRD-214

Test plan

  • yarn workspace @forestadmin/workflow-executor build passes
  • yarn workspace @forestadmin/workflow-executor lint passes
  • yarn workspace @forestadmin/workflow-executor test passes
  • CI green on this branch

🤖 Generated with Claude Code

Note

[!NOTE]

Scaffold @forestadmin/workflow-executor package in the monorepo

Adds the initial package structure for @forestadmin/workflow-executor with an empty src/index.ts, TypeScript and ESLint configs, Jest setup, and a package.json configured for the workspace. The package is also added to the CI matrix in build.yml so it participates in build and test jobs.

Changes since #1493 opened

  • Defined workflow step type system including step definitions, execution data, and execution history types [29f5646]
  • Defined record and execution context type system [29f5646]
  • Defined port interfaces for external system integration [29f5646]
  • Exported package API through main module entry point [29f5646]
  • Added tests verifying exported module API [29f5646]
  • Implemented AI-powered workflow step execution via BaseStepExecutor abstract class and ConditionStepExecutor concrete implementation [127b579]
  • Introduced custom error classes for workflow execution failures [127b579]
  • Refactored execution types to support immutability, scoped context, and specialized step records [127b579]
  • Refactored step execution data types to move execution details into concrete types and support condition-specific fields [127b579]
  • Refactored step history types to introduce specialized status types and enforce privacy constraints [127b579]
  • Modified RunStore interface to scope all methods to the current run by removing runId parameter [127b579]
  • Tightened ConditionStepDefinition.options type constraint to require at least one option [127b579]
  • Added @langchain/core version 1.1.33 and zod version 4.3.6 as runtime dependencies to @forestadmin/workflow-executor package [127b579]
  • Exported new types, error classes, and executor classes from @forestadmin/workflow-executor package public API [127b579]
  • Added unit tests for BaseStepExecutor and ConditionStepExecutor [127b579]
  • Expanded architecture documentation in CLAUDE.md [127b579]
  • Refactored type system to replace RecordRef with CollectionRef and support composite primary keys as arrays [cb8036b]
  • Implemented AgentClientAgentPort adapter class with methods for record operations, related data access, and action execution [cb8036b]
  • Added RecordNotFoundError class, updated package exports, added @forestadmin/agent-client dependency, and created test suite for AgentClientAgentPort [cb8036b]
  • Introduced AiClient class in @forestadmin/ai-proxy package to manage AI model instances and MCP tool lifecycle [0ebae51]
  • Extracted configuration validation, model creation, and configuration resolution into standalone utility functions [0ebae51]
  • Refactored Router class in @forestadmin/ai-proxy to use extracted validation and configuration utilities [0ebae51]
  • Exported createBaseChatModel function and AiClient class from @forestadmin/ai-proxy package public API [0ebae51]
  • Added test coverage for AiClient class, createBaseChatModel function, and getAiConfiguration function [0ebae51]
  • Implemented ForestServerWorkflowPort adapter class that implements WorkflowPort interface using @forestadmin/forestadmin-client ServerUtils.query to communicate with Forest server endpoints [c25a953]
  • Renamed WorkflowPort interface method from completeStepExecution to updateStepExecution [c25a953]
  • Added @forestadmin/forestadmin-client version 1.37.17 as dependency to @forestadmin/workflow-executor package and exported ForestServerWorkflowPort from package index [c25a953]
  • Exported ServerUtils as named export from @forestadmin/forestadmin-client package [c25a953]
  • Renamed core domain types from StepHistory to StepOutcome, CollectionRef to CollectionSchema, RecordFieldRef to FieldSchema, ActionRef to ActionSchema, and removed 'id' field from BaseStepDefinition and 'type' field from FieldSchema [c9877fe]
  • Refactored ExecutionContext to be generic over TStep, replaced step and stepHistory parameters with context fields, and changed history structure from StepRecord entries to Step entries [c9877fe]
  • Updated BaseStepExecutor.buildPreviousStepsMessages to format step summaries with separate Input and Output lines instead of a single Result line, sourcing data from stepOutcome and matched step executions [c9877fe]
  • Changed ConditionStepExecutor.execute to be parameterless, return stepOutcome instead of stepHistory, and read step metadata from ExecutionContext [c9877fe]
  • Implemented ReadRecordStepExecutor to select records, choose relevant fields via tool-calling, fetch specified fields from the agent, and persist execution data [c9877fe]
  • Modified AgentPort.getRecord to accept optional fieldNames parameter and removed getActions method, updated AgentClientAgentPort to fetch only specified fields and return simplified RecordData [c9877fe]
  • Renamed WorkflowPort.getCollectionRef to getCollectionSchema and changed updateStepExecution to accept StepOutcome instead of StepHistory [c9877fe]
  • Removed record-centric methods from RunStore interface, retaining only getStepExecutions and saveStepExecution [c9877fe]
  • Expanded StepExecutionData union with ReadRecordStepExecutionData and LoadRelatedRecordStepExecutionData types, made ConditionStepExecutionData fields required, and added isExecutedStepOnExecutor helper function [c9877fe]
  • Added NoRecordsError, NoReadableFieldsError, and NoResolvedFieldsError classes extending WorkflowExecutorError [c9877fe]
  • Updated index.ts public exports to replace StepHistory exports with StepOutcome equivalents, add new execution data types and error classes, replace record types with schema types, and add ReadRecordStepExecutor [c9877fe]
  • Added HTTP server with routes for fetching run steps and triggering poll operations [613ec1b]
  • Introduced Runner class to orchestrate workflow execution and HTTP server lifecycle [613ec1b]
  • Refactored RunStore interface and all implementations to scope operations by runId [613ec1b]
  • Added Koa framework and related dependencies for HTTP server functionality [613ec1b]
  • Updated documentation to reflect new Runner, adapters, and HTTP server components [613ec1b]
  • Implemented five new step executor classes extending a common base [39de72a]
  • Refactored BaseStepExecutor to provide unified execution wrapper with error handling, confirmation support, and shared utilities [39de72a]
  • Introduced StepExecutorFactory to instantiate executors by step type with fallback error handling [39de72a]
  • Implemented Runner class with polling loop, HTTP server, and step execution orchestration [39de72a]
  • Updated ExecutorHttpServer to return generic error messages and handle RunNotFoundError with 404 response [39de72a]
  • Extended WorkflowExecutorError base class and added thirteen new error subclasses with user-facing messages [39de72a]
  • Renamed AI task types to record task types and added MCP task types throughout the type system [39de72a]
  • Refactored AgentPort interface and AgentClientAgentPort adapter to use query objects with optional fields and pagination [39de72a]
  • Added StepSummaryBuilder and StepExecutionFormatters for generating multi-line step summaries with custom formatting [39de72a]
  • Added getPendingStepExecutionsForRun() method to WorkflowPort and ForestServerWorkflowPort adapter [39de72a]
  • Updated ExecutionContext interface to replace history with previousSteps, add logger, and include userConfirmed flag [39de72a]
  • Added ConsoleLogger adapter implementing Logger port for structured error logging [39de72a]
  • Added SafeAgentPort wrapper to convert infrastructure errors to AgentPortError [39de72a]
  • Updated @forestadmin/ai-proxy exports and replaced @langchain/core dependency in @forestadmin/workflow-executor [39de72a]
  • Added jest.config.ts moduleNameMapper to resolve @anthropic-ai/sdk subpath imports [39de72a]
  • Updated package index exports to include new executors, errors, logger, and remove isExecutedStepOnExecutor [39de72a]
  • Added JWT authentication and authorization middleware to all /runs/:runId endpoints in ExecutorHttpServer [de39c30]
  • Implemented configuration validation for secrets in Runner.start [de39c30]
  • Added hasRunAccess method to WorkflowPort interface and implemented it in ForestServerWorkflowPort [de39c30]
  • Exported ConfigurationError and validateSecrets from the package entry point [de39c30]
  • Added dependencies jsonwebtoken, koa-jwt, and @types/jsonwebtoken to the package [de39c30]
  • Updated all test files to accommodate JWT authentication, authorization checks, and expanded WorkflowPort interface [de39c30]
  • Refactored step executor confirmation flow to store and retrieve user confirmation state from pendingData in RunStore instead of ExecutionContext [9399bb7]
  • Added HTTP endpoint for updating pending step data with user confirmation [9399bb7]
  • Modified McpTaskStepExecutor to persist formatted responses with best-effort error handling [9399bb7]
  • Added @koa/bodyparser dependency version ^6.1.0 to @forestadmin/workflow-executor package [9399bb7]
  • Added init and close lifecycle methods to the RunStore interface with optional Logger parameters [50583e8]
  • Implemented DatabaseStore class as a Sequelize-backed RunStore with migration support [50583e8]
  • Implemented InMemoryStore class as a Map-backed RunStore [50583e8]
  • Implemented factory functions buildDatabaseRunStore and buildInMemoryRunStore to construct initialized RunStore instances [50583e8]
  • Integrated RunStore lifecycle methods into Runner.start and Runner.stop [50583e8]
  • Exported InMemoryStore, DatabaseStore, DatabaseStoreOptions, buildDatabaseRunStore, and buildInMemoryRunStore from the package index [50583e8]
  • Updated test mocks and expectations across executor test suites to include RunStore lifecycle methods [50583e8]
  • Added runtime dependencies sequelize and umzug, and dev dependencies @types/sequelize and sqlite3 [50583e8]
  • Added test suites for DatabaseStore and InMemoryStore [50583e8]
  • Added schema-based validation for the PATCH /runs/:runId/steps/:stepIndex/pending-data endpoint [6e7858a]
  • Refactored LoadRelatedRecordStepExecutor to derive relatedCollectionName from schema at resolution time instead of storing it in pendingData [6e7858a]
  • Added relatedCollectionName optional property to FieldSchema interface [6e7858a]
  • Updated test mocks and expectations across multiple test files to align with removal of relatedCollectionName from LoadRelatedRecordPendingData [6e7858a]
  • Added Runner.patchPendingData and Runner.getRunStepExecutions methods to centralize pending-data validation and persistence logic [a52c00a]
  • Introduced PendingDataNotFoundError and InvalidPendingDataError classes with distinction between boundary and domain error families [a52c00a]
  • Removed RunStore dependency from http.ExecutorHttpServer and delegated run retrieval and pending-data patching to Runner [a52c00a]
  • Moved pending-data-validators module from src/http/ to src/ directory [a52c00a]
  • Updated test suites to mock Runner methods instead of RunStore and verify new error handling behavior [a52c00a]
  • Defined new PATCH /runs/:runId/steps/:stepIndex/pending-data endpoint with Zod-validated request bodies, strict rejection of unknown fields, and response status codes (204/400/404) for updating pending data [cf7d069]
  • Updated executor behavior to evaluate userConfirmed field in pendingData during step execution, returning awaiting-input status when undefined, executing when true, and skipping when false [cf7d069]
  • Revised update-record, trigger-action, mcp-task, and load-related-record step pending data contracts to include userConfirmed field and defined step-type-specific PATCH request body schemas [cf7d069]
  • Removed userConfirmed field from PendingStepExecution interface, updated last-updated date, reworded note about POST /complete body, and removed TODOs about unimplemented pending-data endpoint [cf7d069]
  • Added user: StepUser parameter to all AgentPort interface methods (getRecord, updateRecord, getRelatedData, executeAction) and propagated user context from ExecutionContext through all step executors (ReadRecordStepExecutor, UpdateRecordStepExecutor, LoadRelatedRecordStepExecutor, TriggerRecordActionStepExecutor) to their respective AgentPort calls, and updated SafeAgentPort wrapper to forward the user parameter [cf8e699]
  • Redesigned AgentClientAgentPort to construct per-call RemoteAgentClient instances authenticated with short-lived JWTs signed using authSecret and scoped to 'step-execution', replacing the previous constructor parameters (client, collectionSchemas) with agentUrl, authSecret, and schemaCache, and building action endpoints dynamically from cached schemas [cf8e699]
  • Removed PATCH /runs/:runId/steps/:stepIndex/pending-data endpoint from ExecutorHttpServer and extended POST /runs/:runId/trigger to accept optional pendingData in the request body, validate bearerUserId from the decoded JWT token, and handle UserMismatchError, PendingDataNotFoundError, and InvalidPendingDataError with specific HTTP status codes (403, 404, 400 respectively) [cf8e699]
  • Changed Runner.triggerPoll signature to accept an options object containing optional pendingData and bearerUserId, added user authorization check that throws UserMismatchError when bearerUserId does not match the pending step's user.id, made patchPendingData private, and invoked it within triggerPoll when pendingData is provided [cf8e699]
  • Introduced SchemaCache class providing TTL-based in-memory caching of CollectionSchema objects with configurable expiration (default 10 minutes), an iterator for non-expired entries, and integrated it into ExecutionContext, StepContextConfig, BaseStepExecutor.getCollectionSchema, AgentClientAgentPort, and Runner via buildCommonDependencies [cf8e699]
  • Changed ConditionStepExecutor.doExecute to return an error outcome with status: 'error' and a user-facing message instructing to rephrase the prompt when the AI does not select an option, replacing the previous 'manual-decision' status, and removed the ConditionStepStatus type from step-outcome.ts [cf8e699]
  • Added buildInMemoryExecutor and buildDatabaseExecutor factory functions that construct a Runner instance with wired dependencies including InMemoryStore/DatabaseStore, ForestServerWorkflowPort, AiClient, SchemaCache, and AgentClientAgentPort using provided ExecutorOptions or DatabaseExecutorOptions [cf8e699]
  • Added endpoint: string property to the ActionSchema interface in types/record.ts [cf8e699]
  • Removed once<T> memoization utility from Runner and updated executeStep and runPollCycle to call fetchRemoteTools directly for each step execution instead of using a per-cycle memoized loader [cf8e699]
  • Updated WorkflowPort.hasRunAccess signature to accept user: StepUser instead of userToken: string, changed ExecutorHttpServer to use a new hasRunAccessMiddleware that calls hasRunAccess with the decoded user object from ctx.state.user for the GET /runs/:runId route, and removed the previous router-level access middleware that validated raw tokens [cf8e699]
  • Implemented graceful shutdown with state management in Runner class and WorkflowExecutor facade [7142e6c]
  • Separated HTTP server concerns from Runner into ExecutorHttpServer composition at facade level [7142e6c]
  • Added unauthenticated GET /health endpoint to ExecutorHttpServer returning runner state [7142e6c]
  • Extended Logger interface with optional info() method and implemented in ConsoleLogger [7142e6c]

Macroscope summarized 17f26ca.

@linear
Copy link
Copy Markdown

linear bot commented Mar 17, 2026

matthv and others added 2 commits March 17, 2026 15:00
…premature deps, add smoke test

- Rewrite CLAUDE.md with project overview and architecture principles, remove changelog
- Remove unused dependencies (ai-proxy, sequelize, zod) per YAGNI
- Add smoke test so CI passes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Scra3 Scra3 force-pushed the feat/prd-214-setup-workflow-executor-package branch from 083894b to 4510b7b Compare March 17, 2026 14:00
@qltysh
Copy link
Copy Markdown

qltysh bot commented Mar 17, 2026

Qlty

Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.03%.

Modified Files with Diff Coverage (12)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/ai-proxy/src/router.ts100.0%
Coverage rating: A Coverage rating: A
packages/ai-proxy/src/index.ts100.0%
New file Coverage rating: A
packages/ai-proxy/src/get-ai-configuration.ts100.0%
New file Coverage rating: A
packages/ai-proxy/src/validate-ai-configurations.ts100.0%
New file Coverage rating: A
...ges/workflow-executor/src/executors/condition-step-executor.ts94.1%61
New file Coverage rating: A
packages/workflow-executor/src/types/step-definition.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/executors/base-step-executor.ts100.0%
New file Coverage rating: A
packages/ai-proxy/src/create-base-chat-model.ts100.0%
New file Coverage rating: A
packages/ai-proxy/src/ai-client.ts100.0%
New file Coverage rating: A
...ages/workflow-executor/src/adapters/agent-client-agent-port.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/index.ts100.0%
Total99.4%
🤖 Increase coverage with AI coding...

In the `feat/prd-214-setup-workflow-executor-package` branch, add test coverage for this new code:

- `packages/workflow-executor/src/executors/condition-step-executor.ts` -- Line 61

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

… document system architecture

- Lint now covers src and test directories
- Replace require() with import, use stronger assertion (toHaveLength)
- Add System Architecture section describing Front/Orchestrator/Executor/Agent
- Mark Architecture Principles as planned (not yet implemented)
- Remove redundant test/.gitkeep
- Make index.ts a valid module with export {}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
export type McpConfiguration = unknown;

export interface WorkflowPort {
getPendingStepExecutions(): Promise<PendingStepExecution[]>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method will retrieve the workflowRun and workflowSteps of one pending run. It will take an optional runId, and return an object with complete workflowRun, workflowSteps, workflowRecords

Comment on lines +5 to +21
interface BaseStepHistory {
stepId: string;
stepIndex: number;
status: StepStatus;
/** Present when status is 'error'. */
error?: string;
}

export interface ConditionStepHistory extends BaseStepHistory {
type: 'condition';
/** Present when status is 'success'. */
selectedOption?: string;
}

export interface AiTaskStepHistory extends BaseStepHistory {
type: 'ai-task';
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are those types ? this does not correspond to anything

return { ...ref, recordId, values: updatedRecord };
}

async getRelatedData(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium adapters/agent-client-agent-port.ts:76

getRelatedData passes relationName (the field name, e.g., 'author') to getCollectionRef, which looks up collection metadata by collection name. This returns a fallback CollectionRef with primaryKeyFields: ['id'] instead of the actual related collection's metadata. Consequently, extractRecordId extracts the wrong fields from related records, returning incorrect or undefined values for the primary key. Consider obtaining the target collection name from the relation's metadata rather than using the field name directly.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/adapters/agent-client-agent-port.ts around line 76:

`getRelatedData` passes `relationName` (the field name, e.g., `'author'`) to `getCollectionRef`, which looks up collection metadata by collection name. This returns a fallback `CollectionRef` with `primaryKeyFields: ['id']` instead of the actual related collection's metadata. Consequently, `extractRecordId` extracts the wrong fields from related records, returning incorrect or `undefined` values for the primary key. Consider obtaining the target collection name from the relation's metadata rather than using the field name directly.

Evidence trail:
packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 72-87 (getRelatedData method passes relationName to getCollectionRef at line 76), lines 100-112 (getCollectionRef looks up by collectionName and returns fallback with primaryKeyFields: ['id']), packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts lines 152-174 (test only works because relation name 'posts' matches collection name 'posts'; fallback test confirms behavior when they don't match)

@qltysh
Copy link
Copy Markdown

qltysh bot commented Mar 18, 2026

16 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 14): createWorkflowExecutor 7
qlty Structure Function with many returns (count = 4): getAiConfiguration 5
qlty Structure Deeply nested control flow (level = 4) 2
qlty Duplication Found 16 lines of similar code in 2 locations (mass = 92) 2

Comment on lines +30 to +35
function extractRecordId(
primaryKeyFields: string[],
record: Record<string, unknown>,
): Array<string | number> {
return primaryKeyFields.map(field => record[field] as string | number);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low adapters/agent-client-agent-port.ts:30

extractRecordId returns undefined for missing primary key fields, but the string | number type assertion hides this. When passed to encodePk, undefined becomes the literal string "undefined", causing lookups to silently fail with wrong record IDs. Consider adding a runtime check for missing fields and throwing an error, or return the actual values and let encodePk validate them.

function extractRecordId(
  primaryKeyFields: string[],
  record: Record<string, unknown>,
): Array<string | number> {
-  return primaryKeyFields.map(field => record[field] as string | number);
+  return primaryKeyFields.map(field => {
+    const value = record[field];
+    if (value === undefined || value === null) {
+      throw new Error(`Missing primary key field: ${field}`);
+    }
+    return value as string | number;
+  });
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/adapters/agent-client-agent-port.ts around lines 30-35:

`extractRecordId` returns `undefined` for missing primary key fields, but the `string | number` type assertion hides this. When passed to `encodePk`, `undefined` becomes the literal string `"undefined"`, causing lookups to silently fail with wrong record IDs. Consider adding a runtime check for missing fields and throwing an error, or return the actual values and let `encodePk` validate them.

Evidence trail:
packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 25-33 (REVIEWED_COMMIT): `encodePk` uses `String(v)` which converts undefined to "undefined"; `extractRecordId` uses type assertion `as string | number` on `record[field]` which can be undefined at runtime. Line 83 shows `extractRecordId` is called in `getRelatedData` to create record IDs that are returned and could be used in subsequent operations.

Comment on lines +9 to +15
// TODO: finalize route paths with the team — these are placeholders
const ROUTES = {
pendingStepExecutions: '/liana/v1/workflow-step-executions/pending',
updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${runId}/complete`,
collectionRef: (collectionName: string) => `/liana/v1/collections/${collectionName}`,
mcpServerConfigs: '/liana/mcp-server-configs-with-details',
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low adapters/forest-server-workflow-port.ts:9

ROUTES.updateStepExecution(runId) and ROUTES.collectionRef(collectionName) interpolate raw values into URL paths without encoding, so special characters like /, ?, or % in runId or collectionName produce malformed URLs (e.g., collectionName="a/b" becomes /liana/v1/collections/a/b with three path segments). Consider wrapping the parameters with encodeURIComponent() to ensure they are safely encoded.

-// TODO: finalize route paths with the team — these are placeholders
 const ROUTES = {
   pendingStepExecutions: '/liana/v1/workflow-step-executions/pending',
-  updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${runId}/complete`,
-  collectionRef: (collectionName: string) => `/liana/v1/collections/${collectionName}`,
+  updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${encodeURIComponent(runId)}/complete`,
+  collectionRef: (collectionName: string) => `/liana/v1/collections/${encodeURIComponent(collectionName)}`,
   mcpServerConfigs: '/liana/mcp-server-configs-with-details',
 };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/adapters/forest-server-workflow-port.ts around lines 9-15:

`ROUTES.updateStepExecution(runId)` and `ROUTES.collectionRef(collectionName)` interpolate raw values into URL paths without encoding, so special characters like `/`, `?`, or `%` in `runId` or `collectionName` produce malformed URLs (e.g., `collectionName="a/b"` becomes `/liana/v1/collections/a/b` with three path segments). Consider wrapping the parameters with `encodeURIComponent()` to ensure they are safely encoded.

Evidence trail:
packages/workflow-executor/src/adapters/forest-server-workflow-port.ts lines 11-13 (ROUTES definitions with template literals), packages/forestadmin-client/src/utils/server.ts lines 63-70 (queryWithHeaders passes path directly to new URL() without encoding)

Copy link
Copy Markdown
Member

@matthv matthv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't want to merge it yet

Comment on lines +3 to +7
export function causeMessage(error: unknown): string | undefined {
const { cause } = error as { cause?: unknown };

return cause instanceof Error ? cause.message : undefined;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/errors.ts:3

causeMessage(null) and causeMessage(undefined) throw TypeError: Cannot destructure property 'cause' of 'error' as it is null/undefined. Since the parameter is unknown and this is called in catch blocks where any thrown value is possible, consider adding a guard before destructuring.

-export function causeMessage(error: unknown): string | undefined {
-  const { cause } = error as { cause?: unknown };
-
-  return cause instanceof Error ? cause.message : undefined;
-}
Also found in 3 other location(s)

packages/workflow-executor/src/executors/base-step-executor.ts:163

In invokeWithTools, toolCall.name is used directly without a null/undefined check on line 163, but the fallback pattern toolCall.name ?? &#39;unknown&#39; on line 166 indicates that toolCall.name can be undefined. If toolCall.args is present but toolCall.name is undefined, the method returns { toolName: undefined, ... } which violates the declared return type { toolName: string; args: T }.

packages/workflow-executor/src/executors/mcp-task-step-executor.ts:148

If toolResult is a value that causes JSON.stringify to return undefined (such as a bare function or symbol), resultStr will be undefined and line 148 will throw TypeError: Cannot read properties of undefined (reading &#39;length&#39;). While MCP tools typically return JSON-serializable data, defensive code could check for resultStr === undefined after the stringify.

packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts:42

In handleConfirmation, the code spreads pendingData with a cast ...(pendingData as ActionRef) on line 42, but pendingData is typed as optional (ActionRef | undefined) in TriggerRecordActionStepExecutionData. If pendingData is undefined (e.g., due to data corruption or a race condition during persistence), spreading it produces an empty object, leaving target.name and target.displayName as undefined. This then passes undefined to executeAction at line 89 and stores undefined values in executionParams at line 98. Adding a guard to verify pendingData exists before proceeding would prevent silent failures.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/errors.ts around lines 3-7:

`causeMessage(null)` and `causeMessage(undefined)` throw `TypeError: Cannot destructure property 'cause' of 'error' as it is null/undefined`. Since the parameter is `unknown` and this is called in catch blocks where any thrown value is possible, consider adding a guard before destructuring.

Evidence trail:
packages/workflow-executor/src/errors.ts lines 3-6 (function definition showing direct destructuring without null/undefined guard), packages/workflow-executor/src/executors/step-executor-factory.ts line 78 (usage in catch block), packages/workflow-executor/src/runner.ts line 169 (usage in catch block). JavaScript specification: destructuring from null/undefined throws TypeError.

Also found in 3 other location(s):
- packages/workflow-executor/src/executors/base-step-executor.ts:163 -- In `invokeWithTools`, `toolCall.name` is used directly without a null/undefined check on line 163, but the fallback pattern `toolCall.name ?? 'unknown'` on line 166 indicates that `toolCall.name` can be undefined. If `toolCall.args` is present but `toolCall.name` is undefined, the method returns `{ toolName: undefined, ... }` which violates the declared return type `{ toolName: string; args: T }`.
- packages/workflow-executor/src/executors/mcp-task-step-executor.ts:148 -- If `toolResult` is a value that causes `JSON.stringify` to return `undefined` (such as a bare function or symbol), `resultStr` will be `undefined` and line 148 will throw `TypeError: Cannot read properties of undefined (reading 'length')`. While MCP tools typically return JSON-serializable data, defensive code could check for `resultStr === undefined` after the stringify.
- packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts:42 -- In `handleConfirmation`, the code spreads `pendingData` with a cast `...(pendingData as ActionRef)` on line 42, but `pendingData` is typed as optional (`ActionRef | undefined`) in `TriggerRecordActionStepExecutionData`. If `pendingData` is `undefined` (e.g., due to data corruption or a race condition during persistence), spreading it produces an empty object, leaving `target.name` and `target.displayName` as `undefined`. This then passes `undefined` to `executeAction` at line 89 and stores `undefined` values in `executionParams` at line 98. Adding a guard to verify `pendingData` exists before proceeding would prevent silent failures.

Comment on lines +1492 to +1494
expect(selectRecordTool.schema.shape.recordIdentifier.options).not.toContain(
expect.stringContaining('stepIndex: 3'),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium executors/load-related-record-step-executor.test.ts:1492

The assertion at lines 1492-1494 checks that recordIdentifier.options does not contain 'stepIndex: 3', but the actual format used in select-record tool identifiers is 'Step N - ...' (as shown at line 1447). Since 'stepIndex: 3' never appears in the options, this assertion always passes regardless of whether the pending execution is correctly excluded from the pool.

-      expect(selectRecordTool.schema.shape.recordIdentifier.options).not.toContain(
-        expect.stringContaining('stepIndex: 3'),
-      );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts around lines 1492-1494:

The assertion at lines 1492-1494 checks that `recordIdentifier.options` does not contain `'stepIndex: 3'`, but the actual format used in `select-record` tool identifiers is `'Step N - ...'` (as shown at line 1447). Since `'stepIndex: 3'` never appears in the options, this assertion always passes regardless of whether the pending execution is correctly excluded from the pool.

Evidence trail:
packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts lines 1492-1494 (assertion checking 'stepIndex: 3'), line 1446 (mock args using format 'Step 2 - Orders #99'), packages/workflow-executor/src/executors/base-step-executor.ts lines 263-267 (toRecordIdentifier method defining format as `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`), packages/workflow-executor/test/executors/read-record-step-executor.test.ts lines 565-567 (expected options format showing 'Step 3 - Customers #42')

Comment on lines +345 to +347
return selectedDisplayNames.map(
dn => nonRelationFields.find(f => f.displayName === dn)?.fieldName ?? dn,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low executors/load-related-record-step-executor.ts:345

In selectRelevantFields, when the AI returns a displayName that doesn't exist in nonRelationFields, the find returns undefined and the fallback ?? dn preserves the raw display name. This invalid field name then fails to match any key in c.values during selectBestRecordIndex, causing the filtered candidates to have empty values objects and making the AI's record selection arbitrary. Consider throwing InvalidAIResponseError when find returns undefined instead of falling back to the raw display name.

+    return selectedDisplayNames.map(dn => {
+      const field = nonRelationFields.find(f => f.displayName === dn);
+      if (!field) {
+        throw new InvalidAIResponseError(
+          `AI returned unknown field name "${dn}" for collection "${schema.collectionName}"`,
+        );
+      }
+      return field.fieldName;
+    });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/executors/load-related-record-step-executor.ts around lines 345-347:

In `selectRelevantFields`, when the AI returns a `displayName` that doesn't exist in `nonRelationFields`, the `find` returns `undefined` and the fallback `?? dn` preserves the raw display name. This invalid field name then fails to match any key in `c.values` during `selectBestRecordIndex`, causing the filtered candidates to have empty `values` objects and making the AI's record selection arbitrary. Consider throwing `InvalidAIResponseError` when `find` returns `undefined` instead of falling back to the raw display name.

Evidence trail:
1. packages/workflow-executor/src/executors/load-related-record-step-executor.ts lines 337-347: Comment states 'Zod's .min(1) shapes the prompt but is NOT validated against the AI response' and the mapping logic `nonRelationFields.find(f => f.displayName === dn)?.fieldName ?? dn`.
2. packages/workflow-executor/src/executors/base-step-executor.ts lines 149-163: `invokeWithTools` returns `toolCall.args as T` without runtime Zod validation.
3. packages/workflow-executor/src/executors/load-related-record-step-executor.ts lines 357-365: `filteredCandidates` filters `c.values` entries by checking `fieldNames.includes(k)`.
4. packages/workflow-executor/src/types/record.ts line 37: `RecordData` definition shows `values: Record<string, unknown>` keyed by technical field names.


export default class ConsoleLogger implements Logger {
error(message: string, context: Record<string, unknown>): void {
console.error(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context }));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium adapters/console-logger.ts:5

The spread ...context comes after message and timestamp, so any message or timestamp keys in context overwrite the explicit parameters. Callers logging { message: "other" } will see their context value instead of the actual error message. Consider moving the spread first so explicit parameters take precedence.

Suggested change
console.error(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context }));
console.error(JSON.stringify({ ...context, message, timestamp: new Date().toISOString() }));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/adapters/console-logger.ts around line 5:

The spread `...context` comes after `message` and `timestamp`, so any `message` or `timestamp` keys in `context` overwrite the explicit parameters. Callers logging `{ message: "other" }` will see their context value instead of the actual error message. Consider moving the spread first so explicit parameters take precedence.

Evidence trail:
packages/workflow-executor/src/adapters/console-logger.ts:5 - The code `{ message, timestamp: new Date().toISOString(), ...context }` shows explicit properties defined first and spread operator last. In JavaScript, object spread puts later properties after earlier ones, so `context.message` would overwrite the `message` parameter if present. This is standard JavaScript object spread behavior documented at MDN and in the ECMAScript specification.

matthv and others added 2 commits March 24, 2026 15:38
…erver (#1504)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: alban bertolini <albanb@forestadmin.com>
Comment on lines +106 to +112
const baseData: McpTaskStepExecutionData = {
...existingExecution,
type: 'mcp-task',
stepIndex: this.context.stepIndex,
executionParams: { name: target.name, input: target.input },
executionResult: baseExecutionResult,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low executors/mcp-task-step-executor.ts:106

When executeToolAndPersist is called with existingExecution (re-entry from handleConfirmationFlow), the spread ...existingExecution at line 107 copies pendingData into baseData. After successful execution, the saved record still contains pendingData, so findPendingExecution will incorrectly match it as pending on subsequent re-entries and trigger duplicate execution. Consider explicitly clearing pendingData: undefined when constructing baseData to mark the step as completed.

-    const baseData: McpTaskStepExecutionData = {
-      ...existingExecution,
+    const baseData: McpTaskStepExecutionData = {
+      ...existingExecution,
+      pendingData: undefined,
       type: 'mcp-task',
       stepIndex: this.context.stepIndex,
       executionParams: { name: target.name, input: target.input },
       executionResult: baseExecutionResult,
     };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/executors/mcp-task-step-executor.ts around lines 106-112:

When `executeToolAndPersist` is called with `existingExecution` (re-entry from `handleConfirmationFlow`), the spread `...existingExecution` at line 107 copies `pendingData` into `baseData`. After successful execution, the saved record still contains `pendingData`, so `findPendingExecution` will incorrectly match it as pending on subsequent re-entries and trigger duplicate execution. Consider explicitly clearing `pendingData: undefined` when constructing `baseData` to mark the step as completed.

Evidence trail:
packages/workflow-executor/src/executors/mcp-task-step-executor.ts lines 106-114 (baseData construction with spread of existingExecution), packages/workflow-executor/src/executors/base-step-executor.ts lines 94-102 (findPendingExecution only checks type and stepIndex, not executionResult), packages/workflow-executor/src/executors/base-step-executor.ts lines 111-136 (handleConfirmationFlow checks pendingData.userConfirmed but not executionResult existence), packages/workflow-executor/src/types/step-execution-data.ts lines 88-96 (McpTaskStepExecutionData type showing optional pendingData field), git_grep for 'pendingData: undefined' returned no matches confirming pendingData is never explicitly cleared.

Comment on lines +39 to +47
async loadRemoteTools(mcpConfig: McpConfiguration): Promise<McpClient['tools']> {
await this.closeMcpClient('Error closing previous MCP connection');

const newClient = new McpClient(mcpConfig, this.logger);
const tools = await newClient.loadTools();
this.mcpClient = newClient;

return tools;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/ai-client.ts:39

In loadRemoteTools, if newClient.loadTools() throws, the McpClient instance is orphaned and never closed. Since this.mcpClient is only assigned after success, closeConnections() cannot reach the failed client, leaving resources leaked. Consider wrapping loadTools() in a try/finally to ensure cleanup on failure.

  async loadRemoteTools(mcpConfig: McpConfiguration): Promise<McpClient['tools']> {
     await this.closeMcpClient('Error closing previous MCP connection');
 
     const newClient = new McpClient(mcpConfig, this.logger);
-    const tools = await newClient.loadTools();
-    this.mcpClient = newClient;
+    try {
+      const tools = await newClient.loadTools();
+      this.mcpClient = newClient;
 
-    return tools;
+      return tools;
+    } catch (error) {
+      await newClient.closeConnections();
+      throw error;
+    }
   }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/ai-proxy/src/ai-client.ts around lines 39-47:

In `loadRemoteTools`, if `newClient.loadTools()` throws, the `McpClient` instance is orphaned and never closed. Since `this.mcpClient` is only assigned after success, `closeConnections()` cannot reach the failed client, leaving resources leaked. Consider wrapping `loadTools()` in a try/finally to ensure cleanup on failure.

Evidence trail:
packages/ai-proxy/src/ai-client.ts lines 39-45 (loadRemoteTools function) and lines 52-60 (closeMcpClient function) at REVIEWED_COMMIT. The code shows newClient is instantiated at line 42, loadTools() is called at line 43, and this.mcpClient is assigned at line 44. If line 43 throws, line 44 never executes, leaving newClient orphaned with no cleanup path.


import { RecordNotFoundError } from '../errors';

function buildPkFilter(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low adapters/agent-client-agent-port.ts:13

In buildPkFilter, when id.length < primaryKeyFields.length, id[i] evaluates to undefined for missing indices, producing filter conditions with value: undefined. This creates an And aggregator with Equal conditions comparing against undefined, which likely matches zero records or causes unexpected query behavior instead of the intended record lookup.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/adapters/agent-client-agent-port.ts around line 13:

In `buildPkFilter`, when `id.length < primaryKeyFields.length`, `id[i]` evaluates to `undefined` for missing indices, producing filter conditions with `value: undefined`. This creates an `And` aggregator with `Equal` conditions comparing against `undefined`, which likely matches zero records or causes unexpected query behavior instead of the intended record lookup.

Evidence trail:
packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 13-27 at REVIEWED_COMMIT - the `buildPkFilter` function uses `primaryKeyFields.map((field, i) => ({ field, operator: 'Equal', value: id[i] }))` which iterates over `primaryKeyFields` length and accesses `id[i]` without checking if `id` has enough elements. JavaScript array access beyond bounds returns `undefined`.

Comment on lines +172 to +178

throw new MalformedToolCallError(toolCall.name ?? 'unknown', 'args field is missing or null');
}

const invalidCall = response.invalid_tool_calls?.[0];

if (invalidCall) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low executors/base-step-executor.ts:172

invokeWithTools returns toolCall.name directly at line 174 without validating it exists. When the AI returns a tool call with defined args but undefined name, the function returns { toolName: undefined, args }, violating the declared return type { toolName: string; args: T }. This can cause downstream failures when code expects a valid string tool name. Consider checking toolCall.name before returning and throwing MalformedToolCallError if it's missing, consistent with how args is validated.

    if (toolCall !== undefined) {
-      if (toolCall.args !== undefined && toolCall.args !== null) {
-        return { toolName: toolCall.name, args: toolCall.args as T };
+      if (toolCall.name !== undefined && toolCall.name !== null && toolCall.args !== undefined && toolCall.args !== null) {
+        return { toolName: toolCall.name, args: toolCall.args as T };
       }
 
-      throw new MalformedToolCallError(toolCall.name ?? 'unknown', 'args field is missing or null');
+      throw new MalformedToolCallError(toolCall.name ?? 'unknown', 'name or args field is missing or null');
     }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/executors/base-step-executor.ts around lines 172-178:

`invokeWithTools` returns `toolCall.name` directly at line 174 without validating it exists. When the AI returns a tool call with defined `args` but undefined `name`, the function returns `{ toolName: undefined, args }`, violating the declared return type `{ toolName: string; args: T }`. This can cause downstream failures when code expects a valid string tool name. Consider checking `toolCall.name` before returning and throwing `MalformedToolCallError` if it's missing, consistent with how `args` is validated.

Evidence trail:
packages/workflow-executor/src/executors/base-step-executor.ts lines 160-175 (REVIEWED_COMMIT) - shows the `invokeWithTools` function with return type `Promise<{ toolName: string; args: T }>` returning `toolCall.name` without validation. packages/ai-proxy/package.json shows `@langchain/core: 1.1.15`. @langchain/core ToolCall type definition (https://github.com/langchain-ai/langchainjs/blob/main/libs/langchain-core/src/messages/tool.ts) shows `name?: string` is optional.

Comment on lines +317 to +324
describe('no records available', () => {
it('returns error when no records are available', () => {
const error = new NoRecordsError();

expect(error).toBeInstanceOf(NoRecordsError);
expect(error.message).toBe('No records available');
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low executors/read-record-step-executor.test.ts:317

The test 'returns error when no records are available' only instantiates NoRecordsError and checks its properties — it never creates a ReadRecordStepExecutor or calls execute(). This gives false confidence that the executor handles the "no records" scenario when it actually tests nothing about executor behavior. Consider removing this test or implementing it to verify the executor throws when no records are available.

-  describe('no records available', () => {
-    it('returns error when no records are available', () => {
-      const error = new NoRecordsError();
-
-      expect(error).toBeInstanceOf(NoRecordsError);
-      expect(error.message).toBe('No records available');
-    });
-  });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/test/executors/read-record-step-executor.test.ts around lines 317-324:

The test `'returns error when no records are available'` only instantiates `NoRecordsError` and checks its properties — it never creates a `ReadRecordStepExecutor` or calls `execute()`. This gives false confidence that the executor handles the "no records" scenario when it actually tests nothing about executor behavior. Consider removing this test or implementing it to verify the executor throws when no records are available.

Evidence trail:
packages/workflow-executor/test/executors/read-record-step-executor.test.ts lines 317-323 (the test in question - only creates NoRecordsError and checks properties)
packages/workflow-executor/test/executors/read-record-step-executor.test.ts lines 326-340 (adjacent test showing proper executor test pattern with ReadRecordStepExecutor instantiation and execute() call)
packages/workflow-executor/test/executors/read-record-step-executor.test.ts lines 1-12 (imports showing both NoRecordsError and ReadRecordStepExecutor are available)

…ain (#1512)

Co-authored-by: alban bertolini <albanb@forestadmin.com>
return promise;
}

private async doExecuteStep(step: PendingStepExecution, key: string): Promise<void> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/runner.ts:236

In doExecuteStep, the step is deleted from inFlightSteps in the finally block (line 253) before updateStepExecution completes (lines 256-267). If runPollCycle executes between the delete and updateStepExecution completing, the same step is refetched from the server, passes the !this.inFlightSteps.has(...) filter on line 204, and gets executed a second time concurrently. Move the delete to after updateStepExecution succeeds or fails, or wrap both in a single try/finally.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/runner.ts around line 236:

In `doExecuteStep`, the step is deleted from `inFlightSteps` in the `finally` block (line 253) before `updateStepExecution` completes (lines 256-267). If `runPollCycle` executes between the delete and `updateStepExecution` completing, the same step is refetched from the server, passes the `!this.inFlightSteps.has(...)` filter on line 204, and gets executed a second time concurrently. Move the `delete` to after `updateStepExecution` succeeds or fails, or wrap both in a single try/finally.

Evidence trail:
packages/workflow-executor/src/runner.ts lines 235-268 (REVIEWED_COMMIT): The `doExecuteStep` method shows the `finally` block with `this.inFlightSteps.delete(key)` at line 253, followed by the `updateStepExecution` call in a separate try block at lines 256-267. The `runPollCycle` method at lines 200-212 shows the filter `!this.inFlightSteps.has(Runner.stepKey(s))` at line 203.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants