From 95bf2f363cb7d3ac40b81951a1435045e8918fcd Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Thu, 15 Jan 2026 16:21:41 +0100 Subject: [PATCH] feat(viewer-cloud): add ExecutionLogger for workflow execution tracking CF11-005: Execution Logger Integration (Part 1 - Logger Class) - Add ExecutionLogger class wrapping ExecutionStore - Implement execution lifecycle: startExecution, completeExecution - Implement node lifecycle: startNode, completeNode, skipNode - Add configurable settings (enabled, captureInputs, captureOutputs) - Add automatic data truncation for large payloads - Add retention cleanup utility - Add comprehensive unit tests with MockExecutionStore CloudRunner integration (Part 2) deferred until Phase 5 TASK-007C creates the workflow runtime engine. --- .../src/execution-history/ExecutionLogger.ts | 394 ++++++++++++++++ .../src/execution-history/index.ts | 4 + .../tests/execution-logger.test.ts | 439 ++++++++++++++++++ 3 files changed, 837 insertions(+) create mode 100644 packages/noodl-viewer-cloud/src/execution-history/ExecutionLogger.ts create mode 100644 packages/noodl-viewer-cloud/tests/execution-logger.test.ts diff --git a/packages/noodl-viewer-cloud/src/execution-history/ExecutionLogger.ts b/packages/noodl-viewer-cloud/src/execution-history/ExecutionLogger.ts new file mode 100644 index 0000000..9055044 --- /dev/null +++ b/packages/noodl-viewer-cloud/src/execution-history/ExecutionLogger.ts @@ -0,0 +1,394 @@ +/** + * ExecutionLogger - High-level API for logging workflow executions + * + * Provides lifecycle methods for tracking workflow and node executions. + * Wraps ExecutionStore with additional features like: + * - Configuration for enabling/disabling capture + * - Automatic data truncation + * - State management for current execution + * - Retention policy enforcement + * + * @module execution-history/ExecutionLogger + * + * @example + * ```typescript + * const logger = new ExecutionLogger(store, { enabled: true }); + * + * // Start a workflow execution + * const execId = logger.startExecution({ + * workflowId: 'wf-123', + * workflowName: 'My Workflow', + * triggerType: 'webhook', + * triggerData: { path: '/api/hook' } + * }); + * + * // Log node executions + * const stepId = logger.startNode({ + * nodeId: 'node-1', + * nodeType: 'noodl.logic.condition', + * inputData: { value: true } + * }); + * logger.completeNode(stepId, true, { result: false }); + * + * // Complete the workflow + * logger.completeExecution(true); + * ``` + */ + +import type { ExecutionStore } from './store'; +import type { TriggerType } from './types'; + +/** + * Configuration for ExecutionLogger + */ +export interface LoggerConfig { + /** Whether logging is enabled (default: true) */ + enabled: boolean; + + /** Whether to capture input data for nodes (default: true) */ + captureInputs: boolean; + + /** Whether to capture output data for nodes (default: true) */ + captureOutputs: boolean; + + /** Maximum size for JSON data in bytes (default: 100KB) */ + maxDataSize: number; + + /** Number of days to retain execution records (default: 30) */ + retentionDays: number; +} + +/** + * Parameters for starting an execution + */ +export interface StartExecutionParams { + /** ID of the workflow component */ + workflowId: string; + + /** Display name of the workflow */ + workflowName: string; + + /** Type of trigger that started the workflow */ + triggerType: TriggerType; + + /** Trigger-specific data */ + triggerData?: Record; + + /** Additional metadata */ + metadata?: Record; +} + +/** + * Parameters for starting a node execution + */ +export interface StartNodeParams { + /** ID of the node in the graph */ + nodeId: string; + + /** Type of node */ + nodeType: string; + + /** Display name of the node */ + nodeName?: string; + + /** Input data received by the node */ + inputData?: Record; +} + +/** Default logger configuration */ +const DEFAULT_CONFIG: LoggerConfig = { + enabled: true, + captureInputs: true, + captureOutputs: true, + maxDataSize: 100_000, // 100KB + retentionDays: 30 +}; + +/** + * ExecutionLogger class + * + * High-level API for logging workflow executions with lifecycle management. + */ +export class ExecutionLogger { + private store: ExecutionStore; + private config: LoggerConfig; + + // Current execution state + private currentExecutionId: string | null = null; + private executionStartTime: number = 0; + private stepIndex: number = 0; + + // Track active steps for timing + private activeSteps: Map = new Map(); + + /** + * Create an ExecutionLogger + * + * @param store - ExecutionStore instance + * @param config - Optional configuration overrides + */ + constructor(store: ExecutionStore, config?: Partial) { + this.store = store; + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + // =========================================================================== + // CONFIGURATION + // =========================================================================== + + /** + * Check if logging is enabled + */ + get isEnabled(): boolean { + return this.config.enabled; + } + + /** + * Enable or disable logging + */ + setEnabled(enabled: boolean): void { + this.config.enabled = enabled; + } + + /** + * Update configuration + */ + updateConfig(updates: Partial): void { + this.config = { ...this.config, ...updates }; + } + + /** + * Get current configuration + */ + getConfig(): Readonly { + return { ...this.config }; + } + + // =========================================================================== + // EXECUTION LIFECYCLE + // =========================================================================== + + /** + * Start logging a workflow execution + * + * @param params - Execution parameters + * @returns Execution ID (empty string if logging is disabled) + */ + startExecution(params: StartExecutionParams): string { + if (!this.config.enabled) { + return ''; + } + + // Clean up any previous execution state + this.resetState(); + + const now = Date.now(); + this.executionStartTime = now; + + const executionId = this.store.createExecution({ + workflowId: params.workflowId, + workflowName: params.workflowName, + triggerType: params.triggerType, + triggerData: this.truncateData(params.triggerData), + status: 'running', + startedAt: now, + metadata: this.truncateData(params.metadata) + }); + + this.currentExecutionId = executionId; + return executionId; + } + + /** + * Complete the current workflow execution + * + * @param success - Whether the execution succeeded + * @param error - Error if execution failed + */ + completeExecution(success: boolean, error?: Error): void { + if (!this.config.enabled || !this.currentExecutionId) { + return; + } + + const now = Date.now(); + const durationMs = now - this.executionStartTime; + + this.store.updateExecution(this.currentExecutionId, { + status: success ? 'success' : 'error', + completedAt: now, + durationMs, + errorMessage: error?.message, + errorStack: error?.stack + }); + + // Clean up state + this.resetState(); + } + + /** + * Get the current execution ID + */ + getCurrentExecutionId(): string | null { + return this.currentExecutionId; + } + + /** + * Check if there's an active execution + */ + hasActiveExecution(): boolean { + return this.currentExecutionId !== null; + } + + // =========================================================================== + // NODE LIFECYCLE + // =========================================================================== + + /** + * Start logging a node execution + * + * @param params - Node parameters + * @returns Step ID (empty string if logging is disabled or no active execution) + */ + startNode(params: StartNodeParams): string { + if (!this.config.enabled || !this.currentExecutionId) { + return ''; + } + + const now = Date.now(); + + const stepId = this.store.addStep({ + executionId: this.currentExecutionId, + nodeId: params.nodeId, + nodeType: params.nodeType, + nodeName: params.nodeName, + stepIndex: this.stepIndex++, + startedAt: now, + status: 'running', + inputData: this.config.captureInputs ? this.truncateData(params.inputData) : undefined + }); + + // Track start time for duration calculation + this.activeSteps.set(stepId, now); + + return stepId; + } + + /** + * Complete a node execution + * + * @param stepId - Step ID returned from startNode + * @param success - Whether the node execution succeeded + * @param outputData - Output data produced by the node + * @param error - Error if node failed + */ + completeNode(stepId: string, success: boolean, outputData?: Record, error?: Error): void { + if (!this.config.enabled || !stepId) { + return; + } + + const now = Date.now(); + const startTime = this.activeSteps.get(stepId); + const durationMs = startTime ? now - startTime : undefined; + + this.store.updateStep(stepId, { + status: success ? 'success' : 'error', + completedAt: now, + durationMs, + outputData: this.config.captureOutputs ? this.truncateData(outputData) : undefined, + errorMessage: error?.message + }); + + // Clean up tracking + this.activeSteps.delete(stepId); + } + + /** + * Mark a node as skipped + * + * @param params - Node parameters + * @returns Step ID + */ + skipNode(params: StartNodeParams): string { + if (!this.config.enabled || !this.currentExecutionId) { + return ''; + } + + const now = Date.now(); + + return this.store.addStep({ + executionId: this.currentExecutionId, + nodeId: params.nodeId, + nodeType: params.nodeType, + nodeName: params.nodeName, + stepIndex: this.stepIndex++, + startedAt: now, + completedAt: now, + durationMs: 0, + status: 'skipped' + }); + } + + // =========================================================================== + // UTILITIES + // =========================================================================== + + /** + * Truncate data if it exceeds the maximum size + */ + private truncateData(data?: Record): Record | undefined { + if (data === undefined || data === null) { + return undefined; + } + + try { + const json = JSON.stringify(data); + if (json.length <= this.config.maxDataSize) { + return data; + } + + // Return truncated indicator + return { + __truncated: true, + __originalSize: json.length, + __maxSize: this.config.maxDataSize, + __preview: json.substring(0, 1000) + '...' + }; + } catch (e) { + return { + __error: 'Failed to serialize data', + __message: e instanceof Error ? e.message : 'Unknown error' + }; + } + } + + /** + * Reset internal state + */ + private resetState(): void { + this.currentExecutionId = null; + this.executionStartTime = 0; + this.stepIndex = 0; + this.activeSteps.clear(); + } + + // =========================================================================== + // RETENTION + // =========================================================================== + + /** + * Run retention cleanup based on configured policy + * + * @returns Number of executions deleted + */ + runRetentionCleanup(): number { + const maxAgeMs = this.config.retentionDays * 24 * 60 * 60 * 1000; + return this.store.cleanupByAge(maxAgeMs); + } + + /** + * Get the underlying store for advanced queries + */ + getStore(): ExecutionStore { + return this.store; + } +} diff --git a/packages/noodl-viewer-cloud/src/execution-history/index.ts b/packages/noodl-viewer-cloud/src/execution-history/index.ts index efc17ed..6561514 100644 --- a/packages/noodl-viewer-cloud/src/execution-history/index.ts +++ b/packages/noodl-viewer-cloud/src/execution-history/index.ts @@ -54,6 +54,10 @@ export { ExecutionStore } from './store'; export type { SQLiteDatabase } from './store'; +// Export logger +export { ExecutionLogger } from './ExecutionLogger'; +export type { LoggerConfig, StartExecutionParams, StartNodeParams } from './ExecutionLogger'; + // Export types export type { // Status types diff --git a/packages/noodl-viewer-cloud/tests/execution-logger.test.ts b/packages/noodl-viewer-cloud/tests/execution-logger.test.ts new file mode 100644 index 0000000..e063f58 --- /dev/null +++ b/packages/noodl-viewer-cloud/tests/execution-logger.test.ts @@ -0,0 +1,439 @@ +/** + * Unit tests for ExecutionLogger + * + * Tests lifecycle methods, configuration, and data handling. + */ + +import { ExecutionLogger, ExecutionStore, SQLiteDatabase } from '../src/execution-history'; + +/** + * Mock ExecutionStore that tracks calls for testing + */ +class MockExecutionStore { + public executions: Map> = new Map(); + public steps: Map> = new Map(); + public calls: Array<{ method: string; args: unknown[] }> = []; + private executionCounter = 0; + private stepCounter = 0; + + createExecution(options: Record): string { + const id = `exec_${++this.executionCounter}`; + this.executions.set(id, { id, ...options }); + this.calls.push({ method: 'createExecution', args: [options] }); + return id; + } + + updateExecution(id: string, updates: Record): void { + const exec = this.executions.get(id); + if (exec) { + Object.assign(exec, updates); + } + this.calls.push({ method: 'updateExecution', args: [id, updates] }); + } + + addStep(options: Record): string { + const id = `step_${++this.stepCounter}`; + this.steps.set(id, { id, ...options }); + this.calls.push({ method: 'addStep', args: [options] }); + return id; + } + + updateStep(id: string, updates: Record): void { + const step = this.steps.get(id); + if (step) { + Object.assign(step, updates); + } + this.calls.push({ method: 'updateStep', args: [id, updates] }); + } + + cleanupByAge(maxAgeMs: number): number { + this.calls.push({ method: 'cleanupByAge', args: [maxAgeMs] }); + return 0; + } + + // Test helpers + reset(): void { + this.executions.clear(); + this.steps.clear(); + this.calls = []; + this.executionCounter = 0; + this.stepCounter = 0; + } +} + +describe('ExecutionLogger', () => { + let store: MockExecutionStore; + let logger: ExecutionLogger; + + beforeEach(() => { + store = new MockExecutionStore(); + logger = new ExecutionLogger(store as unknown as ExecutionStore); + }); + + describe('configuration', () => { + it('should be enabled by default', () => { + expect(logger.isEnabled).toBe(true); + }); + + it('should allow disabling logging', () => { + logger.setEnabled(false); + expect(logger.isEnabled).toBe(false); + }); + + it('should return default config values', () => { + const config = logger.getConfig(); + expect(config.enabled).toBe(true); + expect(config.captureInputs).toBe(true); + expect(config.captureOutputs).toBe(true); + expect(config.maxDataSize).toBe(100_000); + expect(config.retentionDays).toBe(30); + }); + + it('should allow custom config on construction', () => { + const customLogger = new ExecutionLogger(store as unknown as ExecutionStore, { + captureInputs: false, + retentionDays: 7 + }); + const config = customLogger.getConfig(); + expect(config.captureInputs).toBe(false); + expect(config.retentionDays).toBe(7); + }); + + it('should allow updating config', () => { + logger.updateConfig({ maxDataSize: 50_000 }); + expect(logger.getConfig().maxDataSize).toBe(50_000); + }); + }); + + describe('execution lifecycle', () => { + it('should create an execution on start', () => { + const execId = logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test Workflow', + triggerType: 'manual' + }); + + expect(execId).toBe('exec_1'); + expect(store.executions.size).toBe(1); + + const exec = store.executions.get(execId); + expect(exec?.workflowId).toBe('wf-123'); + expect(exec?.workflowName).toBe('Test Workflow'); + expect(exec?.triggerType).toBe('manual'); + expect(exec?.status).toBe('running'); + }); + + it('should return empty string when disabled', () => { + logger.setEnabled(false); + const execId = logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual' + }); + expect(execId).toBe(''); + expect(store.executions.size).toBe(0); + }); + + it('should complete execution with success', () => { + const execId = logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual' + }); + + logger.completeExecution(true); + + const exec = store.executions.get(execId); + expect(exec?.status).toBe('success'); + expect(exec?.completedAt).toBeDefined(); + expect(exec?.durationMs).toBeDefined(); + }); + + it('should complete execution with error', () => { + const execId = logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual' + }); + + const error = new Error('Test error'); + logger.completeExecution(false, error); + + const exec = store.executions.get(execId); + expect(exec?.status).toBe('error'); + expect(exec?.errorMessage).toBe('Test error'); + }); + + it('should track current execution ID', () => { + expect(logger.hasActiveExecution()).toBe(false); + expect(logger.getCurrentExecutionId()).toBeNull(); + + logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual' + }); + + expect(logger.hasActiveExecution()).toBe(true); + expect(logger.getCurrentExecutionId()).toBe('exec_1'); + + logger.completeExecution(true); + + expect(logger.hasActiveExecution()).toBe(false); + expect(logger.getCurrentExecutionId()).toBeNull(); + }); + + it('should store trigger data', () => { + logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'webhook', + triggerData: { path: '/api/hook', method: 'POST' } + }); + + const exec = store.executions.get('exec_1'); + expect(exec?.triggerData).toEqual({ path: '/api/hook', method: 'POST' }); + }); + + it('should store metadata', () => { + logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual', + metadata: { version: '1.0', user: 'test@example.com' } + }); + + const exec = store.executions.get('exec_1'); + expect(exec?.metadata).toEqual({ version: '1.0', user: 'test@example.com' }); + }); + }); + + describe('node lifecycle', () => { + beforeEach(() => { + logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual' + }); + }); + + it('should create a step on startNode', () => { + const stepId = logger.startNode({ + nodeId: 'node-1', + nodeType: 'noodl.logic.condition', + nodeName: 'Check Condition' + }); + + expect(stepId).toBe('step_1'); + expect(store.steps.size).toBe(1); + + const step = store.steps.get(stepId); + expect(step?.nodeId).toBe('node-1'); + expect(step?.nodeType).toBe('noodl.logic.condition'); + expect(step?.nodeName).toBe('Check Condition'); + expect(step?.status).toBe('running'); + expect(step?.stepIndex).toBe(0); + }); + + it('should capture input data when enabled', () => { + logger.startNode({ + nodeId: 'node-1', + nodeType: 'test', + inputData: { value: 42, name: 'test' } + }); + + const step = store.steps.get('step_1'); + expect(step?.inputData).toEqual({ value: 42, name: 'test' }); + }); + + it('should not capture input data when disabled', () => { + logger.updateConfig({ captureInputs: false }); + + logger.startNode({ + nodeId: 'node-1', + nodeType: 'test', + inputData: { value: 42 } + }); + + const step = store.steps.get('step_1'); + expect(step?.inputData).toBeUndefined(); + }); + + it('should complete node with success', () => { + const stepId = logger.startNode({ + nodeId: 'node-1', + nodeType: 'test' + }); + + logger.completeNode(stepId, true, { result: 'done' }); + + const step = store.steps.get(stepId); + expect(step?.status).toBe('success'); + expect(step?.outputData).toEqual({ result: 'done' }); + expect(step?.completedAt).toBeDefined(); + }); + + it('should complete node with error', () => { + const stepId = logger.startNode({ + nodeId: 'node-1', + nodeType: 'test' + }); + + const error = new Error('Node failed'); + logger.completeNode(stepId, false, undefined, error); + + const step = store.steps.get(stepId); + expect(step?.status).toBe('error'); + expect(step?.errorMessage).toBe('Node failed'); + }); + + it('should not capture output data when disabled', () => { + logger.updateConfig({ captureOutputs: false }); + + const stepId = logger.startNode({ + nodeId: 'node-1', + nodeType: 'test' + }); + + logger.completeNode(stepId, true, { secret: 'data' }); + + const step = store.steps.get(stepId); + expect(step?.outputData).toBeUndefined(); + }); + + it('should increment step index for each node', () => { + logger.startNode({ nodeId: 'node-1', nodeType: 'test' }); + logger.startNode({ nodeId: 'node-2', nodeType: 'test' }); + logger.startNode({ nodeId: 'node-3', nodeType: 'test' }); + + const step1 = store.steps.get('step_1'); + const step2 = store.steps.get('step_2'); + const step3 = store.steps.get('step_3'); + + expect(step1?.stepIndex).toBe(0); + expect(step2?.stepIndex).toBe(1); + expect(step3?.stepIndex).toBe(2); + }); + + it('should return empty string for startNode without active execution', () => { + logger.completeExecution(true); + + const stepId = logger.startNode({ + nodeId: 'node-1', + nodeType: 'test' + }); + + expect(stepId).toBe(''); + }); + + it('should handle skipNode', () => { + const stepId = logger.skipNode({ + nodeId: 'node-1', + nodeType: 'noodl.logic.branch', + nodeName: 'Skip Branch' + }); + + const step = store.steps.get(stepId); + expect(step?.status).toBe('skipped'); + expect(step?.durationMs).toBe(0); + }); + }); + + describe('data truncation', () => { + beforeEach(() => { + logger.startExecution({ + workflowId: 'wf-123', + workflowName: 'Test', + triggerType: 'manual' + }); + }); + + it('should truncate large input data', () => { + // Set small max size for testing + logger.updateConfig({ maxDataSize: 100 }); + + const largeData = { bigValue: 'x'.repeat(200) }; + logger.startNode({ + nodeId: 'node-1', + nodeType: 'test', + inputData: largeData + }); + + const step = store.steps.get('step_1'); + expect(step?.inputData?.__truncated).toBe(true); + expect(step?.inputData?.__originalSize).toBeGreaterThan(100); + }); + + it('should not truncate small data', () => { + const smallData = { value: 'small' }; + logger.startNode({ + nodeId: 'node-1', + nodeType: 'test', + inputData: smallData + }); + + const step = store.steps.get('step_1'); + expect(step?.inputData).toEqual(smallData); + }); + }); + + describe('retention', () => { + it('should call cleanupByAge with correct value', () => { + logger.updateConfig({ retentionDays: 7 }); + logger.runRetentionCleanup(); + + expect(store.calls).toContainEqual({ + method: 'cleanupByAge', + args: [7 * 24 * 60 * 60 * 1000] + }); + }); + }); + + describe('getStore', () => { + it('should return the underlying store', () => { + const returnedStore = logger.getStore(); + expect(returnedStore).toBe(store); + }); + }); + + describe('state management', () => { + it('should reset state between executions', () => { + // First execution + logger.startExecution({ + workflowId: 'wf-1', + workflowName: 'First', + triggerType: 'manual' + }); + logger.startNode({ nodeId: 'node-1', nodeType: 'test' }); + logger.startNode({ nodeId: 'node-2', nodeType: 'test' }); + logger.completeExecution(true); + + // Second execution should start fresh + logger.startExecution({ + workflowId: 'wf-2', + workflowName: 'Second', + triggerType: 'manual' + }); + logger.startNode({ nodeId: 'node-3', nodeType: 'test' }); + + // Step index should be 0 for new execution + const step = store.steps.get('step_3'); + expect(step?.stepIndex).toBe(0); + }); + + it('should handle multiple executions sequentially', () => { + for (let i = 1; i <= 3; i++) { + logger.startExecution({ + workflowId: `wf-${i}`, + workflowName: `Workflow ${i}`, + triggerType: 'manual' + }); + logger.startNode({ nodeId: `node-${i}`, nodeType: 'test' }); + logger.completeExecution(true); + } + + expect(store.executions.size).toBe(3); + expect(store.steps.size).toBe(3); + }); + }); +});