diff --git a/packages/agent-client/src/approval-request-creator.ts b/packages/agent-client/src/approval-request-creator.ts index d0adb398ef..2303fc420a 100644 --- a/packages/agent-client/src/approval-request-creator.ts +++ b/packages/agent-client/src/approval-request-creator.ts @@ -9,7 +9,9 @@ export type ApprovalRequestPayload = { inputs: ApprovalRequestInput[]; }; -export type CreateApprovalRequest = (payload: ApprovalRequestPayload) => Promise; +export type CreateApprovalRequest = ( + payload: ApprovalRequestPayload, +) => Promise<{ id: string } | undefined>; const APPROVAL_REQUEST_PATH = '/api/action-approvals'; @@ -19,7 +21,8 @@ export default function makeCreateApprovalRequest(options: { renderingId: number | string; }): CreateApprovalRequest { return async payload => { - await ServerUtils.queryWithBearerToken({ + // JSON:API create response — the created approval's id is the resource id (data.id). + const body = await ServerUtils.queryWithBearerToken<{ data?: { id?: string | number } }>({ forestServerUrl: options.forestServerUrl, bearerToken: options.forestServerToken, method: 'post', @@ -39,5 +42,7 @@ export default function makeCreateApprovalRequest(options: { }, }, }); + + return body?.data?.id ? { id: String(body.data.id) } : undefined; }; } diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index e36d3d4e7c..652661e805 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -83,7 +83,9 @@ export type BaseActionContext = { recordIds?: RecordId[]; }; -export type ActionExecuteResult = { success: string; html?: string } | { approvalRequested: true }; +export type ActionExecuteResult = + | { success: string; html?: string } + | { approvalRequested: true; approvalRequest?: { id: string } }; export type ActionEndpointsByCollection = { [collectionName: string]: { @@ -151,8 +153,10 @@ export default class Action { value: field.getValue(), })); + let approvalRequest: { id: string } | undefined; + try { - await this.createApprovalRequest({ + approvalRequest = await this.createApprovalRequest({ collectionName: this.collectionName, actionName: this.actionName, recordIds: this.ids ?? [], @@ -162,7 +166,7 @@ export default class Action { throw new ApprovalRequestCreationError(cause); } - return { approvalRequested: true }; + return { approvalRequested: true, ...(approvalRequest && { approvalRequest }) }; } throw mapped; diff --git a/packages/agent-client/test/approval-request-creator.test.ts b/packages/agent-client/test/approval-request-creator.test.ts index 6ed3c22786..4ce1df9f75 100644 --- a/packages/agent-client/test/approval-request-creator.test.ts +++ b/packages/agent-client/test/approval-request-creator.test.ts @@ -45,4 +45,35 @@ describe('makeCreateApprovalRequest', () => { }, }); }); + + it('returns the approval id read from the server response data.id', async () => { + queryWithBearerToken.mockResolvedValue({ data: { id: 'req_42', type: 'action-approvals' } }); + const create = makeCreateApprovalRequest({ + forestServerUrl: 'https://api.forestadmin.com', + forestServerToken: 'server-token', + renderingId: 42, + }); + + const result = await create({ + collectionName: 'users', + actionName: 'refund', + recordIds: ['1'], + inputs: [], + }); + + expect(result).toEqual({ id: 'req_42' }); + }); + + it('returns undefined (no throw) when the response carries no usable id', async () => { + queryWithBearerToken.mockResolvedValue({ data: { attributes: { status: 'pending' } } }); + const create = makeCreateApprovalRequest({ + forestServerUrl: 'https://api.forestadmin.com', + forestServerToken: 'server-token', + renderingId: 42, + }); + + await expect( + create({ collectionName: 'users', actionName: 'refund', recordIds: ['1'], inputs: [] }), + ).resolves.toBeUndefined(); + }); }); diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 35d0fbe063..927152b359 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -184,6 +184,30 @@ describe('Action', () => { expect(result).toEqual({ approvalRequested: true }); }); + it('includes the approval request id when the creator returns one', async () => { + fieldsFormStates.getFields.mockReturnValue([] as any); + const createApprovalRequest = jest.fn().mockResolvedValue({ id: 'req_42' }); + const approvalAction = new Action( + 'users', + 'send-email', + httpRequester, + '/forest/actions/send-email', + fieldsFormStates, + ['1'], + undefined, + createApprovalRequest, + ); + httpRequester.query.mockRejectedValue( + new AgentHttpError(403, { + errors: [{ name: 'CustomActionRequiresApprovalError', detail: 'Needs approval' }], + }), + ); + + const result = await approvalAction.execute(); + + expect(result).toEqual({ approvalRequested: true, approvalRequest: { id: 'req_42' } }); + }); + it('throws ApprovalRequestCreationError when filing the approval request fails', async () => { fieldsFormStates.getFields.mockReturnValue([] as any); const createApprovalRequest = jest.fn().mockRejectedValue(new Error('forest server down')); 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 c478638f14..0e79649788 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,8 +1,10 @@ import type { + ActionCaller, ActionForm, ActionFormField, AgentPort, ExecuteActionQuery, + ExecuteActionResult, GetActionFormInfoQuery, GetActionFormQuery, GetRecordQuery, @@ -19,6 +21,7 @@ import type { ActionEndpointsByCollection, SelectOptions } from '@forestadmin/ag import { ActionFormValidationError as ClientActionFormValidationError, ActionRequiresApprovalError as ClientActionRequiresApprovalError, + ApprovalRequestCreationError as ClientApprovalRequestCreationError, HttpRequester, createRemoteAgentClient, } from '@forestadmin/agent-client'; @@ -29,6 +32,7 @@ import { ActionRequiresApprovalError, AgentPortError, AgentProbeError, + ApprovalRequestCreationError, RecordNotFoundError, WorkflowExecutorError, extractErrorMessage, @@ -45,6 +49,10 @@ function mapActionExecutionError(action: string, cause: unknown): unknown { return new ActionFormValidationError(action, cause); } + if (cause instanceof ClientApprovalRequestCreationError) { + return new ApprovalRequestCreationError(action, cause); + } + return cause; } @@ -74,11 +82,18 @@ export default class AgentClientAgentPort implements AgentPort { private readonly agentUrl: string; private readonly authSecret: string; private readonly schemaCache: SchemaCache; - - constructor(params: { agentUrl: string; authSecret: string; schemaCache: SchemaCache }) { + private readonly forestServerUrl?: string; + + constructor(params: { + agentUrl: string; + authSecret: string; + schemaCache: SchemaCache; + forestServerUrl?: string; + }) { this.agentUrl = params.agentUrl; this.authSecret = params.authSecret; this.schemaCache = params.schemaCache; + this.forestServerUrl = params.forestServerUrl; } async getRecord({ collection, id, fields }: GetRecordQuery, user: StepUser): Promise { @@ -216,10 +231,10 @@ export default class AgentClientAgentPort implements AgentPort { async executeAction( { collection, action, id, values }: ExecuteActionQuery, - user: StepUser, - ): Promise { + { user, forestServerToken }: ActionCaller, + ): Promise { return this.callAgent('executeAction', async () => { - const client = this.createClient(user); + const client = this.createClient(user, forestServerToken); const recordIds = id?.length ? [id] : []; const act = await client.collection(collection).action(action, { recordIds }); @@ -234,7 +249,18 @@ export default class AgentClientAgentPort implements AgentPort { } try { - return await act.execute(); + const executeResult = await act.execute(); + + return typeof executeResult === 'object' && + executeResult !== null && + 'approvalRequested' in executeResult + ? { + approvalRequested: true, + ...(executeResult.approvalRequest && { + approvalRequest: executeResult.approvalRequest, + }), + } + : { result: executeResult }; } catch (cause) { throw mapActionExecutionError(action, cause); } @@ -259,7 +285,8 @@ export default class AgentClientAgentPort implements AgentPort { ): Promise { return this.callAgent('getActionForm', async () => { const client = this.createClient(user); - const act = await client.collection(collection).action(action, { recordIds: [id] }); + const recordIds = id?.length ? [id] : []; + const act = await client.collection(collection).action(action, { recordIds }); // Soft-apply so dependent fields are revealed by change hooks; unknown fields (dropped by a // prior hook) come back in skippedFields rather than throwing (mirrors MCP get-action-form). @@ -311,11 +338,20 @@ export default class AgentClientAgentPort implements AgentPort { ); } - private createClient(user: StepUser) { + private createClient(user: StepUser, forestServerToken?: string) { return createRemoteAgentClient({ url: this.agentUrl, token: this.mintToken(user), actionEndpoints: this.buildActionEndpoints(user.renderingId), + ...(this.forestServerUrl && forestServerToken + ? { + forestServer: { + serverUrl: this.forestServerUrl, + serverToken: forestServerToken, + renderingId: user.renderingId, + }, + } + : {}), }); } 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 index bc050f6324..70d72627e7 100644 --- 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 @@ -32,6 +32,10 @@ export default function toUpdateStepRequest( context.selectedOption = outcome.selectedOption; } + if (outcome.type === 'record' && outcome.approvalRequest !== undefined) { + context.approvalRequest = outcome.approvalRequest; + } + const attributes: ServerStepHistoryUpdate = { done: outcome.status !== 'awaiting-input', context, diff --git a/packages/workflow-executor/src/build-workflow-executor.ts b/packages/workflow-executor/src/build-workflow-executor.ts index 1d2aa90163..343e8e895c 100644 --- a/packages/workflow-executor/src/build-workflow-executor.ts +++ b/packages/workflow-executor/src/build-workflow-executor.ts @@ -105,6 +105,7 @@ function buildCommonDependencies(options: ExecutorOptions) { agentUrl: options.agentUrl, authSecret: options.authSecret, schemaCache, + forestServerUrl, }); const activityLogsService = new ActivityLogsService(new ForestHttpApi(), { diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 530c97405c..4f5307879c 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -155,6 +155,17 @@ export class ActionRequiresApprovalError extends WorkflowExecutorError { } } +// Approval-gated action whose approval request couldn't be filed — neither executed nor approved. +export class ApprovalRequestCreationError extends WorkflowExecutorError { + constructor(actionName: string, cause?: unknown) { + super( + `Action "${actionName}" requires an approval, but the approval request could not be created`, + 'This action requires an approval, but the approval request could not be created. Please retry.', + ); + this.cause = cause; + } +} + export class RunStorePortError extends UnavailableError { constructor(operation: string, cause: unknown) { super( diff --git a/packages/workflow-executor/src/executors/agent-with-log.ts b/packages/workflow-executor/src/executors/agent-with-log.ts index 9602fbb0d6..b233b60d1e 100644 --- a/packages/workflow-executor/src/executors/agent-with-log.ts +++ b/packages/workflow-executor/src/executors/agent-with-log.ts @@ -3,6 +3,7 @@ import type { ActionForm, AgentPort, ExecuteActionQuery, + ExecuteActionResult, GetActionFormInfoQuery, GetActionFormQuery, GetRecordQuery, @@ -22,6 +23,7 @@ export interface AgentWithLogDeps { schemaResolver: SchemaResolver; user: StepUser; activityLog: ActivityLog; + forestServerToken?: string; } // Wraps AgentPort and runs each data-access call through the ActivityLog so it records an @@ -37,11 +39,14 @@ export default class AgentWithLog { private readonly activityLog: ActivityLog; + private readonly forestServerToken?: string; + constructor(deps: AgentWithLogDeps) { this.agentPort = deps.agentPort; this.schemaResolver = deps.schemaResolver; this.user = deps.user; this.activityLog = deps.activityLog; + this.forestServerToken = deps.forestServerToken; } async getRecord(query: GetRecordQuery): Promise { @@ -95,7 +100,7 @@ export default class AgentWithLog { ); } - async executeAction(query: ExecuteActionQuery, opts: WriteOptions): Promise { + async executeAction(query: ExecuteActionQuery, opts: WriteOptions): Promise { const { collectionId } = await this.resolveSchema(query.collection); return this.activityLog.track( @@ -107,7 +112,11 @@ export default class AgentWithLog { label: `triggered the action "${query.action}"`, }, { - operation: () => this.agentPort.executeAction(query, this.user), + operation: () => + this.agentPort.executeAction(query, { + user: this.user, + forestServerToken: this.forestServerToken, + }), beforeCall: opts.beforeCall, }, ); diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 0c42454778..cc01e32dec 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -59,6 +59,7 @@ export default class StepExecutorFactory { activityLogPort: ActivityLogPort, fetchRemoteTools: (mcpServerId: string) => Promise, incomingPendingData?: unknown, + forestServerToken?: string, ): Promise { try { const context = StepExecutorFactory.buildContext( @@ -66,6 +67,7 @@ export default class StepExecutorFactory { contextConfig, activityLogPort, incomingPendingData, + forestServerToken, ); switch (step.stepDefinition.type) { @@ -134,6 +136,7 @@ export default class StepExecutorFactory { cfg: StepContextConfig, activityLogPort: ActivityLogPort, incomingPendingData?: unknown, + forestServerToken?: string, ): ExecutionContext { const schemaResolver = new SchemaResolver( cfg.schemaCache, @@ -161,6 +164,7 @@ export default class StepExecutorFactory { schemaResolver, user: step.user, activityLog, + forestServerToken, }), activityLog, runStore: cfg.runStore, 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 109e401ae9..a09af61908 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 @@ -7,6 +7,7 @@ import type { } from '../types/step-execution-data'; import type { ActionSchema, CollectionSchema, RecordRef } from '../types/validated/collection'; import type { TriggerActionStepDefinition } from '../types/validated/step-definition'; +import type { RecordStepStatus } from '../types/validated/step-outcome'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; @@ -41,16 +42,42 @@ Important rules: interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; + isGlobal?: boolean; } export default class TriggerRecordActionStepExecutor extends RecordStepExecutor { + // approvalRequest lives here, off the shared record builder; super spreads it onto the outcome. + protected override buildOutcomeResult(outcome: { + status: RecordStepStatus; + error?: string; + approvalRequest?: { id: string }; + }): StepExecutionResult { + return super.buildOutcomeResult(outcome); + } + protected override async checkIdempotency(): Promise { const existing = await this.findPendingExecution( 'trigger-action', ); if (existing?.idempotencyPhase === 'done') { - return this.buildOutcomeResult({ status: 'success' }); + const result = existing.executionResult; + const isPendingApproval = + result && !('skipped' in result) && result.submissionOutcome === 'pending-approval'; + const approvalRequest = isPendingApproval ? result.approvalRequest : undefined; + + if (isPendingApproval && !approvalRequest) { + this.context.logger( + 'Warn', + 'Replayed a pending-approval step with no approval id; no deep-link available.', + { stepIndex: this.context.stepIndex, renderingId: this.context.user.renderingId }, + ); + } + + return this.buildOutcomeResult({ + status: 'success', + ...(approvalRequest && { approvalRequest }), + }); } if (existing?.idempotencyPhase === 'executing') { @@ -125,12 +152,15 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< selectedRecordRef, displayName: action.displayName, name: action.name, + isGlobal: action.type === 'global', }; const form = await this.context.agent.getActionForm({ collection: selectedRecordRef.collectionName, action: target.name, - id: selectedRecordRef.recordId, + // Global actions run on no record — building the form against one yields the wrong + // record-scoped defaults/dynamic fields (must match executeAction, which omits the id too). + ...(target.isGlobal ? {} : { id: selectedRecordRef.recordId }), }); const hasForm = form.fields.length > 0; @@ -151,6 +181,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< selectedRecordRef, target.name, form, + target.isGlobal, ); const reviewState = { fields: filledForm.fields, aiFilledValues }; @@ -207,6 +238,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< recordRef: RecordRef, action: string, initialForm: ActionForm, + isGlobal?: boolean, ): Promise<{ aiFilledValues: AiFilledFormValue[]; form: ActionForm }> { const MAX_ITERATIONS = 3; const accumulator: Record = {}; @@ -239,7 +271,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< form = await this.context.agent.getActionForm({ collection: recordRef.collectionName, action, - id: recordRef.recordId, + ...(isGlobal ? {} : { id: recordRef.recordId }), values: accumulator, }); @@ -338,11 +370,12 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< ): Promise { const { selectedRecordRef, displayName, name } = target; - const actionResult = await this.context.agent.executeAction( + const outcome = await this.context.agent.executeAction( { collection: selectedRecordRef.collectionName, action: name, - id: selectedRecordRef.recordId, + // Global actions run on no record — omit the id so the approval isn't linked to one. + ...(target.isGlobal ? {} : { id: selectedRecordRef.recordId }), ...(form && { values: form.values }), }, { @@ -356,20 +389,56 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< }, ); + const submission = form && { + submittedBy: 'ai' as const, + submittedValues: form.values, + ...(form.aiFilledValues.length && { aiFilledValues: form.aiFilledValues }), + }; + + if ('approvalRequested' in outcome) { + if (!outcome.approvalRequest) { + // The approval exists server-side but no id came back — the step still succeeds, but + // there's no deep-link to surface. Log it so the missing link isn't a silent mystery. + this.context.logger( + 'Warn', + 'Approval request created but the server returned no id; the step has no approval deep-link.', + { + collectionName: selectedRecordRef.collectionName, + actionName: name, + renderingId: this.context.user.renderingId, + }, + ); + } + + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'trigger-action', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { + success: true, + submissionOutcome: 'pending-approval', + ...(submission || { submittedBy: 'ai' as const }), + ...(outcome.approvalRequest && { approvalRequest: outcome.approvalRequest }), + }, + selectedRecordRef, + idempotencyPhase: 'done', + }); + + return this.buildOutcomeResult({ + status: 'success', + ...(outcome.approvalRequest && { approvalRequest: outcome.approvalRequest }), + }); + } + await this.context.runStore.saveStepExecution(this.context.runId, { type: 'trigger-action', stepIndex: this.context.stepIndex, executionParams: { displayName, name }, executionResult: { success: true, - actionResult, + actionResult: outcome.result, // Form-bearing Full AI: record what the executor submitted. - ...(form && { - submissionOutcome: 'executed', - submittedBy: 'ai', - submittedValues: form.values, - ...(form.aiFilledValues.length && { aiFilledValues: form.aiFilledValues }), - }), + ...(submission && { submissionOutcome: 'executed', ...submission }), }, selectedRecordRef, idempotencyPhase: 'done', diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 6db28360ad..8bb9d128ce 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -48,7 +48,8 @@ export type ExecuteActionQuery = { export type GetActionFormQuery = { collection: string; action: string; - id: Id[]; + // Omitted for global actions (no record context), like ExecuteActionQuery. + id?: Id[]; // Optional values to apply before reading the form back (fires the change hooks so dependent // fields appear/update). Soft-applied: unknown fields are reported in `skippedFields`. values?: Record; @@ -79,6 +80,13 @@ export type GetActionFormInfoQuery = { collection: string; action: string; id: I export type ResolvePolymorphicTypeQuery = { collection: string; id: Id[]; relation: string }; +export type ExecuteActionResult = + | { approvalRequested: true; approvalRequest?: { id: string } } + | { result: unknown }; + +// Kept off StepUser: mintToken splats StepUser into the agent JWT, a token there would leak. +export type ActionCaller = { user: StepUser; forestServerToken?: string }; + export interface AgentPort { getRecord(query: GetRecordQuery, user: StepUser): Promise; updateRecord(query: UpdateRecordQuery, user: StepUser): Promise; @@ -94,7 +102,7 @@ export interface AgentPort { query: ResolvePolymorphicTypeQuery, user: StepUser, ): Promise<{ type: string; id: string } | null>; - executeAction(query: ExecuteActionQuery, user: StepUser): Promise; + executeAction(query: ExecuteActionQuery, caller: ActionCaller): 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 }>; diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index ae07bb9e04..5a0ba81966 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -315,6 +315,7 @@ export default class Runner { this.config.activityLogPortFactory.forRun(currentToken), mcpServerId => this.remoteToolFetcher.fetch(mcpServerId), currentIncomingData, + currentToken, ); result = await executor.execute(); } catch (error) { diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 0585c237d6..d59f5fba15 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -126,6 +126,7 @@ export interface TriggerRecordActionStepExecutionData // Who submitted the action (audit): 'ai' = Full AI (executor), 'user' = AI-assisted // (human via the native front). Absent for formless/legacy flows. submittedBy?: 'ai' | 'user'; + approvalRequest?: { id: string }; } | { skipped: true }; pendingData?: TriggerActionPendingData; diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index 64e79fb5c8..c6c49414b6 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -72,6 +72,7 @@ export const ActionSchemaSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), endpoint: z.string().min(1), + type: z.enum(['single', 'bulk', 'global']).optional(), /** 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. */ diff --git a/packages/workflow-executor/src/types/validated/step-outcome.ts b/packages/workflow-executor/src/types/validated/step-outcome.ts index 964e37e170..58b4337aee 100644 --- a/packages/workflow-executor/src/types/validated/step-outcome.ts +++ b/packages/workflow-executor/src/types/validated/step-outcome.ts @@ -39,6 +39,7 @@ export const RecordStepOutcomeSchema = z ...baseOutcomeFields, type: z.literal('record'), status: RecordStepStatusSchema, + approvalRequest: z.object({ id: z.string() }).optional(), }) .strict(); export type RecordStepOutcome = z.infer; 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 feca9de356..d6b0f78c5c 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 @@ -4,6 +4,7 @@ import type { StepUser } from '../../src/types/execution-context'; import { AgentHttpError, ActionRequiresApprovalError as ClientApprovalError, + ApprovalRequestCreationError as ClientApprovalRequestCreationError, ActionFormValidationError as ClientFormValidationError, HttpRequester, createRemoteAgentClient, @@ -16,6 +17,7 @@ import { ActionRequiresApprovalError, AgentPortError, AgentProbeError, + ApprovalRequestCreationError, RecordNotFoundError, } from '../../src/errors'; import SchemaCache from '../../src/schema-cache'; @@ -49,10 +51,18 @@ jest.mock('@forestadmin/agent-client', () => { } } + class MockApprovalRequestCreationError extends Error { + constructor(public readonly cause?: unknown) { + super('The approval request could not be created.'); + this.name = 'ApprovalRequestCreationError'; + } + } + return { AgentHttpError: MockAgentHttpError, ActionRequiresApprovalError: MockActionRequiresApprovalError, ActionFormValidationError: MockActionFormValidationError, + ApprovalRequestCreationError: MockApprovalRequestCreationError, createRemoteAgentClient: jest.fn(), HttpRequester: { is404Error: jest.fn() }, }; @@ -743,7 +753,7 @@ describe('AgentClientAgentPort', () => { }); describe('executeAction', () => { - it('should forward the RecordId array to agent-client and call execute', async () => { + it('should forward the RecordId array to agent-client and normalize the executed result', async () => { mockAction.execute.mockResolvedValue({ success: 'done' }); const result = await port.executeAction( @@ -752,17 +762,86 @@ describe('AgentClientAgentPort', () => { action: 'sendEmail', id: [1], }, - user, + { user }, ); expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: [[1]] }); - expect(result).toEqual({ success: 'done' }); + // The opaque executed result is wrapped under `result` (port contract). + expect(result).toEqual({ result: { success: 'done' } }); + }); + + it('normalizes an approval-gated submit into approvalRequested + the approval id', async () => { + mockAction.execute.mockResolvedValue({ + approvalRequested: true, + approvalRequest: { id: 'req_42' }, + }); + + const result = await port.executeAction( + { collection: 'users', action: 'sendEmail', id: [1] }, + { user }, + ); + + expect(result).toEqual({ approvalRequested: true, approvalRequest: { id: 'req_42' } }); + }); + + it('wires the forestServer connection into agent-client when a server token is supplied', async () => { + const portWithServer = new AgentClientAgentPort({ + agentUrl: 'http://localhost:3310', + authSecret: 'test-secret', + schemaCache: (port as unknown as { schemaCache: SchemaCache }).schemaCache, + forestServerUrl: 'https://api.forestadmin.com', + }); + mockAction.execute.mockResolvedValue({ success: 'done' }); + + await portWithServer.executeAction( + { collection: 'users', action: 'sendEmail', id: [1] }, + { user, forestServerToken: 'server-token' }, + ); + + expect(mockedCreateRemoteAgentClient).toHaveBeenCalledWith( + expect.objectContaining({ + forestServer: { + serverUrl: 'https://api.forestadmin.com', + serverToken: 'server-token', + renderingId: 1, + }, + }), + ); + }); + + it('omits the forestServer connection when no server token is supplied', async () => { + const portWithServer = new AgentClientAgentPort({ + agentUrl: 'http://localhost:3310', + authSecret: 'test-secret', + schemaCache: (port as unknown as { schemaCache: SchemaCache }).schemaCache, + forestServerUrl: 'https://api.forestadmin.com', + }); + mockAction.execute.mockResolvedValue({ success: 'done' }); + + await portWithServer.executeAction( + { collection: 'users', action: 'sendEmail', id: [1] }, + { user }, + ); + + expect(mockedCreateRemoteAgentClient).toHaveBeenCalledWith( + expect.not.objectContaining({ forestServer: expect.anything() }), + ); + }); + + it('maps an approval-request creation failure to ApprovalRequestCreationError', async () => { + mockAction.execute.mockRejectedValue( + new ClientApprovalRequestCreationError(new Error('forest server down')), + ); + + await expect( + port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }, { user }), + ).rejects.toBeInstanceOf(ApprovalRequestCreationError); }); it('should call execute with empty recordIds when ids is not provided', async () => { mockAction.execute.mockResolvedValue(undefined); - await port.executeAction({ collection: 'users', action: 'archive' }, user); + await port.executeAction({ collection: 'users', action: 'archive' }, { user }); expect(mockCollection.action).toHaveBeenCalledWith('archive', { recordIds: [] }); expect(mockAction.execute).toHaveBeenCalled(); @@ -772,7 +851,7 @@ describe('AgentClientAgentPort', () => { mockAction.execute.mockRejectedValue(new Error('Action failed')); await expect( - port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }, user), + port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }, { user }), ).rejects.toThrow('Action failed'); }); @@ -781,7 +860,7 @@ describe('AgentClientAgentPort', () => { await port.executeAction( { collection: 'users', action: 'refund', id: [1], values: { amount: 50 } }, - user, + { user }, ); expect(mockAction.setFields).toHaveBeenCalledWith({ amount: 50 }); @@ -794,7 +873,7 @@ describe('AgentClientAgentPort', () => { await expect( port.executeAction( { collection: 'users', action: 'refund', id: [1], values: { x: 1 } }, - user, + { user }, ), ).rejects.toBeInstanceOf(ActionFormValidationError); expect(mockAction.execute).not.toHaveBeenCalled(); @@ -804,7 +883,7 @@ describe('AgentClientAgentPort', () => { mockAction.execute.mockRejectedValue(new ClientApprovalError('Needs approval', [7, 9])); const error = await port - .executeAction({ collection: 'users', action: 'refund', id: [1] }, user) + .executeAction({ collection: 'users', action: 'refund', id: [1] }, { user }) .catch((e: unknown) => e); expect(error).toBeInstanceOf(ActionRequiresApprovalError); @@ -815,7 +894,7 @@ describe('AgentClientAgentPort', () => { mockAction.execute.mockRejectedValue(new ClientFormValidationError('invalid')); await expect( - port.executeAction({ collection: 'users', action: 'refund', id: [1] }, user), + port.executeAction({ collection: 'users', action: 'refund', id: [1] }, { user }), ).rejects.toBeInstanceOf(ActionFormValidationError); }); @@ -825,7 +904,7 @@ describe('AgentClientAgentPort', () => { ); await expect( - port.executeAction({ collection: 'users', action: 'refund', id: [1] }, user), + port.executeAction({ collection: 'users', action: 'refund', id: [1] }, { user }), ).rejects.toBeInstanceOf(AgentPortError); }); }); @@ -886,6 +965,15 @@ describe('AgentClientAgentPort', () => { expect(form.requiredFields).toEqual([]); expect(mockAction.tryToSetFields).not.toHaveBeenCalled(); }); + + it('builds the form against no record when the id is omitted (global action)', async () => { + mockAction.getFields.mockReturnValue([]); + + await port.getActionForm({ collection: 'users', action: 'archive' }, user); + + // Parity with executeAction: a global action carries no recordId. + expect(mockCollection.action).toHaveBeenCalledWith('archive', { recordIds: [] }); + }); }); describe('getActionFormInfo', () => { @@ -949,7 +1037,7 @@ describe('AgentClientAgentPort', () => { schemaCache, }); - await customPort.executeAction({ collection: 'users', action: 'refund', id: [1] }, user); + await customPort.executeAction({ collection: 'users', action: 'refund', id: [1] }, { user }); expect(mockedCreateRemoteAgentClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -969,7 +1057,7 @@ describe('AgentClientAgentPort', () => { 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); + await port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }, { user }); expect(mockedCreateRemoteAgentClient).toHaveBeenCalledWith( expect.objectContaining({ 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 index cdef5e425f..ce9819bd42 100644 --- 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 @@ -119,6 +119,36 @@ describe('toUpdateStepRequest', () => { expect(body.executionStatus).toEqual({ type: 'success' }); }); + it('forwards approvalRequest in the context for a pending-approval record outcome', () => { + const outcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 1, + status: 'success', + approvalRequest: { id: 'req_42' }, + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body.stepUpdate.attributes.context).toEqual({ + status: 'success', + approvalRequest: { id: 'req_42' }, + }); + }); + + it('omits approvalRequest from the context when the record outcome has none', () => { + const outcome: StepOutcome = { + type: 'record', + stepId: 'step-1', + stepIndex: 1, + status: 'success', + }; + + const body = toUpdateStepRequest('42', outcome); + + expect(body.stepUpdate.attributes.context).not.toHaveProperty('approvalRequest'); + }); + it('maps an mcp awaiting-input outcome like a record', () => { const outcome: StepOutcome = { type: 'mcp', diff --git a/packages/workflow-executor/test/build-workflow-executor.test.ts b/packages/workflow-executor/test/build-workflow-executor.test.ts index c9e1131af1..781e60c087 100644 --- a/packages/workflow-executor/test/build-workflow-executor.test.ts +++ b/packages/workflow-executor/test/build-workflow-executor.test.ts @@ -88,6 +88,7 @@ describe('buildInMemoryExecutor', () => { agentUrl: 'http://localhost:3310', authSecret: 'test-secret', schemaCache: expect.any(SchemaCache), + forestServerUrl: 'https://api.forestadmin.com', }); }); diff --git a/packages/workflow-executor/test/executors/agent-with-log.test.ts b/packages/workflow-executor/test/executors/agent-with-log.test.ts index 45d35c2ec6..7ab13c7179 100644 --- a/packages/workflow-executor/test/executors/agent-with-log.test.ts +++ b/packages/workflow-executor/test/executors/agent-with-log.test.ts @@ -245,6 +245,21 @@ describe('AgentWithLog', () => { expect.objectContaining({ action: 'action', type: 'write', recordId: [42] }), ); }); + + it('forwards the forestServerToken to the port (enables approval-request creation)', async () => { + const { deps, agentPort } = makeDeps({ forestServerToken: 'server-token' }); + const agent = new AgentWithLog(deps); + + await agent.executeAction( + { collection: 'customers', action: 'send-email', id: [42] }, + { beforeCall: async () => undefined }, + ); + + expect(agentPort.executeAction).toHaveBeenCalledWith( + { collection: 'customers', action: 'send-email', id: [42] }, + { user: expect.objectContaining({ id: 1 }), forestServerToken: 'server-token' }, + ); + }); }); describe('getActionFormInfo (unaudited passthrough)', () => { 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 f6037a9fa1..bcf25ca224 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 @@ -47,7 +47,7 @@ function makeMockAgentPort(): AgentPort { getRecord: jest.fn(), updateRecord: jest.fn(), getRelatedData: jest.fn(), - executeAction: jest.fn().mockResolvedValue(undefined), + executeAction: jest.fn().mockResolvedValue({ result: undefined }), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), // Default: a formless action (no fields) — matches the prior behavior of most tests. getActionForm: jest @@ -207,7 +207,9 @@ describe('TriggerRecordActionStepExecutor', () => { describe('executionType=FullyAutomated: trigger direct (Branch B)', () => { it('triggers the action and returns success', async () => { const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ + result: { message: 'Email sent' }, + }); const mockModel = makeMockModel({ actionName: 'Send Welcome Email', reasoning: 'User requested welcome email', @@ -226,7 +228,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( { collection: 'customers', action: 'send-welcome-email', id: [42] }, - expect.objectContaining({ id: 1 }), + { user: expect.objectContaining({ id: 1 }), forestServerToken: undefined }, ); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -245,12 +247,258 @@ describe('TriggerRecordActionStepExecutor', () => { }), ); }); + + it('does NOT attach a record when the action is global', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); + const context = makeContext({ + agentPort, + // The selected action is global → it runs on no record. + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema({ + actions: [ + { + name: 'send-welcome-email', + displayName: 'Send Welcome Email', + endpoint: '/forest/actions/send-welcome-email', + type: 'global', + }, + ], + }), + }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + // The query carries no `id` for a global action. + const query = (agentPort.executeAction as jest.Mock).mock.calls[0][0]; + expect(query).toEqual({ collection: 'customers', action: 'send-welcome-email' }); + expect('id' in query).toBe(false); + }); + + it('builds the form without a record id for a global action', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getActionForm as jest.Mock) + .mockResolvedValueOnce({ + fields: [{ name: 'amount', type: 'Number', isRequired: true }], + canExecute: false, + requiredFields: ['amount'], + skippedFields: [], + }) + .mockResolvedValueOnce({ + fields: [{ name: 'amount', type: 'Number', isRequired: true, value: 50 }], + canExecute: true, + requiredFields: [], + skippedFields: [], + }); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); + const context = makeContext({ + model: makeMockModel({ values: { amount: 50 } }, 'fill_action_form').model, + agentPort, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema({ + actions: [ + { + name: 'send-welcome-email', + displayName: 'Send Welcome Email', + endpoint: '/forest/actions/send-welcome-email', + type: 'global', + }, + ], + }), + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { + selectedRecordStepId: 'workflow-start', + actionName: 'send-welcome-email', + }, + }), + }); + + await new TriggerRecordActionStepExecutor(context).execute(); + + // Both the detection call and the AI-fill re-fetch must build the form against no record. + const { calls } = (agentPort.getActionForm as jest.Mock).mock; + expect(calls.length).toBeGreaterThanOrEqual(2); + calls.forEach(([formQuery]) => expect('id' in formQuery).toBe(false)); + }); + + it('files an approval request (non-blocking success) when the action is approval-gated', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ + approvalRequested: true, + approvalRequest: { id: 'req_42' }, + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + agentPort, + runStore, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + // Non-blocking: the run continues (success), not awaiting-input. + expect(result.stepOutcome).toEqual( + expect.objectContaining({ status: 'success', approvalRequest: { id: 'req_42' } }), + ); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { + success: true, + submissionOutcome: 'pending-approval', + submittedBy: 'ai', + approvalRequest: { id: 'req_42' }, + }, + }), + ); + // No action result was produced — the action did not execute. + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)![1]; + expect('actionResult' in saved.executionResult).toBe(false); + }); + + it('records pending-approval without an id when the server returned none', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ approvalRequested: true }); + const runStore = makeMockRunStore(); + const context = makeContext({ + agentPort, + runStore, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect('approvalRequest' in result.stepOutcome).toBe(false); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)![1]; + expect(saved.executionResult.submissionOutcome).toBe('pending-approval'); + expect('approvalRequest' in saved.executionResult).toBe(false); + }); + + it('rebuilds the approval outcome on idempotent replay (phase=done)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { + success: true, + submissionOutcome: 'pending-approval', + submittedBy: 'ai', + approvalRequest: { id: 'req_42' }, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const agentPort = makeMockAgentPort(); + const context = makeContext({ + agentPort, + runStore, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome).toEqual( + expect.objectContaining({ status: 'success', approvalRequest: { id: 'req_42' } }), + ); + // Replay must NOT re-trigger the action. + expect(agentPort.executeAction).not.toHaveBeenCalled(); + }); + + it('rebuilds an executed outcome on replay (phase=done) without an approvalRequest', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { + success: true, + submissionOutcome: 'executed', + submittedBy: 'ai', + actionResult: { ok: true }, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const agentPort = makeMockAgentPort(); + const context = makeContext({ agentPort, runStore }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(result.stepOutcome).not.toHaveProperty('approvalRequest'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + }); + + it('warns and rebuilds success on replay of a pending-approval step with no approval id', async () => { + const logger = jest.fn(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { + success: true, + submissionOutcome: 'pending-approval', + submittedBy: 'ai', + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const agentPort = makeMockAgentPort(); + const context = makeContext({ agentPort, runStore, logger }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(result.stepOutcome).not.toHaveProperty('approvalRequest'); + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('no approval id'), + expect.anything(), + ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + }); + + it('rebuilds a success outcome on replay (phase=done) when the step was skipped', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 0, + idempotencyPhase: 'done', + executionResult: { skipped: true }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const agentPort = makeMockAgentPort(); + const context = makeContext({ agentPort, runStore }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(result.stepOutcome).not.toHaveProperty('approvalRequest'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + }); }); describe('operation activity log', () => { it('logs the action against the acted record and its collection, not the trigger', async () => { const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockResolvedValue({ ok: true }); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); const activityLogPort = { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), markSucceeded: jest.fn().mockResolvedValue(undefined), @@ -328,7 +576,7 @@ describe('TriggerRecordActionStepExecutor', () => { ]), }); const agentPort = makeMockAgentPort(); - (agentPort.executeAction as jest.Mock).mockResolvedValue({ ok: true }); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); const activityLogPort = { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), markSucceeded: jest.fn().mockResolvedValue(undefined), @@ -689,7 +937,7 @@ describe('TriggerRecordActionStepExecutor', () => { it('fills every required field then submits the action with the AI values', async () => { const agentPort = makeMockAgentPort(); mockFillThenComplete(agentPort); - (agentPort.executeAction as jest.Mock).mockResolvedValue({ success: 'ok' }); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { success: 'ok' } }); const runStore = makeMockRunStore(); const result = await new TriggerRecordActionStepExecutor( @@ -699,7 +947,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( expect.objectContaining({ action: 'send-welcome-email', values: { amount: 50 } }), - expect.anything(), + { user: expect.anything(), forestServerToken: undefined }, ); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -929,7 +1177,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( { collection: 'customers', action: 'archive', id: [42] }, - expect.objectContaining({ id: 1 }), + { user: expect.objectContaining({ id: 1 }), forestServerToken: undefined }, ); }); @@ -959,7 +1207,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( { collection: 'customers', action: 'archive', id: [42] }, - expect.objectContaining({ id: 1 }), + { user: expect.objectContaining({ id: 1 }), forestServerToken: undefined }, ); }); }); @@ -1332,7 +1580,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(mockModel.bindTools).not.toHaveBeenCalled(); expect(agentPort.executeAction).toHaveBeenCalledWith( expect.objectContaining({ action: 'send-welcome-email' }), - context.user, + { user: context.user, forestServerToken: undefined }, ); // Pre-recorded reference is the technical name; the persisted displayName is resolved // from the schema, not received on the wire. @@ -1416,7 +1664,7 @@ describe('TriggerRecordActionStepExecutor', () => { // Triggers action A ('archive'), not action B ('send' / displayName 'archive'). expect(agentPort.executeAction).toHaveBeenCalledWith( expect.objectContaining({ action: 'archive' }), - context.user, + { user: context.user, forestServerToken: undefined }, ); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -1590,7 +1838,7 @@ describe('TriggerRecordActionStepExecutor', () => { action: 'send-welcome-email', id: [42], }), - context.user, + { user: context.user, forestServerToken: undefined }, ); }); diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index 74b12b05a6..7bfe84ff6c 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -166,7 +166,7 @@ function createMockAgentPort(): jest.Mocked { getRelatedData: jest.fn().mockResolvedValue([]), getSingleRelatedData: jest.fn().mockResolvedValue(null), resolvePolymorphicType: jest.fn().mockResolvedValue(null), - executeAction: jest.fn().mockResolvedValue(undefined), + executeAction: jest.fn().mockResolvedValue({ result: undefined }), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), getActionForm: jest .fn() diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index e0afcde406..d18cccef6d 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -1255,6 +1255,7 @@ describe('chain', () => { expect.anything(), expect.any(Function), { userConfirmed: true }, + 'token-0', ); expect(createSpy).toHaveBeenNthCalledWith( 2, @@ -1263,6 +1264,7 @@ describe('chain', () => { expect.anything(), expect.any(Function), undefined, + 'token-1', ); createSpy.mockRestore(); @@ -2350,6 +2352,7 @@ describe('triggerPoll with options', () => { expect.anything(), expect.any(Function), { userConfirmed: true, value: 'new' }, + 'test-forest-token', ); createSpy.mockRestore(); @@ -2379,6 +2382,7 @@ describe('triggerPoll with options', () => { expect.anything(), expect.any(Function), undefined, + 'test-forest-token', ); createSpy.mockRestore();