From c7d7642377f7f56bca9ce8fee3efa7fb371f16cb Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 5 Jun 2026 12:15:07 +0200 Subject: [PATCH 1/2] feat(workflow-executor): align datasource activity-log labels with the browser engine [PRD-449] Activity logs emitted by AgentWithLog now carry an ISO label matching the legacy browser engine, so the audit-trail front renders them (a label-less update entry was crashing it). - update -> "updated" (static) - action -> triggered the action "" (from query.action) - listRelatedData -> list relation "" (resolved from source schema) - index (read) -> no label (ISO; getRecord is shared across step types) Labels are set at the call site in AgentWithLog, after name resolution by construction, so no executor or base-class refactor is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/executors/agent-with-log.ts | 51 +++++++--- .../test/executors/agent-with-log.test.ts | 98 +++++++++++++++++-- .../load-related-record-step-executor.test.ts | 2 + ...rigger-record-action-step-executor.test.ts | 2 + .../update-record-step-executor.test.ts | 2 + 5 files changed, 132 insertions(+), 23 deletions(-) diff --git a/packages/workflow-executor/src/executors/agent-with-log.ts b/packages/workflow-executor/src/executors/agent-with-log.ts index b48dace014..1d30127e45 100644 --- a/packages/workflow-executor/src/executors/agent-with-log.ts +++ b/packages/workflow-executor/src/executors/agent-with-log.ts @@ -10,7 +10,7 @@ import type { } from '../ports/agent-port'; import type SchemaResolver from '../schema-resolver'; import type { StepUser } from '../types/execution-context'; -import type { RecordData } from '../types/validated/collection'; +import type { CollectionSchema, RecordData } from '../types/validated/collection'; type WriteOptions = { beforeCall: () => Promise }; @@ -42,7 +42,7 @@ export default class AgentWithLog { } async getRecord(query: GetRecordQuery): Promise { - const collectionId = await this.resolveCollectionId(query.collection); + const { collectionId } = await this.resolveSchema(query.collection); return this.activityLog.track( { action: 'index', type: 'read', collectionId, recordId: query.id }, @@ -51,28 +51,40 @@ export default class AgentWithLog { } async getRelatedData(query: GetRelatedDataQuery): Promise { - const collectionId = await this.resolveCollectionId(query.collection); + const schema = await this.resolveSchema(query.collection); return this.activityLog.track( - { action: 'listRelatedData', type: 'read', collectionId, recordId: query.id }, + { + action: 'listRelatedData', + type: 'read', + collectionId: schema.collectionId, + recordId: query.id, + label: this.relationLabel(schema, query.relation), + }, { operation: () => this.agentPort.getRelatedData(query, this.user) }, ); } async getSingleRelatedData(query: GetSingleRelatedDataQuery): Promise { - const collectionId = await this.resolveCollectionId(query.collection); + const schema = await this.resolveSchema(query.collection); return this.activityLog.track( - { action: 'listRelatedData', type: 'read', collectionId, recordId: query.id }, + { + action: 'listRelatedData', + type: 'read', + collectionId: schema.collectionId, + recordId: query.id, + label: this.relationLabel(schema, query.relation), + }, { operation: () => this.agentPort.getSingleRelatedData(query, this.user) }, ); } async updateRecord(query: UpdateRecordQuery, opts: WriteOptions): Promise { - const collectionId = await this.resolveCollectionId(query.collection); + const { collectionId } = await this.resolveSchema(query.collection); return this.activityLog.track( - { action: 'update', type: 'write', collectionId, recordId: query.id }, + { action: 'update', type: 'write', collectionId, recordId: query.id, label: 'updated' }, { operation: () => this.agentPort.updateRecord(query, this.user), beforeCall: opts.beforeCall, @@ -81,10 +93,16 @@ export default class AgentWithLog { } async executeAction(query: ExecuteActionQuery, opts: WriteOptions): Promise { - const collectionId = await this.resolveCollectionId(query.collection); + const { collectionId } = await this.resolveSchema(query.collection); return this.activityLog.track( - { action: 'action', type: 'write', collectionId, recordId: query.id }, + { + action: 'action', + type: 'write', + collectionId, + recordId: query.id, + label: `triggered the action "${query.action}"`, + }, { operation: () => this.agentPort.executeAction(query, this.user), beforeCall: opts.beforeCall, @@ -98,9 +116,16 @@ export default class AgentWithLog { return this.agentPort.getActionFormInfo(query, this.user); } - private async resolveCollectionId(collectionName: string): Promise { - const schema = await this.schemaResolver.resolve(collectionName); + // ISO with the browser engine: `list relation ""`. 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}"`; + } - return schema.collectionId; + private resolveSchema(collectionName: string): Promise { + return this.schemaResolver.resolve(collectionName); } } diff --git a/packages/workflow-executor/test/executors/agent-with-log.test.ts b/packages/workflow-executor/test/executors/agent-with-log.test.ts index 48608fc270..e0b0482f35 100644 --- a/packages/workflow-executor/test/executors/agent-with-log.test.ts +++ b/packages/workflow-executor/test/executors/agent-with-log.test.ts @@ -21,18 +21,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' }), @@ -93,8 +103,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, schemaResolver } = makeDeps(); + (schemaResolver.resolve as jest.Mock).mockResolvedValue(schema); const agent = new AgentWithLog(deps); await agent.getRelatedData({ @@ -105,30 +117,96 @@ 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, schemaResolver } = makeDeps(); + (schemaResolver.resolve 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 and forwards beforeCall before the side effect', async () => { + 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(); (agentPort.updateRecord as jest.Mock).mockImplementation(async () => { 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 643f782ac5..8595881350 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 @@ -796,6 +796,7 @@ describe('LoadRelatedRecordStepExecutor', () => { type: 'read', collectionId: 'col-customers', recordId: [42], + label: 'list relation "Order"', }); }); @@ -819,6 +820,7 @@ describe('LoadRelatedRecordStepExecutor', () => { type: 'read', collectionId: 'col-customers', recordId: [42], + label: 'list relation "Order"', }), ); }); diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index 5d41864336..f23010df22 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -259,6 +259,7 @@ describe('TriggerRecordActionStepExecutor', () => { type: 'write', collectionId: 'col-customers', recordId: [42], + label: 'triggered the action "send-welcome-email"', }); }); @@ -344,6 +345,7 @@ describe('TriggerRecordActionStepExecutor', () => { type: 'write', collectionId: 'col-orders', recordId: [99], + label: 'triggered the action "cancel-order"', }); }); }); diff --git a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts index cfa10fae8b..a1f410ba81 100644 --- a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -258,6 +258,7 @@ describe('UpdateRecordStepExecutor', () => { type: 'write', collectionId: 'col-customers', recordId: [42], + label: 'updated', }); }); @@ -356,6 +357,7 @@ describe('UpdateRecordStepExecutor', () => { type: 'write', collectionId: 'col-orders', recordId: [99], + label: 'updated', }); }); From 4dad2f2c2e8e1c3de3d769a4ec1d9e75225c94d7 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 8 Jun 2026 16:23:01 +0200 Subject: [PATCH 2/2] chore(code review): filter relationships --- packages/workflow-executor/src/executors/agent-with-log.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/executors/agent-with-log.ts b/packages/workflow-executor/src/executors/agent-with-log.ts index 1d30127e45..a2f2fb16cd 100644 --- a/packages/workflow-executor/src/executors/agent-with-log.ts +++ b/packages/workflow-executor/src/executors/agent-with-log.ts @@ -120,7 +120,9 @@ export default class AgentWithLog { // 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; + const displayName = + schema.fields.filter(f => f.isRelationship).find(f => f.fieldName === relation) + ?.displayName ?? relation; return `list relation "${displayName}"`; }