Skip to content
Open
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
55 changes: 41 additions & 14 deletions packages/workflow-executor/src/executors/agent-with-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
import type { WorkflowPort } from '../ports/workflow-port';
import type SchemaCache from '../schema-cache';
import type { StepUser } from '../types/execution-context';
import type { RecordData } from '../types/validated/collection';
import type { CollectionSchema, RecordData } from '../types/validated/collection';

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

Expand Down Expand Up @@ -52,46 +52,66 @@ export default class AgentWithLog {
}

async getRecord(query: GetRecordQuery): Promise<RecordData> {
const collectionId = await this.resolveCollectionId(query.collection);
const { collectionId } = await this.resolveSchema(query.collection);

// ISO with the browser engine: `index` reads carry no label (trackRead is called without one).
return this.audit({ action: 'index', type: 'read', collectionId, recordId: query.id }, () =>
this.agentPort.getRecord(query, this.user),
);
}

async getRelatedData(query: GetRelatedDataQuery): Promise<RecordData[]> {
const collectionId = await this.resolveCollectionId(query.collection);
const schema = await this.resolveSchema(query.collection);

return this.audit(
{ action: 'listRelatedData', type: 'read', collectionId, recordId: query.id },
{
action: 'listRelatedData',
type: 'read',
collectionId: schema.collectionId,
recordId: query.id,
label: this.relationLabel(schema, query.relation),
},
() => this.agentPort.getRelatedData(query, this.user),
);
}

async getSingleRelatedData(query: GetSingleRelatedDataQuery): Promise<RecordData | null> {
const collectionId = await this.resolveCollectionId(query.collection);
const schema = await this.resolveSchema(query.collection);

return this.audit(
{ action: 'listRelatedData', type: 'read', collectionId, recordId: query.id },
{
action: 'listRelatedData',
type: 'read',
collectionId: schema.collectionId,
recordId: query.id,
label: this.relationLabel(schema, query.relation),
},
() => this.agentPort.getSingleRelatedData(query, this.user),
);
}

async updateRecord(query: UpdateRecordQuery, opts: WriteOptions): Promise<RecordData> {
const collectionId = await this.resolveCollectionId(query.collection);
const { collectionId } = await this.resolveSchema(query.collection);

return this.audit(
{ action: 'update', type: 'write', collectionId, recordId: query.id },
{ action: 'update', type: 'write', collectionId, recordId: query.id, label: 'updated' },
() => this.agentPort.updateRecord(query, this.user),
opts.beforeCall,
);
}

async executeAction(query: ExecuteActionQuery, opts: WriteOptions): Promise<unknown> {
const collectionId = await this.resolveCollectionId(query.collection);
const { collectionId } = await this.resolveSchema(query.collection);

return this.audit(
{ action: 'action', type: 'write', collectionId, recordId: query.id },
{
action: 'action',
type: 'write',
collectionId,
recordId: query.id,
// ISO with the browser engine: the action's TECHNICAL name, not displayName.
label: `triggered the action "${query.action}"`,
},
() => this.agentPort.executeAction(query, this.user),
opts.beforeCall,
);
Expand Down Expand Up @@ -131,11 +151,18 @@ export default class AgentWithLog {
}
}

private async resolveCollectionId(collectionName: string): Promise<string> {
const schema = await this.schemaCache.getOrLoad(collectionName, () =>
// ISO with the browser engine: `list relation "<displayName>"`. The query carries the technical
// relation name; resolve its displayName from the source schema, falling back to the technical
// name when the field is absent (resilient to orchestrator schema drift).
private relationLabel(schema: CollectionSchema, relation: string): string {
const displayName = schema.fields.find(f => f.fieldName === relation)?.displayName ?? relation;

return `list relation "${displayName}"`;
}

private resolveSchema(collectionName: string): Promise<CollectionSchema> {
return this.schemaCache.getOrLoad(collectionName, () =>
this.workflowPort.getCollectionSchema(collectionName, this.runId),
);

return schema.collectionId;
}
}
96 changes: 87 additions & 9 deletions packages/workflow-executor/test/executors/agent-with-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,28 @@ function makeUser(): StepUser {
} as StepUser;
}

