diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index f0d56a802a..eb70b27c23 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -86,6 +86,7 @@ interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase { executionType: | ServerStepExecutionTypeEnum.FullyAutomated | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; + preRecordedArgs?: { selectedRecordStepId?: string; relationName?: string }; } export interface ServerWorkflowTaskMcpServer extends ServerWorkflowTaskBase { diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index f343a390f2..2242a92355 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -42,6 +42,7 @@ function mapTask(task: ServerWorkflowTask): StepDefinition { return LoadRelatedRecordStepDefinitionSchema.parse({ ...base, type: StepType.LoadRelatedRecord, + preRecordedArgs: task.preRecordedArgs, }); default: throw new InvalidStepDefinitionError( diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 8ad2f7e113..3f396015ea 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -378,6 +378,18 @@ export class InvalidPreRecordedArgsError extends WorkflowExecutorError { } } +// The "Related to" source step ran but loaded no record, so this step has nothing to load from. +// Distinct from a bad config — the user can continue without. +export class SourceRecordMissingError extends WorkflowExecutorError { + constructor(sourceTitle?: string) { + const from = sourceTitle ? `"${sourceTitle}"` : 'its source step'; + super( + `Source step ${from} loaded no record`, + `This step loads from ${from}, but that step didn't load any record, so nothing can be loaded here.`, + ); + } +} + // Boundary error — surfaces from Runner.start() and is caught at the CLI/HTTP layer, not by step executors. export class AgentProbeError extends Error { // Manual `cause` assignment: Error accepts it natively since Node 16.9 but our TS target is ES2020. diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 9813d3d686..3adea358f6 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -21,10 +21,15 @@ import { NoRelationshipFieldsError, RelatedRecordNotFoundError, RelationNotFoundError, + SourceRecordMissingError, StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; -import { StepExecutionMode } from '../types/validated/step-definition'; +import { + StepExecutionMode, + StepType, + WORKFLOW_START_STEP_ID, +} from '../types/validated/step-definition'; const SELECT_RELATION_SYSTEM_PROMPT = `You are an AI agent loading a related record based on a user request. You are given relations to follow, each shown as " (→ )". @@ -145,12 +150,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { preRecordedArgs } = this.context.stepDefinition; - const records = await this.getAvailableRecordRefs(); const sourceRecords = - preRecordedArgs?.selectedRecordStepIndex !== undefined - ? [this.requireRecordAtStepIndex(records, preRecordedArgs.selectedRecordStepIndex)] - : records; + preRecordedArgs?.selectedRecordStepId !== undefined + ? [await this.resolveSourceRecordRef(preRecordedArgs.selectedRecordStepId)] + : await this.getAvailableRecordRefs(); const candidates = await this.buildRelationCandidates(sourceRecords); @@ -187,14 +191,36 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor r.stepIndex === stepIndex); + private async resolveSourceRecordRef(stepId: string): Promise { + if (stepId === WORKFLOW_START_STEP_ID) { + return this.context.baseRecordRef; + } + + const matches = this.context.previousSteps.filter( + step => + step.stepDefinition.type === StepType.LoadRelatedRecord && + step.stepOutcome.stepId === stepId, + ); + const sourceStep = matches[matches.length - 1]; + + if (sourceStep) { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const execution = this.resolveStepExecution(sourceStep, stepExecutions); - if (!match) { - throw new InvalidPreRecordedArgsError(`No record found at step index ${stepIndex}`); + if ( + execution?.type === 'load-related-record' && + execution.executionResult !== undefined && + 'record' in execution.executionResult + ) { + return execution.executionResult.record; + } + + // The source step exists but loaded nothing → clear "no source record" message, + // distinct from a config pointing at a non-existent step. + throw new SourceRecordMissingError(sourceStep.stepDefinition.title); } - return match; + throw new InvalidPreRecordedArgsError(`No source record found for step "${stepId}"`); } private async buildRelationCandidates(records: RecordRef[]): Promise { @@ -473,29 +499,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor= relatedData.length - ) { - throw new InvalidPreRecordedArgsError( - `Record index ${preRecordedArgs.selectedRecordIndex} is out of range (0-${ - relatedData.length - 1 - })`, - ); - } - - return { - relatedData, - bestIndex: preRecordedArgs.selectedRecordIndex, - suggestedFields: [], - relatedSchema, - }; - } - + // The final record stays AI-suggested + user-confirmed — only the source + relation are + // pinned deterministically. Index-based record pinning was removed (not revise-safe). const suggestedFields = await this.selectRelevantFields( relatedSchema, this.context.stepDefinition.prompt, diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index c6e7dcf290..67370dd4a6 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -97,15 +97,23 @@ export const LoadRelatedRecordStepDefinitionSchema = z.object({ .catch(AutomatedWithConfirmation), preRecordedArgs: z .object({ - selectedRecordStepIndex: z.number().int().optional(), - /** Technical name of the relation to follow */ + /** + * "Related to" — the source record, referenced by the stable BPMN step id of the previous + * Load Related Record step that loaded it, or WORKFLOW_START_STEP_ID for the trigger record. + * Stable across revisions (clones keep the id) and known at build time (unlike the runtime + * stepIndex), so the editor can write it deterministically. + */ + selectedRecordStepId: z.string().optional(), + /** "From collection" — the relation to follow (technical name) */ relationName: z.string().optional(), - selectedRecordIndex: z.number().int().optional(), }) .optional(), }); export type LoadRelatedRecordStepDefinition = z.infer; +/** Sentinel "Related to" reference for the record the workflow was triggered on (the base record). */ +export const WORKFLOW_START_STEP_ID = 'workflow-start'; + export const McpStepDefinitionSchema = z.object({ ...sharedFields, type: z.literal(StepType.Mcp), diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index 24f751c622..764b9c1146 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -14,7 +14,11 @@ import AgentWithLog from '../../src/executors/agent-with-log'; import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; import SchemaCache from '../../src/schema-cache'; import SchemaResolver from '../../src/schema-resolver'; -import { StepExecutionMode, StepType } from '../../src/types/validated/step-definition'; +import { + StepExecutionMode, + StepType, + WORKFLOW_START_STEP_ID, +} from '../../src/types/validated/step-definition'; function makeStep( overrides: Partial = {}, @@ -257,7 +261,11 @@ function makePendingExecution( }; } -function makeLoadRelatedPreviousStep(stepIndex: number, originalStepIndex?: number): Step { +function makeLoadRelatedPreviousStep( + stepIndex: number, + originalStepIndex?: number, + stepId = `load-${stepIndex}`, +): Step { return { stepDefinition: { type: StepType.LoadRelatedRecord, @@ -266,7 +274,7 @@ function makeLoadRelatedPreviousStep(stepIndex: number, originalStepIndex?: numb }, stepOutcome: { type: 'record', - stepId: `load-${stepIndex}`, + stepId, stepIndex, status: 'success', }, @@ -3322,7 +3330,7 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); - it('pins the source record via selectedRecordStepIndex (among several records)', async () => { + it('pins the source record via selectedRecordStepId (among several records)', async () => { // Base customers #42 (step 0) + a loaded order #99 (step 1) are both available; // pinning step 1 must make the relation follow the ORDER, not the base customer. const loadedOrder: RecordRef = { collectionName: 'orders', recordId: [99], stepIndex: 1 }; @@ -3367,7 +3375,7 @@ describe('LoadRelatedRecordStepExecutor', () => { previousSteps: [makeLoadRelatedPreviousStep(1)], stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { selectedRecordStepIndex: 1, relationName: 'customer' }, + preRecordedArgs: { selectedRecordStepId: 'load-1', relationName: 'customer' }, }), }); @@ -3389,102 +3397,131 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); - it('errors with the pre-recorded-args message when selectedRecordStepIndex matches no record', async () => { + it('resolves the WORKFLOW_START_STEP_ID sentinel to the base (trigger) record', async () => { + const agentPort = makeMockAgentPort(); + const { model, bindTools } = makeMockModel(); const context = makeContext({ + model, + agentPort, stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { selectedRecordStepIndex: 99 }, + preRecordedArgs: { selectedRecordStepId: WORKFLOW_START_STEP_ID, relationName: 'order' }, }), }); const result = await new LoadRelatedRecordStepExecutor(context).execute(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).not.toHaveBeenCalled(); + // Followed the 'order' relation off the base customer record, not a previous step. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'customers', relation: 'order' }), + expect.objectContaining({ id: 1 }), + ); }); - it('errors with the pre-recorded-args message when the pinned relation matches nothing', async () => { + it('resolves selectedRecordStepId after a revise shifts the index (revise-safe)', async () => { + // After a revise, the source step is a clone at a shifted index (4) but keeps its stable + // step id ('load-1') — what the editor referenced. Resolving by id ignores the index shift. + const loadedOrder: RecordRef = { collectionName: 'orders', recordId: [99], stepIndex: 4 }; + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { + fieldName: 'customer', + displayName: 'Customer', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'customers', + }, + ], + }); + const { model } = makeMockModel(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 4, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: loadedOrder, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const agentPort = makeMockAgentPort([ + makeRelatedRecordData({ collectionName: 'customers', recordId: [7], values: {} }), + ]); const context = makeContext({ + model, + runStore, + agentPort, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), + previousSteps: [makeLoadRelatedPreviousStep(4, 1, 'load-1')], stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { relationName: 'nonexistent' }, + preRecordedArgs: { selectedRecordStepId: 'load-1', relationName: 'customer' }, }), }); const result = await new LoadRelatedRecordStepExecutor(context).execute(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'orders', id: [99], relation: 'customer' }), + expect.objectContaining({ id: 1 }), + ); }); - it('skips AI record selection when selectedRecordIndex is pre-recorded with HasMany', async () => { - const relatedData = [ - makeRelatedRecordData({ - collectionName: 'addresses', - recordId: [101], - values: { city: 'Paris' }, - }), - makeRelatedRecordData({ - collectionName: 'addresses', - recordId: [102], - values: { city: 'Lyon' }, + it('errors with the pre-recorded-args message when selectedRecordStepId matches no record', async () => { + const context = makeContext({ + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { selectedRecordStepId: 'load-missing' }, }), - ]; + }); - const { model, bindTools } = makeMockModel(); - const runStore = makeMockRunStore(); + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); + }); + + it('surfaces a distinct "no source record" message when the source step loaded nothing', async () => { + // The source step exists in the live path but its run-store execution has no record. + const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]) }); const context = makeContext({ - model, runStore, - agentPort: makeMockAgentPort(relatedData), + previousSteps: [makeLoadRelatedPreviousStep(1)], stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { relationName: 'address', selectedRecordIndex: 1 }, + preRecordedArgs: { selectedRecordStepId: 'load-1', relationName: 'customer' }, }), }); - const executor = new LoadRelatedRecordStepExecutor(context); - const result = await executor.execute(); + const result = await new LoadRelatedRecordStepExecutor(context).execute(); - expect(result.stepOutcome.status).toBe('success'); - expect(bindTools).not.toHaveBeenCalled(); - expect(runStore.saveStepExecution).toHaveBeenCalledWith( - 'run-1', - expect.objectContaining({ - executionResult: expect.objectContaining({ - record: expect.objectContaining({ recordId: [102] }), - }), - }), - ); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain("didn't load any record"); }); - it('returns error when selectedRecordIndex is out of range', async () => { - const relatedData = [ - makeRelatedRecordData({ - collectionName: 'addresses', - recordId: [1], - values: { city: 'Paris' }, - }), - makeRelatedRecordData({ - collectionName: 'addresses', - recordId: [2], - values: { city: 'Lyon' }, - }), - ]; - const { model } = makeMockModel(); + it('errors with the pre-recorded-args message when the pinned relation matches nothing', async () => { const context = makeContext({ - model, - agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { relationName: 'address', selectedRecordIndex: 99 }, + preRecordedArgs: { relationName: 'nonexistent' }, }), }); - const executor = new LoadRelatedRecordStepExecutor(context); - const result = await executor.execute(); + const result = await new LoadRelatedRecordStepExecutor(context).execute(); expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); }); it('returns error when a pre-recorded relationName does not resolve', async () => {