Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/workflow-executor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"test": "jest"
},
"dependencies": {
"@forestadmin/agent-client": "1.4.13",
"@langchain/core": "1.1.33",
"zod": "4.3.6"
}
Expand Down
129 changes: 129 additions & 0 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { AgentPort } from '../ports/agent-port';
import type { ActionRef, CollectionRef, RecordData } from '../types/record';
import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client';

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

function buildPkFilter(
primaryKeyFields: string[],
recordId: Array<string | number>,
): SelectOptions['filters'] {
if (primaryKeyFields.length === 1) {
return { field: primaryKeyFields[0], operator: 'Equal', value: recordId[0] };
}

return {
aggregator: 'And',
conditions: primaryKeyFields.map((field, i) => ({
field,
operator: 'Equal',
value: recordId[i],
})),
};
}

// agent-client methods (update, relation, action) still expect the pipe-encoded string format
function encodePk(recordId: Array<string | number>): string {
return recordId.map(v => String(v)).join('|');
}

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

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 uses a type assertion as string | number on record[field], but when the field is missing, this passes undefined through silently. When the resulting array is passed to encodePk, String(undefined) produces the literal string "undefined", corrupting the record ID. This is reachable in getRelatedData when the API response omits expected primary key fields, or when getCollectionRef defaults to ['id'] for an unknown collection but the actual records use a different key.

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}`);
+    }
+    if (typeof value !== 'string' && typeof value !== 'number') {
+      throw new Error(`Invalid primary key type for ${field}: ${typeof value}`);
+    }
+    return value;
+  });
}
🚀 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` uses a type assertion `as string | number` on `record[field]`, but when the field is missing, this passes `undefined` through silently. When the resulting array is passed to `encodePk`, `String(undefined)` produces the literal string `"undefined"`, corrupting the record ID. This is reachable in `getRelatedData` when the API response omits expected primary key fields, or when `getCollectionRef` defaults to `['id']` for an unknown collection but the actual records use a different key.

Evidence trail:
packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 25-33 (extractRecordId and encodePk functions), lines 73-84 (getRelatedData usage), lines 104-112 (getCollectionRef default to ['id']). JavaScript behavior: `String(undefined)` returns `"undefined"`.


export default class AgentClientAgentPort implements AgentPort {
private readonly client: RemoteAgentClient;
private readonly collectionRefs: Record<string, CollectionRef>;

constructor(params: {
client: RemoteAgentClient;
collectionRefs: Record<string, CollectionRef>;
}) {
this.client = params.client;
this.collectionRefs = params.collectionRefs;
}

async getRecord(collectionName: string, recordId: Array<string | number>): Promise<RecordData> {
const ref = this.getCollectionRef(collectionName);
const records = await this.client.collection(collectionName).list<Record<string, unknown>>({
filters: buildPkFilter(ref.primaryKeyFields, recordId),
pagination: { size: 1, number: 1 },
});

if (records.length === 0) {
throw new RecordNotFoundError(collectionName, encodePk(recordId));
}

return { ...ref, recordId, values: records[0] };
}

async updateRecord(
collectionName: string,
recordId: Array<string | number>,
values: Record<string, unknown>,
): Promise<RecordData> {
const ref = this.getCollectionRef(collectionName);
const updatedRecord = await this.client
.collection(collectionName)
.update<Record<string, unknown>>(encodePk(recordId), values);

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

async getRelatedData(
collectionName: string,
recordId: Array<string | number>,
relationName: string,
): Promise<RecordData[]> {
const relatedRef = this.getCollectionRef(relationName);

const records = await this.client
.collection(collectionName)
.relation(relationName, encodePk(recordId))
.list<Record<string, unknown>>();

return records.map(record => ({
...relatedRef,
recordId: extractRecordId(relatedRef.primaryKeyFields, record),
values: record,
}));
}

async getActions(collectionName: string): Promise<ActionRef[]> {
const ref = this.collectionRefs[collectionName];

return ref ? ref.actions : [];
}

async executeAction(
collectionName: string,
actionName: string,
recordIds: Array<string | number>[],
): Promise<unknown> {
const encodedIds = recordIds.map(id => encodePk(id));
const action = await this.client
.collection(collectionName)
.action(actionName, { recordIds: encodedIds });

return action.execute();
}

private getCollectionRef(collectionName: string): CollectionRef {
const ref = this.collectionRefs[collectionName];

if (!ref) {
return {
collectionName,
collectionDisplayName: collectionName,
primaryKeyFields: ['id'],
fields: [],
actions: [],
};
}

return ref;
}
}
6 changes: 6 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ export class MalformedToolCallError extends WorkflowExecutorError {
this.toolName = toolName;
}
}

export class RecordNotFoundError extends WorkflowExecutorError {
constructor(collectionName: string, recordId: string) {
super(`Record not found: collection "${collectionName}", id "${recordId}"`);
}
}
10 changes: 8 additions & 2 deletions packages/workflow-executor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type {
StepExecutionData,
} from './types/step-execution-data';

