@outputai/core 0.7.1-next.db8ddd7.0 → 0.7.1-next.ed233ce.0

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.
Files changed (77) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/interface/evaluator.js +7 -20
  6. package/src/interface/evaluator.spec.js +117 -1
  7. package/src/interface/step.js +8 -9
  8. package/src/interface/step.spec.js +124 -0
  9. package/src/interface/validations/index.js +108 -0
  10. package/src/interface/validations/index.spec.js +182 -0
  11. package/src/interface/validations/schemas.js +113 -0
  12. package/src/interface/validations/schemas.spec.js +209 -0
  13. package/src/interface/webhook.js +1 -1
  14. package/src/interface/webhook.spec.js +1 -1
  15. package/src/interface/workflow.d.ts +10 -9
  16. package/src/interface/workflow.js +76 -164
  17. package/src/interface/workflow.spec.js +637 -521
  18. package/src/interface/workflow_activity_options.js +16 -0
  19. package/src/interface/workflow_utils.js +1 -1
  20. package/src/interface/zod_integration.spec.js +2 -2
  21. package/src/internal_utils/aggregations.js +0 -10
  22. package/src/internal_utils/aggregations.spec.js +1 -48
  23. package/src/internal_utils/errors.js +14 -8
  24. package/src/internal_utils/errors.spec.js +73 -27
  25. package/src/utils/index.d.ts +19 -0
  26. package/src/utils/utils.js +53 -0
  27. package/src/utils/utils.spec.js +105 -1
  28. package/src/worker/bundle.js +26 -0
  29. package/src/worker/bundle.spec.js +53 -0
  30. package/src/worker/bundler_options.js +1 -1
  31. package/src/worker/bundler_options.spec.js +1 -1
  32. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  33. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  34. package/src/worker/check.js +24 -0
  35. package/src/worker/connection_monitor.js +112 -0
  36. package/src/worker/connection_monitor.spec.js +199 -0
  37. package/src/worker/index.js +146 -41
  38. package/src/worker/index.spec.js +281 -109
  39. package/src/worker/interceptors/activity.js +7 -24
  40. package/src/worker/interceptors/activity.spec.js +97 -66
  41. package/src/worker/interceptors/index.js +4 -7
  42. package/src/worker/interceptors/modules.js +15 -0
  43. package/src/worker/interceptors/workflow.js +6 -8
  44. package/src/worker/interceptors/workflow.spec.js +49 -42
  45. package/src/worker/interruption.js +33 -0
  46. package/src/worker/interruption.spec.js +86 -0
  47. package/src/worker/loader/activities.js +75 -0
  48. package/src/worker/loader/activities.spec.js +213 -0
  49. package/src/worker/loader/hooks.js +28 -0
  50. package/src/worker/loader/hooks.spec.js +64 -0
  51. package/src/worker/loader/matchers.js +46 -0
  52. package/src/worker/loader/matchers.spec.js +140 -0
  53. package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
  54. package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
  55. package/src/worker/loader/workflows.js +82 -0
  56. package/src/worker/loader/workflows.spec.js +256 -0
  57. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  58. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  59. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  60. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  61. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  62. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  63. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  64. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  65. package/src/interface/validations/runtime.js +0 -20
  66. package/src/interface/validations/runtime.spec.js +0 -29
  67. package/src/interface/validations/schema_utils.js +0 -8
  68. package/src/interface/validations/schema_utils.spec.js +0 -67
  69. package/src/interface/validations/static.js +0 -137
  70. package/src/interface/validations/static.spec.js +0 -397
  71. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  72. package/src/worker/loader.js +0 -202
  73. package/src/worker/loader.spec.js +0 -498
  74. package/src/worker/shutdown.js +0 -26
  75. package/src/worker/shutdown.spec.js +0 -82
  76. package/src/worker/start_catalog.js +0 -96
  77. package/src/worker/start_catalog.spec.js +0 -179
@@ -1,498 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { join } from 'node:path';
3
- import { tmpdir } from 'node:os';
4
- import { staticMatchers } from './loader_tools.js';
5
-
6
- vi.mock( '#consts', () => ( {
7
- ACTIVITY_SEND_HTTP_REQUEST: '__internal#sendHttpRequest',
8
- ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations',
9
- WORKFLOWS_INDEX_FILENAME: '__workflows_entrypoint.js',
10
- WORKFLOW_CATALOG: 'catalog',
11
- ACTIVITY_OPTIONS_FILENAME: '__activity_options.js',
12
- SHARED_STEP_PREFIX: '$shared'
13
- } ) );
14
-
15
- const sendHttpRequestMock = vi.fn();
16
- const getTraceDestinationsMock = vi.fn();
17
- vi.mock( '#internal_activities', () => ( {
18
- sendHttpRequest: sendHttpRequestMock,
19
- getTraceDestinations: getTraceDestinationsMock
20
- } ) );
21
-
22
- const { importComponentsMock, findSharedActivitiesFromWorkflowsMock, findWorkflowsInNodeModulesMock, matchFilesMock } = vi.hoisted( () => ( {
23
- importComponentsMock: vi.fn(),
24
- findSharedActivitiesFromWorkflowsMock: vi.fn(),
25
- findWorkflowsInNodeModulesMock: vi.fn(),
26
- matchFilesMock: vi.fn()
27
- } ) );
28
-
29
- vi.mock( './loader_tools.js', async importOriginal => {
30
- const actual = await importOriginal();
31
- return {
32
- ...actual,
33
- importComponents: importComponentsMock,
34
- findSharedActivitiesFromWorkflows: findSharedActivitiesFromWorkflowsMock,
35
- findWorkflowsInNodeModules: findWorkflowsInNodeModulesMock,
36
- matchFiles: matchFilesMock
37
- };
38
- } );
39
-
40
- const fsMocks = vi.hoisted( () => ( {
41
- mkdirSync: vi.fn(),
42
- writeFileSync: vi.fn(),
43
- existsSync: vi.fn().mockReturnValue( false )
44
- } ) );
45
- vi.mock( 'node:fs', () => ( {
46
- mkdirSync: fsMocks.mkdirSync,
47
- writeFileSync: fsMocks.writeFileSync,
48
- existsSync: fsMocks.existsSync
49
- } ) );
50
-
51
- describe( 'worker/loader', () => {
52
- beforeEach( () => {
53
- vi.clearAllMocks();
54
- importComponentsMock.mockReset();
55
- importComponentsMock.mockImplementation( async function *() {} );
56
- findSharedActivitiesFromWorkflowsMock.mockReset();
57
- findSharedActivitiesFromWorkflowsMock.mockReturnValue( [] );
58
- findWorkflowsInNodeModulesMock.mockReset();
59
- findWorkflowsInNodeModulesMock.mockReturnValue( [] );
60
- matchFilesMock.mockReset();
61
- matchFilesMock.mockReturnValue( [] );
62
- } );
63
-
64
- it( 'loadActivities returns map including system activity and writes options file', async () => {
65
- const { loadActivities } = await import( './loader.js' );
66
-
67
- importComponentsMock.mockImplementationOnce( async function *() {
68
- yield {
69
- fn: () => {},
70
- metadata: { name: 'Act1', options: { activityOptions: { retry: { maximumAttempts: 3 } } } },
71
- path: '/a/steps.js'
72
- };
73
- } );
74
- importComponentsMock.mockImplementationOnce( async function *() {} );
75
-
76
- const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
77
- const activities = await loadActivities( '/root', workflows );
78
- expect( activities['A#Act1'] ).toBeTypeOf( 'function' );
79
- expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
80
-
81
- expect( fsMocks.writeFileSync ).toHaveBeenCalledTimes( 1 );
82
- const [ writtenPath, contents ] = fsMocks.writeFileSync.mock.calls[0];
83
- expect( writtenPath ).toMatch( /temp\/__activity_options\.js$/ );
84
- expect( contents ).toContain( 'export default' );
85
- expect( JSON.parse( contents.replace( /^export default\s*/, '' ).replace( /;\s*$/, '' ) ) ).toEqual( {
86
- 'A#Act1': { retry: { maximumAttempts: 3 } }
87
- } );
88
- expect( fsMocks.mkdirSync ).toHaveBeenCalled();
89
- } );
90
-
91
- it( 'loadActivities omits activity options when component has no options or no activityOptions', async () => {
92
- const { loadActivities } = await import( './loader.js' );
93
- importComponentsMock.mockImplementationOnce( async function *() {
94
- yield { fn: () => {}, metadata: { name: 'NoOptions' }, path: '/a/steps.js' };
95
- yield { fn: () => {}, metadata: { name: 'EmptyOptions', options: {} }, path: '/a/steps2.js' };
96
- } );
97
- importComponentsMock.mockImplementationOnce( async function *() {} );
98
-
99
- await loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] );
100
- const written = JSON.parse(
101
- fsMocks.writeFileSync.mock.calls[0][1].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
102
- );
103
- expect( written['A#NoOptions'] ).toBeUndefined();
104
- expect( written['A#EmptyOptions'] ).toBeUndefined();
105
- } );
106
-
107
- it( 'loadActivities throws when two activities in the same workflow share a name', async () => {
108
- const { loadActivities } = await import( './loader.js' );
109
- importComponentsMock.mockImplementationOnce( async function *() {
110
- yield { fn: () => {}, metadata: { name: 'DuplicateActivity' }, path: '/a/steps.js' };
111
- yield { fn: () => {}, metadata: { name: 'DuplicateActivity' }, path: '/a/evaluators.js' };
112
- } );
113
-
114
- await expect( loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] ) ).rejects.toThrow(
115
- 'Activity "DuplicateActivity" in workflow "A" conflicts with another activity in the same workflow. \
116
- Activity names must be unique within a workflow.'
117
- );
118
- } );
119
-
120
- it( 'loadActivities throws when two shared activities share a name', async () => {
121
- const { loadActivities } = await import( './loader.js' );
122
- importComponentsMock.mockImplementationOnce( async function *() {} );
123
- importComponentsMock.mockImplementationOnce( async function *() {
124
- yield { fn: () => {}, metadata: { name: 'DuplicateShared' }, path: '/root/shared/steps/a.js' };
125
- yield { fn: () => {}, metadata: { name: 'DuplicateShared' }, path: '/root/shared/evaluators/a.js' };
126
- } );
127
-
128
- await expect( loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] ) ).rejects.toThrow(
129
- 'Shared activity "DuplicateShared" conflicts with another shared activity. Shared activity names must be unique.'
130
- );
131
- } );
132
-
133
- describe( 'loadWorkflows', () => {
134
- it( 'returns local workflows from importComponents with metadata spread onto each entry', async () => {
135
- const { loadWorkflows } = await import( './loader.js' );
136
- const localFiles = [ { path: '/b/workflow.js', url: 'file:///b/workflow.js' } ];
137
- matchFilesMock.mockReturnValueOnce( localFiles );
138
-
139
- importComponentsMock.mockImplementationOnce( async function *() {
140
- yield { metadata: { name: 'Flow1', description: 'd' }, path: '/b/workflow.js' };
141
- } );
142
-
143
- const workflows = await loadWorkflows( '/root' );
144
- expect( workflows ).toEqual( [ { name: 'Flow1', description: 'd', path: '/b/workflow.js', external: false } ] );
145
- expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, localFiles );
146
- expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
147
- expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledWith( '/root' );
148
- } );
149
-
150
- it( 'calls matchFiles with rootDir and workflowFile matcher', async () => {
151
- const { loadWorkflows } = await import( './loader.js' );
152
- const localFiles = [ { path: '/my/app/workflow.js', url: 'file:///my/app/workflow.js' } ];
153
- const externalFiles = [ { path: '/my/app/node_modules/pkg/workflow.js', url: 'file:///my/app/node_modules/pkg/workflow.js' } ];
154
- matchFilesMock.mockReturnValueOnce( localFiles );
155
- findWorkflowsInNodeModulesMock.mockReturnValue( externalFiles );
156
-
157
- await loadWorkflows( '/my/app' );
158
-
159
- expect( matchFilesMock ).toHaveBeenCalledOnce();
160
- expect( matchFilesMock ).toHaveBeenCalledWith( '/my/app', [ staticMatchers.workflowFile ] );
161
- expect( importComponentsMock ).toHaveBeenCalledTimes( 1 );
162
- expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, [ ...localFiles, ...externalFiles ] );
163
- expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
164
- expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledWith( '/my/app' );
165
- } );
166
-
167
- it( 'appends node_modules workflows after local ones and sets external: true', async () => {
168
- const { loadWorkflows } = await import( './loader.js' );
169
-
170
- importComponentsMock.mockImplementationOnce( async function *() {
171
- yield { metadata: { name: 'LocalFlow', description: 'local' }, path: '/my/app/workflows/wf/workflow.js' };
172
- yield {
173
- metadata: { name: '__sum_numbers', description: 'from catalog' },
174
- path: '/my/app/node_modules/catalog_pkg/src/w/workflow.js'
175
- };
176
- } );
177
- findWorkflowsInNodeModulesMock.mockReturnValue( [ { path: '/my/app/node_modules/catalog_pkg/src/w/workflow.js' } ] );
178
-
179
- const workflows = await loadWorkflows( '/my/app' );
180
- expect( workflows ).toEqual( [
181
- { name: 'LocalFlow', description: 'local', path: '/my/app/workflows/wf/workflow.js', external: false },
182
- {
183
- name: '__sum_numbers',
184
- description: 'from catalog',
185
- path: '/my/app/node_modules/catalog_pkg/src/w/workflow.js',
186
- external: true
187
- }
188
- ] );
189
- } );
190
-
191
- it( 'returns only external workflows when the project root has none', async () => {
192
- const { loadWorkflows } = await import( './loader.js' );
193
-
194
- importComponentsMock.mockImplementationOnce( async function *() {
195
- yield { metadata: { name: 'PkgFlow', description: 'pkg' }, path: '/proj/node_modules/a/w/workflow.js' };
196
- } );
197
- findWorkflowsInNodeModulesMock.mockReturnValue( [ { path: '/proj/node_modules/a/w/workflow.js' } ] );
198
-
199
- const workflows = await loadWorkflows( '/proj' );
200
- expect( workflows ).toEqual( [
201
- {
202
- name: 'PkgFlow',
203
- description: 'pkg',
204
- path: '/proj/node_modules/a/w/workflow.js',
205
- external: true
206
- }
207
- ] );
208
- } );
209
-
210
- it( 'throws when a local workflow path is under a shared directory', async () => {
211
- const { loadWorkflows } = await import( './loader.js' );
212
- importComponentsMock.mockImplementationOnce( async function *() {
213
- yield { metadata: { name: 'Invalid' }, path: '/root/shared/workflow.js' };
214
- } );
215
-
216
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow( 'Workflow directory can\'t be named "shared"' );
217
- expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
218
- } );
219
-
220
- it( 'throws when a workflow name conflicts with an earlier workflow name', async () => {
221
- const { loadWorkflows } = await import( './loader.js' );
222
- importComponentsMock.mockImplementationOnce( async function *() {
223
- yield { metadata: { name: 'duplicate' }, path: '/root/a/workflow.js' };
224
- yield { metadata: { name: 'duplicate' }, path: '/root/b/workflow.js' };
225
- } );
226
-
227
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
228
- 'Workflow name "duplicate" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
229
- );
230
- } );
231
-
232
- it( 'throws when a workflow name conflicts with an earlier alias', async () => {
233
- const { loadWorkflows } = await import( './loader.js' );
234
- importComponentsMock.mockImplementationOnce( async function *() {
235
- yield { metadata: { name: 'alpha', aliases: [ 'legacy' ] }, path: '/root/a/workflow.js' };
236
- yield { metadata: { name: 'legacy' }, path: '/root/b/workflow.js' };
237
- } );
238
-
239
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
240
- 'Workflow name "legacy" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
241
- );
242
- } );
243
-
244
- it( 'throws when an alias conflicts with an earlier workflow name', async () => {
245
- const { loadWorkflows } = await import( './loader.js' );
246
- importComponentsMock.mockImplementationOnce( async function *() {
247
- yield { metadata: { name: 'alpha' }, path: '/root/a/workflow.js' };
248
- yield { metadata: { name: 'beta', aliases: [ 'alpha' ] }, path: '/root/b/workflow.js' };
249
- } );
250
-
251
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
252
- 'Workflow "beta" alias "alpha" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
253
- );
254
- } );
255
-
256
- it( 'throws when an alias conflicts with an earlier alias', async () => {
257
- const { loadWorkflows } = await import( './loader.js' );
258
- importComponentsMock.mockImplementationOnce( async function *() {
259
- yield { metadata: { name: 'alpha', aliases: [ 'shared_alias' ] }, path: '/root/a/workflow.js' };
260
- yield { metadata: { name: 'beta', aliases: [ 'shared_alias' ] }, path: '/root/b/workflow.js' };
261
- } );
262
-
263
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
264
- 'Workflow "beta" alias "shared_alias" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
265
- );
266
- } );
267
-
268
- it( 'throws when an alias is identical to its workflow name', async () => {
269
- const { loadWorkflows } = await import( './loader.js' );
270
- importComponentsMock.mockImplementationOnce( async function *() {
271
- yield { metadata: { name: 'alpha', aliases: [ 'alpha' ] }, path: '/root/a/workflow.js' };
272
- } );
273
-
274
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
275
- 'Workflow "alpha" alias "alpha" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
276
- );
277
- } );
278
-
279
- it( 'allows workflow names and aliases that only differ by case', async () => {
280
- const { loadWorkflows } = await import( './loader.js' );
281
- importComponentsMock.mockImplementationOnce( async function *() {
282
- yield { metadata: { name: 'Alpha', aliases: [ 'Legacy' ] }, path: '/root/a/workflow.js' };
283
- yield { metadata: { name: 'alpha', aliases: [ 'legacy' ] }, path: '/root/b/workflow.js' };
284
- } );
285
-
286
- await expect( loadWorkflows( '/root' ) ).resolves.toEqual( [
287
- { name: 'Alpha', aliases: [ 'Legacy' ], path: '/root/a/workflow.js', external: false },
288
- { name: 'alpha', aliases: [ 'legacy' ], path: '/root/b/workflow.js', external: false }
289
- ] );
290
- } );
291
-
292
- it( 'throws when a workflow name is reserved for the internal catalog', async () => {
293
- const { loadWorkflows } = await import( './loader.js' );
294
- importComponentsMock.mockImplementationOnce( async function *() {
295
- yield { metadata: { name: 'catalog' }, path: '/root/catalog/workflow.js' };
296
- } );
297
-
298
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
299
- 'Workflow name "catalog" is reserved for the internal catalog workflow.'
300
- );
301
- } );
302
-
303
- it( 'throws when a workflow alias is reserved for the internal catalog', async () => {
304
- const { loadWorkflows } = await import( './loader.js' );
305
- importComponentsMock.mockImplementationOnce( async function *() {
306
- yield { metadata: { name: 'alpha', aliases: [ 'catalog' ] }, path: '/root/a/workflow.js' };
307
- } );
308
-
309
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
310
- 'Workflow "alpha" alias "catalog" is reserved for the internal catalog workflow.'
311
- );
312
- } );
313
- } );
314
-
315
- it( 'createWorkflowsEntryPoint writes index and returns its path', async () => {
316
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
317
-
318
- const workflows = [ { name: 'W', path: '/abs/wf.js' } ];
319
- const entry = createWorkflowsEntryPoint( workflows );
320
-
321
- expect( fsMocks.writeFileSync ).toHaveBeenCalledTimes( 1 );
322
- const [ writtenPath, contents ] = fsMocks.writeFileSync.mock.calls[0];
323
- expect( entry ).toBe( writtenPath );
324
- expect( contents ).toContain( 'export { default as W } from \'/abs/wf.js\';' );
325
- expect( contents ).toContain( 'export { default as catalog }' );
326
- expect( fsMocks.mkdirSync ).toHaveBeenCalledTimes( 1 );
327
- } );
328
-
329
- it( 'createWorkflowsEntryPoint generates alias exports', async () => {
330
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
331
-
332
- const workflows = [ { name: 'W', path: '/abs/wf.js', aliases: [ 'W_old', 'W_legacy' ] } ];
333
- createWorkflowsEntryPoint( workflows );
334
-
335
- const [ , contents ] = fsMocks.writeFileSync.mock.calls[0];
336
- expect( contents ).toContain( 'export { default as W } from \'/abs/wf.js\';' );
337
- expect( contents ).toContain( 'export { default as W_old } from \'/abs/wf.js\';' );
338
- expect( contents ).toContain( 'export { default as W_legacy } from \'/abs/wf.js\';' );
339
- } );
340
-
341
- it( 'loadActivities uses folder-based matchers for steps/evaluators and shared', async () => {
342
- const { loadActivities } = await import( './loader.js' );
343
- const workflowFiles = [ { path: '/a/steps/foo.js' } ];
344
- const sharedFiles = [ { path: '/root/shared/steps/baz.js' } ];
345
- matchFilesMock.mockReturnValueOnce( workflowFiles );
346
- matchFilesMock.mockReturnValueOnce( sharedFiles );
347
- importComponentsMock.mockImplementationOnce( async function *() {} );
348
- importComponentsMock.mockImplementationOnce( async function *() {} );
349
-
350
- const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
351
- await loadActivities( '/root', workflows );
352
-
353
- expect( matchFilesMock ).toHaveBeenCalledTimes( 2 );
354
- const [ firstDir, firstMatchers ] = matchFilesMock.mock.calls[0];
355
- expect( firstDir ).toBe( '/a' );
356
- expect( Array.isArray( firstMatchers ) ).toBe( true );
357
- expect( firstMatchers.some( fn => fn( '/a/steps/foo.js' ) ) ).toBe( true );
358
- expect( firstMatchers.some( fn => fn( '/a/evaluators/bar.js' ) ) ).toBe( true );
359
- expect( firstMatchers.some( fn => fn( '/a/steps.js' ) ) ).toBe( true );
360
- expect( firstMatchers.some( fn => fn( '/a/evaluators.js' ) ) ).toBe( true );
361
-
362
- const [ secondDir, secondMatchers ] = matchFilesMock.mock.calls[1];
363
- expect( secondDir ).toBe( '/root' );
364
- expect( secondMatchers.some( fn => fn( '/root/shared/steps/baz.js' ) ) ).toBe( true );
365
- expect( secondMatchers.some( fn => fn( '/root/shared/evaluators/qux.js' ) ) ).toBe( true );
366
-
367
- expect( importComponentsMock ).toHaveBeenCalledTimes( 2 );
368
- expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, workflowFiles );
369
- expect( importComponentsMock ).toHaveBeenNthCalledWith( 2, sharedFiles );
370
- } );
371
-
372
- it( 'loads shared activities from external workflow packages', async () => {
373
- const { loadActivities } = await import( './loader.js' );
374
- const externalSharedFiles = [ { path: '/root/node_modules/pkg/shared/steps/prepare.js' } ];
375
- const localWorkflow = { name: 'Local', path: '/root/workflows/local/workflow.js' };
376
- const externalWorkflow = { name: 'External', path: '/root/node_modules/pkg/workflows/a/workflow.js', external: true };
377
- findSharedActivitiesFromWorkflowsMock.mockReturnValue( externalSharedFiles );
378
- importComponentsMock.mockImplementationOnce( async function *() {} );
379
- importComponentsMock.mockImplementationOnce( async function *() {} );
380
- importComponentsMock.mockImplementationOnce( async function *() {
381
- yield {
382
- fn: () => {},
383
- metadata: { name: 'ExternalShared', options: { activityOptions: { retry: { maximumAttempts: 2 } } } },
384
- path: '/root/node_modules/pkg/shared/steps/prepare.js'
385
- };
386
- } );
387
-
388
- const activities = await loadActivities( '/root', [ localWorkflow, externalWorkflow ] );
389
-
390
- expect( findSharedActivitiesFromWorkflowsMock ).toHaveBeenCalledWith( [ externalWorkflow ] );
391
- expect( importComponentsMock ).toHaveBeenNthCalledWith( 3, externalSharedFiles );
392
- expect( activities['$shared#ExternalShared'] ).toBeTypeOf( 'function' );
393
- const written = JSON.parse(
394
- fsMocks.writeFileSync.mock.calls[0][1].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
395
- );
396
- expect( written['$shared#ExternalShared'] ).toEqual( { retry: { maximumAttempts: 2 } } );
397
- } );
398
-
399
- it( 'loadActivities includes nested workflow steps and shared evaluators', async () => {
400
- const { loadActivities } = await import( './loader.js' );
401
- importComponentsMock.mockImplementationOnce( async function *() {
402
- yield { fn: () => {}, metadata: { name: 'ActNested' }, path: '/a/steps/foo.js' };
403
- } );
404
- importComponentsMock.mockImplementationOnce( async function *() {
405
- yield { fn: () => {}, metadata: { name: 'SharedEval' }, path: '/root/shared/evaluators/bar.js' };
406
- } );
407
-
408
- const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
409
- const activities = await loadActivities( '/root', workflows );
410
- expect( activities['A#ActNested'] ).toBeTypeOf( 'function' );
411
- expect( activities['$shared#SharedEval'] ).toBeTypeOf( 'function' );
412
- } );
413
-
414
- it( 'collects workflow nested steps and evaluators across multiple subfolders', async () => {
415
- const { loadActivities } = await import( './loader.js' );
416
- importComponentsMock.mockImplementationOnce( async function *() {
417
- yield { fn: () => {}, metadata: { name: 'StepPrimary' }, path: '/a/steps/primary/foo.js' };
418
- yield { fn: () => {}, metadata: { name: 'StepSecondary' }, path: '/a/steps/secondary/bar.js' };
419
- yield { fn: () => {}, metadata: { name: 'EvalPrimary' }, path: '/a/evaluators/primary/baz.js' };
420
- yield { fn: () => {}, metadata: { name: 'EvalSecondary' }, path: '/a/evaluators/secondary/qux.js' };
421
- } );
422
- importComponentsMock.mockImplementationOnce( async function *() {} );
423
-
424
- const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
425
- const activities = await loadActivities( '/root', workflows );
426
- expect( activities['A#StepPrimary'] ).toBeTypeOf( 'function' );
427
- expect( activities['A#StepSecondary'] ).toBeTypeOf( 'function' );
428
- expect( activities['A#EvalPrimary'] ).toBeTypeOf( 'function' );
429
- expect( activities['A#EvalSecondary'] ).toBeTypeOf( 'function' );
430
- } );
431
-
432
- it( 'collects shared nested steps and evaluators across multiple subfolders', async () => {
433
- const { loadActivities } = await import( './loader.js' );
434
- importComponentsMock.mockImplementationOnce( async function *() {} );
435
- importComponentsMock.mockImplementationOnce( async function *() {
436
- yield { fn: () => {}, metadata: { name: 'SharedStepPrimary' }, path: '/root/shared/steps/primary/a.js' };
437
- yield { fn: () => {}, metadata: { name: 'SharedStepSecondary' }, path: '/root/shared/steps/secondary/b.js' };
438
- yield { fn: () => {}, metadata: { name: 'SharedEvalPrimary' }, path: '/root/shared/evaluators/primary/c.js' };
439
- yield { fn: () => {}, metadata: { name: 'SharedEvalSecondary' }, path: '/root/shared/evaluators/secondary/d.js' };
440
- } );
441
-
442
- const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
443
- const activities = await loadActivities( '/root', workflows );
444
- expect( activities['$shared#SharedStepPrimary'] ).toBeTypeOf( 'function' );
445
- expect( activities['$shared#SharedStepSecondary'] ).toBeTypeOf( 'function' );
446
- expect( activities['$shared#SharedEvalPrimary'] ).toBeTypeOf( 'function' );
447
- expect( activities['$shared#SharedEvalSecondary'] ).toBeTypeOf( 'function' );
448
- } );
449
-
450
- describe( 'loadHooks', () => {
451
- it( 'resolves without importing when package.json does not exist', async () => {
452
- fsMocks.existsSync.mockReturnValue( false );
453
- const { loadHooks } = await import( './loader.js' );
454
- await expect( loadHooks( '/root' ) ).resolves.toBeUndefined();
455
- expect( fsMocks.existsSync ).toHaveBeenCalledWith( join( '/root', 'package.json' ) );
456
- } );
457
-
458
- it( 'imports hook files listed in package.json outputai.hookFiles', async () => {
459
- vi.doUnmock( 'node:fs' );
460
- vi.resetModules();
461
- const fs = await import( 'node:fs' );
462
- const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
463
- try {
464
- fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
465
- outputai: { hookFiles: [ 'hook.js' ] }
466
- } ) );
467
- fs.writeFileSync( join( tmpDir, 'hook.js' ), 'globalThis.__loadHooksTestLoaded = true;' );
468
-
469
- const { loadHooks } = await import( './loader.js' );
470
- await loadHooks( tmpDir );
471
- expect( globalThis.__loadHooksTestLoaded ).toBe( true );
472
- } finally {
473
- delete globalThis.__loadHooksTestLoaded;
474
- fs.rmSync( tmpDir, { recursive: true, force: true } );
475
- }
476
- } );
477
-
478
- it( 'imports hook files from legacy package.json output.hookFiles', async () => {
479
- vi.doUnmock( 'node:fs' );
480
- vi.resetModules();
481
- const fs = await import( 'node:fs' );
482
- const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
483
- try {
484
- fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
485
- output: { hookFiles: [ 'legacy_hook.js' ] }
486
- } ) );
487
- fs.writeFileSync( join( tmpDir, 'legacy_hook.js' ), 'globalThis.__loadHooksLegacyTestLoaded = true;' );
488
-
489
- const { loadHooks } = await import( './loader.js' );
490
- await loadHooks( tmpDir );
491
- expect( globalThis.__loadHooksLegacyTestLoaded ).toBe( true );
492
- } finally {
493
- delete globalThis.__loadHooksLegacyTestLoaded;
494
- fs.rmSync( tmpDir, { recursive: true, force: true } );
495
- }
496
- } );
497
- } );
498
- } );
@@ -1,26 +0,0 @@
1
- const FORCE_QUIT_GRACE_MS = 1000;
2
-
3
- export const registerShutdown = ( { worker, log } ) => {
4
- const state = { isShuttingDown: false, shutdownStartedAt: null };
5
-
6
- const shutdown = signal => {
7
- if ( state.isShuttingDown ) {
8
- const elapsed = Date.now() - state.shutdownStartedAt;
9
-
10
- // If running with npx, 2 kill signals are received in rapid succession,
11
- // this ignores the second interruption when it is right after the first.
12
- if ( elapsed < FORCE_QUIT_GRACE_MS ) {
13
- return;
14
- }
15
- log.warn( 'Force quitting...' );
16
- process.exit( 1 );
17
- }
18
- state.isShuttingDown = true;
19
- state.shutdownStartedAt = Date.now();
20
- log.info( 'Shutting down...', { signal } );
21
- worker.shutdown();
22
- };
23
-
24
- process.on( 'SIGTERM', () => shutdown( 'SIGTERM' ) );
25
- process.on( 'SIGINT', () => shutdown( 'SIGINT' ) );
26
- };
@@ -1,82 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { registerShutdown } from './shutdown.js';
3
-
4
- describe( 'worker/shutdown', () => {
5
- const mockLog = { info: vi.fn(), warn: vi.fn() };
6
- const shutdownMock = vi.fn();
7
- const mockWorker = { shutdown: shutdownMock };
8
- const onHandlers = {};
9
- const exitMock = vi.fn();
10
- const originalOn = process.on;
11
- const originalExit = process.exit;
12
-
13
- beforeEach( () => {
14
- vi.clearAllMocks();
15
- Object.keys( onHandlers ).forEach( k => delete onHandlers[k] );
16
- process.on = vi.fn( ( event, handler ) => {
17
- onHandlers[event] = handler;
18
- } );
19
- process.exit = exitMock;
20
- } );
21
-
22
- afterEach( () => {
23
- process.on = originalOn;
24
- process.exit = originalExit;
25
- } );
26
-
27
- it( 'registers SIGTERM and SIGINT handlers', () => {
28
- registerShutdown( { worker: mockWorker, log: mockLog } );
29
-
30
- expect( process.on ).toHaveBeenCalledWith( 'SIGTERM', expect.any( Function ) );
31
- expect( process.on ).toHaveBeenCalledWith( 'SIGINT', expect.any( Function ) );
32
- } );
33
-
34
- it( 'on first signal: logs, calls worker.shutdown(), does not exit', () => {
35
- registerShutdown( { worker: mockWorker, log: mockLog } );
36
-
37
- onHandlers.SIGTERM();
38
-
39
- expect( mockLog.info ).toHaveBeenCalledWith( 'Shutting down...', { signal: 'SIGTERM' } );
40
- expect( shutdownMock ).toHaveBeenCalledTimes( 1 );
41
- expect( mockLog.warn ).not.toHaveBeenCalled();
42
- expect( exitMock ).not.toHaveBeenCalled();
43
- } );
44
-
45
- it( 'on first SIGINT: logs with SIGINT', () => {
46
- registerShutdown( { worker: mockWorker, log: mockLog } );
47
-
48
- onHandlers.SIGINT();
49
-
50
- expect( mockLog.info ).toHaveBeenCalledWith( 'Shutting down...', { signal: 'SIGINT' } );
51
- expect( shutdownMock ).toHaveBeenCalledTimes( 1 );
52
- } );
53
-
54
- it( 'on second signal within grace period: ignores (no force quit)', () => {
55
- vi.useFakeTimers();
56
- registerShutdown( { worker: mockWorker, log: mockLog } );
57
-
58
- onHandlers.SIGTERM();
59
- onHandlers.SIGINT();
60
-
61
- expect( mockLog.info ).toHaveBeenCalledTimes( 1 );
62
- expect( shutdownMock ).toHaveBeenCalledTimes( 1 );
63
- expect( mockLog.warn ).not.toHaveBeenCalled();
64
- expect( exitMock ).not.toHaveBeenCalled();
65
-
66
- vi.useRealTimers();
67
- } );
68
-
69
- it( 'on second signal after grace period: logs force quit and exits with 1', () => {
70
- vi.useFakeTimers();
71
- registerShutdown( { worker: mockWorker, log: mockLog } );
72
-
73
- onHandlers.SIGTERM();
74
- vi.advanceTimersByTime( 1001 );
75
- onHandlers.SIGINT();
76
-
77
- expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
78
- expect( exitMock ).toHaveBeenCalledWith( 1 );
79
-
80
- vi.useRealTimers();
81
- } );
82
- } );