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

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

isGlobal is only set on the transient ActionTarget in handleFirstCall, but pauseForConfirmation never persists it — only displayName, name, and selectedRecordRef are saved. On re-entry in Branch A, saveFrontendResult rebuilds the target without isGlobal, so the flag is lost. Because paused flows execute natively on the frontend, a global action in Manual or AutomatedWithConfirmation mode keeps carrying the source selectedRecordRef, causing the frontend to submit the source record and create approval requests wrongly linked to it. Consider persisting isGlobal in pauseForConfirmation's pendingData and restoring it when rebuilding the target in the confirmation handler.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts around line 136:

`isGlobal` is only set on the transient `ActionTarget` in `handleFirstCall`, but `pauseForConfirmation` never persists it — only `displayName`, `name`, and `selectedRecordRef` are saved. On re-entry in Branch A, `saveFrontendResult` rebuilds the target without `isGlobal`, so the flag is lost. Because paused flows execute natively on the frontend, a global action in `Manual` or `AutomatedWithConfirmation` mode keeps carrying the source `selectedRecordRef`, causing the frontend to submit the source record and create approval requests wrongly linked to it. Consider persisting `isGlobal` in `pauseForConfirmation`'s `pendingData` and restoring it when rebuilding the target in the confirmation handler.

Original file line number Diff line number Diff line change
Expand Up @@ -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<TriggerActionStepDefinition> {
Expand Down Expand Up @@ -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({

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/trigger-record-action-step-executor.ts:155

handleFirstCall sets isGlobal on target and executeOnExecutor omits the record id for global actions, but the getActionForm call at line 156 always passes id: selectedRecordRef.recordId. AgentPort.getActionForm forwards that as recordIds: [id], so global actions with forms or load/change hooks still run in the context of the source record — showing record-scoped form data, triggering record-dependent hooks, or failing when the global action expects no record. The fix should also omit the id here when target.isGlobal is true.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts around line 155:

`handleFirstCall` sets `isGlobal` on `target` and `executeOnExecutor` omits the record id for global actions, but the `getActionForm` call at line 156 always passes `id: selectedRecordRef.recordId`. `AgentPort.getActionForm` forwards that as `recordIds: [id]`, so global actions with forms or load/change hooks still run in the context of the source record — showing record-scoped form data, triggering record-dependent hooks, or failing when the global action expects no record. The fix should also omit the id here when `target.isGlobal` is true.

Expand Down Expand Up @@ -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 }),
},
{
Expand Down
3 changes: 3 additions & 0 deletions packages/workflow-executor/src/types/validated/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,36 @@
);
});

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({
Expand Down Expand Up @@ -280,7 +310,7 @@
}),
);
// No action result was produced — the action did not execute.
const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)![1];

Check warning on line 313 in packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion
expect('actionResult' in saved.executionResult).toBe(false);
});

Expand All @@ -298,7 +328,7 @@

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];

Check warning on line 331 in packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion
expect(saved.executionResult.submissionOutcome).toBe('pending-approval');
expect('approvalRequest' in saved.executionResult).toBe(false);
});
Expand Down
Loading