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/forestadmin-client-activity-log-port.ts b/packages/workflow-executor/src/adapters/forestadmin-client-activity-log-port.ts index 07e0dcf97c..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 @@ -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, + // 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 new file mode 100644 index 0000000000..e7e3eba9d2 --- /dev/null +++ b/packages/workflow-executor/src/adapters/record-id-serializer.ts @@ -0,0 +1,21 @@ +import type { RecordId } from '../types/validated/collection'; + +import { RecordIdSerializationError } from '../errors'; + +export function serializeRecordId(recordId: RecordId): string { + return recordId + .map(part => { + const serialized = String(part); + + if (serialized.includes('|')) { + throw new RecordIdSerializationError(serialized); + } + + return serialized; + }) + .join('|'); +} + +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 f7c796e076..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 @@ -13,6 +13,7 @@ import type { import { z } from 'zod'; +import { deserializeRecordId } from './record-id-serializer'; import toStepDefinition from './step-definition-mapper'; import { DomainValidationError, @@ -139,6 +140,12 @@ export default function toAvailableStepExecution( ); } + 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; @@ -149,7 +156,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/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/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 { 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..13dc67dc3c 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,12 +41,15 @@ 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. + // 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. + // The .pipe(...) rejects empty segments that would build a bogus PK. selectedRecordId: z - .array(z.union([z.string(), z.number()])) + .string() .min(1) + .transform(deserializeRecordId) + .pipe(z.array(z.string().min(1))) .optional(), }) .strict() 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..7a8a7de619 --- /dev/null +++ b/packages/workflow-executor/src/http/step-serializer.ts @@ -0,0 +1,54 @@ +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) { + const { availableRecordIds, suggestedRecord } = step.pendingData; + + result.pendingData = { + ...step.pendingData, + availableRecordIds: availableRecordIds.map(c => ({ + ...c, + recordId: serializeRecordId(c.recordId), + })), + ...(suggestedRecord && { + suggestedRecord: { + ...suggestedRecord, + recordId: serializeRecordId(suggestedRecord.recordId), + }, + }), + }; + } + + 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/src/ports/activity-log-port.ts b/packages/workflow-executor/src/ports/activity-log-port.ts index 15ce3f4801..d12ab9ce63 100644 --- a/packages/workflow-executor/src/ports/activity-log-port.ts +++ b/packages/workflow-executor/src/ports/activity-log-port.ts @@ -1,9 +1,11 @@ +import type { RecordId } from '../types/validated/collection'; + export interface CreateActivityLogArgs { renderingId: number; action: string; type: 'read' | 'write'; collectionId?: string; - recordId?: string | number; + 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..5ca727fc8a 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -94,12 +94,15 @@ 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(), }) .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/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/adapters/record-id-serializer.test.ts b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts new file mode 100644 index 0000000000..f4ef112bd5 --- /dev/null +++ b/packages/workflow-executor/test/adapters/record-id-serializer.test.ts @@ -0,0 +1,47 @@ +import { deserializeRecordId, serializeRecordId } from '../../src/adapters/record-id-serializer'; +import { RecordIdSerializationError } from '../../src/errors'; + +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'); + }); + + // '|' 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', () => { + 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']); + }); + + // 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/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 01c119f14a..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 @@ -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: [] }); @@ -590,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: [ 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], }; } 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..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 @@ -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'] }), }), }), ); @@ -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]), 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..d4703d7a48 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,74 @@ 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 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: { + 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' }, + 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.availableRecordIds[0].recordId).toBe('o1|o2'); + expect(step.pendingData.suggestedRecord.recordId).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..4e09b0c503 100644 --- a/packages/workflow-executor/test/http/pending-data-validators.test.ts +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -148,21 +148,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 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', () => { @@ -175,6 +185,10 @@ describe('patchBodySchemas', () => { 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(); }); 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); + }); +});