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 f492d3615c..8ea5f57d60 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -244,10 +244,29 @@ export default class AgentClientAgentPort implements AgentPort { user, ); - const linkage = parent.values[toCamelCase(relation)] as - | Record - | null - | undefined; + const raw = parent.values[toCamelCase(relation)]; + + // A forest-rails smart field declared with `field ... reference:` serializes as a plain + // attribute, so the value IS the related id. The same apimap shape coming from the + // `belongs_to` DSL, and every forest-express reference field, still arrives as a linkage — + // hence a check on the runtime shape rather than on the schema. + // An empty string is the idiomatic Ruby answer for an unset association (`&.id.to_s`), and it + // would serialize to an id-less by-id URL, which agents route to the index action instead. + if (raw != null && raw !== '' && typeof raw !== 'object') { + return this.getRecord( + { + collection: relatedSchema.collectionName, + // Only a composite key is pipe-packed. The value of a smart field is written by the + // client, so splitting a single-key id would tear a legitimate "a|b" in two. + id: + relatedSchema.primaryKeyFields.length > 1 ? String(raw).split('|') : [raw as string], + ...(fields?.length ? { fields } : {}), + }, + user, + ); + } + + const linkage = raw as Record | null | undefined; const packedId = linkage?.id as string | undefined; if (!linkage || !packedId) return null; 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 81d62f4aaa..366b26d412 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 @@ -112,6 +112,7 @@ describe('AgentClientAgentPort', () => { let mockCollection: ReturnType['mockCollection']; let mockRelation: ReturnType['mockRelation']; let mockAction: ReturnType['mockAction']; + let mockClient: ReturnType['client']; let user: StepUser; let port: AgentClientAgentPort; @@ -119,7 +120,7 @@ describe('AgentClientAgentPort', () => { jest.clearAllMocks(); const mocks = createMockClient(); - ({ mockCollection, mockRelation, mockAction } = mocks); + ({ mockCollection, mockRelation, mockAction, client: mockClient } = mocks); mockedCreateRemoteAgentClient.mockReturnValue(mocks.client as any); const schemaCache = new SchemaCache(); @@ -846,6 +847,131 @@ describe('AgentClientAgentPort', () => { expect(result?.recordId).toEqual(['acme', '7']); }); + it('reads the target by id when the projected value is a scalar', async () => { + mockCollection.getOne + .mockResolvedValueOnce({ card: 'uuid-1' }) + .mockResolvedValueOnce({ id: 'uuid-1', reference: 'CARD-1' }); + + const result = await port.getSingleRelatedData( + { + collection: 'claims', + id: [42], + relation: 'card', + relatedSchema: { ...ordersSchema, collectionName: 'cards' }, + }, + user, + ); + + expect(mockCollection.getOne).toHaveBeenNthCalledWith(1, [42], { fields: ['card@@@id'] }); + expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['uuid-1'], {}); + expect(mockClient.collection).toHaveBeenCalledWith('cards'); + expect(result).toEqual({ + collectionName: 'cards', + recordId: ['uuid-1'], + values: { id: 'uuid-1', reference: 'CARD-1' }, + }); + }); + + it('passes the caller fields to the scalar target read', async () => { + mockCollection.getOne + .mockResolvedValueOnce({ card: 'uuid-1' }) + .mockResolvedValueOnce({ reference: 'CARD-1' }); + + await port.getSingleRelatedData( + { + collection: 'claims', + id: [42], + relation: 'card', + relatedSchema: { ...ordersSchema, collectionName: 'cards' }, + fields: ['reference'], + }, + user, + ); + + expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['uuid-1'], { + fields: ['reference'], + }); + }); + + it('splits a scalar attribute only when the target key is composite', async () => { + mockCollection.getOne + .mockResolvedValueOnce({ card: 'acme|7' }) + .mockResolvedValueOnce({ id: 'acme|7' }); + + const result = await port.getSingleRelatedData( + { + collection: 'claims', + id: [42], + relation: 'card', + relatedSchema: { + ...ordersSchema, + collectionName: 'cards', + primaryKeyFields: ['tenantId', 'cardId'], + }, + }, + user, + ); + + expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['acme', '7'], {}); + expect(result?.recordId).toEqual(['acme', '7']); + }); + + // The value is produced by client-written code, so a pipe in it is data, not packing. + it('keeps a pipe in a scalar attribute intact when the target key is single', async () => { + mockCollection.getOne + .mockResolvedValueOnce({ card: 'acme|corp' }) + .mockResolvedValueOnce({ id: 'acme|corp' }); + + const result = await port.getSingleRelatedData( + { + collection: 'claims', + id: [42], + relation: 'card', + relatedSchema: { ...ordersSchema, collectionName: 'cards' }, + }, + user, + ); + + expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['acme|corp'], {}); + expect(result?.recordId).toEqual(['acme|corp']); + }); + + it('returns null when the scalar attribute is null, without reading the target', async () => { + mockCollection.getOne.mockResolvedValue({ card: null }); + + const result = await port.getSingleRelatedData( + { + collection: 'claims', + id: [42], + relation: 'card', + relatedSchema: { ...ordersSchema, collectionName: 'cards' }, + }, + user, + ); + + expect(result).toBeNull(); + expect(mockCollection.getOne).toHaveBeenCalledTimes(1); + }); + + // `object.card&.id.to_s` is the idiomatic Ruby getter, and it answers "" for an unset + // association. Reading it as an id would build an id-less URL that agents route to the index. + it('returns null when the scalar attribute is an empty string, without reading the target', async () => { + mockCollection.getOne.mockResolvedValue({ card: '' }); + + const result = await port.getSingleRelatedData( + { + collection: 'claims', + id: [42], + relation: 'card', + relatedSchema: { ...ordersSchema, collectionName: 'cards' }, + }, + user, + ); + + expect(result).toBeNull(); + expect(mockCollection.getOne).toHaveBeenCalledTimes(1); + }); + it('returns null when the parent has no linkage to the xToOne relation', async () => { mockCollection.getOne.mockResolvedValue({ order: null }); 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 c5f9c99f57..b4ff5d5daf 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 @@ -4344,6 +4344,57 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); + // Regression: a forest-rails smart field reaches the executor as a plain BelongsTo now that the + // server derives the relation from a `reference` whose target is in the apimap. Pinning it must + // follow it, where it used to fail as an invalid pre-recorded arg — the field was not a relation. + it('follows a pinned reference-only BelongsTo', async () => { + const { model, bindTools } = makeMockModel(); + const runStore = makeMockRunStore(); + const agentPort = makeMockAgentPort([ + makeRelatedRecordData({ collectionName: 'cards', recordId: ['card-1'], values: {} }), + ]); + const context = makeContext({ + model, + runStore, + agentPort, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema({ + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { + fieldName: 'card', + displayName: 'Card', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'cards', + }, + ], + }), + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'card' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).not.toHaveBeenCalled(); + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + relation: 'card', + relatedSchema: expect.objectContaining({ collectionName: 'cards' }), + }), + expect.anything(), + ); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ executionParams: { displayName: 'Card', name: 'card' } }), + ); + }); + 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.