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 0287864633..9579bfad1f 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 @@ -42,6 +42,9 @@ Important rules: interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; + // A global action runs on no record — the executor must not attach one (record_ids stays empty, + // so an approval request it triggers isn't wrongly linked to a record). + isGlobal?: boolean; } export default class TriggerRecordActionStepExecutor extends RecordStepExecutor { @@ -146,6 +149,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< selectedRecordRef, displayName: action.displayName, name: action.name, + isGlobal: action.type === 'global', }; const form = await this.context.agent.getActionForm({ @@ -363,7 +367,9 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< { collection: selectedRecordRef.collectionName, action: name, - id: selectedRecordRef.recordId, + // A global action runs on no record: omit the id so it isn't attached (notably to the + // approval request it may trigger). Single/bulk actions keep their record. + ...(target.isGlobal ? {} : { id: selectedRecordRef.recordId }), ...(form && { values: form.values }), }, { diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index 64e79fb5c8..113f12affd 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -72,6 +72,9 @@ export const ActionSchemaSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), endpoint: z.string().min(1), + // Action scope. Optional for resilience to orchestrator drift; a 'global' action runs on no + // record, so the executor must not attach one (e.g. to an approval request). + 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/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index 426d3d76b6..02ac45ce8e 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 @@ -249,6 +249,36 @@ 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('files an approval request (non-blocking success) when the action is approval-gated', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockResolvedValue({