@forgehive/task 0.2.1 → 0.2.3

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/src/index.ts CHANGED
@@ -50,9 +50,6 @@ export interface ReplayConfig<B extends Boundaries = Boundaries> {
50
50
  }
51
51
  }
52
52
 
53
- // ToDo: Add a type for the boundaries data
54
-
55
-
56
53
  // Make BoundaryLog generic
57
54
  export type BoundaryLog<I extends unknown[] = unknown[], O = unknown> = BoundaryRecord<I, O>;
58
55
 
@@ -139,10 +136,19 @@ type BoundaryData = Array<{input: unknown[], output?: unknown}>
139
136
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
140
137
  export type InferSchemaType<S> = S extends Schema<any> ? InferSchema<S> : Record<string, unknown>;
141
138
 
139
+ // Type for execution record boundaries that are automatically injected
140
+ // When adding new execution boundaries, add their types here
141
+ export type ExecutionRecordBoundaries = {
142
+ setMetadata: (key: string, value: string) => Promise<void>
143
+ // Future execution boundaries can be added here:
144
+ // setContext: (context: Record<string, unknown>) => Promise<void>
145
+ // addTag: (tag: string) => Promise<void>
146
+ }
147
+
142
148
  // Helper type for task function with proper typing
143
149
  export type TaskFunction<S, B extends Boundaries> =
144
150
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
- (argv: InferSchemaType<S>, boundaries: WrappedBoundaries<B>) => Promise<any>;
151
+ (argv: InferSchemaType<S>, boundaries: WrappedBoundaries<B> & ExecutionRecordBoundaries) => Promise<any>;
146
152
 
147
153
  /**
148
154
  * Utility function to compute the execution record type based on output and error state
@@ -163,7 +169,11 @@ export const Task = class Task<
163
169
  B extends Boundaries = Boundaries,
164
170
  Func extends BaseFunction = BaseFunction
165
171
  > implements TaskInstanceType<Func, B> {
166
- public version: string = '0.1.7'
172
+ public version: string = '0.1.8'
173
+
174
+ // Static property for global listener
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ static globalListener?: (record: ExecutionRecord<any, any, any>) => Promise<void>
167
177
 
168
178
  _fn: Func
169
179
  _mode: Mode
@@ -181,6 +191,29 @@ export const Task = class Task<
181
191
  _schema: Schema<Record<string, SchemaType>> | undefined
182
192
  _listener?: ((record: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>) => void) | undefined
183
193
 
194
+ // Static method to set global listener
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
+ static listenExecutionRecords(listener: (record: ExecutionRecord<any, any, any>) => Promise<void>): void {
197
+ this.globalListener = listener
198
+ }
199
+
200
+ // Static method to emit to global listener with error handling
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ static emitExecutionRecord(record: ExecutionRecord<any, any, any>): void {
203
+ if (this.globalListener) {
204
+ // Call listener on next tick to avoid blocking task execution
205
+ process.nextTick(async () => {
206
+ try {
207
+ await this.globalListener!(record)
208
+ } catch (error) {
209
+ // Log error but don't affect task execution
210
+ // eslint-disable-next-line no-console
211
+ console.error('ExecutionRecord listener error:', error)
212
+ }
213
+ })
214
+ }
215
+ }
216
+
184
217
  constructor (fn: Func, conf: TaskConfig<B> = {
185
218
  name: undefined,
186
219
  description: undefined,
@@ -297,9 +330,13 @@ export const Task = class Task<
297
330
  Plus all the boundary data
298
331
  */
299
332
  emit (data: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>): void {
300
- if (typeof this._listener === 'undefined') { return }
333
+ // Emit to instance listener
334
+ if (typeof this._listener !== 'undefined') {
335
+ this._listener(data)
336
+ }
301
337
 
302
- this._listener(data)
338
+ // Emit to global listener (non-blocking)
339
+ Task.emitExecutionRecord(data)
303
340
  }
304
341
 
305
342
  getBoundaries (): WrappedBoundaries<B> {
@@ -355,6 +392,28 @@ export const Task = class Task<
355
392
  this._boundaryMocks = {}
356
393
  }
357
394
 
395
+ /**
396
+ * Creates execution record boundaries that modify execution metadata and logging
397
+ * These boundaries are automatically injected into all task executions
398
+ *
399
+ * To add a new execution boundary:
400
+ * 1. Add the boundary function here
401
+ * 2. Update the ExecutionRecordBoundaries type to include the new boundary
402
+ * 3. That's it! The boundary will be available in all tasks automatically
403
+ */
404
+ _createExecutionBoundaries(metadata: Record<string, string>): Record<string, WrappedBoundaryFunction> {
405
+ return {
406
+ // Allows setting metadata key-value pairs from within task execution
407
+ setMetadata: createBoundary(async (...args: unknown[]): Promise<void> => {
408
+ const [key, value] = args as [string, string]
409
+ metadata[key] = value
410
+ })
411
+
412
+ // Future execution boundaries can be added here:
413
+ // addMetrics: createBoundary(async (...args: unknown[]): Promise<void> => { ... }),
414
+ }
415
+ }
416
+
358
417
  _createBounderies ({
359
418
  definition,
360
419
  baseData,
@@ -431,9 +490,16 @@ export const Task = class Task<
431
490
  mode: this._mode
432
491
  })
433
492
 
493
+ // Create and inject execution record boundaries (setMetadata, etc.)
494
+ const executionRecordBoundaries = this._createExecutionBoundaries(metadata)
495
+ const allBoundaries = {
496
+ ...executionBoundaries,
497
+ ...executionRecordBoundaries
498
+ }
499
+
434
500
  // Start run for each boundary
435
- for (const name in executionBoundaries) {
436
- const boundary = executionBoundaries[name]
501
+ for (const name in allBoundaries) {
502
+ const boundary = allBoundaries[name]
437
503
  boundary.startRun()
438
504
  }
439
505
 
@@ -470,7 +536,7 @@ export const Task = class Task<
470
536
  // Execute the task function
471
537
  output = await this._fn(
472
538
  argv as Parameters<Func>[0],
473
- executionBoundaries as unknown as Parameters<Func>[1]
539
+ allBoundaries as unknown as Parameters<Func>[1]
474
540
  )
475
541
 
476
542
  logItem.output = output
@@ -507,6 +573,9 @@ export const Task = class Task<
507
573
  }
508
574
  }
509
575
 
576
+ // Filter out setMetadata boundary calls from the logs (they should not appear in execution record)
577
+ // Note: setMetadata is not part of the original boundaries definition, so it won't be in boundariesRunLog anyway
578
+
510
579
  // Set boundaries in log item before emitting
511
580
  logItem.boundaries = boundariesRunLog
512
581
 
@@ -566,9 +635,18 @@ export const Task = class Task<
566
635
  boundaryModes: config.boundaries
567
636
  })
568
637
 
638
+ // Create and inject execution record boundaries (setMetadata, etc.)
639
+ // Clone the metadata to avoid mutating the original metadata
640
+ const replayMetadata = { ...(logItem.metadata || {}) }
641
+ const executionRecordBoundaries = this._createExecutionBoundaries(replayMetadata)
642
+ const allBoundaries = {
643
+ ...executionBoundaries,
644
+ ...executionRecordBoundaries
645
+ }
646
+
569
647
  // Start run for each boundary
570
- for (const name in executionBoundaries) {
571
- const boundary = executionBoundaries[name]
648
+ for (const name in allBoundaries) {
649
+ const boundary = allBoundaries[name]
572
650
  boundary.startRun()
573
651
  }
574
652
 
@@ -603,7 +681,7 @@ export const Task = class Task<
603
681
  // Execute the task function with replay boundaries
604
682
  output = await this._fn(
605
683
  argv,
606
- executionBoundaries as unknown as Parameters<Func>[1]
684
+ allBoundaries as unknown as Parameters<Func>[1]
607
685
  )
608
686
 
609
687
  logItem.output = output
@@ -632,9 +710,15 @@ export const Task = class Task<
632
710
  }
633
711
  }
634
712
 
713
+ // Filter out setMetadata boundary calls from the logs (they should not appear in execution record)
714
+ // Note: setMetadata is not part of the original boundaries definition, so it won't be in boundariesRunLog anyway
715
+
635
716
  // Set boundaries in log item before emitting
636
717
  logItem.boundaries = boundariesRunLog
637
718
 
719
+ // Update the log item metadata with changes made during replay
720
+ logItem.metadata = replayMetadata
721
+
638
722
  // Emit the log item
639
723
  this.emit(logItem)
640
724
 
@@ -684,6 +768,12 @@ export const Task = class Task<
684
768
  // Call the task's safeRun method
685
769
  const [outcome, error, log] = await this.safeRun(eventArgs)
686
770
 
771
+ // Extend log metadata with environment info
772
+ log.metadata = {
773
+ ...log.metadata,
774
+ environment: 'hive-lambda'
775
+ }
776
+
687
777
  // Send log to Hive if environment variables are present
688
778
  await this._sendToHive(log)
689
779
 
@@ -823,7 +913,7 @@ export interface CreateTaskConfig<
823
913
  description?: string
824
914
  schema: S
825
915
  boundaries: B
826
- fn: (argv: InferSchemaType<S>, boundaries: WrappedBoundaries<B>) => Promise<R>
916
+ fn: (argv: InferSchemaType<S>, boundaries: WrappedBoundaries<B> & ExecutionRecordBoundaries) => Promise<R>
827
917
  mode?: Mode
828
918
  boundariesData?: BoundaryTapeData
829
919
  }