export type { RecordFieldRef, RecordRef, RecordData } from './types/record';
export type { RecordFieldRef, ActionRef, CollectionRef, RecordData } from './types/record';

export type {
StepRecord,
Expand All @@ -33,6 +33,12 @@ export type { AgentPort } from './ports/agent-port';
export type { McpConfiguration, WorkflowPort } from './ports/workflow-port';
export type { RunStore } from './ports/run-store';

export { WorkflowExecutorError, MissingToolCallError, MalformedToolCallError } from './errors';
export {
WorkflowExecutorError,
MissingToolCallError,
MalformedToolCallError,
RecordNotFoundError,
} from './errors';
export { default as BaseStepExecutor } from './executors/base-step-executor';
export { default as ConditionStepExecutor } from './executors/condition-step-executor';
export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port';
16 changes: 10 additions & 6 deletions packages/workflow-executor/src/ports/agent-port.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
/** @draft Types derived from the workflow-executor spec -- subject to change. */

import type { RecordData } from '../types/record';
import type { ActionRef, RecordData } from '../types/record';

export interface AgentPort {
getRecord(collectionName: string, recordId: string): Promise<RecordData>;
getRecord(collectionName: string, recordId: Array<string | number>): Promise<RecordData>;
updateRecord(
collectionName: string,
recordId: string,
recordId: Array<string | number>,
values: Record<string, unknown>,
): Promise<RecordData>;
getRelatedData(
collectionName: string,
recordId: string,
recordId: Array<string | number>,
relationName: string,
): Promise<RecordData[]>;
getActions(collectionName: string): Promise<string[]>;
executeAction(collectionName: string, actionName: string, recordIds: string[]): Promise<unknown>;
getActions(collectionName: string): Promise<ActionRef[]>;
executeAction(
collectionName: string,
actionName: string,
recordIds: Array<string | number>[],
): Promise<unknown>;
}
4 changes: 2 additions & 2 deletions packages/workflow-executor/src/ports/workflow-port.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @draft Types derived from the workflow-executor spec -- subject to change. */

import type { PendingStepExecution } from '../types/execution';
import type { RecordRef } from '../types/record';
import type { CollectionRef } from '../types/record';
import type { StepHistory } from '../types/step-history';

/** Placeholder -- will be typed as McpConfiguration from @forestadmin/ai-proxy/mcp-client once added as dependency. */
Expand All @@ -10,6 +10,6 @@ export type McpConfiguration = unknown;
export interface WorkflowPort {
getPendingStepExecutions(): Promise<PendingStepExecution[]>;
completeStepExecution(runId: string, stepHistory: StepHistory): Promise<void>;
getCollectionRef(collectionName: string): Promise<RecordRef>;
getCollectionRef(collectionName: string): Promise<CollectionRef>;
getMcpServerConfigs(): Promise<McpConfiguration[]>;
}
4 changes: 2 additions & 2 deletions packages/workflow-executor/src/types/execution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @draft Types derived from the workflow-executor spec -- subject to change. */

import type { RecordRef } from './record';
import type { CollectionRef } from './record';
import type { StepDefinition } from './step-definition';
import type { StepHistory } from './step-history';
import type { AgentPort } from '../ports/agent-port';
Expand All @@ -20,7 +20,7 @@ export interface PendingStepExecution {
readonly step: StepDefinition;
readonly stepHistory: StepHistory;
readonly previousSteps: ReadonlyArray<StepRecord>;
readonly availableRecords: ReadonlyArray<RecordRef>;
readonly availableRecords: ReadonlyArray<CollectionRef>;
readonly userInput?: UserInput;
}

Expand Down
13 changes: 10 additions & 3 deletions packages/workflow-executor/src/types/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ export interface RecordFieldRef {
referencedCollectionName?: string;
}

export interface RecordRef {
recordId: string;
export interface ActionRef {
name: string;
displayName: string;
}

export interface CollectionRef {
collectionName: string;
collectionDisplayName: string;
primaryKeyFields: string[];
fields: RecordFieldRef[];
actions: ActionRef[];
}

export interface RecordData extends RecordRef {
export interface RecordData extends CollectionRef {
recordId: Array<string | number>;
values: Record<string, unknown>;
}
4 changes: 2 additions & 2 deletions packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @draft Types derived from the workflow-executor spec -- subject to change. */

import type { RecordRef } from './record';
import type { CollectionRef } from './record';

interface BaseStepExecutionData {
stepIndex: number;
Expand All @@ -17,7 +17,7 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData {
executionParams?: Record<string, unknown>;
executionResult?: Record<string, unknown>;
toolConfirmationInterruption?: Record<string, unknown>;
selectedRecordRef?: RecordRef;
selectedRecord?: CollectionRef;
}

export type StepExecutionData = ConditionStepExecutionData | AiTaskStepExecutionData;
Loading
Loading