function makeSchema(collectionId = 'col-customers'): CollectionSchema {
function makeSchema(
collectionId = 'col-customers',
fields: CollectionSchema['fields'] = [],
): CollectionSchema {
return {
collectionName: 'customers',
collectionId,
collectionDisplayName: 'Customers',
primaryKeyFields: ['id'],
referenceField: null,
fields: [],
fields,
actions: [],
};
}

function makeRelationField(
fieldName: string,
displayName: string,
): CollectionSchema['fields'][number] {
return { fieldName, displayName, isRelationship: true, relationType: 'BelongsTo' };
}

function makeActivityLogPort() {
return {
createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }),
Expand Down Expand Up @@ -100,8 +110,10 @@ describe('AgentWithLog', () => {
expect(result).toEqual({ collectionName: 'customers', recordId: [42], values: {} });
});

it('logs getRelatedData as listRelatedData/read', async () => {
const { deps, activityLogPort } = makeDeps();
it('logs getRelatedData as listRelatedData/read labelled with the relation displayName', async () => {
const schema = makeSchema('col-customers', [makeRelationField('orders', 'Orders')]);
const { deps, activityLogPort, schemaCache } = makeDeps();
(schemaCache.getOrLoad as jest.Mock).mockResolvedValue(schema);
const agent = new AgentWithLog(deps);

await agent.getRelatedData({
Expand All @@ -112,29 +124,95 @@ describe('AgentWithLog', () => {
limit: 50,
});

expect(activityLogPort.createPending).toHaveBeenCalledWith({
renderingId: 1,
action: 'listRelatedData',
type: 'read',
collectionId: 'col-customers',
recordId: [42],
label: 'list relation "Orders"',
});
});

it('logs getSingleRelatedData as listRelatedData/read labelled with the relation displayName (xToOne)', async () => {
const schema = makeSchema('col-customers', [makeRelationField('order', 'Order')]);
const { deps, activityLogPort, schemaCache } = makeDeps();
(schemaCache.getOrLoad as jest.Mock).mockResolvedValue(schema);
const agent = new AgentWithLog(deps);

await agent.getSingleRelatedData({
collection: 'customers',
id: [42],
relation: 'order',
relatedSchema: makeSchema('col-orders'),
});

expect(activityLogPort.createPending).toHaveBeenCalledWith(
expect.objectContaining({ action: 'listRelatedData', type: 'read', recordId: [42] }),
expect.objectContaining({
action: 'listRelatedData',
type: 'read',
label: 'list relation "Order"',
}),
);
});

it('logs getSingleRelatedData as listRelatedData/read (xToOne)', async () => {
it('falls back to the technical relation name when the field is absent from the schema', async () => {
const { deps, activityLogPort } = makeDeps();
const agent = new AgentWithLog(deps);

await agent.getSingleRelatedData({
await agent.getRelatedData({
collection: 'customers',
id: [42],
relation: 'order',
relation: 'orders',
relatedSchema: makeSchema('col-orders'),
limit: 50,
});

expect(activityLogPort.createPending).toHaveBeenCalledWith(
expect.objectContaining({ action: 'listRelatedData', type: 'read', recordId: [42] }),
expect.objectContaining({ label: 'list relation "orders"' }),
);
});
});

describe('write methods', () => {
it('logs updateRecord as update/write with the static "updated" label', async () => {
const { deps, activityLogPort } = makeDeps();
const agent = new AgentWithLog(deps);

await agent.updateRecord(
{ collection: 'customers', id: [42], values: { name: 'X' } },
{ beforeCall: async () => undefined },
);

expect(activityLogPort.createPending).toHaveBeenCalledWith({
renderingId: 1,
action: 'update',
type: 'write',
collectionId: 'col-customers',
recordId: [42],
label: 'updated',
});
});

it('logs executeAction labelled with the action technical name', async () => {
const { deps, activityLogPort } = makeDeps();
const agent = new AgentWithLog(deps);

await agent.executeAction(
{ collection: 'customers', action: 'send_email', id: [42] },
{ beforeCall: async () => undefined },
);

expect(activityLogPort.createPending).toHaveBeenCalledWith({
renderingId: 1,
action: 'action',
type: 'write',
collectionId: 'col-customers',
recordId: [42],
label: 'triggered the action "send_email"',
});
});

it('runs beforeCall between createPending and the agent call (audit precedes the side effect)', async () => {
const order: string[] = [];
const { deps, agentPort, activityLogPort } = makeDeps();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@
type: 'read',
collectionId: 'col-customers',
recordId: [42],
label: 'list relation "Order"',
});
});

Expand All @@ -772,6 +773,7 @@
type: 'read',
collectionId: 'col-customers',
recordId: [42],
label: 'list relation "Order"',
}),
);
});
Expand Down Expand Up @@ -1895,7 +1897,7 @@

await new LoadRelatedRecordStepExecutor(context).execute();

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

Check warning on line 1900 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
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ describe('TriggerRecordActionStepExecutor', () => {
type: 'write',
collectionId: 'col-customers',
recordId: [42],
label: 'triggered the action "send-welcome-email"',
});
});

Expand Down Expand Up @@ -296,6 +297,7 @@ describe('TriggerRecordActionStepExecutor', () => {
type: 'write',
collectionId: 'col-orders',
recordId: [99],
label: 'triggered the action "cancel-order"',
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ describe('UpdateRecordStepExecutor', () => {
type: 'write',
collectionId: 'col-customers',
recordId: [42],
label: 'updated',
});
});

Expand Down Expand Up @@ -308,6 +309,7 @@ describe('UpdateRecordStepExecutor', () => {
type: 'write',
collectionId: 'col-orders',
recordId: [99],
label: 'updated',
});
});

Expand Down
Loading