Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,29 @@ export default class AgentClientAgentPort implements AgentPort {
user,
);

const linkage = parent.values[toCamelCase(relation)] as
| Record<string, unknown>
| null
| undefined;
const raw = parent.values[toCamelCase(relation)];

// A forest-rails smart field declared with `field ... reference:` serializes as a plain
// attribute, so the value IS the related id. The same apimap shape coming from the
// `belongs_to` DSL, and every forest-express reference field, still arrives as a linkage —
// hence a check on the runtime shape rather than on the schema.
// An empty string is the idiomatic Ruby answer for an unset association (`&.id.to_s`), and it
// would serialize to an id-less by-id URL, which agents route to the index action instead.
if (raw != null && raw !== '' && typeof raw !== 'object') {
return this.getRecord(
{
collection: relatedSchema.collectionName,
// Only a composite key is pipe-packed. The value of a smart field is written by the
// client, so splitting a single-key id would tear a legitimate "a|b" in two.
id:
relatedSchema.primaryKeyFields.length > 1 ? String(raw).split('|') : [raw as string],
...(fields?.length ? { fields } : {}),
},
user,
);
}

const linkage = raw as Record<string, unknown> | null | undefined;
const packedId = linkage?.id as string | undefined;

if (!linkage || !packedId) return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,16 @@
let mockCollection: ReturnType<typeof createMockClient>['mockCollection'];
let mockRelation: ReturnType<typeof createMockClient>['mockRelation'];
let mockAction: ReturnType<typeof createMockClient>['mockAction'];
let mockClient: ReturnType<typeof createMockClient>['client'];
let user: StepUser;
let port: AgentClientAgentPort;

beforeEach(() => {
jest.clearAllMocks();

const mocks = createMockClient();
({ mockCollection, mockRelation, mockAction } = mocks);
({ mockCollection, mockRelation, mockAction, client: mockClient } = mocks);
mockedCreateRemoteAgentClient.mockReturnValue(mocks.client as any);

Check warning on line 124 in packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Unexpected any. Specify a different type

const schemaCache = new SchemaCache();
schemaCache.set(1, 'users', {
Expand Down Expand Up @@ -846,6 +847,131 @@
expect(result?.recordId).toEqual(['acme', '7']);
});

it('reads the target by id when the projected value is a scalar', async () => {
mockCollection.getOne
.mockResolvedValueOnce({ card: 'uuid-1' })
.mockResolvedValueOnce({ id: 'uuid-1', reference: 'CARD-1' });

const result = await port.getSingleRelatedData(
{
collection: 'claims',
id: [42],
relation: 'card',
relatedSchema: { ...ordersSchema, collectionName: 'cards' },
},
user,
);

expect(mockCollection.getOne).toHaveBeenNthCalledWith(1, [42], { fields: ['card@@@id'] });
expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['uuid-1'], {});
expect(mockClient.collection).toHaveBeenCalledWith('cards');
expect(result).toEqual({
collectionName: 'cards',
recordId: ['uuid-1'],
values: { id: 'uuid-1', reference: 'CARD-1' },
});
});

it('passes the caller fields to the scalar target read', async () => {
mockCollection.getOne
.mockResolvedValueOnce({ card: 'uuid-1' })
.mockResolvedValueOnce({ reference: 'CARD-1' });

await port.getSingleRelatedData(
{
collection: 'claims',
id: [42],
relation: 'card',
relatedSchema: { ...ordersSchema, collectionName: 'cards' },
fields: ['reference'],
},
user,
);

expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['uuid-1'], {
fields: ['reference'],
});
});

it('splits a scalar attribute only when the target key is composite', async () => {
mockCollection.getOne
.mockResolvedValueOnce({ card: 'acme|7' })
.mockResolvedValueOnce({ id: 'acme|7' });

const result = await port.getSingleRelatedData(
{
collection: 'claims',
id: [42],
relation: 'card',
relatedSchema: {
...ordersSchema,
collectionName: 'cards',
primaryKeyFields: ['tenantId', 'cardId'],
},
},
user,
);

expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['acme', '7'], {});
expect(result?.recordId).toEqual(['acme', '7']);
});

