diff --git a/packages/workflow-executor/src/adapters/record-id-serializer.ts b/packages/workflow-executor/src/adapters/record-id-serializer.ts new file mode 100644 index 0000000000..a491031d3f --- /dev/null +++ b/packages/workflow-executor/src/adapters/record-id-serializer.ts @@ -0,0 +1,7 @@ +export function serializeRecordId(recordId: Array): string { + return recordId.map(String).join('|'); +} + +export function deserializeRecordId(value: string): Array { + return value.split('|'); +} diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index f7c796e076..328297d68c 100644 --- a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -13,6 +13,7 @@ import type { import { z } from 'zod'; +import { deserializeRecordId } from './record-id-serializer'; import toStepDefinition from './step-definition-mapper'; import { DomainValidationError, @@ -149,7 +150,7 @@ export default function toAvailableStepExecution( collectionId: run.collectionId, baseRecordRef: { collectionName: run.collectionName, - recordId: [run.selectedRecordId], + recordId: deserializeRecordId(run.selectedRecordId), stepIndex: 0, }, stepDefinition: toStepDefinition(pending.stepDefinition), diff --git a/packages/workflow-executor/src/http/executor-http-server.ts b/packages/workflow-executor/src/http/executor-http-server.ts index 5438cb6da8..a1926cbb9b 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -10,6 +10,7 @@ import http from 'http'; import Koa from 'koa'; import koaJwt from 'koa-jwt'; +import serializeStepForWire from './step-serializer'; import ConsoleLogger from '../adapters/console-logger'; import { RunNotFoundError, @@ -160,7 +161,7 @@ export default class ExecutorHttpServer { private async handleGetRun(ctx: Koa.Context): Promise { const steps = await this.options.runner.getRunStepExecutions(ctx.params.runId); - ctx.body = { steps }; + ctx.body = { steps: steps.map(serializeStepForWire) }; } private async handleTrigger(ctx: Koa.Context): Promise { diff --git a/packages/workflow-executor/src/http/pending-data-validators.ts b/packages/workflow-executor/src/http/pending-data-validators.ts index df0515016f..94eb55ee52 100644 --- a/packages/workflow-executor/src/http/pending-data-validators.ts +++ b/packages/workflow-executor/src/http/pending-data-validators.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { deserializeRecordId } from '../adapters/record-id-serializer'; + // Per-step-type schemas for the userConfirmation payload sent by the front via // POST /runs/:runId/trigger. Validated into `execution.userConfirmation`; schemas // use .strict() to reject unknown fields. @@ -30,18 +32,16 @@ const loadRelatedRecordPatchSchema = z // The executor re-derives relatedCollectionName and displayName from FieldSchema when // processing the confirmation. name: z.string().min(1).optional(), - // User may override the AI-selected record; must be non-empty when provided. + // User may override the AI-selected record; pipe-separated string (e.g. 'id1|id2'). // Required when overriding the relation name — the original record ID belongs to a // different collection and cannot be reused for the new relation. - selectedRecordId: z - .array(z.union([z.string(), z.number()])) - .min(1) - .optional(), + selectedRecordId: z.string().min(1).transform(deserializeRecordId).optional(), }) .strict() .refine(data => data.name === undefined || data.selectedRecordId !== undefined, { message: 'selectedRecordId is required when overriding the relation name', }); + const guidancePatchSchema = z .object({ userInput: z.string().optional(), diff --git a/packages/workflow-executor/src/http/step-serializer.ts b/packages/workflow-executor/src/http/step-serializer.ts new file mode 100644 index 0000000000..4f247e40dc --- /dev/null +++ b/packages/workflow-executor/src/http/step-serializer.ts @@ -0,0 +1,43 @@ +import type { StepExecutionData } from '../types/step-execution-data'; +import type { RecordRef } from '../types/validated/collection'; + +import { serializeRecordId } from '../adapters/record-id-serializer'; + +function serializeRecordRef(ref: RecordRef): unknown { + return { ...ref, recordId: serializeRecordId(ref.recordId) }; +} + +export default function serializeStepForWire(step: StepExecutionData): unknown { + switch (step.type) { + case 'read-record': + case 'update-record': + case 'trigger-action': + return { ...step, selectedRecordRef: serializeRecordRef(step.selectedRecordRef) }; + + case 'load-related-record': { + const result: Record = { + ...step, + selectedRecordRef: serializeRecordRef(step.selectedRecordRef), + }; + + if (step.pendingData) { + result.pendingData = { + ...step.pendingData, + selectedRecordId: serializeRecordId(step.pendingData.selectedRecordId), + }; + } + + if (step.executionResult && 'record' in step.executionResult) { + result.executionResult = { + ...step.executionResult, + record: serializeRecordRef(step.executionResult.record), + }; + } + + return result; + } + + default: + return step; + } +} diff --git a/packages/workflow-executor/test/adapters/record-id-serializer.test.ts b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts new file mode 100644 index 0000000000..da011748aa --- /dev/null +++ b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts @@ -0,0 +1,33 @@ +import { deserializeRecordId, serializeRecordId } from '../../src/adapters/record-id-serializer'; + +describe('serializeRecordId', () => { + it('single id → no pipe', () => { + expect(serializeRecordId(['42'])).toBe('42'); + }); + + it('composite ids → pipe-joined', () => { + expect(serializeRecordId(['id1', 'id2'])).toBe('id1|id2'); + }); + + it('numbers are stringified', () => { + expect(serializeRecordId([42, 99])).toBe('42|99'); + }); + + it('mixed string and number ids', () => { + expect(serializeRecordId(['org', 42])).toBe('org|42'); + }); +}); + +describe('deserializeRecordId', () => { + it('single id → single-element array', () => { + expect(deserializeRecordId('42')).toEqual(['42']); + }); + + it('pipe string → multi-element array', () => { + expect(deserializeRecordId('id1|id2')).toEqual(['id1', 'id2']); + }); + + it('three segments', () => { + expect(deserializeRecordId('a|b|c')).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 01c119f14a..412b0e5412 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -133,7 +133,7 @@ describe('toAvailableStepExecution', () => { expect(result?.runId).toBe('999'); }); - it('should wrap selectedRecordId in an array for baseRecordRef', () => { + it('should deserialize selectedRecordId into an array for baseRecordRef', () => { const run = makeRun({ selectedRecordId: 'rec-abc' }); const result = toAvailableStepExecution(run); @@ -141,6 +141,14 @@ describe('toAvailableStepExecution', () => { expect(result?.baseRecordRef.recordId).toEqual(['rec-abc']); }); + it('splits a pipe-separated selectedRecordId into a multi-element recordId array', () => { + const run = makeRun({ selectedRecordId: 'pk1|pk2' }); + + const result = toAvailableStepExecution(run); + + expect(result?.baseRecordRef.recordId).toEqual(['pk1', 'pk2']); + }); + it('should return null when workflowHistory is empty', () => { const run = makeRun({ workflowHistory: [] }); 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 3972918435..b43c04a1c9 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 @@ -847,7 +847,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const context = makeContext({ agentPort, runStore, - incomingPendingData: { userConfirmed: true, selectedRecordId: [42] }, + incomingPendingData: { userConfirmed: true, selectedRecordId: '42' }, }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -867,7 +867,7 @@ describe('LoadRelatedRecordStepExecutor', () => { selectedRecordId: [99], // AI suggestion preserved }), executionResult: expect.objectContaining({ - record: expect.objectContaining({ collectionName: 'orders', recordId: [42] }), + record: expect.objectContaining({ collectionName: 'orders', recordId: ['42'] }), }), }), ); @@ -893,7 +893,7 @@ describe('LoadRelatedRecordStepExecutor', () => { incomingPendingData: { userConfirmed: true, name: 'address', - selectedRecordId: [7], + selectedRecordId: '7', }, }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -914,7 +914,7 @@ describe('LoadRelatedRecordStepExecutor', () => { executionParams: { name: 'address', displayName: 'Address' }, executionResult: expect.objectContaining({ relation: { name: 'address', displayName: 'Address' }, - record: expect.objectContaining({ collectionName: 'addresses', recordId: [7] }), + record: expect.objectContaining({ collectionName: 'addresses', recordId: ['7'] }), }), }), ); diff --git a/packages/workflow-executor/test/http/executor-http-server.test.ts b/packages/workflow-executor/test/http/executor-http-server.test.ts index c15abf8780..4222099e9d 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -315,6 +315,72 @@ describe('ExecutorHttpServer', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ error: 'Internal server error' }); }); + + it('serializes selectedRecordRef.recordId to pipe-separated string', async () => { + const steps = [ + { + type: 'update-record' as const, + stepIndex: 1, + selectedRecordRef: { collectionName: 'orders', recordId: ['pk1', 'pk2'], stepIndex: 0 }, + }, + ]; + const runner = createMockRunner({ + getRunStepExecutions: jest.fn().mockResolvedValue(steps), + }); + + const response = await request(createServer({ runner }).callback) + .get('/runs/run-1') + .set('Authorization', `Bearer ${signToken({ id: 1 })}`); + + expect(response.status).toBe(200); + expect(response.body.steps[0].selectedRecordRef.recordId).toBe('pk1|pk2'); + }); + + it('serializes load-related-record pendingData.selectedRecordId and executionResult.record.recordId', async () => { + const steps = [ + { + type: 'load-related-record' as const, + stepIndex: 2, + selectedRecordRef: { collectionName: 'customers', recordId: ['c1'], stepIndex: 0 }, + pendingData: { + name: 'orders', + displayName: 'Orders', + selectedRecordId: ['o1', 'o2'], + }, + executionResult: { + relation: { name: 'orders', displayName: 'Orders' }, + record: { collectionName: 'orders', recordId: ['o1', 'o2'], stepIndex: 2 }, + }, + }, + ]; + const runner = createMockRunner({ + getRunStepExecutions: jest.fn().mockResolvedValue(steps), + }); + + const response = await request(createServer({ runner }).callback) + .get('/runs/run-1') + .set('Authorization', `Bearer ${signToken({ id: 1 })}`); + + expect(response.status).toBe(200); + const step = response.body.steps[0]; + expect(step.selectedRecordRef.recordId).toBe('c1'); + expect(step.pendingData.selectedRecordId).toBe('o1|o2'); + expect(step.executionResult.record.recordId).toBe('o1|o2'); + }); + + it('passes through steps without selectedRecordRef unchanged', async () => { + const steps = [{ type: 'condition' as const, stepIndex: 0 }]; + const runner = createMockRunner({ + getRunStepExecutions: jest.fn().mockResolvedValue(steps), + }); + + const response = await request(createServer({ runner }).callback) + .get('/runs/run-1') + .set('Authorization', `Bearer ${signToken({ id: 1 })}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ steps }); + }); }); describe('POST /runs/:runId/trigger', () => { diff --git a/packages/workflow-executor/test/http/pending-data-validators.test.ts b/packages/workflow-executor/test/http/pending-data-validators.test.ts index 09504bb910..1ee7a1feb0 100644 --- a/packages/workflow-executor/test/http/pending-data-validators.test.ts +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -73,17 +73,31 @@ describe('patchBodySchemas', () => { expect(schema.parse({ userConfirmed: true })).toEqual({ userConfirmed: true }); }); - it('accepts confirmation with selectedRecordId override only', () => { - expect(schema.parse({ userConfirmed: true, selectedRecordId: [42] })).toEqual({ - userConfirmed: true, - selectedRecordId: [42], - }); + it('deserializes selectedRecordId from pipe string to array', () => { + const result = schema.parse({ userConfirmed: true, selectedRecordId: 'pk1|pk2' }) as { + selectedRecordId: unknown; + }; + + expect(result.selectedRecordId).toEqual(['pk1', 'pk2']); + }); + + it('deserializes single selectedRecordId', () => { + const result = schema.parse({ userConfirmed: true, selectedRecordId: '42' }) as { + selectedRecordId: unknown; + }; + + expect(result.selectedRecordId).toEqual(['42']); }); it('accepts confirmation with both name and selectedRecordId (relation override)', () => { - expect(schema.parse({ userConfirmed: true, name: 'address', selectedRecordId: [7] })).toEqual( - { userConfirmed: true, name: 'address', selectedRecordId: [7] }, - ); + const result = schema.parse({ + userConfirmed: true, + name: 'address', + selectedRecordId: '7', + }) as { selectedRecordId: unknown }; + + expect(result).toMatchObject({ userConfirmed: true, name: 'address' }); + expect(result.selectedRecordId).toEqual(['7']); }); it('rejects name override without selectedRecordId — original record ID belongs to a different collection', () => { @@ -96,6 +110,10 @@ describe('patchBodySchemas', () => { expect(() => schema.parse({ userConfirmed: true, name: '' })).toThrow(); }); + it('rejects empty selectedRecordId string', () => { + expect(() => schema.parse({ userConfirmed: true, selectedRecordId: '' })).toThrow(); + }); + it('rejects unknown fields (strict schema)', () => { expect(() => schema.parse({ userConfirmed: true, extra: 'leak' })).toThrow(); });