Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | number>,
): SelectOptions['filters'] {
function buildPkFilter(primaryKeyFields: string[], id: RecordId): SelectOptions['filters'] {
if (primaryKeyFields.length === 1) {
return { field: primaryKeyFields[0], operator: 'Equal', value: id[0] };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 },
Expand Down
21 changes: 21 additions & 0 deletions packages/workflow-executor/src/adapters/record-id-serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { RecordId } from '../types/validated/collection';

import { RecordIdSerializationError } from '../errors';

export function serializeRecordId(recordId: RecordId): string {
Comment thread
hercemer42 marked this conversation as resolved.
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('|');
Comment thread
hercemer42 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {

import { z } from 'zod';

import { deserializeRecordId } from './record-id-serializer';
import toStepDefinition from './step-definition-mapper';
import {
DomainValidationError,
Expand Down Expand Up @@ -139,6 +140,12 @@ export default function toAvailableStepExecution(
);
}

if (!run.selectedRecordId) {
Comment thread
hercemer42 marked this conversation as resolved.
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;

Expand All @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
action: 'listRelatedData',
type: 'read',
collectionId: this.context.collectionId,
recordId: this.context.baseRecordRef.recordId[0],
recordId: this.context.baseRecordRef.recordId,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default class ReadRecordStepExecutor extends RecordStepExecutor<ReadRecor
action: 'index',
type: 'read',
collectionId: this.context.collectionId,
recordId: this.context.baseRecordRef.recordId[0],
recordId: this.context.baseRecordRef.recordId,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
action: 'action',
type: 'write',
collectionId: this.context.collectionId,
recordId: this.context.baseRecordRef.recordId[0],
recordId: this.context.baseRecordRef.recordId,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
action: 'update',
type: 'write',
collectionId: this.context.collectionId,
recordId: this.context.baseRecordRef.recordId[0],
recordId: this.context.baseRecordRef.recordId,
};
}

Expand Down Expand Up @@ -165,11 +165,11 @@
// A user override of `null` (clearing the field) must win over the AI suggestion, so
// distinguish "no override" (undefined) from "override to null".
const rawValue =
userConfirmation?.value !== undefined ? userConfirmation.value : pendingData!.value;

Check warning on line 168 in packages/workflow-executor/src/executors/update-record-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion

const target: UpdateTarget = {
selectedRecordRef,
...pendingData!,

Check warning on line 172 in packages/workflow-executor/src/executors/update-record-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion
// The value comes from an `unknown` HTTP value (may be a boolean or array), so coerce
// it to the field's native type before updating. Idempotent on already-typed values.
value: await this.coerceOverride(selectedRecordRef, pendingData, rawValue),
Expand Down
3 changes: 2 additions & 1 deletion packages/workflow-executor/src/http/executor-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import http from 'http';
import Koa from 'koa';
import koaJwt from 'koa-jwt';

import serializeStepForWire from './step-serializer';
import ConsoleLogger from '../adapters/console-logger';
import {
RunNotFoundError,
Expand Down Expand Up @@ -160,7 +161,7 @@ export default class ExecutorHttpServer {

private async handleGetRun(ctx: Koa.Context): Promise<void> {
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<void> {
Expand Down
13 changes: 9 additions & 4 deletions packages/workflow-executor/src/http/pending-data-validators.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()
Expand Down
54 changes: 54 additions & 0 deletions packages/workflow-executor/src/http/step-serializer.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
...step,
selectedRecordRef: serializeRecordRef(step.selectedRecordRef),
};

if (step.pendingData) {
Comment thread
hercemer42 marked this conversation as resolved.
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;
}
}
4 changes: 3 additions & 1 deletion packages/workflow-executor/src/ports/activity-log-port.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we still have string | number in activity logs ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is — the array is only the internal representation. The adapter serializes it to the pipe wire format before sending (forestadmin-client-activity-log-port.ts:39: recordId: args.recordId?.length ? serializeRecordId(args.recordId) : undefined), so the activity-log payload still goes out as a string. RecordId (array of segments) also lets it carry composite primary keys, which a scalar string | number couldn't.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecordId will be formatted when we send the request.

label?: string;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -133,7 +133,7 @@ export interface RecordStepExecutionData extends BaseStepExecutionData {

// -- Load Related Record --
export interface LoadRelatedRecordCandidate {
recordId: Array<string | number>;
recordId: RecordId;
referenceFieldValue: string | null;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/workflow-executor/src/types/validated/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,15 @@ export type CollectionSchema = z.infer<typeof CollectionSchemaSchema>;
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<typeof RecordRefSchema>;

// A record's primary key: one segment for a simple key, several for a composite key.
export type RecordId = RecordRef['recordId'];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was clearer to leave Array<string | number>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just renaming it RecordIdArray is ok

Copy link
Copy Markdown
Member Author

@Scra3 Scra3 Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to have one source of truth.


// No stepIndex — the agent doesn't know about steps.
export type RecordData = Omit<RecordRef, 'stepIndex'> & { values: Record<string, unknown> };
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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', '']);
});
});
Loading
Loading