// The value is produced by client-written code, so a pipe in it is data, not packing.
it('keeps a pipe in a scalar attribute intact when the target key is single', async () => {
mockCollection.getOne
.mockResolvedValueOnce({ card: 'acme|corp' })
.mockResolvedValueOnce({ id: 'acme|corp' });

const result = await port.getSingleRelatedData(
{
collection: 'claims',
id: [42],
relation: 'card',
relatedSchema: { ...ordersSchema, collectionName: 'cards' },
},
user,
);

expect(mockCollection.getOne).toHaveBeenNthCalledWith(2, ['acme|corp'], {});
expect(result?.recordId).toEqual(['acme|corp']);
});

it('returns null when the scalar attribute is null, without reading the target', async () => {
mockCollection.getOne.mockResolvedValue({ card: null });

const result = await port.getSingleRelatedData(
{
collection: 'claims',
id: [42],
relation: 'card',
relatedSchema: { ...ordersSchema, collectionName: 'cards' },
},
user,
);

expect(result).toBeNull();
expect(mockCollection.getOne).toHaveBeenCalledTimes(1);
});

// `object.card&.id.to_s` is the idiomatic Ruby getter, and it answers "" for an unset
// association. Reading it as an id would build an id-less URL that agents route to the index.
it('returns null when the scalar attribute is an empty string, without reading the target', async () => {
mockCollection.getOne.mockResolvedValue({ card: '' });

const result = await port.getSingleRelatedData(
{
collection: 'claims',
id: [42],
relation: 'card',
relatedSchema: { ...ordersSchema, collectionName: 'cards' },
},
user,
);

expect(result).toBeNull();
expect(mockCollection.getOne).toHaveBeenCalledTimes(1);
});

it('returns null when the parent has no linkage to the xToOne relation', async () => {
mockCollection.getOne.mockResolvedValue({ order: null });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3122,7 +3122,7 @@

await new LoadRelatedRecordStepExecutor(context).execute();

const firstRow = JSON.parse(selectRecordPrompt(invoke).match(/\[0\] (\{[^\n]*\})/)![1]);

Check warning on line 3125 in packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion
expect(Object.keys(firstRow)).toHaveLength(6);
});

Expand Down Expand Up @@ -4344,6 +4344,57 @@
);
});

// Regression: a forest-rails smart field reaches the executor as a plain BelongsTo now that the
// server derives the relation from a `reference` whose target is in the apimap. Pinning it must
// follow it, where it used to fail as an invalid pre-recorded arg — the field was not a relation.
it('follows a pinned reference-only BelongsTo', async () => {
const { model, bindTools } = makeMockModel();
const runStore = makeMockRunStore();
const agentPort = makeMockAgentPort([
makeRelatedRecordData({ collectionName: 'cards', recordId: ['card-1'], values: {} }),
]);
const context = makeContext({
model,
runStore,
agentPort,
workflowPort: makeMockWorkflowPort({
customers: makeCollectionSchema({
fields: [
{ fieldName: 'email', displayName: 'Email', isRelationship: false },
{
fieldName: 'card',
displayName: 'Card',
isRelationship: true,
relationType: 'BelongsTo',
relatedCollectionName: 'cards',
},
],
}),
}),
stepDefinition: makeStep({
executionType: StepExecutionMode.FullyAutomated,
preRecordedArgs: { relationName: 'card' },
}),
});

const result = await new LoadRelatedRecordStepExecutor(context).execute();

expect(result.stepOutcome.status).toBe('success');
expect(bindTools).not.toHaveBeenCalled();
expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'customers',
relation: 'card',
relatedSchema: expect.objectContaining({ collectionName: 'cards' }),
}),
expect.anything(),
);
expect(runStore.saveStepExecution).toHaveBeenCalledWith(
'run-1',
expect.objectContaining({ executionParams: { displayName: 'Card', name: 'card' } }),
);
});

it('pins the source record via selectedRecordStepId (among several records)', async () => {
// Base customers #42 (step 0) + a loaded order #99 (step 1) are both available;
// pinning step 1 must make the relation follow the ORDER, not the base customer.
Expand Down
Loading