From aa62e44a6fd84986c105e20675e1734b0ad13c85 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 26 May 2026 14:33:59 +0200 Subject: [PATCH 1/9] 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 | 13 ++-- .../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 | 39 +++++++---- 9 files changed, 190 insertions(+), 23 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 2c4f849b45..eb069a7009 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. @@ -39,13 +41,10 @@ const loadRelatedRecordPatchSchema = z // orchestrator); the executor re-derives displayName + relatedCollectionName from // the live schema when processing the confirmation. fieldName: z.string().min(1).optional(), - // User may override the AI-selected record; must be non-empty when provided. - // Required when confirming with a relation override — 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(), + // User may override the AI-selected record; pipe-separated string (e.g. 'id1|id2'), + // deserialized to an id array. Required when confirming with a relation override — + // the original record ID belongs to a different collection and cannot be reused. + selectedRecordId: z.string().min(1).transform(deserializeRecordId).optional(), }) .strict() .refine( 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 33ab88a942..1b446c74ca 100644 --- a/packages/workflow-executor/test/http/pending-data-validators.test.ts +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -148,33 +148,46 @@ 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 fieldName and selectedRecordId (relation override)', () => { - expect( - schema.parse({ - userConfirmed: true, - fieldName: 'address', - selectedRecordId: [7], - }), - ).toEqual({ userConfirmed: true, fieldName: 'address', selectedRecordId: [7] }); + const result = schema.parse({ + userConfirmed: true, + fieldName: 'address', + selectedRecordId: '7', + }) as { selectedRecordId: unknown }; + + expect(result).toMatchObject({ userConfirmed: true, fieldName: 'address' }); + expect(result.selectedRecordId).toEqual(['7']); }); it('rejects fieldName override on confirm without selectedRecordId — original record ID belongs to a different collection', () => { expect(() => schema.parse({ userConfirmed: true, fieldName: 'address' })).toThrow( 'selectedRecordId is required when confirming with a relation override', ); - }); it('rejects empty string fieldName — empty string is not a valid field name', () => { expect(() => schema.parse({ userConfirmed: true, fieldName: '' })).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 ec3c09558f60210d476ba192c0adbd024ff1e0b3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 26 May 2026 14:45:31 +0200 Subject: [PATCH 2/9] 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 662b143b2b92d7204b165cc79671aad7ef82d3c1 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 26 May 2026 15:12:14 +0200 Subject: [PATCH 3/9] 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 6966f34cd9..d54fc03d1e 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 @@ -1086,7 +1086,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const context = makeContext({ agentPort, runStore, - incomingPendingData: { userConfirmed: true, selectedRecordId: [42] }, + incomingPendingData: { userConfirmed: true, selectedRecordId: '42' }, }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1105,7 +1105,7 @@ describe('LoadRelatedRecordStepExecutor', () => { suggestedRecord: cand([99]), // AI suggestion preserved }), executionResult: expect.objectContaining({ - record: expect.objectContaining({ collectionName: 'orders', recordId: [42] }), + record: expect.objectContaining({ collectionName: 'orders', recordId: ['42'] }), }), }), ); @@ -1134,7 +1134,7 @@ describe('LoadRelatedRecordStepExecutor', () => { incomingPendingData: { userConfirmed: true, fieldName: 'address', - selectedRecordId: [7], + selectedRecordId: '7', }, }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1154,7 +1154,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'] }), }), }), ); From 6847d827af3081f08ea2c08c1844affed687873c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 3 Jun 2026 16:39:52 +0200 Subject: [PATCH 4/9] fix(workflow-executor): adapt pipe serialization to refactored pendingData shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebase: LoadRelatedRecordPendingData no longer has selectedRecordId — it carries availableRecordIds[] + suggestedRecord. Serialize those recordIds to the pipe-string wire format instead, and update the http-server/validators tests (fieldName + string selectedRecordId input). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflow-executor/src/http/step-serializer.ts | 13 ++++++++++++- .../test/http/executor-http-server.test.ts | 12 +++++++----- .../test/http/pending-data-validators.test.ts | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/workflow-executor/src/http/step-serializer.ts b/packages/workflow-executor/src/http/step-serializer.ts index 4f247e40dc..7a8a7de619 100644 --- a/packages/workflow-executor/src/http/step-serializer.ts +++ b/packages/workflow-executor/src/http/step-serializer.ts @@ -21,9 +21,20 @@ export default function serializeStepForWire(step: StepExecutionData): unknown { }; if (step.pendingData) { + const { availableRecordIds, suggestedRecord } = step.pendingData; + result.pendingData = { ...step.pendingData, - selectedRecordId: serializeRecordId(step.pendingData.selectedRecordId), + availableRecordIds: availableRecordIds.map(c => ({ + ...c, + recordId: serializeRecordId(c.recordId), + })), + ...(suggestedRecord && { + suggestedRecord: { + ...suggestedRecord, + recordId: serializeRecordId(suggestedRecord.recordId), + }, + }), }; } 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 4222099e9d..d4703d7a48 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -336,16 +336,17 @@ describe('ExecutorHttpServer', () => { expect(response.body.steps[0].selectedRecordRef.recordId).toBe('pk1|pk2'); }); - it('serializes load-related-record pendingData.selectedRecordId and executionResult.record.recordId', async () => { + it('serializes load-related-record pendingData candidate recordIds 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'], + availableFields: [{ name: 'orders', displayName: 'Orders' }], + suggestedField: { name: 'orders', displayName: 'Orders' }, + availableRecordIds: [{ recordId: ['o1', 'o2'], referenceFieldValue: null }], + suggestedRecord: { recordId: ['o1', 'o2'], referenceFieldValue: null }, }, executionResult: { relation: { name: 'orders', displayName: 'Orders' }, @@ -364,7 +365,8 @@ describe('ExecutorHttpServer', () => { 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.pendingData.availableRecordIds[0].recordId).toBe('o1|o2'); + expect(step.pendingData.suggestedRecord.recordId).toBe('o1|o2'); expect(step.executionResult.record.recordId).toBe('o1|o2'); }); 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 1b446c74ca..4e09b0c503 100644 --- a/packages/workflow-executor/test/http/pending-data-validators.test.ts +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -179,6 +179,7 @@ describe('patchBodySchemas', () => { expect(() => schema.parse({ userConfirmed: true, fieldName: 'address' })).toThrow( 'selectedRecordId is required when confirming with a relation override', ); + }); it('rejects empty string fieldName — empty string is not a valid field name', () => { expect(() => schema.parse({ userConfirmed: true, fieldName: '' })).toThrow(); From 13b1bdf205c87bd022310f5801576448c59b3b86 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 3 Jun 2026 16:55:47 +0200 Subject: [PATCH 5/9] fix(workflow-executor): pipe-serialize composite recordId in activity logs buildActivityLogArgs passed recordId[0], logging only the first segment of a composite key. Pass the full recordId array and serialize it to the pipe wire format inside the activity-log adapter (keeps the wire format out of executors). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../forestadmin-client-activity-log-port.ts | 4 +++- .../load-related-record-step-executor.ts | 2 +- .../src/executors/read-record-step-executor.ts | 2 +- .../trigger-record-action-step-executor.ts | 2 +- .../executors/update-record-step-executor.ts | 2 +- .../src/ports/activity-log-port.ts | 3 ++- ...orestadmin-client-activity-log-port.test.ts | 18 ++++++++++++++++++ .../test/executors/base-step-executor.test.ts | 4 ++-- 8 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts index 07e0dcf97c..5ca2249a2f 100644 --- a/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts +++ b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts @@ -10,6 +10,7 @@ import type { ActivityLogsServiceInterface, } from '@forestadmin/forestadmin-client'; +import { serializeRecordId } from './record-id-serializer'; import withRetry from './with-retry'; import { ActivityLogCreationError, extractErrorMessage } from '../errors'; @@ -34,7 +35,8 @@ export default class ForestadminClientActivityLogPort implements ActivityLogPort // The lib writes this value verbatim into relationships.collection.data.id // (JSON:API). The Forest server audit-trail API expects the numeric collectionId. collectionName: args.collectionId, - recordId: args.recordId, + // Composite keys are serialized to the pipe wire format here, never in the executor. + recordId: args.recordId ? serializeRecordId(args.recordId) : undefined, label: args.label, }), { logger: this.logger }, 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 519dfd6421..232e359f38 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,7 +62,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor; label?: string; } diff --git a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts index 1614b330ab..428206a2d9 100644 --- a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts +++ b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts @@ -63,6 +63,24 @@ describe('ForestadminClientActivityLogPort', () => { expect(service.createActivityLog).toHaveBeenCalledTimes(1); }); + it('serializes a composite recordId to the pipe wire format', async () => { + const service = makeService(); + service.createActivityLog.mockResolvedValue({ id: 'log-1', attributes: { index: '0' } }); + const port = makePort(service); + + await port.createPending({ + renderingId: 5, + action: 'update', + type: 'write', + collectionId: 'col-1', + recordId: ['tenant', 7], + }); + + expect(service.createActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ recordId: 'tenant|7' }), + ); + }); + it('retries on 503 and succeeds on the second attempt', async () => { const service = makeService(); service.createActivityLog diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts index d9e3da6efc..bef89c9760 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -542,7 +542,7 @@ describe('BaseStepExecutor', () => { action: 'update', type: 'write' as const, collectionId: 'col-1', - recordId: 42, + recordId: [42], }; } @@ -667,7 +667,7 @@ describe('BaseStepExecutor', () => { action: 'update', type: 'write' as const, collectionName: 'customers', - recordId: 42, + recordId: [42], }; } From 84c7fd859dd39b2269d3a6baff4fb03b782ed781 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 3 Jun 2026 17:07:59 +0200 Subject: [PATCH 6/9] fix(workflow-executor): address review on recordId pipe serialization - mapper: guard empty/missing run.selectedRecordId with InvalidStepDefinitionError (was silently deserializing to a bogus [''] baseRecordRef), mirroring the collectionId/collectionName guards - record-id-serializer: deserializeRecordId returns string[] (ids travel as opaque strings); document the reserved '|' delimiter + its accepted round-trip limitation; drop redundant .map(String) - activity-log adapter: use args.recordId?.length (an empty array is no record id) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/forestadmin-client-activity-log-port.ts | 4 ++-- .../src/adapters/record-id-serializer.ts | 9 +++++++-- .../src/adapters/run-to-available-step-mapper.ts | 9 +++++++++ .../test/adapters/record-id-serializer.test.ts | 7 +++++++ .../test/adapters/run-to-available-step-mapper.test.ts | 9 +++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts index 5ca2249a2f..f72ed8089e 100644 --- a/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts +++ b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts @@ -35,8 +35,8 @@ export default class ForestadminClientActivityLogPort implements ActivityLogPort // The lib writes this value verbatim into relationships.collection.data.id // (JSON:API). The Forest server audit-trail API expects the numeric collectionId. collectionName: args.collectionId, - // Composite keys are serialized to the pipe wire format here, never in the executor. - recordId: args.recordId ? serializeRecordId(args.recordId) : undefined, + // Record ids are serialized to the pipe wire format here, never in the executor. + recordId: args.recordId?.length ? serializeRecordId(args.recordId) : undefined, label: args.label, }), { logger: this.logger }, diff --git a/packages/workflow-executor/src/adapters/record-id-serializer.ts b/packages/workflow-executor/src/adapters/record-id-serializer.ts index a491031d3f..5704f1e9ca 100644 --- a/packages/workflow-executor/src/adapters/record-id-serializer.ts +++ b/packages/workflow-executor/src/adapters/record-id-serializer.ts @@ -1,7 +1,12 @@ +// Wire format for (composite) record ids exchanged with the front and orchestrator: the segments +// are joined with '|'. This is a cross-system contract (orchestrator + front + agent-client all use +// it). Limitation: a primary-key value that itself contains '|' is not round-trip safe — accepted, +// as it is the convention used throughout the Forest stack. export function serializeRecordId(recordId: Array): string { - return recordId.map(String).join('|'); + return recordId.join('|'); } -export function deserializeRecordId(value: string): Array { +// Always returns strings: ids travel as opaque strings across systems (no number round-trip). +export function deserializeRecordId(value: string): string[] { 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 328297d68c..aacc6419e2 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 @@ -140,6 +140,15 @@ export default function toAvailableStepExecution( ); } + // selectedRecordId is typed `string` but arrives unvalidated from the wire — guard so an empty + // value fails loudly instead of deserializing to a bogus [''] baseRecordRef (or throwing a raw + // TypeError on undefined). Mirrors the collectionName/collectionId guards above. + if (!run.selectedRecordId) { + throw new InvalidStepDefinitionError( + `Run ${run.id} has no selectedRecordId — cannot build baseRecordRef`, + ); + } + const pending = run.workflowHistory.at(-1) ?? null; if (!pending || pending.done) return null; diff --git a/packages/workflow-executor/test/adapters/record-id-serializer.test.ts b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts index da011748aa..e3598ada3e 100644 --- a/packages/workflow-executor/test/adapters/record-id-serializer.test.ts +++ b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts @@ -30,4 +30,11 @@ describe('deserializeRecordId', () => { it('three segments', () => { expect(deserializeRecordId('a|b|c')).toEqual(['a', 'b', 'c']); }); + + // Known/accepted limitation: '|' is the reserved segment delimiter, so a primary-key value + // that itself contains '|' is not round-trip safe (it over-splits). This is the convention + // used across the Forest stack; pinned here so the behavior is explicit. + it('over-splits a value that contains the reserved pipe (documented limitation)', () => { + expect(deserializeRecordId(serializeRecordId(['a|b']))).toEqual(['a', 'b']); + }); }); 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 412b0e5412..8919712a43 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 @@ -598,6 +598,15 @@ describe('toAvailableStepExecution', () => { ); }); + it('should throw InvalidStepDefinitionError when selectedRecordId is empty', () => { + const run = makeRun({ selectedRecordId: '' }); + + expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run)).toThrow( + 'Run 42 has no selectedRecordId — cannot build baseRecordRef', + ); + }); + it('should propagate mapper errors from toStepDefinition', () => { const run = makeRun({ workflowHistory: [ From eabb6ec4042b2a2eba5710e7d5ef06cb69ffddc7 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 3 Jun 2026 17:20:46 +0200 Subject: [PATCH 7/9] refactor(workflow-executor): introduce RecordId type alias for Array Replace the inline Array recordId repetitions with a single RecordId alias (derived from RecordRef['recordId']) in step-execution-data, record-id-serializer, activity-log port, and the agent-client PK filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 7 ++----- .../workflow-executor/src/adapters/record-id-serializer.ts | 4 +++- packages/workflow-executor/src/ports/activity-log-port.ts | 4 +++- .../workflow-executor/src/types/step-execution-data.ts | 4 ++-- .../workflow-executor/src/types/validated/collection.ts | 3 +++ .../executors/load-related-record-step-executor.test.ts | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) 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 4ec3b9e3b9..3a7ea4f2c9 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -9,7 +9,7 @@ import type { } from '../ports/agent-port'; import type SchemaCache from '../schema-cache'; import type { StepUser } from '../types/execution-context'; -import type { CollectionSchema, RecordData } from '../types/validated/collection'; +import type { CollectionSchema, RecordData, RecordId } from '../types/validated/collection'; import type { ActionEndpointsByCollection, SelectOptions } from '@forestadmin/agent-client'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; @@ -45,10 +45,7 @@ function restoreFieldNames( return Object.fromEntries(Object.entries(values).map(([k, v]) => [camelToOriginal[k] ?? k, v])); } -function buildPkFilter( - primaryKeyFields: string[], - id: Array, -): SelectOptions['filters'] { +function buildPkFilter(primaryKeyFields: string[], id: RecordId): SelectOptions['filters'] { if (primaryKeyFields.length === 1) { return { field: primaryKeyFields[0], operator: 'Equal', value: id[0] }; } diff --git a/packages/workflow-executor/src/adapters/record-id-serializer.ts b/packages/workflow-executor/src/adapters/record-id-serializer.ts index 5704f1e9ca..6790abdae3 100644 --- a/packages/workflow-executor/src/adapters/record-id-serializer.ts +++ b/packages/workflow-executor/src/adapters/record-id-serializer.ts @@ -1,8 +1,10 @@ +import type { RecordId } from '../types/validated/collection'; + // Wire format for (composite) record ids exchanged with the front and orchestrator: the segments // are joined with '|'. This is a cross-system contract (orchestrator + front + agent-client all use // it). Limitation: a primary-key value that itself contains '|' is not round-trip safe — accepted, // as it is the convention used throughout the Forest stack. -export function serializeRecordId(recordId: Array): string { +export function serializeRecordId(recordId: RecordId): string { return recordId.join('|'); } diff --git a/packages/workflow-executor/src/ports/activity-log-port.ts b/packages/workflow-executor/src/ports/activity-log-port.ts index 3719bb66ec..dc2e03cf92 100644 --- a/packages/workflow-executor/src/ports/activity-log-port.ts +++ b/packages/workflow-executor/src/ports/activity-log-port.ts @@ -1,10 +1,12 @@ +import type { RecordId } from '../types/validated/collection'; + export interface CreateActivityLogArgs { renderingId: number; action: string; type: 'read' | 'write'; collectionId?: string; // Full (possibly composite) record id. The adapter serializes it to the pipe wire format. - recordId?: Array; + recordId?: RecordId; label?: string; } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 06bc3e9b12..6cab27af62 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordRef } from './validated/collection'; +import type { RecordId, RecordRef } from './validated/collection'; import type { LoadRelatedRecordConfirmation, McpConfirmation, @@ -133,7 +133,7 @@ export interface RecordStepExecutionData extends BaseStepExecutionData { // -- Load Related Record -- export interface LoadRelatedRecordCandidate { - recordId: Array; + recordId: RecordId; referenceFieldValue: string | null; } diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index fe1776196b..25d58e7479 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -101,5 +101,8 @@ export const RecordRefSchema = z .strict(); export type RecordRef = z.infer; +// A record's primary key: one segment for a simple key, several for a composite key. +export type RecordId = RecordRef['recordId']; + // No stepIndex — the agent doesn't know about steps. export type RecordData = Omit & { values: Record }; 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 d54fc03d1e..5ffe45226c 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 @@ -1253,7 +1253,7 @@ describe('LoadRelatedRecordStepExecutor', () => { suggestedRecord: cand([99]), }, // Frontend confirms a relation that no longer exists in availableFields. - userConfirmation: { userConfirmed: true, fieldName: 'ghost', selectedRecordId: [7] }, + userConfirmation: { userConfirmed: true, fieldName: 'ghost', selectedRecordId: ['7'] }, }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), From b79ad2106a5dde895e0797591c57f5d3a4560006 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 4 Jun 2026 10:05:44 +0200 Subject: [PATCH 8/9] fix(workflow-executor): harden composite record-id (de)serialization (PR #1594 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the pipe record-id serializer: - serializeRecordId now throws RecordIdSerializationError when a part contains the "|" separator, instead of silently corrupting the key (mirrors @forestadmin/agent-client). New typed error replaces a would-be plain Error. - Reject empty id segments from a malformed pipe value (e.g. 'a|' → ['a','']) at the validation boundaries: RecordRefSchema.recordId string branch and the selectedRecordId override schema now require .min(1) per segment, so an empty PK can't reach the datasource. - Add a dedicated step-serializer.test.ts covering the load-related-record branches (pendingData absent, suggestedRecord absent, skipped executionResult) and composite-id pipe serialization. deserializeRecordId stays a permissive split by design — validation lives at the consuming boundaries per the codebase's boundary-validation convention. Note: the "move guards after the pending/done check" suggestion was intentionally not applied — the current placement surfaces genuinely broken runs (e.g. collectionName null) in the malformed bucket, which is the intended observability behavior (covered by an existing test). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/record-id-serializer.ts | 22 +++- packages/workflow-executor/src/errors.ts | 9 ++ .../src/http/pending-data-validators.ts | 8 +- .../src/types/validated/collection.ts | 2 +- .../adapters/record-id-serializer.test.ts | 17 ++- .../test/http/step-serializer.test.ts | 122 ++++++++++++++++++ 6 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 packages/workflow-executor/test/http/step-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 index 6790abdae3..cca4e952f1 100644 --- a/packages/workflow-executor/src/adapters/record-id-serializer.ts +++ b/packages/workflow-executor/src/adapters/record-id-serializer.ts @@ -1,14 +1,30 @@ import type { RecordId } from '../types/validated/collection'; +import { RecordIdSerializationError } from '../errors'; + // Wire format for (composite) record ids exchanged with the front and orchestrator: the segments // are joined with '|'. This is a cross-system contract (orchestrator + front + agent-client all use -// it). Limitation: a primary-key value that itself contains '|' is not round-trip safe — accepted, -// as it is the convention used throughout the Forest stack. +// it). Throws — rather than silently corrupting the key — when a part contains the '|' separator, +// since that would produce a malformed id that round-trips to the wrong record. Mirrors the +// hardened serializer in `@forestadmin/agent-client` (src/record-id.ts). export function serializeRecordId(recordId: RecordId): string { - return recordId.join('|'); + return recordId + .map(part => { + const serialized = String(part); + + if (serialized.includes('|')) { + throw new RecordIdSerializationError(serialized); + } + + return serialized; + }) + .join('|'); } // Always returns strings: ids travel as opaque strings across systems (no number round-trip). +// Stays intentionally permissive — empty/malformed segments (e.g. from a partial 'a|' wire value) +// are rejected at the validation boundaries that consume the result: `RecordRefSchema.recordId` +// (mapper output) and the `selectedRecordId` schema in `pending-data-validators.ts` (front input). export function deserializeRecordId(value: string): string[] { return value.split('|'); } diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index e1675de8f5..70af3e8abe 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -367,6 +367,15 @@ export class UnsupportedStepTypeError extends WorkflowExecutorError { } } +export class RecordIdSerializationError extends WorkflowExecutorError { + constructor(part: string) { + super( + `Composite record id part "${part}" cannot contain the "|" separator`, + 'A record identifier contains an unsupported character and cannot be processed.', + ); + } +} + export class InvalidStepDefinitionError extends WorkflowExecutorError { constructor(detail: string) { super( diff --git a/packages/workflow-executor/src/http/pending-data-validators.ts b/packages/workflow-executor/src/http/pending-data-validators.ts index eb069a7009..13dc67dc3c 100644 --- a/packages/workflow-executor/src/http/pending-data-validators.ts +++ b/packages/workflow-executor/src/http/pending-data-validators.ts @@ -44,7 +44,13 @@ const loadRelatedRecordPatchSchema = z // User may override the AI-selected record; pipe-separated string (e.g. 'id1|id2'), // deserialized to an id array. Required when confirming with a relation override — // the original record ID belongs to a different collection and cannot be reused. - selectedRecordId: z.string().min(1).transform(deserializeRecordId).optional(), + // The .pipe(...) rejects empty segments that would build a bogus PK. + selectedRecordId: z + .string() + .min(1) + .transform(deserializeRecordId) + .pipe(z.array(z.string().min(1))) + .optional(), }) .strict() .refine( diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index 25d58e7479..5ca727fc8a 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -94,7 +94,7 @@ export type CollectionSchema = z.infer; export const RecordRefSchema = z .object({ collectionName: z.string().min(1), - recordId: z.array(z.union([z.string(), z.number()])).min(1), + recordId: z.array(z.union([z.string().min(1), z.number()])).min(1), // Index of the workflow step that loaded this record. stepIndex: z.number().int().nonnegative(), }) diff --git a/packages/workflow-executor/test/adapters/record-id-serializer.test.ts b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts index e3598ada3e..f4ef112bd5 100644 --- a/packages/workflow-executor/test/adapters/record-id-serializer.test.ts +++ b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts @@ -1,4 +1,5 @@ import { deserializeRecordId, serializeRecordId } from '../../src/adapters/record-id-serializer'; +import { RecordIdSerializationError } from '../../src/errors'; describe('serializeRecordId', () => { it('single id → no pipe', () => { @@ -16,6 +17,12 @@ describe('serializeRecordId', () => { it('mixed string and number ids', () => { expect(serializeRecordId(['org', 42])).toBe('org|42'); }); + + // '|' is the reserved segment delimiter, so a part that contains it would over-split on the way + // back and match the wrong record. Fail loudly instead of silently corrupting the key. + it('throws when a part contains the reserved pipe separator', () => { + expect(() => serializeRecordId(['a|b'])).toThrow(RecordIdSerializationError); + }); }); describe('deserializeRecordId', () => { @@ -31,10 +38,10 @@ describe('deserializeRecordId', () => { expect(deserializeRecordId('a|b|c')).toEqual(['a', 'b', 'c']); }); - // Known/accepted limitation: '|' is the reserved segment delimiter, so a primary-key value - // that itself contains '|' is not round-trip safe (it over-splits). This is the convention - // used across the Forest stack; pinned here so the behavior is explicit. - it('over-splits a value that contains the reserved pipe (documented limitation)', () => { - expect(deserializeRecordId(serializeRecordId(['a|b']))).toEqual(['a', 'b']); + // deserialize stays permissive (bare split). Empty segments from a malformed wire value are + // rejected at the validation boundaries that consume the result, not here. + it('over-splits a raw value containing the reserved pipe (rejected at the boundary, not here)', () => { + expect(deserializeRecordId('a|b|c')).toEqual(['a', 'b', 'c']); + expect(deserializeRecordId('a|')).toEqual(['a', '']); }); }); diff --git a/packages/workflow-executor/test/http/step-serializer.test.ts b/packages/workflow-executor/test/http/step-serializer.test.ts new file mode 100644 index 0000000000..1ff16a3c6c --- /dev/null +++ b/packages/workflow-executor/test/http/step-serializer.test.ts @@ -0,0 +1,122 @@ +import type { + LoadRelatedRecordStepExecutionData, + StepExecutionData, +} from '../../src/types/step-execution-data'; +import type { RecordRef } from '../../src/types/validated/collection'; + +import serializeStepForWire from '../../src/http/step-serializer'; + +function makeRecordRef(recordId: RecordRef['recordId'], stepIndex = 0): RecordRef { + return { collectionName: 'orders', recordId, stepIndex }; +} + +describe('serializeStepForWire', () => { + describe('record steps (read/update/trigger)', () => { + it('serializes a composite selectedRecordRef.recordId to the pipe wire format', () => { + const step: StepExecutionData = { + type: 'update-record', + stepIndex: 1, + selectedRecordRef: makeRecordRef(['order-1', 42]), + executionResult: { updatedValues: { status: 'active' } }, + }; + + const result = serializeStepForWire(step) as { selectedRecordRef: { recordId: unknown } }; + + expect(result.selectedRecordRef.recordId).toBe('order-1|42'); + }); + }); + + describe('load-related-record', () => { + it('serializes recordIds in pendingData (availableRecordIds + suggestedRecord) and the selectedRecordRef', () => { + const step: LoadRelatedRecordStepExecutionData = { + type: 'load-related-record', + stepIndex: 2, + selectedRecordRef: makeRecordRef(['cust-1']), + pendingData: { + availableFields: [{ name: 'order', displayName: 'Order' }], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [ + { recordId: ['o-1', 7], referenceFieldValue: 'A' }, + { recordId: ['o-2', 8], referenceFieldValue: 'B' }, + ], + suggestedRecord: { recordId: ['o-1', 7], referenceFieldValue: 'A' }, + }, + }; + + const result = serializeStepForWire(step) as { + selectedRecordRef: { recordId: unknown }; + pendingData: { + availableRecordIds: Array<{ recordId: unknown }>; + suggestedRecord: { recordId: unknown }; + }; + }; + + expect(result.selectedRecordRef.recordId).toBe('cust-1'); + expect(result.pendingData.availableRecordIds.map(c => c.recordId)).toEqual([ + 'o-1|7', + 'o-2|8', + ]); + expect(result.pendingData.suggestedRecord.recordId).toBe('o-1|7'); + }); + + it('omits suggestedRecord when the relation has no linked record', () => { + const step: LoadRelatedRecordStepExecutionData = { + type: 'load-related-record', + stepIndex: 2, + selectedRecordRef: makeRecordRef(['cust-1']), + pendingData: { + availableFields: [{ name: 'order', displayName: 'Order' }], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [], + }, + }; + + const result = serializeStepForWire(step) as { pendingData: Record }; + + expect(result.pendingData.availableRecordIds).toEqual([]); + expect('suggestedRecord' in result.pendingData).toBe(false); + }); + + it('does not add a pendingData key when there is no pendingData', () => { + const step: LoadRelatedRecordStepExecutionData = { + type: 'load-related-record', + stepIndex: 2, + selectedRecordRef: makeRecordRef(['cust-1']), + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: makeRecordRef(['o-1', 7], 2), + }, + }; + + const result = serializeStepForWire(step) as Record & { + executionResult: { record: { recordId: unknown } }; + }; + + expect('pendingData' in result).toBe(false); + expect(result.executionResult.record.recordId).toBe('o-1|7'); + }); + + it('passes a skipped executionResult through untouched (no record to serialize)', () => { + const step: LoadRelatedRecordStepExecutionData = { + type: 'load-related-record', + stepIndex: 2, + selectedRecordRef: makeRecordRef(['cust-1']), + executionResult: { skipped: true }, + }; + + const result = serializeStepForWire(step) as { executionResult: unknown }; + + expect(result.executionResult).toEqual({ skipped: true }); + }); + }); + + it('returns non-record steps unchanged', () => { + const step: StepExecutionData = { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: 'noted' }, + }; + + expect(serializeStepForWire(step)).toBe(step); + }); +}); From 62001d944ccfd03dc51e6c8f14433113bb033587 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 4 Jun 2026 10:09:41 +0200 Subject: [PATCH 9/9] chore(workflow-executor): remove redundant comments Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/record-id-serializer.ts | 9 --------- .../src/adapters/run-to-available-step-mapper.ts | 3 --- .../workflow-executor/src/ports/activity-log-port.ts | 1 - 3 files changed, 13 deletions(-) diff --git a/packages/workflow-executor/src/adapters/record-id-serializer.ts b/packages/workflow-executor/src/adapters/record-id-serializer.ts index cca4e952f1..e7e3eba9d2 100644 --- a/packages/workflow-executor/src/adapters/record-id-serializer.ts +++ b/packages/workflow-executor/src/adapters/record-id-serializer.ts @@ -2,11 +2,6 @@ import type { RecordId } from '../types/validated/collection'; import { RecordIdSerializationError } from '../errors'; -// Wire format for (composite) record ids exchanged with the front and orchestrator: the segments -// are joined with '|'. This is a cross-system contract (orchestrator + front + agent-client all use -// it). Throws — rather than silently corrupting the key — when a part contains the '|' separator, -// since that would produce a malformed id that round-trips to the wrong record. Mirrors the -// hardened serializer in `@forestadmin/agent-client` (src/record-id.ts). export function serializeRecordId(recordId: RecordId): string { return recordId .map(part => { @@ -21,10 +16,6 @@ export function serializeRecordId(recordId: RecordId): string { .join('|'); } -// Always returns strings: ids travel as opaque strings across systems (no number round-trip). -// Stays intentionally permissive — empty/malformed segments (e.g. from a partial 'a|' wire value) -// are rejected at the validation boundaries that consume the result: `RecordRefSchema.recordId` -// (mapper output) and the `selectedRecordId` schema in `pending-data-validators.ts` (front input). export function deserializeRecordId(value: string): string[] { 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 aacc6419e2..02c2d51f4d 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 @@ -140,9 +140,6 @@ export default function toAvailableStepExecution( ); } - // selectedRecordId is typed `string` but arrives unvalidated from the wire — guard so an empty - // value fails loudly instead of deserializing to a bogus [''] baseRecordRef (or throwing a raw - // TypeError on undefined). Mirrors the collectionName/collectionId guards above. if (!run.selectedRecordId) { throw new InvalidStepDefinitionError( `Run ${run.id} has no selectedRecordId — cannot build baseRecordRef`, diff --git a/packages/workflow-executor/src/ports/activity-log-port.ts b/packages/workflow-executor/src/ports/activity-log-port.ts index dc2e03cf92..d12ab9ce63 100644 --- a/packages/workflow-executor/src/ports/activity-log-port.ts +++ b/packages/workflow-executor/src/ports/activity-log-port.ts @@ -5,7 +5,6 @@ export interface CreateActivityLogArgs { action: string; type: 'read' | 'write'; collectionId?: string; - // Full (possibly composite) record id. The adapter serializes it to the pipe wire format. recordId?: RecordId; label?: string; }