From 4fb60325bbafeada238ccc05e430cae93aede225 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 5 Jun 2026 12:15:07 +0200 Subject: [PATCH] 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 | 55 ++++++++--- .../test/executors/agent-with-log.test.ts | 96 +++++++++++++++++-- .../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, 134 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 6a252d6d28..d051d8e9dd 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 { 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'; @@ -52,46 +52,66 @@ export default class AgentWithLog { } async getRecord(query: GetRecordQuery): Promise { - 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 { - 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 { - 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 { - 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 { - 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, ); @@ -131,11 +151,18 @@ export default class AgentWithLog { } } - private async resolveCollectionId(collectionName: string): Promise { - const schema = await this.schemaCache.getOrLoad(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}"`; + } + + private resolveSchema(collectionName: string): Promise { + return this.schemaCache.getOrLoad(collectionName, () => this.workflowPort.getCollectionSchema(collectionName, this.runId), ); - - return schema.collectionId; } } 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 044dab76fb..429cfe90c4 100644 --- a/packages/workflow-executor/test/executors/agent-with-log.test.ts +++ b/packages/workflow-executor/test/executors/agent-with-log.test.ts @@ -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' }), @@ -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({ @@ -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(); 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 66ed147ee8..698c8ea8b0 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 @@ -749,6 +749,7 @@ describe('LoadRelatedRecordStepExecutor', () => { type: 'read', collectionId: 'col-customers', recordId: [42], + label: 'list relation "Order"', }); }); @@ -772,6 +773,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 436ba68a19..f163b00eb0 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 @@ -212,6 +212,7 @@ describe('TriggerRecordActionStepExecutor', () => { type: 'write', collectionId: 'col-customers', recordId: [42], + label: 'triggered the action "send-welcome-email"', }); }); @@ -296,6 +297,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 11b9d23257..80946b8d4e 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 @@ -211,6 +211,7 @@ describe('UpdateRecordStepExecutor', () => { type: 'write', collectionId: 'col-customers', recordId: [42], + label: 'updated', }); }); @@ -308,6 +309,7 @@ describe('UpdateRecordStepExecutor', () => { type: 'write', collectionId: 'col-orders', recordId: [99], + label: 'updated', }); });