From 654f2236b598ed07d81dd12469e1106a2e267664 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 19 Jun 2026 19:40:00 +0200 Subject: [PATCH 1/4] feat(workflow-executor): apply deterministic Load Related Record config (revise-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forward preRecordedArgs from the orchestrator wire through the step-definition mapper (it was dropped) and the ServerWorkflowTaskLoadRelatedRecord type. - Resolve the "Related to" source revise-safely: match a previous load-related step on its own stepIndex OR originalStepIndex (mirrors resolveStepExecution), plus the workflow-start record — instead of a strict own-index match on the flattened pool, which wrongly reported a valid chained source as missing after a revise. - Remove selectedRecordIndex (index-based record pinning): brittle, not revise-safe, unused — the final record stays AI-suggested + user-confirmed. fixes PRD-551 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/server-types.ts | 2 + .../src/adapters/step-definition-mapper.ts | 1 + .../load-related-record-step-executor.ts | 64 ++++----- .../src/types/validated/step-definition.ts | 4 +- .../load-related-record-step-executor.test.ts | 121 ++++++++---------- 5 files changed, 92 insertions(+), 100 deletions(-) diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index f0d56a802a..e582ab4dd3 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -86,6 +86,8 @@ interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase { executionType: | ServerStepExecutionTypeEnum.FullyAutomated | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; + // Deterministic build-time config (PRD-471). Validated by the step-definition schema. + preRecordedArgs?: { selectedRecordStepIndex?: number; 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/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 9813d3d686..e22aa33a8f 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 @@ -24,7 +24,7 @@ import { StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; -import { StepExecutionMode } from '../types/validated/step-definition'; +import { StepExecutionMode, StepType } 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 +145,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; + ? [await this.resolveSourceRecordRef(preRecordedArgs.selectedRecordStepIndex)] + : await this.getAvailableRecordRefs(); const candidates = await this.buildRelationCandidates(sourceRecords); @@ -187,14 +186,36 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor r.stepIndex === stepIndex); + // Revise-safe source resolution. The editor's selectedRecordStepIndex references a step by its + // original graph position; on a revise, indices shift, so match a previous load-related step on + // its own stepIndex OR its originalStepIndex (mirrors resolveStepExecution / getAvailableRecordRefs), + // and also accept the workflow-start record. A strict own-index match would wrongly report a valid + // chained source as "no source record" after a revise. + private async resolveSourceRecordRef(stepIndex: number): Promise { + if (this.context.baseRecordRef.stepIndex === stepIndex) { + return this.context.baseRecordRef; + } + + const sourceStep = this.context.previousSteps.find( + step => + step.stepDefinition.type === StepType.LoadRelatedRecord && + (step.stepOutcome.stepIndex === stepIndex || step.originalStepIndex === stepIndex), + ); - if (!match) { - throw new InvalidPreRecordedArgsError(`No record found at step index ${stepIndex}`); + if (sourceStep) { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const execution = this.resolveStepExecution(sourceStep, stepExecutions); + + if ( + execution?.type === 'load-related-record' && + execution.executionResult !== undefined && + 'record' in execution.executionResult + ) { + return execution.executionResult.record; + } } - return match; + throw new InvalidPreRecordedArgsError(`No record found at step index ${stepIndex}`); } private async buildRelationCandidates(records: RecordRef[]): Promise { @@ -473,29 +494,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 (PRD-471). 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..881e00b827 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -97,10 +97,10 @@ export const LoadRelatedRecordStepDefinitionSchema = z.object({ .catch(AutomatedWithConfirmation), preRecordedArgs: z .object({ + /** "Related to" — the source record, by step index */ selectedRecordStepIndex: z.number().int().optional(), - /** Technical name of the relation to follow */ + /** "From collection" — the relation to follow (technical name) */ relationName: z.string().optional(), - selectedRecordIndex: z.number().int().optional(), }) .optional(), }); 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..d5d9cb3a56 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 @@ -3389,25 +3389,70 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); - it('errors with the pre-recorded-args message when selectedRecordStepIndex matches no record', async () => { + it('resolves selectedRecordStepIndex via originalStepIndex after a revise (revise-safe)', async () => { + // After a revise, the source step is a clone at a shifted own index (4) whose execution is + // persisted at that own index, while the editor reference still points at the original index + // (1). A strict own-index match would report "no source record"; the fallback must resolve it. + 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)], stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { selectedRecordStepIndex: 99 }, + preRecordedArgs: { selectedRecordStepIndex: 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('errors with the pre-recorded-args message when the pinned relation matches nothing', async () => { + it('errors with the pre-recorded-args message when selectedRecordStepIndex matches no record', async () => { const context = makeContext({ stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { relationName: 'nonexistent' }, + preRecordedArgs: { selectedRecordStepIndex: 99 }, }), }); @@ -3417,74 +3462,18 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); }); - 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' }, - }), - ]; - - const { model, bindTools } = makeMockModel(); - const runStore = makeMockRunStore(); - const context = makeContext({ - model, - runStore, - agentPort: makeMockAgentPort(relatedData), - stepDefinition: makeStep({ - executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { relationName: 'address', selectedRecordIndex: 1 }, - }), - }); - const executor = new LoadRelatedRecordStepExecutor(context); - - const result = await executor.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] }), - }), - }), - ); - }); - - 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 () => { From 9f5a1d597ae59acd8323c30e5382a605f5296eda Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Jun 2026 08:11:52 +0200 Subject: [PATCH 2/4] refactor(workflow-executor): reference the source by stable step id, not runtime index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Related to" source is now referenced by the source step's stable BPMN step id (selectedRecordStepId) or the WORKFLOW_START_STEP_ID sentinel, instead of the runtime stepIndex. This is knowable by the editor at build time (the runtime index is not) and survives revisions by construction (clones keep their step id), so the own→originalStepIndex index juggling is gone. In a loop the most recent live-path occurrence wins. fixes PRD-551 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/server-types.ts | 2 +- .../load-related-record-step-executor.ts | 31 +++++----- .../src/types/validated/step-definition.ts | 12 +++- .../load-related-record-step-executor.test.ts | 56 ++++++++++++++----- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index e582ab4dd3..3fbd351fbd 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -87,7 +87,7 @@ interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase { | ServerStepExecutionTypeEnum.FullyAutomated | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; // Deterministic build-time config (PRD-471). Validated by the step-definition schema. - preRecordedArgs?: { selectedRecordStepIndex?: number; relationName?: string }; + preRecordedArgs?: { selectedRecordStepId?: string; relationName?: string }; } export interface ServerWorkflowTaskMcpServer extends ServerWorkflowTaskBase { 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 e22aa33a8f..30f9898bde 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 @@ -24,7 +24,11 @@ import { StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; -import { StepExecutionMode, StepType } 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 " (→ )". @@ -147,8 +151,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { - if (this.context.baseRecordRef.stepIndex === stepIndex) { + // Revise-safe source resolution. The "Related to" reference is a stable BPMN step id (or the + // WORKFLOW_START_STEP_ID sentinel), not a runtime index — so it survives the index shifts a + // revision causes (clones keep their step id) and is knowable by the editor at build time. + // previousSteps are already restricted to the live path; in a loop the same id can appear more + // than once, so we take the most recent occurrence. + private async resolveSourceRecordRef(stepId: string): Promise { + if (stepId === WORKFLOW_START_STEP_ID) { return this.context.baseRecordRef; } - const sourceStep = this.context.previousSteps.find( + const matches = this.context.previousSteps.filter( step => step.stepDefinition.type === StepType.LoadRelatedRecord && - (step.stepOutcome.stepIndex === stepIndex || step.originalStepIndex === stepIndex), + step.stepOutcome.stepId === stepId, ); + const sourceStep = matches[matches.length - 1]; if (sourceStep) { const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); @@ -215,7 +220,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 881e00b827..67370dd4a6 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -97,8 +97,13 @@ export const LoadRelatedRecordStepDefinitionSchema = z.object({ .catch(AutomatedWithConfirmation), preRecordedArgs: z .object({ - /** "Related to" — the source record, by step index */ - selectedRecordStepIndex: z.number().int().optional(), + /** + * "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(), }) @@ -106,6 +111,9 @@ export const LoadRelatedRecordStepDefinitionSchema = z.object({ }); 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 d5d9cb3a56..c945024f40 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,10 +3397,32 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); - it('resolves selectedRecordStepIndex via originalStepIndex after a revise (revise-safe)', async () => { - // After a revise, the source step is a clone at a shifted own index (4) whose execution is - // persisted at that own index, while the editor reference still points at the original index - // (1). A strict own-index match would report "no source record"; the fallback must resolve it. + 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: { selectedRecordStepId: WORKFLOW_START_STEP_ID, relationName: 'order' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + 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('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', @@ -3432,10 +3462,10 @@ describe('LoadRelatedRecordStepExecutor', () => { customers: makeCollectionSchema(), orders: ordersSchema, }), - previousSteps: [makeLoadRelatedPreviousStep(4, 1)], + previousSteps: [makeLoadRelatedPreviousStep(4, 1, 'load-1')], stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { selectedRecordStepIndex: 1, relationName: 'customer' }, + preRecordedArgs: { selectedRecordStepId: 'load-1', relationName: 'customer' }, }), }); @@ -3448,11 +3478,11 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); - it('errors with the pre-recorded-args message when selectedRecordStepIndex matches no record', async () => { + it('errors with the pre-recorded-args message when selectedRecordStepId matches no record', async () => { const context = makeContext({ stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - preRecordedArgs: { selectedRecordStepIndex: 99 }, + preRecordedArgs: { selectedRecordStepId: 'load-missing' }, }), }); From ed69e2b2bb6d906d22887b9a54daa31ddb8288f5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 07:47:55 +0200 Subject: [PATCH 3/4] feat(workflow-executor): distinct "no source record" error (PRD-550) When the "Related to" source step ran but loaded no record, throw SourceRecordMissingError with a clear user message (naming the source step when available) instead of the generic "pre-configured step parameters are invalid". A config pointing at a non-existent step still throws InvalidPreRecordedArgsError. fixes PRD-551 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/workflow-executor/src/errors.ts | 12 ++++++++++++ .../load-related-record-step-executor.ts | 5 +++++ .../load-related-record-step-executor.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 8ad2f7e113..7ee1dcecbf 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 +// (PRD-550 "no source record"). 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 30f9898bde..48ffda96ac 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,6 +21,7 @@ import { NoRelationshipFieldsError, RelatedRecordNotFoundError, RelationNotFoundError, + SourceRecordMissingError, StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; @@ -218,6 +219,10 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { 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({ + runStore, + previousSteps: [makeLoadRelatedPreviousStep(1)], + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { selectedRecordStepId: 'load-1', relationName: 'customer' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain("didn't load any record"); + }); + it('errors with the pre-recorded-args message when the pinned relation matches nothing', async () => { const context = makeContext({ stepDefinition: makeStep({ From 8e8f1677d7c4ad58eef5c57641792e3bd79ef478 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 22 Jun 2026 11:49:51 +0200 Subject: [PATCH 4/4] fix(workflow-executor): address review comments Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/workflow-executor/src/adapters/server-types.ts | 1 - packages/workflow-executor/src/errors.ts | 4 ++-- .../src/executors/load-related-record-step-executor.ts | 9 ++------- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 3fbd351fbd..eb70b27c23 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -86,7 +86,6 @@ interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase { executionType: | ServerStepExecutionTypeEnum.FullyAutomated | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; - // Deterministic build-time config (PRD-471). Validated by the step-definition schema. preRecordedArgs?: { selectedRecordStepId?: string; relationName?: string }; } diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 7ee1dcecbf..3f396015ea 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -378,8 +378,8 @@ export class InvalidPreRecordedArgsError extends WorkflowExecutorError { } } -// The "Related to" source step ran but loaded no record, so this step has nothing to load from -// (PRD-550 "no source record"). Distinct from a bad config — the user can continue without. +// 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'; 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 48ffda96ac..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 @@ -191,11 +191,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { if (stepId === WORKFLOW_START_STEP_ID) { return this.context.baseRecordRef; @@ -220,7 +215,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor