@output.ai/core 0.4.3 → 0.4.5

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.3",
3
+ "version": "0.4.5",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,29 @@
1
+ import * as z from 'zod';
2
+
3
+ const envVarSchema = z.object( {
4
+ OUTPUT_AWS_REGION: z.string(),
5
+ OUTPUT_AWS_ACCESS_KEY_ID: z.string(),
6
+ OUTPUT_AWS_SECRET_ACCESS_KEY: z.string(),
7
+ OUTPUT_TRACE_REMOTE_S3_BUCKET: z.string(),
8
+ OUTPUT_REDIS_URL: z.string(),
9
+ OUTPUT_REDIS_TRACE_TTL: z.coerce.number().int().positive().default( 60 * 60 * 24 * 7 ) // 7 days
10
+ } );
11
+
12
+ const env = {};
13
+
14
+ export const loadEnv = () => {
15
+ const parsedFields = envVarSchema.parse( process.env );
16
+ env.awsRegion = parsedFields.OUTPUT_AWS_REGION;
17
+ env.awsAccessKeyId = parsedFields.OUTPUT_AWS_ACCESS_KEY_ID;
18
+ env.awsSecretAccessKey = parsedFields.OUTPUT_AWS_SECRET_ACCESS_KEY;
19
+ env.remoteS3Bucket = parsedFields.OUTPUT_TRACE_REMOTE_S3_BUCKET;
20
+ env.redisUrl = parsedFields.OUTPUT_REDIS_URL;
21
+ env.redisIncompleteWorkflowsTTL = parsedFields.OUTPUT_REDIS_TRACE_TTL;
22
+ };
23
+
24
+ export const getVars = () => {
25
+ if ( Object.keys( env ).length === 0 ) {
26
+ throw new Error( 'Env vars not loaded. Use loadEnv() first.' );
27
+ }
28
+ return env;
29
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ async function loadModule() {
4
+ vi.resetModules();
5
+ return import( './configs.js' );
6
+ }
7
+
8
+ describe( 'tracing/processors/s3/configs', () => {
9
+ const required = {
10
+ OUTPUT_AWS_REGION: 'us-east-1',
11
+ OUTPUT_AWS_ACCESS_KEY_ID: 'id',
12
+ OUTPUT_AWS_SECRET_ACCESS_KEY: 'sek',
13
+ OUTPUT_TRACE_REMOTE_S3_BUCKET: 'bkt',
14
+ OUTPUT_REDIS_URL: 'redis://localhost:6379'
15
+ };
16
+
17
+ beforeEach( () => {
18
+ vi.stubEnv( 'OUTPUT_AWS_REGION', required.OUTPUT_AWS_REGION );
19
+ vi.stubEnv( 'OUTPUT_AWS_ACCESS_KEY_ID', required.OUTPUT_AWS_ACCESS_KEY_ID );
20
+ vi.stubEnv( 'OUTPUT_AWS_SECRET_ACCESS_KEY', required.OUTPUT_AWS_SECRET_ACCESS_KEY );
21
+ vi.stubEnv( 'OUTPUT_TRACE_REMOTE_S3_BUCKET', required.OUTPUT_TRACE_REMOTE_S3_BUCKET );
22
+ vi.stubEnv( 'OUTPUT_REDIS_URL', required.OUTPUT_REDIS_URL );
23
+ } );
24
+
25
+ afterEach( () => {
26
+ vi.unstubAllEnvs();
27
+ } );
28
+
29
+ it( 'loadEnv() throws when required env vars are missing', async () => {
30
+ vi.stubEnv( 'OUTPUT_REDIS_URL', undefined );
31
+ const { loadEnv } = await loadModule();
32
+ expect( () => loadEnv() ).toThrow( /OUTPUT_REDIS_URL/ );
33
+ } );
34
+
35
+ it( 'loadEnv() populates getVars() with parsed env', async () => {
36
+ const { loadEnv, getVars } = await loadModule();
37
+ loadEnv();
38
+ const vars = getVars();
39
+ expect( vars.awsRegion ).toBe( required.OUTPUT_AWS_REGION );
40
+ expect( vars.awsAccessKeyId ).toBe( required.OUTPUT_AWS_ACCESS_KEY_ID );
41
+ expect( vars.awsSecretAccessKey ).toBe( required.OUTPUT_AWS_SECRET_ACCESS_KEY );
42
+ expect( vars.remoteS3Bucket ).toBe( required.OUTPUT_TRACE_REMOTE_S3_BUCKET );
43
+ expect( vars.redisUrl ).toBe( required.OUTPUT_REDIS_URL );
44
+ expect( vars.redisIncompleteWorkflowsTTL ).toBe( 60 * 60 * 24 * 7 );
45
+ } );
46
+
47
+ it( 'loadEnv() uses OUTPUT_REDIS_TRACE_TTL when set', async () => {
48
+ vi.stubEnv( 'OUTPUT_REDIS_TRACE_TTL', '3600' );
49
+ const { loadEnv, getVars } = await loadModule();
50
+ loadEnv();
51
+ expect( getVars().redisIncompleteWorkflowsTTL ).toBe( 3600 );
52
+ } );
53
+
54
+ it( 'getVars() throws when loadEnv() was not called', async () => {
55
+ const { getVars } = await loadModule();
56
+ expect( () => getVars() ).toThrow( 'Env vars not loaded. Use loadEnv() first.' );
57
+ } );
58
+
59
+ it( 'loadEnv() throws when OUTPUT_REDIS_TRACE_TTL is invalid', async () => {
60
+ vi.stubEnv( 'OUTPUT_REDIS_TRACE_TTL', 'not-a-number' );
61
+ const { loadEnv } = await loadModule();
62
+ expect( () => loadEnv() ).toThrow();
63
+ } );
64
+ } );
@@ -2,25 +2,54 @@ import { upload } from './s3_client.js';
2
2
  import { getRedisClient } from './redis_client.js';
3
3
  import buildTraceTree from '../../tools/build_trace_tree.js';
4
4
  import { EOL } from 'node:os';
5
+ import { loadEnv, getVars } from './configs.js';
6
+ import { createChildLogger } from '#logger';
5
7
 
6
- const oneMonthInSeconds = 60 * 60 * 24 * 30;
8
+ const log = createChildLogger( 'S3 Processor' );
7
9
 
8
- const addEntry = async ( { entry, executionContext: { workflowName, workflowId } } ) => {
9
- const key = `traces/${workflowName}/${workflowId}`;
10
+ const createRedisKey = ( { workflowId, workflowName } ) => `traces/${workflowName}/${workflowId}`;
11
+
12
+ /**
13
+ * Add new entry to list of entries
14
+ * @param {object} entry
15
+ * @param {string} key
16
+ */
17
+ const addEntry = async ( entry, key ) => {
10
18
  const client = await getRedisClient();
11
19
  await client.multi()
12
20
  .zAdd( key, [ { score: entry.timestamp, value: JSON.stringify( entry ) } ], { NX: true } )
13
- .expire( key, oneMonthInSeconds, 'GT' )
21
+ .expire( key, getVars().redisIncompleteWorkflowsTTL, 'GT' )
14
22
  .exec();
15
23
  };
16
24
 
17
- const getAllEntries = async ( { workflowName, workflowId } ) => {
18
- const key = `traces/${workflowName}/${workflowId}`;
25
+ /**
26
+ * Returns entries from cache, parsed to object
27
+ * @param {string} key
28
+ * @returns {object[]}
29
+ */
30
+ const getEntries = async key => {
19
31
  const client = await getRedisClient();
20
32
  const zList = await client.zRange( key, 0, -1 );
21
33
  return zList.map( v => JSON.parse( v ) );
22
34
  };
23
35
 
36
+ /**
37
+ * Removes the entries from cache
38
+ * @param {string} key
39
+ */
40
+ const bustEntries = async key => {
41
+ const client = await getRedisClient();
42
+ await client.del( key );
43
+ };
44
+
45
+ /**
46
+ * Return the S3 key for the trace file
47
+ * @param {object} args
48
+ * @param {number} args.startTime
49
+ * @param {string} args.workflowId
50
+ * @param {string} args.workflowName
51
+ * @returns
52
+ */
24
53
  const getS3Key = ( { startTime, workflowId, workflowName } ) => {
25
54
  const isoDate = new Date( startTime ).toISOString();
26
55
  const [ year, month, day ] = isoDate.split( /\D/, 3 );
@@ -32,6 +61,7 @@ const getS3Key = ( { startTime, workflowId, workflowName } ) => {
32
61
  * Init this processor
33
62
  */
34
63
  export const init = async () => {
64
+ loadEnv();
35
65
  await getRedisClient();
36
66
  };
37
67
 
@@ -44,19 +74,26 @@ export const init = async () => {
44
74
  */
45
75
  export const exec = async ( { entry, executionContext } ) => {
46
76
  const { workflowName, workflowId, startTime } = executionContext;
77
+ const cacheKey = createRedisKey( { workflowId, workflowName } );
47
78
 
48
- await addEntry( { entry, executionContext } );
79
+ await addEntry( entry, cacheKey );
49
80
 
50
81
  const isRootWorkflowEnd = !entry.parentId && entry.phase !== 'start';
51
82
  if ( !isRootWorkflowEnd ) {
52
- return 0;
83
+ return;
53
84
  }
54
85
 
55
- const content = buildTraceTree( await getAllEntries( { workflowName, workflowId } ) );
56
- return upload( {
86
+ const content = buildTraceTree( await getEntries( cacheKey ) );
87
+ // if the trace tree is incomplete it will return null, in this case we can safely discard
88
+ if ( !content ) {
89
+ log.warn( 'Incomplete trace file discarded', { workflowId, error: 'incomplete_trace_file' } );
90
+ return;
91
+ }
92
+ await upload( {
57
93
  key: getS3Key( { workflowId, workflowName, startTime } ),
58
94
  content: JSON.stringify( content, undefined, 2 ) + EOL
59
95
  } );
96
+ await bustEntries( cacheKey );
60
97
  };
61
98
 
62
99
  /**
@@ -68,4 +105,4 @@ export const exec = async ( { entry, executionContext } ) => {
68
105
  * @returns {string} The S3 url of the trace file
69
106
  */
70
107
  export const getDestination = ( { startTime, workflowId, workflowName } ) =>
71
- `https://${process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET}.s3.amazonaws.com/${getS3Key( { workflowId, workflowName, startTime } )}`;
108
+ `https://${getVars().remoteS3Bucket}.s3.amazonaws.com/${getS3Key( { workflowId, workflowName, startTime } )}`;
@@ -1,12 +1,24 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
+ const loadEnvMock = vi.fn();
4
+ const getVarsMock = vi.fn( () => ( {
5
+ remoteS3Bucket: 'bkt',
6
+ redisIncompleteWorkflowsTTL: 3600
7
+ } ) );
8
+ vi.mock( './configs.js', () => ( { loadEnv: loadEnvMock, getVars: getVarsMock } ) );
9
+
3
10
  const redisMulti = {
4
11
  zAdd: vi.fn().mockReturnThis(),
5
12
  expire: vi.fn().mockReturnThis(),
6
13
  exec: vi.fn()
7
14
  };
8
15
  const zRangeMock = vi.fn();
9
- const getRedisClientMock = vi.fn( async () => ( { multi: () => redisMulti, zRange: zRangeMock } ) );
16
+ const delMock = vi.fn().mockResolvedValue( undefined );
17
+ const getRedisClientMock = vi.fn( async () => ( {
18
+ multi: () => redisMulti,
19
+ zRange: zRangeMock,
20
+ del: delMock
21
+ } ) );
10
22
  vi.mock( './redis_client.js', () => ( { getRedisClient: getRedisClientMock } ) );
11
23
 
12
24
  const uploadMock = vi.fn();
@@ -18,12 +30,13 @@ vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMoc
18
30
  describe( 'tracing/processors/s3', () => {
19
31
  beforeEach( () => {
20
32
  vi.clearAllMocks();
21
- process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'bkt';
33
+ getVarsMock.mockReturnValue( { remoteS3Bucket: 'bkt', redisIncompleteWorkflowsTTL: 3600 } );
22
34
  } );
23
35
 
24
- it( 'init(): ensures redis client is created', async () => {
36
+ it( 'init(): loads config and ensures redis client is created', async () => {
25
37
  const { init } = await import( './index.js' );
26
38
  await init();
39
+ expect( loadEnvMock ).toHaveBeenCalledTimes( 1 );
27
40
  expect( getRedisClientMock ).toHaveBeenCalledTimes( 1 );
28
41
  } );
29
42
 
@@ -59,6 +72,36 @@ describe( 'tracing/processors/s3', () => {
59
72
  const { key, content } = uploadMock.mock.calls[0][0];
60
73
  expect( key ).toMatch( /^WF\/2020\/01\/02\// );
61
74
  expect( JSON.parse( content.trim() ).count ).toBe( 3 );
75
+
76
+ expect( delMock ).toHaveBeenCalledTimes( 1 );
77
+ expect( delMock ).toHaveBeenCalledWith( 'traces/WF/id1' );
78
+ } );
79
+
80
+ it( 'getDestination(): returns S3 URL using bucket and key from getVars', async () => {
81
+ getVarsMock.mockReturnValue( { remoteS3Bucket: 'my-bucket', redisIncompleteWorkflowsTTL: 3600 } );
82
+ const { getDestination } = await import( './index.js' );
83
+ const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
84
+ const url = getDestination( { workflowId: 'id1', workflowName: 'WF', startTime } );
85
+ expect( getVarsMock ).toHaveBeenCalled();
86
+ expect( url ).toBe(
87
+ 'https://my-bucket.s3.amazonaws.com/WF/2020/01/02/2020-01-02-03-04-05-678Z_id1.json'
88
+ );
89
+ } );
90
+
91
+ it( 'exec(): when buildTraceTree returns null (incomplete tree), does not upload or bust cache', async () => {
92
+ const { exec } = await import( './index.js' );
93
+ const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
94
+ const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
95
+
96
+ redisMulti.exec.mockResolvedValue( [] );
97
+ zRangeMock.mockResolvedValue( [ JSON.stringify( { id: 'wf', phase: 'end', timestamp: startTime } ) ] );
98
+ buildTraceTreeMock.mockReturnValueOnce( null );
99
+
100
+ await exec( { ...ctx, entry: { id: 'wf', phase: 'end', timestamp: startTime } } );
101
+
102
+ expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 1 );
103
+ expect( uploadMock ).not.toHaveBeenCalled();
104
+ expect( delMock ).not.toHaveBeenCalled();
62
105
  } );
63
106
  } );
64
107
 
@@ -1,6 +1,6 @@
1
1
  import { createClient } from 'redis';
2
- import { throws } from '#utils';
3
2
  import { createChildLogger } from '#logger';
3
+ import { getVars } from './configs.js';
4
4
 
5
5
  const log = createChildLogger( 'RedisClient' );
6
6
 
@@ -36,11 +36,10 @@ async function connect( url ) {
36
36
  * Concurrent calls during connection will receive the same pending promise.
37
37
  *
38
38
  * @returns {Promise<redis.RedisClientType>} Connected Redis client
39
- * @throws {Error} If OUTPUT_REDIS_URL env var is missing
40
39
  * @throws {Error} If connection fails (wrapped with context)
41
40
  */
42
41
  export async function getRedisClient() {
43
- const url = process.env.OUTPUT_REDIS_URL ?? throws( new Error( 'Missing OUTPUT_REDIS_URL environment variable' ) );
42
+ const url = getVars().redisUrl;
44
43
 
45
44
  const pingResult = await state.client?.ping().catch( err => {
46
45
  log.error( 'Redis ping failed', { error: err.message, code: err.code } );
@@ -14,6 +14,9 @@ vi.mock( '#logger', () => ( {
14
14
  } )
15
15
  } ) );
16
16
 
17
+ const getVarsMock = vi.fn();
18
+ vi.mock( './configs.js', () => ( { getVars: () => getVarsMock() } ) );
19
+
17
20
  const createClientImpl = vi.fn();
18
21
  vi.mock( 'redis', () => ( { createClient: opts => createClientImpl( opts ) } ) );
19
22
 
@@ -25,7 +28,7 @@ async function loadModule() {
25
28
  describe( 'tracing/processors/s3/redis_client', () => {
26
29
  beforeEach( () => {
27
30
  vi.clearAllMocks();
28
- delete process.env.OUTPUT_REDIS_URL;
31
+ getVarsMock.mockReturnValue( {} );
29
32
  logCalls.warn = [];
30
33
  logCalls.error = [];
31
34
  } );
@@ -34,13 +37,14 @@ describe( 'tracing/processors/s3/redis_client', () => {
34
37
  vi.useRealTimers();
35
38
  } );
36
39
 
37
- it( 'throws if OUTPUT_REDIS_URL is missing', async () => {
40
+ it( 'throws when config redisUrl is missing', async () => {
41
+ getVarsMock.mockReturnValue( {} );
38
42
  const { getRedisClient } = await loadModule();
39
- await expect( getRedisClient() ).rejects.toThrow( 'Missing OUTPUT_REDIS_URL' );
43
+ await expect( getRedisClient() ).rejects.toThrow();
40
44
  } );
41
45
 
42
46
  it( 'creates client with url, connects once, then reuses cached when ping is PONG', async () => {
43
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
47
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
44
48
 
45
49
  const pingMock = vi.fn().mockResolvedValue( 'PONG' );
46
50
  const connectMock = vi.fn().mockResolvedValue();
@@ -63,7 +67,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
63
67
  } );
64
68
 
65
69
  it( 'closes stale client and reconnects when ping fails', async () => {
66
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
70
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
67
71
 
68
72
  const quitMock = vi.fn().mockResolvedValue();
69
73
  const connectMock = vi.fn().mockResolvedValue();
@@ -92,7 +96,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
92
96
  } );
93
97
 
94
98
  it( 'reconnects successfully even when quit() on stale client rejects', async () => {
95
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
99
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
96
100
 
97
101
  const quitMock = vi.fn().mockRejectedValue( new Error( 'Quit failed' ) );
98
102
  const connectMock = vi.fn().mockResolvedValue();
@@ -121,7 +125,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
121
125
  } );
122
126
 
123
127
  it( 'wraps connect() errors with code and cleans up failed client', async () => {
124
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
128
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
125
129
 
126
130
  const connectErr = new Error( 'Connection refused' );
127
131
  connectErr.code = 'ECONNREFUSED';
@@ -147,7 +151,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
147
151
  } );
148
152
 
149
153
  it( 'logs ping failures with error level', async () => {
150
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
154
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
151
155
 
152
156
  const pingErr = new Error( 'Connection reset' );
153
157
  pingErr.code = 'ECONNRESET';
@@ -1,5 +1,5 @@
1
1
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
2
- import { throws } from '#utils';
2
+ import { getVars } from './configs.js';
3
3
 
4
4
  const state = { s3Client: null };
5
5
 
@@ -12,9 +12,7 @@ const getS3Client = () => {
12
12
  return state.s3Client;
13
13
  }
14
14
 
15
- const region = process.env.OUTPUT_AWS_REGION ?? throws( new Error( 'Missing OUTPUT_AWS_REGION env var' ) );
16
- const secretAccessKey = process.env.OUTPUT_AWS_SECRET_ACCESS_KEY ?? throws( new Error( 'Missing OUTPUT_AWS_SECRET_ACCESS_KEY env var' ) );
17
- const accessKeyId = process.env.OUTPUT_AWS_ACCESS_KEY_ID ?? throws( new Error( 'Missing OUTPUT_AWS_ACCESS_KEY_ID env var' ) );
15
+ const { awsRegion: region, awsSecretAccessKey: secretAccessKey, awsAccessKeyId: accessKeyId } = getVars();
18
16
 
19
17
  return state.s3Client = new S3Client( { region, credentials: { accessKeyId, secretAccessKey } } );
20
18
  };
@@ -26,8 +24,4 @@ const getS3Client = () => {
26
24
  * @param {string} content - File content
27
25
  */
28
26
  export const upload = ( { key, content } ) =>
29
- getS3Client().send( new PutObjectCommand( {
30
- Bucket: process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET ?? throws( new Error( 'Missing OUTPUT_TRACE_REMOTE_S3_BUCKET env var' ) ),
31
- Key: key,
32
- Body: content
33
- } ) );
27
+ getS3Client().send( new PutObjectCommand( { Bucket: getVars().remoteS3Bucket, Key: key, Body: content } ) );
@@ -6,6 +6,9 @@ vi.mock( '#utils', () => ( {
6
6
  }
7
7
  } ) );
8
8
 
9
+ const getVarsMock = vi.fn();
10
+ vi.mock( './configs', () => ( { getVars: () => getVarsMock() } ) );
11
+
9
12
  const sendMock = vi.fn();
10
13
  const ctorState = { args: null };
11
14
  class S3ClientMock {
@@ -32,23 +35,15 @@ async function loadModule() {
32
35
  describe( 'tracing/processors/s3/s3_client', () => {
33
36
  beforeEach( () => {
34
37
  vi.clearAllMocks();
35
- delete process.env.OUTPUT_AWS_REGION;
36
- delete process.env.OUTPUT_AWS_SECRET_ACCESS_KEY;
37
- delete process.env.OUTPUT_AWS_ACCESS_KEY_ID;
38
- delete process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET;
38
+ getVarsMock.mockReturnValue( {
39
+ awsRegion: 'us-east-1',
40
+ awsAccessKeyId: 'id',
41
+ awsSecretAccessKey: 'sek',
42
+ remoteS3Bucket: 'bucket'
43
+ } );
39
44
  } );
40
45
 
41
- it( 'fails fast when required env vars are missing for client creation', async () => {
42
- const { upload } = await loadModule();
43
- expect( () => upload( { key: 'k', content: 'c' } ) ).toThrow();
44
- } );
45
-
46
- it( 'creates client once with env and uploads with bucket/key/content', async () => {
47
- process.env.OUTPUT_AWS_REGION = 'us-east-1';
48
- process.env.OUTPUT_AWS_SECRET_ACCESS_KEY = 'sek';
49
- process.env.OUTPUT_AWS_ACCESS_KEY_ID = 'id';
50
- process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'bucket';
51
-
46
+ it( 'creates client once with config and uploads with bucket/key/content', async () => {
52
47
  const { upload } = await loadModule();
53
48
 
54
49
  await upload( { key: 'wf/key.json', content: '{"a":1}' } );
@@ -46,7 +46,7 @@ const createEntry = id => ( {
46
46
  * The result tree has a single root: the only node without parentId, normally the workflow itself.
47
47
  *
48
48
  * @param {object[]} entries - The list of entries
49
- * @returns {void}
49
+ * @returns {object}
50
50
  */
51
51
  export default entries => {
52
52
  const nodes = new Map();
@@ -71,6 +71,9 @@ export default entries => {
71
71
  }
72
72
  }
73
73
 
74
- const root = nodes.get( entries.find( e => !e.parentId ).id );
75
- return root;
74
+ const rootNode = nodes.get( entries.find( e => !e.parentId )?.id );
75
+ if ( !rootNode ) {
76
+ return null;
77
+ }
78
+ return rootNode;
76
79
  };
@@ -1,7 +1,32 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import buildLogTree from './build_trace_tree.js';
2
+ import buildTraceTree from './build_trace_tree.js';
3
3
 
4
4
  describe( 'build_trace_tree', () => {
5
+ it( 'returns null when entries is empty', () => {
6
+ expect( buildTraceTree( [] ) ).toBeNull();
7
+ } );
8
+
9
+ it( 'returns null when there is no root (all entries have parentId)', () => {
10
+ const entries = [
11
+ { id: 'a', parentId: 'x', phase: 'start', name: 'a', timestamp: 1 },
12
+ { id: 'b', parentId: 'a', phase: 'start', name: 'b', timestamp: 2 }
13
+ ];
14
+ expect( buildTraceTree( entries ) ).toBeNull();
15
+ } );
16
+
17
+ it( 'error phase sets error and endedAt on node', () => {
18
+ const entries = [
19
+ { kind: 'wf', id: 'r', parentId: undefined, phase: 'start', name: 'root', details: {}, timestamp: 100 },
20
+ { kind: 'step', id: 's', parentId: 'r', phase: 'start', name: 'step', details: {}, timestamp: 200 },
21
+ { kind: 'step', id: 's', parentId: 'r', phase: 'error', name: 'step', details: { message: 'failed' }, timestamp: 300 }
22
+ ];
23
+ const result = buildTraceTree( entries );
24
+ expect( result ).not.toBeNull();
25
+ expect( result.children ).toHaveLength( 1 );
26
+ expect( result.children[0].error ).toEqual( { message: 'failed' } );
27
+ expect( result.children[0].endedAt ).toBe( 300 );
28
+ } );
29
+
5
30
  it( 'builds a tree from workflow/step/IO entries with grouping and sorting', () => {
6
31
  const entries = [
7
32
  // workflow start
@@ -28,7 +53,7 @@ describe( 'build_trace_tree', () => {
28
53
  { kind: 'workflow', phase: 'end', name: 'wf', id: 'wf', parentId: undefined, details: { ok: true }, timestamp: 3000 }
29
54
  ];
30
55
 
31
- const result = buildLogTree( entries );
56
+ const result = buildTraceTree( entries );
32
57
 
33
58
  const expected = {
34
59
  id: 'wf',