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 c478813986..8046319029 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -5,6 +5,7 @@ import type { GetRecordQuery, GetRelatedDataQuery, GetSingleRelatedDataQuery, + ResolvePolymorphicTypeQuery, UpdateRecordQuery, } from '../ports/agent-port'; import type SchemaCache from '../schema-cache'; @@ -222,9 +223,9 @@ export default class AgentClientAgentPort implements AgentPort { } } - private createClient(user: StepUser) { + private mintToken(user: StepUser): string { // snake_case aliases: Ruby/Python agents splat JWT claims into Caller.new (snake_case kwargs). - const token = jsonwebtoken.sign( + return jsonwebtoken.sign( { ...user, first_name: user.firstName, @@ -236,14 +237,49 @@ export default class AgentClientAgentPort implements AgentPort { this.authSecret, { expiresIn: '5m' }, ); + } + private createClient(user: StepUser) { return createRemoteAgentClient({ url: this.agentUrl, - token, + token: this.mintToken(user), actionEndpoints: this.buildActionEndpoints(), }); } + // The agent-client deserializer drops relationship `type`, so read the raw record-with-projection + // response: `data.relationships..data = { type, id }`. No UI-exposed discriminator needed. + async resolvePolymorphicType( + { collection, id, relation }: ResolvePolymorphicTypeQuery, + user: StepUser, + ): Promise<{ type: string; id: string } | null> { + return this.callAgent('resolvePolymorphicType', async () => { + const recordId = id.map(String).join('|'); + const params = new URLSearchParams({ + [`fields[${collection}]`]: relation, + [`fields[${relation}]`]: 'id', + timezone: 'Europe/Paris', // matches HttpRequester's default + }); + const base = this.agentUrl.replace(/\/+$/, ''); + const response = await fetch(`${base}/forest/${collection}/${recordId}?${params}`, { + headers: { Authorization: `Bearer ${this.mintToken(user)}`, Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error( + `resolvePolymorphicType ${collection}/${recordId}: HTTP ${response.status}`, + ); + } + + const body = (await response.json()) as { + data?: { relationships?: Record }; + }; + const linkage = body?.data?.relationships?.[relation]?.data; + + return linkage?.type ? { type: String(linkage.type), id: String(linkage.id) } : null; + }); + } + // Hits GET /forest/ (public, no auth required across all agent versions). A 4xx here means // the URL points to something that isn't a Forest agent. JWT is validated naturally on first step. async probe(): Promise { diff --git a/packages/workflow-executor/src/executors/agent-with-log.ts b/packages/workflow-executor/src/executors/agent-with-log.ts index a2f2fb16cd..fa9ef13cb6 100644 --- a/packages/workflow-executor/src/executors/agent-with-log.ts +++ b/packages/workflow-executor/src/executors/agent-with-log.ts @@ -6,6 +6,7 @@ import type { GetRecordQuery, GetRelatedDataQuery, GetSingleRelatedDataQuery, + ResolvePolymorphicTypeQuery, UpdateRecordQuery, } from '../ports/agent-port'; import type SchemaResolver from '../schema-resolver'; @@ -116,6 +117,14 @@ export default class AgentWithLog { return this.agentPort.getActionFormInfo(query, this.user); } + // Unaudited passthrough: resolves a polymorphic relation's target type (metadata probe). The + // actual related-record load is audited separately, so this records NO activity-log entry. + resolvePolymorphicType( + query: ResolvePolymorphicTypeQuery, + ): Promise<{ type: string; id: string } | null> { + return this.agentPort.resolvePolymorphicType(query, this.user); + } + // ISO with the browser engine: `list relation ""`. The query carries the technical // relation name; resolve its displayName from the source schema, falling back to the technical // name when the field is absent (resilient to orchestrator schema drift). 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 ecccf20adc..3ee7e2c02b 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 @@ -62,13 +62,20 @@ interface RelationTarget extends RelationRef { } // A relationship reachable from one available record — the unit the AI chooses among. -// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates filters on it). +// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates resolves it, statically +// or — for polymorphic relations — per record from the discriminator). interface RelationCandidate { record: RecordRef; schema: CollectionSchema; field: FieldSchema & { relatedCollectionName: string }; } +// Followable = has a static target (relatedCollectionName) or is a polymorphic relation resolvable +// per record (polymorphicTypeField names the discriminator). The concrete target is resolved later. +function isFollowableRelation(field: FieldSchema): boolean { + return field.isRelationship && Boolean(field.relatedCollectionName || field.polymorphicTypeField); +} + export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore @@ -101,7 +108,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { + if (field.relatedCollectionName) return field.relatedCollectionName; + if (!field.polymorphicTypeField) return null; + + const linkage = await this.context.agent.resolvePolymorphicType({ + collection: record.collectionName, + id: record.recordId, + relation: field.fieldName, + }); + const discriminator = linkage?.type; + if (!discriminator) return null; + + const models = field.polymorphicReferencedModels ?? []; + + return ( + models.find(m => m === discriminator) ?? + models.find(m => m.toLowerCase() === discriminator.toLowerCase()) ?? + null + ); + } + + private async buildTarget( schema: CollectionSchema, relationName: string, selectedRecordRef: RecordRef, - ): RelationTarget { + ): Promise { const field = this.findFieldByTechnicalName(schema, relationName); if (!field) { throw new RelationNotFoundError(relationName, schema.collectionName); } - if (!field.relatedCollectionName) { + const relatedCollectionName = await this.resolveTargetCollection(field, selectedRecordRef); + + if (!relatedCollectionName) { throw new StepStateError( `Step at index ${this.context.stepIndex} could not resolve relatedCollectionName for relation "${relationName}"`, ); @@ -233,7 +272,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor f.isRelationship) + .filter(isFollowableRelation) .map(f => ({ name: f.fieldName, displayName: f.displayName })); await this.context.runStore.saveStepExecution(this.context.runId, { @@ -395,18 +434,21 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor f.fieldName === name); + const relatedCollectionName = + field && (await this.resolveTargetCollection(field, selectedRecordRef)); - if (!field?.relatedCollectionName) { + if (!relatedCollectionName) { throw new StepStateError( `Step at index ${this.context.stepIndex} could not resolve relatedCollectionName for relation "${name}"`, ); } const record: RecordRef = { - collectionName: field.relatedCollectionName, + collectionName: relatedCollectionName, recordId: selectedRecordId, stepIndex: this.context.stepIndex, }; diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 51ba9e2c4e..f4c7fc2463 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -39,6 +39,8 @@ export type ExecuteActionQuery = { collection: string; action: string; id?: Id[] export type GetActionFormInfoQuery = { collection: string; action: string; id: Id[] }; +export type ResolvePolymorphicTypeQuery = { collection: string; id: Id[]; relation: string }; + export interface AgentPort { getRecord(query: GetRecordQuery, user: StepUser): Promise; updateRecord(query: UpdateRecordQuery, user: StepUser): Promise; @@ -48,6 +50,12 @@ export interface AgentPort { query: GetSingleRelatedDataQuery, user: StepUser, ): Promise; + // Reads a polymorphic relation's target from the raw JSON:API linkage ({ type, id }), which the + // agent-client deserializer drops. Returns null when the record has no linked target. + resolvePolymorphicType( + query: ResolvePolymorphicTypeQuery, + user: StepUser, + ): Promise<{ type: string; id: string } | null>; executeAction(query: ExecuteActionQuery, user: StepUser): Promise; // Old Ruby agents with hooks.load=false return 404; agent-client falls back to the fields // passed via ActionEndpointsByCollection (populated from the orchestrator's schema). diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index ce60ff3a4e..64e79fb5c8 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -49,6 +49,9 @@ export const FieldSchemaSchema = z.object({ isRelationship: z.boolean(), relationType: z.enum(['BelongsTo', 'HasMany', 'HasOne', 'BelongsToMany']).optional(), relatedCollectionName: z.string().optional(), + // Polymorphic relations: discriminator column + candidate target collections, resolved per record. + polymorphicTypeField: z.string().optional(), + polymorphicReferencedModels: z.array(z.string()).optional(), type: ColumnTypeSchema.nullable().optional(), enumValues: z.array(z.string()).min(1).optional(), }); 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 d913902ec4..6f8892f1c6 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 @@ -823,6 +823,74 @@ describe('AgentClientAgentPort', () => { }); }); + describe('resolvePolymorphicType', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(jest.fn()); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + function linkageResponse(data: unknown) { + return new Response(JSON.stringify({ data: { relationships: { commentable: { data } } } }), { + status: 200, + }); + } + + it('reads the linkage type/id and projects the relation on the by-id route', async () => { + fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '99' })); + + const result = await port.resolvePolymorphicType( + { collection: 'comments', id: [7], relation: 'commentable' }, + user, + ); + + expect(result).toEqual({ type: 'orders', id: '99' }); + + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain('http://localhost:3310/forest/comments/7?'); + expect(url).toContain(`${encodeURIComponent('fields[comments]')}=commentable`); + expect(url).toContain(`${encodeURIComponent('fields[commentable]')}=id`); + expect(options.headers.Authorization).toMatch(/^Bearer /); + }); + + it('joins composite ids with "|" in the by-id route', async () => { + fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '1|2' })); + + await port.resolvePolymorphicType( + { collection: 'comments', id: ['tenant-1', 5], relation: 'commentable' }, + user, + ); + + expect(fetchSpy.mock.calls[0][0]).toContain('/forest/comments/tenant-1|5?'); + }); + + it('returns null when the relation has no linkage', async () => { + fetchSpy.mockResolvedValue(linkageResponse(null)); + + const result = await port.resolvePolymorphicType( + { collection: 'comments', id: [7], relation: 'commentable' }, + user, + ); + + expect(result).toBeNull(); + }); + + it('throws AgentPortError when the agent responds non-2xx', async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 500 })); + + await expect( + port.resolvePolymorphicType( + { collection: 'comments', id: [7], relation: 'commentable' }, + user, + ), + ).rejects.toThrow(AgentPortError); + }); + }); + describe('probe', () => { let fetchSpy: jest.SpyInstance; diff --git a/packages/workflow-executor/test/executors/agent-with-log.test.ts b/packages/workflow-executor/test/executors/agent-with-log.test.ts index e0b0482f35..45d35c2ec6 100644 --- a/packages/workflow-executor/test/executors/agent-with-log.test.ts +++ b/packages/workflow-executor/test/executors/agent-with-log.test.ts @@ -62,6 +62,7 @@ function makeDeps(overrides: Partial = {}) { .mockResolvedValue({ collectionName: 'customers', recordId: [42], values: {} }), getRelatedData: jest.fn().mockResolvedValue([]), getSingleRelatedData: jest.fn().mockResolvedValue(null), + resolvePolymorphicType: jest.fn().mockResolvedValue({ type: 'orders', id: '99' }), executeAction: jest.fn().mockResolvedValue({ ok: true }), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: true }), } as unknown as AgentPort; @@ -265,4 +266,24 @@ describe('AgentWithLog', () => { expect(activityLogPort.createPending).not.toHaveBeenCalled(); }); }); + + describe('resolvePolymorphicType (unaudited passthrough)', () => { + it('forwards to the agent port with the injected user and emits no activity log', async () => { + const { deps, agentPort, activityLogPort } = makeDeps(); + const agent = new AgentWithLog(deps); + + const result = await agent.resolvePolymorphicType({ + collection: 'comments', + id: [7], + relation: 'commentable', + }); + + expect(agentPort.resolvePolymorphicType).toHaveBeenCalledWith( + { collection: 'comments', id: [7], relation: 'commentable' }, + expect.objectContaining({ id: 1 }), + ); + expect(result).toEqual({ type: 'orders', id: '99' }); + expect(activityLogPort.createPending).not.toHaveBeenCalled(); + }); + }); }); 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 289e949497..266d6d46d4 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 @@ -86,6 +86,7 @@ function makeMockAgentPort(relatedData: RecordData[] = [makeRelatedRecordData()] updateRecord: jest.fn(), getRelatedData: jest.fn().mockResolvedValue(relatedData), getSingleRelatedData, + resolvePolymorphicType: jest.fn().mockResolvedValue(null), executeAction: jest.fn(), } as unknown as AgentPort; } @@ -328,6 +329,152 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); + describe('multi-target polymorphic relation (resolved per record)', () => { + // Customers' only relation is polymorphic: no relatedCollectionName, just the discriminator + // column + candidate models. The concrete target is read per record at follow time. + function makePolymorphicSchema(models: string[] = ['orders', 'addresses']): CollectionSchema { + return makeCollectionSchema({ + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { + fieldName: 'imageable', + displayName: 'Imageable', + isRelationship: true, + relationType: 'BelongsTo', + polymorphicTypeField: 'imageable_type', + polymorphicReferencedModels: models, + }, + ], + }); + } + + it('reads the discriminator linkage and follows into the resolved collection', async () => { + const agentPort = makeMockAgentPort(); // getSingleRelatedData → orders #99 + (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue({ + type: 'orders', + id: '99', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: makePolymorphicSchema() }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // The discriminator is read from the linkage on the source record... + expect(agentPort.resolvePolymorphicType).toHaveBeenCalledWith( + { collection: 'customers', id: [42], relation: 'imageable' }, + expect.objectContaining({ id: 1 }), + ); + // ...and "orders" becomes the target collection the record is fetched from. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + relation: 'imageable', + relatedSchema: expect.objectContaining({ collectionName: 'orders' }), + }), + expect.anything(), + ); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'orders', recordId: ['99'] }), + }), + }), + ); + }); + + it('maps the discriminator to a candidate case-insensitively (e.g. "Orders" → "orders")', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue({ + type: 'Orders', + id: '99', + }); + const context = makeContext({ + agentPort, + workflowPort: makeMockWorkflowPort({ customers: makePolymorphicSchema(['orders']) }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + relatedSchema: expect.objectContaining({ collectionName: 'orders' }), + }), + expect.anything(), + ); + }); + + it('errors when the only relation is polymorphic and has no linked target on the record', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue(null); // no linkage + const context = makeContext({ + agentPort, + workflowPort: makeMockWorkflowPort({ customers: makePolymorphicSchema() }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled(); + }); + + it('errors when the discriminator value is not among the candidate models', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue({ + type: 'comments', + id: '1', + }); + const context = makeContext({ + agentPort, + workflowPort: makeMockWorkflowPort({ + customers: makePolymorphicSchema(['orders', 'addresses']), + }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled(); + }); + + it('does not offer an unfollowable relation (no relatedCollectionName, not polymorphic)', async () => { + const agentPort = makeMockAgentPort(); + const schema = makeCollectionSchema({ + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + // Relationship with neither a static target nor a polymorphic discriminator → unfollowable. + { + fieldName: 'ghost', + displayName: 'Ghost', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + const context = makeContext({ + agentPort, + workflowPort: makeMockWorkflowPort({ customers: schema }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(agentPort.resolvePolymorphicType).not.toHaveBeenCalled(); + expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled(); + }); + }); + describe('executionType=FullyAutomated: HasMany — 2 AI calls (Branch B)', () => { it('runs selectRelevantFields + selectBestRecord to pick the best candidate', async () => { const hasManySchema = makeCollectionSchema({ diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index 08f8b99514..cd340b437d 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -164,6 +164,7 @@ function createMockAgentPort(): jest.Mocked { }), getRelatedData: jest.fn().mockResolvedValue([]), getSingleRelatedData: jest.fn().mockResolvedValue(null), + resolvePolymorphicType: jest.fn().mockResolvedValue(null), executeAction: jest.fn().mockResolvedValue(undefined), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), probe: jest.fn().mockResolvedValue(undefined),