@@ -0,0 +1,266 @@
1
+ import { createTask, Schema } from '../index'
2
+
3
+ describe('execution-record-boundaries', () => {
4
+ describe('setMetadata boundary', () => {
5
+ it('should add metadata to execution record when setMetadata is called', async () => {
6
+ // Create a schema
7
+ const schema = new Schema({
8
+ value: Schema.number()
9
+ })
10
+
11
+ // Create a task that uses setMetadata
12
+ const task = createTask({
13
+ name: 'metadata-test-task',
14
+ schema,
15
+ boundaries: {
16
+ multiply: async (value: number) => value * 2
17
+ },
18
+ fn: async ({ value }, { multiply, setMetadata }) => {
19
+ await setMetadata('userId', '12345')
20
+ await setMetadata('executionType', 'background')
21
+
22
+ const result = await multiply(value)
23
+ return { result }
24
+ }
25
+ })
26
+
27
+ // Run the task with safeRun
28
+ const [result, error, record] = await task.safeRun({ value: 5 })
29
+
30
+ // Verify the execution was successful
31
+ expect(error).toBeNull()
32
+ expect(result).toEqual({ result: 10 })
33
+
34
+ // Verify the metadata is included in the record
35
+ expect(record.metadata).toEqual({
36
+ userId: '12345',
37
+ executionType: 'background'
38
+ })
39
+
40
+ // Verify the task name and other fields are correct
41
+ expect(record.taskName).toBe('metadata-test-task')
42
+ expect(record.input).toEqual({ value: 5 })
43
+ expect(record.output).toEqual({ result: 10 })
44
+ expect(record.type).toBe('success')
45
+ })
46
+
47
+ it('should not include setMetadata calls in boundary logs', async () => {
48
+ // Create a schema
49
+ const schema = new Schema({
50
+ value: Schema.number()
51
+ })
52
+
53
+ // Create a task that uses setMetadata and other boundaries
54
+ const task = createTask({
55
+ name: 'boundary-log-test',
56
+ schema,
57
+ boundaries: {
58
+ multiply: async (value: number) => value * 2,
59
+ fetchData: async (data: string) => `fetched-${data}`
60
+ },
61
+ fn: async ({ value }, { multiply, fetchData, setMetadata }) => {
62
+ await setMetadata('step', 'start')
63
+
64
+ const multiplied = await multiply(value)
65
+ await setMetadata('step', 'multiplied')
66
+
67
+ const fetched = await fetchData('test')
68
+ await setMetadata('step', 'completed')
69
+
70
+ return { multiplied, fetched }
71
+ }
72
+ })
73
+
74
+ // Run the task with safeRun
75
+ const [result, error, record] = await task.safeRun({ value: 3 })
76
+
77
+ // Verify the execution was successful
78
+ expect(error).toBeNull()
79
+ expect(result).toEqual({ multiplied: 6, fetched: 'fetched-test' })
80
+
81
+ // Verify the metadata is updated to the final value
82
+ expect(record.metadata).toEqual({
83
+ step: 'completed'
84
+ })
85
+
86
+ // Verify that only the actual boundaries appear in the logs
87
+ expect(record.boundaries).toHaveProperty('multiply')
88
+ expect(record.boundaries).toHaveProperty('fetchData')
89
+ expect(record.boundaries).not.toHaveProperty('setMetadata')
90
+
91
+ // Verify the boundary logs contain the expected calls
92
+ expect(record.boundaries.multiply).toHaveLength(1)
93
+ expect(record.boundaries.multiply[0]).toEqual({
94
+ input: [3],
95
+ output: 6
96
+ })
97
+
98
+ expect(record.boundaries.fetchData).toHaveLength(1)
99
+ expect(record.boundaries.fetchData[0]).toEqual({
100
+ input: ['test'],
101
+ output: 'fetched-test'
102
+ })
103
+ })
104
+
105
+ it('should preserve metadata in error scenarios', async () => {
106
+ // Create a schema
107
+ const schema = new Schema({
108
+ value: Schema.number()
109
+ })
110
+
111
+ // Create a task that sets metadata before throwing an error
112
+ const task = createTask({
113
+ name: 'error-metadata-test',
114
+ schema,
115
+ boundaries: {},
116
+ fn: async ({ value }, { setMetadata }) => {
117
+ await setMetadata('errorContext', 'validation')
118
+ await setMetadata('userId', '67890')
119
+
120
+ if (value < 0) {
121
+ throw new Error('Value cannot be negative')
122
+ }
123
+
124
+ return { result: value }
125
+ }
126
+ })
127
+
128
+ // Run the task with a value that will cause an error
129
+ const [result, error, record] = await task.safeRun({ value: -1 })
130
+
131
+ // Verify the execution failed as expected
132
+ expect(result).toBeNull()
133
+ expect(error).not.toBeNull()
134
+ expect(error?.message).toBe('Value cannot be negative')
135
+
136
+ // Verify the metadata is preserved in the error record
137
+ expect(record.metadata).toEqual({
138
+ errorContext: 'validation',
139
+ userId: '67890'
140
+ })
141
+
142
+ // Verify other record fields
143
+ expect(record.taskName).toBe('error-metadata-test')
144
+ expect(record.input).toEqual({ value: -1 })
145
+ expect(record.error).toBe('Value cannot be negative')
146
+ expect(record.type).toBe('error')
147
+ })
148
+
149
+ it('should preserve existing metadata when adding new metadata', async () => {
150
+ // Create a schema
151
+ const schema = new Schema({
152
+ value: Schema.number()
153
+ })
154
+
155
+ // Create a task that uses setMetadata
156
+ const task = createTask({
157
+ name: 'preserve-metadata-test',
158
+ schema,
159
+ boundaries: {},
160
+ fn: async ({ value }, { setMetadata }) => {
161
+ await setMetadata('step1', 'completed')
162
+ await setMetadata('step2', 'in-progress')
163
+ await setMetadata('step1', 'updated') // Overwrite step1
164
+
165
+ return { result: value * 2 }
166
+ }
167
+ })
168
+
169
+ // Run the task with initial metadata context
170
+ const [result, error, record] = await task.safeRun({ value: 4 })
171
+
172
+ // Verify the execution was successful
173
+ expect(error).toBeNull()
174
+ expect(result).toEqual({ result: 8 })
175
+
176
+ // Verify the metadata contains the final values
177
+ expect(record.metadata).toEqual({
178
+ step1: 'updated',
179
+ step2: 'in-progress'
180
+ })
181
+ })
182
+
183
+ it('should work with setMetadata in safeReplay', async () => {
184
+ // Create a schema
185
+ const schema = new Schema({
186
+ value: Schema.number()
187
+ })
188
+
189
+ // Use a counter to ensure different values between original and replay
190
+ let executionCounter = 0
191
+
192
+ // Create a task with boundaries and metadata
193
+ const task = createTask({
194
+ name: 'replay-metadata-test',
195
+ schema,
196
+ boundaries: {
197
+ fetchData: async (value: number) => value * 3
198
+ },
199
+ fn: async ({ value }, { fetchData, setMetadata }) => {
200
+ executionCounter++
201
+ await setMetadata('replayTest', 'true')
202
+ await setMetadata('executionNumber', executionCounter.toString())
203
+
204
+ const result = await fetchData(value)
205
+ return { result }
206
+ }
207
+ })
208
+
209
+ // First, run the task normally to get an execution record
210
+ const [, , originalRecord] = await task.safeRun({ value: 2 })
211
+
212
+ // Verify original metadata was set
213
+ expect(originalRecord.metadata).toHaveProperty('replayTest', 'true')
214
+ expect(originalRecord.metadata).toHaveProperty('executionNumber', '1')
215
+
216
+ // Now replay the task
217
+ const [replayResult, replayError, replayRecord] = await task.safeReplay(
218
+ originalRecord,
219
+ { boundaries: { fetchData: 'replay' } }
220
+ )
221
+
222
+ // Verify the replay was successful
223
+ expect(replayError).toBeNull()
224
+ expect(replayResult).toEqual({ result: 6 })
225
+
226
+ // Verify that the replay has its own metadata (setMetadata was called again)
227
+ expect(replayRecord.metadata).toHaveProperty('replayTest', 'true')
228
+ expect(replayRecord.metadata).toHaveProperty('executionNumber', '2')
229
+
230
+ // The execution numbers should be different since setMetadata was called again
231
+ expect(replayRecord.metadata?.executionNumber).not.toBe(originalRecord.metadata?.executionNumber)
232
+ expect(originalRecord.metadata?.executionNumber).toBe('1')
233
+ expect(replayRecord.metadata?.executionNumber).toBe('2')
234
+ })
235
+
236
+ it('should handle empty metadata gracefully', async () => {
237
+ // Create a schema
238
+ const schema = new Schema({
239
+ value: Schema.number()
240
+ })
241
+
242
+ // Create a task that doesn't use setMetadata
243
+ const task = createTask({
244
+ name: 'no-metadata-test',
245
+ schema,
246
+ boundaries: {
247
+ multiply: async (value: number) => value * 2
248
+ },
249
+ fn: async ({ value }, { multiply }) => {
250
+ const result = await multiply(value)
251
+ return { result }
252
+ }
253
+ })
254
+
255
+ // Run the task without using setMetadata
256
+ const [result, error, record] = await task.safeRun({ value: 7 })
257
+
258
+ // Verify the execution was successful
259
+ expect(error).toBeNull()
260
+ expect(result).toEqual({ result: 14 })
261
+
262
+ // Verify the metadata is empty but defined
263
+ expect(record.metadata).toEqual({})
264
+ })
265
+ })
266
+ })