@output.ai/core 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
package/src/consts.js CHANGED
@@ -10,3 +10,13 @@ export const ComponentType = {
10
10
  INTERNAL_STEP: 'internal_step',
11
11
  STEP: 'step'
12
12
  };
13
+ export const LifecycleEvent = {
14
+ START: 'start',
15
+ END: 'end',
16
+ ERROR: 'error'
17
+ };
18
+ export const LifecycleEventLogMessage = {
19
+ [LifecycleEvent.START]: 'Start',
20
+ [LifecycleEvent.END]: 'End',
21
+ [LifecycleEvent.ERROR]: 'Error'
22
+ };
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ export {
17
17
  EvaluationStringResult,
18
18
  EvaluationBooleanResult,
19
19
  EvaluationFeedback,
20
- // webhook tools
20
+ // tools
21
21
  executeInParallel,
22
22
  sendHttpRequest,
23
23
  sendPostRequestAndAwaitWebhook,
@@ -3,7 +3,7 @@ import { proxyActivities, inWorkflowContext, executeChild, workflowInfo, uuid4,
3
3
  import { validateWorkflow } from './validations/static.js';
4
4
  import { validateWithSchema } from './validations/runtime.js';
5
5
  import { SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS, METADATA_ACCESS_SYMBOL } from '#consts';
6
- import { deepMerge, mergeActivityOptions, setMetadata } from '#utils';
6
+ import { deepMerge, mergeActivityOptions, setMetadata, toUrlSafeBase64 } from '#utils';
7
7
  import { FatalError, ValidationError } from '#errors';
8
8
 
9
9
  /**
@@ -121,7 +121,7 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
121
121
  startWorkflow: async ( childName, input, extra = {} ) =>
122
122
  executeChild( childName, {
123
123
  args: input ? [ input ] : [],
124
- workflowId: `${workflowId}-${childName}-${uuid4()}`,
124
+ workflowId: `${workflowId}-${toUrlSafeBase64( uuid4() )}`,
125
125
  parentClosePolicy: ParentClosePolicy[extra?.detached ? 'ABANDON' : 'TERMINATE'],
126
126
  memo: {
127
127
  executionContext,
@@ -4,15 +4,18 @@ import { FatalError } from '#errors';
4
4
  import { serializeBodyAndInferContentType, serializeFetchResponse } from '#utils';
5
5
  import { sendHttpRequest } from './index.js';
6
6
 
7
- vi.mock( '#utils', () => {
8
- return {
9
- setMetadata: vi.fn(),
10
- isStringboolTrue: vi.fn( () => false ),
11
- serializeBodyAndInferContentType: vi.fn(),
12
- serializeFetchResponse: vi.fn()
13
- };
7
+ vi.mock( '#logger', () => {
8
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
9
+ return { createChildLogger: vi.fn( () => log ) };
14
10
  } );
15
11
 
12
+ vi.mock( '#utils', () => ( {
13
+ setMetadata: vi.fn(),
14
+ isStringboolTrue: vi.fn( () => false ),
15
+ serializeBodyAndInferContentType: vi.fn(),
16
+ serializeFetchResponse: vi.fn()
17
+ } ) );
18
+
16
19
  const mockAgent = new MockAgent();
17
20
  mockAgent.disableNetConnect();
18
21
 
package/src/logger.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import winston from 'winston';
2
+ import { shuffleArray } from '#utils';
2
3
 
3
4
  const isProduction = process.env.NODE_ENV === 'production';
4
5
 
@@ -10,6 +11,16 @@ const levels = {
10
11
  debug: 4
11
12
  };
12
13
 
14
+ const colors = shuffleArray( [
15
+ '033', // blue
16
+ '030', // green
17
+ '208', // orange
18
+ '045', // turquoise
19
+ '129', // purple
20
+ '184' // yellow
21
+ ] );
22
+ const assignedColors = new Map();
23
+
13
24
  // Format metadata as friendly JSON: "{ name: "foo", count: 5 }"
14
25
  const formatMeta = obj => {
15
26
  const entries = Object.entries( obj );
@@ -18,14 +29,20 @@ const formatMeta = obj => {
18
29
  }
19
30
  return ' { ' + entries.map( ( [ k, v ] ) => `${k}: ${JSON.stringify( v )}` ).join( ', ' ) + ' }';
20
31
  };
32
+ // Distribute the namespace in a map and assign it the next available color
33
+ const getColor = v =>
34
+ assignedColors.has( v ) ? assignedColors.get( v ) : assignedColors.set( v, colors[assignedColors.size % colors.length] ).get( v );
35
+
36
+ // Colorize a text using the namespace string
37
+ const colorizeByNamespace = ( namespace, text ) => `\x1b[38;5;${getColor( namespace )}m${text}\x1b[0m`;
21
38
 
22
39
  // Development format: colorized with namespace prefix
23
40
  const devFormat = winston.format.combine(
24
41
  winston.format.colorize(),
25
42
  winston.format.printf( ( { level, message, namespace, service: _, environment: __, ...rest } ) => {
26
- const ns = namespace ? `{Core.${namespace}}` : '{Core}';
43
+ const ns = 'Core' + ( namespace ? `.${namespace}` : '' );
27
44
  const meta = formatMeta( rest );
28
- return `[${level}] ${ns} ${message}${meta}`;
45
+ return `[${level}] ${colorizeByNamespace( ns, `${namespace}: ${message}` )}${meta}`;
29
46
  } )
30
47
  );
31
48
 
@@ -96,6 +96,14 @@ export function serializeBodyAndInferContentType( body: unknown ): SerializedBod
96
96
  */
97
97
  export function isPlainObject( object: unknown ): boolean;
98
98
 
99
+ /**
100
+ * Returns a copy of an array with its content shuffled.
101
+ *
102
+ * @param arr - The array to shuffle
103
+ * @returns A shuffled array copy
104
+ */
105
+ export function shuffleArray( arr: array ): array;
106
+
99
107
  /**
100
108
  * Creates a new object by merging object `b` onto object `a`, biased toward `b`:
101
109
  * - Fields in `b` overwrite fields in `a`.
@@ -108,3 +116,12 @@ export function isPlainObject( object: unknown ): boolean;
108
116
  * @returns A new merged object.
109
117
  */
110
118
  export function deepMerge( a: object, b: object ): object;
119
+
120
+ /**
121
+ * Shortens a UUID to a url-safe base64-like string (custom 64-char alphabet).
122
+ * Temporal-friendly: no Buffer or crypto; safe to use inside workflows.
123
+ *
124
+ * @param uuid - Standard UUID (e.g. `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`).
125
+ * @returns Short string using A–Z, a–z, 0–9, `_`, `-` (typically 21–22 chars).
126
+ */
127
+ export function toUrlSafeBase64( uuid: string ): string;
@@ -162,6 +162,17 @@ export const serializeBodyAndInferContentType = payload => {
162
162
  return { body: String( payload ), contentType: 'text/plain; charset=UTF-8' };
163
163
  };
164
164
 
165
+ /**
166
+ * Receives an array and returns a copy of it with the elements shuffled
167
+ *
168
+ * @param {array} arr
169
+ * @returns {array}
170
+ */
171
+ export const shuffleArray = arr => arr
172
+ .map( v => ( { v, sort: Math.random() } ) )
173
+ .sort( ( a, b ) => a.sort - b.sort )
174
+ .map( ( { v } ) => v );
175
+
165
176
  /**
166
177
  * Creates a new object merging object "b" onto object "a" biased to "b":
167
178
  * - Object "b" will overwrite fields on object "a";
@@ -184,3 +195,20 @@ export const deepMerge = ( a, b ) => {
184
195
  Object.assign( obj, { [k]: isPlainObject( v ) && isPlainObject( a[k] ) ? deepMerge( a[k], v ) : v } )
185
196
  , clone( a ) );
186
197
  };
198
+
199
+ /**
200
+ * Shortens a UUID by re-encoding it to base62.
201
+ *
202
+ * This is a Temporal friendly, without crypto or Buffer.
203
+ * @param {string} uuid
204
+ * @returns {string}
205
+ */
206
+ export const toUrlSafeBase64 = uuid => {
207
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
208
+ const alphabetLen = alphabet.length;
209
+ const base = BigInt( alphabetLen );
210
+ const hex = uuid.replace( /-/g, '' );
211
+
212
+ const toDigits = n => n <= 0n ? [] : toDigits( n / base ).concat( alphabet[Number( n % base )] );
213
+ return toDigits( BigInt( '0x' + hex ) ).join( '' );
214
+ };
@@ -1,6 +1,14 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { Readable } from 'node:stream';
3
- import { clone, mergeActivityOptions, serializeBodyAndInferContentType, serializeFetchResponse, deepMerge, isPlainObject } from './utils.js';
3
+ import {
4
+ clone,
5
+ mergeActivityOptions,
6
+ serializeBodyAndInferContentType,
7
+ serializeFetchResponse,
8
+ deepMerge,
9
+ isPlainObject,
10
+ toUrlSafeBase64
11
+ } from './utils.js';
4
12
 
5
13
  describe( 'clone', () => {
6
14
  it( 'produces a deep copy without shared references', () => {
@@ -411,3 +419,43 @@ describe( 'isPlainObject', () => {
411
419
  expect( isPlainObject( zum ) ).toBe( false );
412
420
  } );
413
421
  } );
422
+
423
+ describe( 'toUrlSafeBase64', () => {
424
+ const urlSafeAlphabet = /^[A-Za-z0-9_-]+$/;
425
+
426
+ it( 'returns a string for a valid UUID', () => {
427
+ const uuid = '550e8400-e29b-41d4-a716-446655440000';
428
+ expect( typeof toUrlSafeBase64( uuid ) ).toBe( 'string' );
429
+ expect( toUrlSafeBase64( uuid ).length ).toBeGreaterThan( 0 );
430
+ } );
431
+
432
+ it( 'output length is 21 or 22 for a standard UUID', () => {
433
+ const uuid = '550e8400-e29b-41d4-a716-446655440000';
434
+ const out = toUrlSafeBase64( uuid );
435
+ expect( out.length ).toBeGreaterThanOrEqual( 21 );
436
+ expect( out.length ).toBeLessThanOrEqual( 22 );
437
+ } );
438
+
439
+ it( 'output contains only url-safe alphabet characters', () => {
440
+ const uuid = '550e8400-e29b-41d4-a716-446655440000';
441
+ const out = toUrlSafeBase64( uuid );
442
+ expect( out ).toMatch( urlSafeAlphabet );
443
+ } );
444
+
445
+ it( 'is deterministic for the same UUID', () => {
446
+ const uuid = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
447
+ expect( toUrlSafeBase64( uuid ) ).toBe( toUrlSafeBase64( uuid ) );
448
+ } );
449
+
450
+ it( 'different UUIDs produce different strings', () => {
451
+ const a = toUrlSafeBase64( '550e8400-e29b-41d4-a716-446655440000' );
452
+ const b = toUrlSafeBase64( '6ba7b810-9dad-11d1-80b4-00c04fd430c8' );
453
+ expect( a ).not.toBe( b );
454
+ } );
455
+
456
+ it( 'strips hyphens and encodes hex (same as 32-char hex)', () => {
457
+ const withHyphens = '550e8400-e29b-41d4-a716-446655440000';
458
+ const hexOnly = '550e8400e29b41d4a716446655440000';
459
+ expect( toUrlSafeBase64( withHyphens ) ).toBe( toUrlSafeBase64( hexOnly ) );
460
+ } );
461
+ } );
@@ -2,9 +2,11 @@ import { Context } from '@temporalio/activity';
2
2
  import { Storage } from '#async_storage';
3
3
  import { addEventStart, addEventEnd, addEventError } from '#tracing';
4
4
  import { headersToObject } from '../sandboxed_utils.js';
5
- import { METADATA_ACCESS_SYMBOL } from '#consts';
5
+ import { LifecycleEventLogMessage, LifecycleEvent, METADATA_ACCESS_SYMBOL } from '#consts';
6
6
  import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
7
+ import { createChildLogger } from '#logger';
7
8
 
9
+ const log = createChildLogger( 'Activity' );
8
10
  /*
9
11
  This interceptor wraps every activity execution with cross-cutting concerns:
10
12
 
@@ -27,11 +29,15 @@ export class ActivityExecutionInterceptor {
27
29
  };
28
30
 
29
31
  async execute( input, next ) {
30
- const { workflowExecution: { workflowId }, activityId, activityType } = Context.current().info;
32
+ const { workflowExecution: { workflowId }, activityId, activityType, workflowType: workflowName } = Context.current().info;
31
33
  const { executionContext } = headersToObject( input.headers );
32
34
  const { type: kind } = this.activities?.[activityType]?.[METADATA_ACCESS_SYMBOL];
33
35
 
36
+ const startDate = Date.now();
37
+ const logContext = { workflowName, workflowId, stepId: activityId, stepName: activityType };
34
38
  const traceArguments = { kind, id: activityId, parentId: workflowId, name: activityType, executionContext };
39
+
40
+ log.info( LifecycleEventLogMessage[LifecycleEvent.START], logContext );
35
41
  addEventStart( { details: input.args[0], ...traceArguments } );
36
42
 
37
43
  const intervals = { heartbeat: null };
@@ -40,10 +46,13 @@ export class ActivityExecutionInterceptor {
40
46
  intervals.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
41
47
 
42
48
  const output = await Storage.runWithContext( async _ => next( input ), { parentId: activityId, executionContext } );
49
+
50
+ log.info( LifecycleEventLogMessage[LifecycleEvent.END], { ...logContext, durationMs: Date.now() - startDate } );
43
51
  addEventEnd( { details: output, ...traceArguments } );
44
52
  return output;
45
53
 
46
54
  } catch ( error ) {
55
+ log.error( LifecycleEventLogMessage[LifecycleEvent.ERROR], { ...logContext, durationMs: Date.now() - startDate, error: error.message } );
47
56
  addEventError( { details: error, ...traceArguments } );
48
57
  throw error;
49
58
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
- const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
3
+ const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
4
4
 
5
5
  const heartbeatMock = vi.fn();
6
6
  const contextInfoMock = {
@@ -37,11 +37,14 @@ vi.mock( '../sandboxed_utils.js', () => ( {
37
37
  headersToObject: () => ( { executionContext: { workflowId: 'wf-1' } } )
38
38
  } ) );
39
39
 
40
- vi.mock( '#consts', () => ( {
41
- get METADATA_ACCESS_SYMBOL() {
42
- return METADATA_ACCESS_SYMBOL;
43
- }
44
- } ) );
40
+ vi.mock( '#consts', async importOriginal => {
41
+ const actual = await importOriginal();
42
+ return {
43
+ ...actual, get METADATA_ACCESS_SYMBOL() {
44
+ return METADATA_ACCESS_SYMBOL;
45
+ }
46
+ };
47
+ } );
45
48
 
46
49
  vi.mock( '../configs.js', () => ( {
47
50
  get activityHeartbeatEnabled() {
@@ -1,11 +1,11 @@
1
- import { WORKFLOW_CATALOG } from '#consts';
1
+ import { LifecycleEvent, LifecycleEventLogMessage, WORKFLOW_CATALOG } from '#consts';
2
2
  import { addEventStart, addEventEnd, addEventError } from '#tracing';
3
3
  import { createChildLogger } from '#logger';
4
4
 
5
- const log = createChildLogger( 'Worker' );
5
+ const log = createChildLogger( 'Workflow' );
6
6
 
7
7
  /**
8
- * Start a workflow trace event
8
+ * Adds a workflow trace event
9
9
  *
10
10
  * @param {function} method - Trace function to call
11
11
  * @param {object} workflowInfo - Temporal workflowInfo object
@@ -20,44 +20,31 @@ const addWorkflowEvent = ( method, workflowInfo, details ) => {
20
20
  };
21
21
 
22
22
  /**
23
- * Log workflow start
23
+ * Logs the internal workflow event
24
24
  *
25
- * @param {object} workflowInfo - Temporal workflowInfo object
26
- */
27
- const logWorkflowStart = workflowInfo => {
28
- const { workflowId, workflowType: workflowName } = workflowInfo;
29
- if ( workflowName === WORKFLOW_CATALOG ) {
30
- return;
31
- }
32
- log.info( 'Workflow started', { workflowName, workflowId } );
33
- };
34
-
35
- /**
36
- * Log workflow completion with duration
37
- *
38
- * @param {object} workflowInfo - Temporal workflowInfo object
25
+ * @param {LifecycleEvent} event
26
+ * @param {Object} workflowInfo
27
+ * @returns {void}
39
28
  */
40
- const logWorkflowEnd = workflowInfo => {
29
+ const logWorkflowEvent = ( event, workflowInfo, error ) => {
41
30
  const { workflowId, workflowType: workflowName, startTime } = workflowInfo;
31
+ // exclude internal catalog
42
32
  if ( workflowName === WORKFLOW_CATALOG ) {
43
33
  return;
44
34
  }
45
- const durationMs = Date.now() - startTime.getTime();
46
- log.info( 'Workflow completed', { workflowName, workflowId, durationMs } );
47
- };
48
35
 
49
- /**
50
- * Log workflow failure with duration
51
- *
52
- * @param {object} workflowInfo - Temporal workflowInfo object
53
- */
54
- const logWorkflowError = workflowInfo => {
55
- const { workflowId, workflowType: workflowName, startTime } = workflowInfo;
56
- if ( workflowName === WORKFLOW_CATALOG ) {
57
- return;
36
+ if ( event === LifecycleEvent.START ) {
37
+ log.info( LifecycleEventLogMessage[event], { workflowName, workflowId } );
38
+ } else if ( event === LifecycleEvent.END ) {
39
+ log.info( LifecycleEventLogMessage[event], { workflowName, workflowId, durationMs: Date.now() - startTime.getTime() } );
40
+ } else if ( event === LifecycleEvent.ERROR ) {
41
+ log.info( LifecycleEventLogMessage[event], {
42
+ workflowName,
43
+ workflowId,
44
+ durationMs: Date.now() - startTime.getTime(),
45
+ error: error.message
46
+ } );
58
47
  }
59
- const durationMs = Date.now() - startTime.getTime();
60
- log.error( 'Workflow failed', { workflowName, workflowId, durationMs } );
61
48
  };
62
49
 
63
50
  /**
@@ -77,25 +64,25 @@ const addEvent = ( method, workflowInfo, options ) => {
77
64
  export const sinks = {
78
65
  trace: {
79
66
  addWorkflowEventStart: {
80
- fn: ( workflowInfo, ...rest ) => {
81
- logWorkflowStart( workflowInfo );
82
- addWorkflowEvent( addEventStart, workflowInfo, ...rest );
67
+ fn: ( workflowInfo, details ) => {
68
+ logWorkflowEvent( LifecycleEvent.START, workflowInfo );
69
+ addWorkflowEvent( addEventStart, workflowInfo, details );
83
70
  },
84
71
  callDuringReplay: false
85
72
  },
86
73
 
87
74
  addWorkflowEventEnd: {
88
- fn: ( workflowInfo, ...rest ) => {
89
- logWorkflowEnd( workflowInfo );
90
- addWorkflowEvent( addEventEnd, workflowInfo, ...rest );
75
+ fn: ( workflowInfo, details ) => {
76
+ logWorkflowEvent( LifecycleEvent.END, workflowInfo );
77
+ addWorkflowEvent( addEventEnd, workflowInfo, details );
91
78
  },
92
79
  callDuringReplay: false
93
80
  },
94
81
 
95
82
  addWorkflowEventError: {
96
- fn: ( workflowInfo, ...rest ) => {
97
- logWorkflowError( workflowInfo );
98
- addWorkflowEvent( addEventError, workflowInfo, ...rest );
83
+ fn: ( workflowInfo, details ) => {
84
+ logWorkflowEvent( LifecycleEvent.ERROR, workflowInfo, details );
85
+ addWorkflowEvent( addEventError, workflowInfo, details );
99
86
  },
100
87
  callDuringReplay: false
101
88
  },
package/src/logger.d.ts DELETED
@@ -1,4 +0,0 @@
1
- import type { Logger } from 'winston';
2
-
3
- export declare const logger: Logger;
4
- export declare function createChildLogger( namespace: string ): Logger;