From 3fc4342cd47897d45b58ed8b7823257300f88ddc Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 26 May 2026 14:33:59 +0200 Subject: [PATCH 1/3] feat(workflow-executor): serialize recordId as pipe string at front/orchestrator boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite primary keys require multiple ID segments. The frontend cannot handle JSON arrays for IDs, so recordId arrays are serialized to a pipe-separated string (e.g. ['id1', 'id2'] ↔ 'id1|id2') at the three communication boundaries: - Orchestrator → Executor: deserialize selectedRecordId string in the run mapper - Executor → Front: serialize recordId arrays in GET /runs/:runId response - Front → Executor: deserialize selectedRecordId pipe string in POST trigger body Co-Authored-By: Claude Sonnet 4.6 --- .../src/adapters/record-id-serializer.ts | 7 ++ .../adapters/run-to-available-step-mapper.ts | 4 +- .../src/http/executor-http-server.ts | 3 +- .../src/http/pending-data-validators.ts | 10 +-- .../src/http/step-serializer.ts | 38 +++++++++++ .../adapters/record-id-serializer.test.ts | 33 ++++++++++ .../run-to-available-step-mapper.test.ts | 10 ++- .../test/http/executor-http-server.test.ts | 66 +++++++++++++++++++ .../test/http/pending-data-validators.test.ts | 34 +++++++--- 9 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 packages/workflow-executor/src/adapters/record-id-serializer.ts create mode 100644 packages/workflow-executor/src/http/step-serializer.ts create mode 100644 packages/workflow-executor/test/adapters/record-id-serializer.test.ts 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..28a9a80c20 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 @@ -3,6 +3,8 @@ import type { ServerStepHistory, ServerUserProfile, } from './server-types'; + +import { deserializeRecordId } from './record-id-serializer'; import type { ConditionStepOutcome, GuidanceStepOutcome, @@ -149,7 +151,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..65db89ca08 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -11,6 +11,7 @@ import Koa from 'koa'; import koaJwt from 'koa-jwt'; import ConsoleLogger from '../adapters/console-logger'; +import { serializeStepForWire } from './step-serializer'; import { RunNotFoundError, UserMismatchError, @@ -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..decd240172 --- /dev/null +++ b/packages/workflow-executor/src/http/step-serializer.ts @@ -0,0 +1,38 @@ +import type { RecordRef } from '../types/validated/collection'; +import type { StepExecutionData } from '../types/step-execution-data'; + +import { serializeRecordId } from '../adapters/record-id-serializer'; + +function serializeRecordRef(ref: RecordRef): unknown { + return { ...ref, recordId: serializeRecordId(ref.recordId) }; +} + +export 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/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(); }); From 1f6390d68cb0b9e26028a9714fbab2b9f20517a1 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 26 May 2026 14:45:31 +0200 Subject: [PATCH 2/3] fix(workflow-executor): fix ESLint errors in serializer and mapper imports Co-Authored-By: Claude Sonnet 4.6 --- .../src/adapters/run-to-available-step-mapper.ts | 3 +-- .../workflow-executor/src/http/executor-http-server.ts | 2 +- packages/workflow-executor/src/http/step-serializer.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) 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 28a9a80c20..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 @@ -3,8 +3,6 @@ import type { ServerStepHistory, ServerUserProfile, } from './server-types'; - -import { deserializeRecordId } from './record-id-serializer'; import type { ConditionStepOutcome, GuidanceStepOutcome, @@ -15,6 +13,7 @@ import type { import { z } from 'zod'; +import { deserializeRecordId } from './record-id-serializer'; import toStepDefinition from './step-definition-mapper'; import { DomainValidationError, diff --git a/packages/workflow-executor/src/http/executor-http-server.ts b/packages/workflow-executor/src/http/executor-http-server.ts index 65db89ca08..a1926cbb9b 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -10,8 +10,8 @@ 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 { serializeStepForWire } from './step-serializer'; import { RunNotFoundError, UserMismatchError, diff --git a/packages/workflow-executor/src/http/step-serializer.ts b/packages/workflow-executor/src/http/step-serializer.ts index decd240172..4f247e40dc 100644 --- a/packages/workflow-executor/src/http/step-serializer.ts +++ b/packages/workflow-executor/src/http/step-serializer.ts @@ -1,5 +1,5 @@ -import type { RecordRef } from '../types/validated/collection'; import type { StepExecutionData } from '../types/step-execution-data'; +import type { RecordRef } from '../types/validated/collection'; import { serializeRecordId } from '../adapters/record-id-serializer'; @@ -7,31 +7,36 @@ function serializeRecordRef(ref: RecordRef): unknown { return { ...ref, recordId: serializeRecordId(ref.recordId) }; } -export function serializeStepForWire(step: StepExecutionData): unknown { +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; } From 7d9cbf446f4fa291be04d14513603b865e45d3e4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 26 May 2026 15:12:14 +0200 Subject: [PATCH 3/3] fix(workflow-executor): update load-related-record tests for pipe-string selectedRecordId input incomingPendingData.selectedRecordId now arrives as a pipe string (e.g. '42') from the front, parsed by the Zod schema to an array. Update test inputs and recordId assertions accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../executors/load-related-record-step-executor.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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'] }), }), }), );