@apiquest/fracture 1.0.2 → 1.0.4

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 (168) hide show
  1. package/README.md +119 -0
  2. package/bin/cli.js +2 -2
  3. package/dist/CollectionRunner.js +3 -3
  4. package/dist/ScriptEngine.js +4 -4
  5. package/dist/cli/plugin-commands.d.ts.map +1 -1
  6. package/dist/cli/plugin-commands.js +2 -1
  7. package/dist/cli/plugin-commands.js.map +1 -1
  8. package/package.json +55 -50
  9. package/src/CollectionAnalyzer.ts +102 -102
  10. package/src/CollectionRunner.ts +1423 -1423
  11. package/src/CollectionRunner.types.ts +9 -9
  12. package/src/CollectionValidator.ts +289 -289
  13. package/src/ConsoleReporter.ts +143 -143
  14. package/src/CookieJar.ts +258 -258
  15. package/src/DagScheduler.ts +439 -439
  16. package/src/Logger.ts +85 -85
  17. package/src/PluginLoader.ts +126 -126
  18. package/src/PluginManager.ts +208 -208
  19. package/src/PluginResolver.ts +154 -154
  20. package/src/QuestAPI.ts +764 -764
  21. package/src/QuestAPI.types.ts +33 -33
  22. package/src/QuestTestAPI.ts +164 -164
  23. package/src/RequestFilter.ts +224 -224
  24. package/src/ScriptEngine.ts +219 -219
  25. package/src/ScriptValidator.ts +428 -428
  26. package/src/TaskGraph.ts +598 -598
  27. package/src/TestCounter.ts +109 -109
  28. package/src/VariableResolver.ts +114 -114
  29. package/src/cli/index.ts +480 -480
  30. package/src/cli/plugin-commands.ts +342 -341
  31. package/src/cli/plugin-discovery.ts +44 -44
  32. package/src/index.ts +24 -24
  33. package/src/utils.ts +52 -52
  34. package/tsconfig.json +20 -20
  35. package/tsconfig.test.json +5 -5
  36. package/vitest.config.ts +22 -22
  37. package/dist/ExecutionTree.d.ts +0 -77
  38. package/dist/ExecutionTree.d.ts.map +0 -1
  39. package/dist/ExecutionTree.js +0 -265
  40. package/dist/ExecutionTree.js.map +0 -1
  41. package/dist/fracture/src/CollectionAnalyzer.d.ts +0 -17
  42. package/dist/fracture/src/CollectionAnalyzer.d.ts.map +0 -1
  43. package/dist/fracture/src/CollectionAnalyzer.js +0 -70
  44. package/dist/fracture/src/CollectionAnalyzer.js.map +0 -1
  45. package/dist/fracture/src/CollectionRunner.d.ts +0 -39
  46. package/dist/fracture/src/CollectionRunner.d.ts.map +0 -1
  47. package/dist/fracture/src/CollectionRunner.js +0 -802
  48. package/dist/fracture/src/CollectionRunner.js.map +0 -1
  49. package/dist/fracture/src/CollectionRunner.types.d.ts +0 -8
  50. package/dist/fracture/src/CollectionRunner.types.d.ts.map +0 -1
  51. package/dist/fracture/src/CollectionRunner.types.js +0 -2
  52. package/dist/fracture/src/CollectionRunner.types.js.map +0 -1
  53. package/dist/fracture/src/CollectionValidator.d.ts +0 -14
  54. package/dist/fracture/src/CollectionValidator.d.ts.map +0 -1
  55. package/dist/fracture/src/CollectionValidator.js +0 -145
  56. package/dist/fracture/src/CollectionValidator.js.map +0 -1
  57. package/dist/fracture/src/ConsoleReporter.d.ts +0 -24
  58. package/dist/fracture/src/ConsoleReporter.d.ts.map +0 -1
  59. package/dist/fracture/src/ConsoleReporter.js +0 -123
  60. package/dist/fracture/src/ConsoleReporter.js.map +0 -1
  61. package/dist/fracture/src/CookieJar.d.ts +0 -70
  62. package/dist/fracture/src/CookieJar.d.ts.map +0 -1
  63. package/dist/fracture/src/CookieJar.js +0 -233
  64. package/dist/fracture/src/CookieJar.js.map +0 -1
  65. package/dist/fracture/src/ExecutionTree.d.ts +0 -77
  66. package/dist/fracture/src/ExecutionTree.d.ts.map +0 -1
  67. package/dist/fracture/src/ExecutionTree.js +0 -258
  68. package/dist/fracture/src/ExecutionTree.js.map +0 -1
  69. package/dist/fracture/src/Logger.d.ts +0 -25
  70. package/dist/fracture/src/Logger.d.ts.map +0 -1
  71. package/dist/fracture/src/Logger.js +0 -78
  72. package/dist/fracture/src/Logger.js.map +0 -1
  73. package/dist/fracture/src/PluginLoader.d.ts +0 -23
  74. package/dist/fracture/src/PluginLoader.d.ts.map +0 -1
  75. package/dist/fracture/src/PluginLoader.js +0 -102
  76. package/dist/fracture/src/PluginLoader.js.map +0 -1
  77. package/dist/fracture/src/PluginManager.d.ts +0 -64
  78. package/dist/fracture/src/PluginManager.d.ts.map +0 -1
  79. package/dist/fracture/src/PluginManager.js +0 -162
  80. package/dist/fracture/src/PluginManager.js.map +0 -1
  81. package/dist/fracture/src/PluginResolver.d.ts +0 -35
  82. package/dist/fracture/src/PluginResolver.d.ts.map +0 -1
  83. package/dist/fracture/src/PluginResolver.js +0 -128
  84. package/dist/fracture/src/PluginResolver.js.map +0 -1
  85. package/dist/fracture/src/QuestAPI.d.ts +0 -9
  86. package/dist/fracture/src/QuestAPI.d.ts.map +0 -1
  87. package/dist/fracture/src/QuestAPI.js +0 -679
  88. package/dist/fracture/src/QuestAPI.js.map +0 -1
  89. package/dist/fracture/src/QuestAPI.types.d.ts +0 -35
  90. package/dist/fracture/src/QuestAPI.types.d.ts.map +0 -1
  91. package/dist/fracture/src/QuestAPI.types.js +0 -3
  92. package/dist/fracture/src/QuestAPI.types.js.map +0 -1
  93. package/dist/fracture/src/QuestTestAPI.d.ts +0 -12
  94. package/dist/fracture/src/QuestTestAPI.d.ts.map +0 -1
  95. package/dist/fracture/src/QuestTestAPI.js +0 -133
  96. package/dist/fracture/src/QuestTestAPI.js.map +0 -1
  97. package/dist/fracture/src/ScriptEngine.d.ts +0 -21
  98. package/dist/fracture/src/ScriptEngine.d.ts.map +0 -1
  99. package/dist/fracture/src/ScriptEngine.js +0 -183
  100. package/dist/fracture/src/ScriptEngine.js.map +0 -1
  101. package/dist/fracture/src/ScriptValidator.d.ts +0 -68
  102. package/dist/fracture/src/ScriptValidator.d.ts.map +0 -1
  103. package/dist/fracture/src/ScriptValidator.js +0 -351
  104. package/dist/fracture/src/ScriptValidator.js.map +0 -1
  105. package/dist/fracture/src/TestCounter.d.ts +0 -18
  106. package/dist/fracture/src/TestCounter.d.ts.map +0 -1
  107. package/dist/fracture/src/TestCounter.js +0 -82
  108. package/dist/fracture/src/TestCounter.js.map +0 -1
  109. package/dist/fracture/src/VariableResolver.d.ts +0 -20
  110. package/dist/fracture/src/VariableResolver.d.ts.map +0 -1
  111. package/dist/fracture/src/VariableResolver.js +0 -100
  112. package/dist/fracture/src/VariableResolver.js.map +0 -1
  113. package/dist/fracture/src/cli/index.d.ts +0 -3
  114. package/dist/fracture/src/cli/index.d.ts.map +0 -1
  115. package/dist/fracture/src/cli/index.js +0 -347
  116. package/dist/fracture/src/cli/index.js.map +0 -1
  117. package/dist/fracture/src/cli/plugin-commands.d.ts +0 -6
  118. package/dist/fracture/src/cli/plugin-commands.d.ts.map +0 -1
  119. package/dist/fracture/src/cli/plugin-commands.js +0 -263
  120. package/dist/fracture/src/cli/plugin-commands.js.map +0 -1
  121. package/dist/fracture/src/cli/plugin-discovery.d.ts +0 -11
  122. package/dist/fracture/src/cli/plugin-discovery.d.ts.map +0 -1
  123. package/dist/fracture/src/cli/plugin-discovery.js +0 -64
  124. package/dist/fracture/src/cli/plugin-discovery.js.map +0 -1
  125. package/dist/fracture/src/index.d.ts +0 -13
  126. package/dist/fracture/src/index.d.ts.map +0 -1
  127. package/dist/fracture/src/index.js +0 -17
  128. package/dist/fracture/src/index.js.map +0 -1
  129. package/dist/fracture/src/utils.d.ts +0 -28
  130. package/dist/fracture/src/utils.d.ts.map +0 -1
  131. package/dist/fracture/src/utils.js +0 -48
  132. package/dist/fracture/src/utils.js.map +0 -1
  133. package/dist/plugin-auth/src/apikey-auth.d.ts +0 -3
  134. package/dist/plugin-auth/src/apikey-auth.d.ts.map +0 -1
  135. package/dist/plugin-auth/src/apikey-auth.js +0 -73
  136. package/dist/plugin-auth/src/apikey-auth.js.map +0 -1
  137. package/dist/plugin-auth/src/basic-auth.d.ts +0 -3
  138. package/dist/plugin-auth/src/basic-auth.d.ts.map +0 -1
  139. package/dist/plugin-auth/src/basic-auth.js +0 -61
  140. package/dist/plugin-auth/src/basic-auth.js.map +0 -1
  141. package/dist/plugin-auth/src/bearer-auth.d.ts +0 -3
  142. package/dist/plugin-auth/src/bearer-auth.d.ts.map +0 -1
  143. package/dist/plugin-auth/src/bearer-auth.js +0 -49
  144. package/dist/plugin-auth/src/bearer-auth.js.map +0 -1
  145. package/dist/plugin-auth/src/helpers.d.ts +0 -3
  146. package/dist/plugin-auth/src/helpers.d.ts.map +0 -1
  147. package/dist/plugin-auth/src/helpers.js +0 -8
  148. package/dist/plugin-auth/src/helpers.js.map +0 -1
  149. package/dist/plugin-auth/src/index.d.ts +0 -10
  150. package/dist/plugin-auth/src/index.d.ts.map +0 -1
  151. package/dist/plugin-auth/src/index.js +0 -25
  152. package/dist/plugin-auth/src/index.js.map +0 -1
  153. package/dist/plugin-auth/src/oauth2-auth.d.ts +0 -35
  154. package/dist/plugin-auth/src/oauth2-auth.d.ts.map +0 -1
  155. package/dist/plugin-auth/src/oauth2-auth.js +0 -266
  156. package/dist/plugin-auth/src/oauth2-auth.js.map +0 -1
  157. package/dist/plugin-http/src/index.d.ts +0 -4
  158. package/dist/plugin-http/src/index.d.ts.map +0 -1
  159. package/dist/plugin-http/src/index.js +0 -266
  160. package/dist/plugin-http/src/index.js.map +0 -1
  161. package/dist/plugin-vault-file/src/index.d.ts +0 -67
  162. package/dist/plugin-vault-file/src/index.d.ts.map +0 -1
  163. package/dist/plugin-vault-file/src/index.js +0 -171
  164. package/dist/plugin-vault-file/src/index.js.map +0 -1
  165. package/dist/types.d.ts +0 -374
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -13
  168. package/dist/types.js.map +0 -1
package/src/TaskGraph.ts CHANGED
@@ -1,598 +1,598 @@
1
- /**
2
- * TaskGraph - DAG Builder for Parallel Execution
3
- *
4
- * Transforms a collection tree into a directed acyclic graph (DAG) where:
5
- * - Nodes represent atomic tasks (script execution or request I/O)
6
- * - Edges represent ordering dependencies (structural + explicit dependsOn)
7
- *
8
- * Design:
9
- * - Script nodes: Serial execution through script queue (collection-pre/post, folder-pre/post, plugin events)
10
- * - Request nodes: Parallel execution via request pool
11
- * - Inherited pre/post scripts execute INSIDE request through script queue
12
- * - These are stored in node metadata, not as separate DAG nodes
13
- *
14
- * Edge types:
15
- * - Structural: Parent-child hierarchy (folder-pre → children → folder-post)
16
- * - DependsOn: Explicit dependencies from request/folder dependsOn fields
17
- * - Event: Request → plugin event scripts → parent folder-post
18
- */
19
-
20
- import type {
21
- Collection,
22
- CollectionItem,
23
- Folder,
24
- Request,
25
- ProtocolScript,
26
- ScriptType,
27
- PathType,
28
- Auth
29
- } from '@apiquest/types';
30
- import { isNullOrWhitespace } from './utils.js';
31
- import { Logger } from './Logger.js';
32
-
33
- /**
34
- * TaskNode represents a node in the DAG
35
- * Can be either a script node or a request node
36
- */
37
- export interface TaskNode {
38
- // Identity
39
- id: string;
40
- name: string;
41
- type: 'script' | 'request' | 'folder-enter' | 'folder-exit';
42
- path: PathType;
43
- parentFolderId?: PathType;
44
-
45
- // Script node fields
46
- scriptType?: ScriptType;
47
- script?: string;
48
-
49
- // Request/Folder condition
50
- condition?: string;
51
-
52
- // Request node: inherited scripts (executed inside request through script queue)
53
- // Array maintains order: outermost to innermost
54
- inheritedPreScripts?: string[]; // Collection → Folders → Request
55
- inheritedPostScripts?: string[]; // Request → Folders → Collection (LIFO)
56
-
57
- // Auth inheritance: Request > Folder > Parent Folder > Collection
58
- effectiveAuth?: Auth;
59
-
60
- // Original item (for request/folder execution)
61
- item?: Request | Folder;
62
-
63
- // Plugin event metadata (for script nodes of type plugin-event)
64
- eventName?: string;
65
- parentRequestId?: string;
66
- }
67
-
68
- /**
69
- * TaskEdge represents a dependency between two nodes
70
- */
71
- export interface TaskEdge {
72
- from: string;
73
- to: string;
74
- type: 'structural' | 'dependsOn' | 'event';
75
- }
76
-
77
- /**
78
- * Builds DAG from collection structure
79
- */
80
- export class TaskGraph {
81
- private nodes: Map<string, TaskNode> = new Map();
82
- private edges: TaskEdge[] = [];
83
- private dependentsMap: Map<string, string[]> = new Map();
84
- private inDegreeMap: Map<string, number> = new Map();
85
- private parentByNodeId: Map<string, PathType> = new Map();
86
- private childrenByFolderId: Map<PathType, string[]> = new Map();
87
- private logger: Logger;
88
- private allowParallel: boolean = false; // Default to sequential
89
-
90
- // Mappings for dependsOn resolution
91
- private startNodeByItemId: Map<string, string> = new Map();
92
- private completionNodeByItemId: Map<string, string> = new Map();
93
- private pendingDependsOn: Array<{ depId: string; targetItemId: string }> = [];
94
-
95
- constructor(baseLogger?: Logger) {
96
- this.logger = baseLogger?.createLogger('TaskGraph') ?? new Logger('TaskGraph');
97
- }
98
-
99
- /**
100
- * Build DAG from collection
101
- * @param collection - The collection to build
102
- * @param allowParallel - Whether parallel execution is enabled (affects DAG structure)
103
- */
104
- public build(collection: Collection, allowParallel: boolean = false): void {
105
- this.allowParallel = allowParallel;
106
- this.logger.debug(`Building DAG: allowParallel=${allowParallel}, collection="${collection.info.name}"`);
107
-
108
- const collectionPreId = 'script:collection-pre';
109
- const collectionPostId = 'script:collection-post';
110
-
111
- // Add collection-level script nodes
112
- this.addNode({
113
- id: collectionPreId,
114
- name: 'collection-pre',
115
- type: 'script',
116
- scriptType: 'collection-pre' as ScriptType,
117
- script: collection.collectionPreScript,
118
- path: 'collection:/'
119
- });
120
-
121
- this.addNode({
122
- id: collectionPostId,
123
- name: 'collection-post',
124
- type: 'script',
125
- scriptType: 'collection-post' as ScriptType,
126
- script: collection.collectionPostScript,
127
- path: 'collection:/'
128
- });
129
-
130
- // Build inherited script arrays
131
- const collectionPreScripts = this.toArray(collection.preRequestScript);
132
- const collectionPostScripts = this.toArray(collection.postRequestScript);
133
-
134
- // Start with collection-level auth (if present)
135
- const collectionAuth = collection.auth;
136
-
137
- // Build child nodes
138
- // In sequential mode: add explicit edges to enforce declaration order
139
- // In parallel mode: siblings can execute concurrently (no sequential edges)
140
- let previousCompletionId: string = collectionPreId;
141
-
142
- for (const item of collection.items) {
143
- this.logger.trace(`Building item: ${item.type}:${item.name}, previousCompletionId=${previousCompletionId}`);
144
- const { startId, endId } = this.buildItem(
145
- item,
146
- 'collection:/',
147
- previousCompletionId, // Sequential: this item waits for previous
148
- collectionPostId,
149
- collectionPreScripts,
150
- collectionPostScripts,
151
- collectionAuth
152
- );
153
-
154
- this.logger.trace(`Built item: startId=${startId}, endId=${endId}`);
155
-
156
- // Update previousCompletionId for next sibling
157
- // Sequential mode: endId (strict ordering)
158
- // Parallel mode: collectionPreId (all siblings start after collection-pre)
159
- previousCompletionId = this.allowParallel ? collectionPreId : endId;
160
- }
161
-
162
- // Final barrier: last item → collection-post
163
- if (!this.allowParallel) {
164
- this.logger.trace(`Adding final sequential barrier: ${previousCompletionId} → ${collectionPostId}`);
165
- this.addEdge(previousCompletionId, collectionPostId, 'structural');
166
- }
167
-
168
- this.logger.debug(`DAG built: ${this.nodes.size} nodes, ${this.edges.length} edges`);
169
-
170
- // Resolve dependsOn edges
171
- this.resolveDependsOnEdges();
172
- }
173
-
174
- /**
175
- * Get all nodes
176
- */
177
- public getNodes(): Map<string, TaskNode> {
178
- return this.nodes;
179
- }
180
-
181
- /**
182
- * Get all edges
183
- */
184
- public getEdges(): TaskEdge[] {
185
- return this.edges;
186
- }
187
-
188
- public getChildrenByFolderId(): Map<PathType, string[]> {
189
- return this.childrenByFolderId;
190
- }
191
-
192
- public getParentByNodeId(): Map<string, PathType> {
193
- return this.parentByNodeId;
194
- }
195
-
196
- /**
197
- * Get dependents for a node
198
- */
199
- public getDependents(nodeId: string): string[] {
200
- return this.dependentsMap.get(nodeId) ?? [];
201
- }
202
-
203
- /**
204
- * Get in-degree for a node
205
- */
206
- public getInDegree(nodeId: string): number {
207
- return this.inDegreeMap.get(nodeId) ?? 0;
208
- }
209
-
210
- /**
211
- * Get nodes with zero in-degree (ready for execution)
212
- */
213
- public getReadyNodes(): TaskNode[] {
214
- const ready: TaskNode[] = [];
215
- for (const [nodeId, degree] of this.inDegreeMap) {
216
- if (degree === 0) {
217
- const node = this.nodes.get(nodeId);
218
- if (node !== undefined) {
219
- ready.push(node);
220
- }
221
- }
222
- }
223
-
224
- this.logger.trace(`getReadyNodes() found ${ready.length} nodes: ${ready.map(n => `${n.type}:${n.name ?? n.id}`).join(', ')}`);
225
-
226
- return ready;
227
- }
228
-
229
- /**
230
- * Mark node as complete and return newly ready nodes
231
- */
232
- public completeNode(nodeId: string): TaskNode[] {
233
- const nowReady: TaskNode[] = [];
234
- const deps = this.getDependents(nodeId);
235
-
236
- for (const depId of deps) {
237
- const currentDegree = this.inDegreeMap.get(depId) ?? 0;
238
- const newDegree = currentDegree - 1;
239
- this.inDegreeMap.set(depId, newDegree);
240
-
241
- if (newDegree === 0) {
242
- const node = this.nodes.get(depId);
243
- if (node !== undefined) {
244
- nowReady.push(node);
245
- }
246
- }
247
- }
248
-
249
- this.logger.trace(`completeNode(${nodeId}) made ${nowReady.length} nodes ready: ${nowReady.map(n => `${n.type}:${n.name ?? n.id}`).join(', ')}`);
250
-
251
- return nowReady;
252
- }
253
-
254
- private addNode(node: TaskNode): void {
255
- this.nodes.set(node.id, node);
256
- if (node.parentFolderId !== undefined) {
257
- this.parentByNodeId.set(node.id, node.parentFolderId);
258
- const children = this.childrenByFolderId.get(node.parentFolderId) ?? [];
259
- children.push(node.id);
260
- this.childrenByFolderId.set(node.parentFolderId, children);
261
- }
262
- if (!this.dependentsMap.has(node.id)) {
263
- this.dependentsMap.set(node.id, []);
264
- }
265
- if (!this.inDegreeMap.has(node.id)) {
266
- this.inDegreeMap.set(node.id, 0);
267
- }
268
- }
269
-
270
- private addEdge(from: string, to: string, type: 'structural' | 'dependsOn' | 'event'): void {
271
- this.edges.push({ from, to, type });
272
-
273
- // Update dependents
274
- const deps = this.dependentsMap.get(from) ?? [];
275
- deps.push(to);
276
- this.dependentsMap.set(from, deps);
277
-
278
- // Increment in-degree
279
- const degree = this.inDegreeMap.get(to) ?? 0;
280
- this.inDegreeMap.set(to, degree + 1);
281
- }
282
-
283
- private buildItem(
284
- item: CollectionItem,
285
- parentPath: PathType,
286
- parentPreId: string,
287
- parentPostId: string,
288
- inheritedPreScripts: string[],
289
- inheritedPostScripts: string[],
290
- parentAuth?: Auth
291
- ): { startId: string; endId: string } {
292
- if (item.type === 'folder') {
293
- return this.buildFolder(
294
- item,
295
- parentPath,
296
- parentPreId,
297
- parentPostId,
298
- inheritedPreScripts,
299
- inheritedPostScripts,
300
- parentAuth
301
- );
302
- } else {
303
- return this.buildRequest(
304
- item,
305
- parentPath,
306
- parentPreId,
307
- parentPostId,
308
- inheritedPreScripts,
309
- inheritedPostScripts,
310
- parentAuth
311
- );
312
- }
313
- }
314
-
315
- private buildFolder(
316
- folder: Folder,
317
- parentPath: PathType,
318
- parentPreId: string,
319
- parentPostId: string,
320
- inheritedPreScripts: string[],
321
- inheritedPostScripts: string[],
322
- parentAuth?: Auth
323
- ): { startId: string; endId: string } {
324
- const folderPath = this.buildPath(parentPath, folder.name, 'folder');
325
- const folderEnterId = `folder-enter:${folderPath}`;
326
- const folderPreId = `script:folder-pre:${folderPath}`;
327
- const folderPostId = `script:folder-post:${folderPath}`;
328
- const folderExitId = `folder-exit:${folderPath}`;
329
-
330
- this.logger.trace(`buildFolder: ${folder.name} (enter=${folderEnterId}, exit=${folderExitId})`);
331
-
332
- // Add folder-enter node (lifecycle: PUSH scope + beforeFolder event)
333
- // ALWAYS executes regardless of script existence
334
- this.addNode({
335
- id: folderEnterId,
336
- name: `${folder.name}-enter`,
337
- type: 'folder-enter',
338
- parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
339
- condition: folder.condition,
340
- path: folderPath,
341
- item: folder
342
- });
343
-
344
- // Add folder-exit node (lifecycle: POP scope + afterFolder event)
345
- // ALWAYS executes regardless of script existence
346
- this.addNode({
347
- id: folderExitId,
348
- name: `${folder.name}-exit`,
349
- type: 'folder-exit',
350
- parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
351
- path: folderPath,
352
- item: folder
353
- });
354
-
355
- // Add folder-pre script node ONLY if script exists
356
- if (!isNullOrWhitespace(folder.folderPreScript)) {
357
- this.addNode({
358
- id: folderPreId,
359
- name: `${folder.name}-pre`,
360
- type: 'script',
361
- scriptType: 'folder-pre' as ScriptType,
362
- script: folder.folderPreScript,
363
- parentFolderId: folderPath,
364
- path: folderPath,
365
- item: folder
366
- });
367
- }
368
-
369
- // Add folder-post script node ONLY if script exists
370
- if (!isNullOrWhitespace(folder.folderPostScript)) {
371
- this.addNode({
372
- id: folderPostId,
373
- name: `${folder.name}-post`,
374
- type: 'script',
375
- scriptType: 'folder-post' as ScriptType,
376
- script: folder.folderPostScript,
377
- parentFolderId: folderPath,
378
- path: folderPath,
379
- item: folder
380
- });
381
- }
382
-
383
- // Structural edges: parent → folder-enter
384
- this.addEdge(parentPreId, folderEnterId, 'structural');
385
-
386
- // folder-enter → folder-pre (if pre-script exists)
387
- if (!isNullOrWhitespace(folder.folderPreScript)) {
388
- this.addEdge(folderEnterId, folderPreId, 'structural');
389
- }
390
-
391
- // folder-post → folder-exit (if post-script exists)
392
- if (!isNullOrWhitespace(folder.folderPostScript)) {
393
- this.addEdge(folderPostId, folderExitId, 'structural');
394
- }
395
-
396
- // folder-exit → parent
397
- this.addEdge(folderExitId, parentPostId, 'structural');
398
-
399
- // Register for dependsOn resolution
400
- // Start node is folder-enter (lifecycle), completion is folder-exit (lifecycle)
401
- this.startNodeByItemId.set(folder.id, folderEnterId);
402
- this.completionNodeByItemId.set(folder.id, folderExitId);
403
-
404
- const folderDeps = folder.dependsOn;
405
- if (folderDeps !== undefined && folderDeps.length > 0) {
406
- for (const depId of folderDeps) {
407
- this.pendingDependsOn.push({ depId, targetItemId: folder.id });
408
- }
409
- }
410
-
411
- // Build inherited scripts for children
412
- const folderPreScripts = [...inheritedPreScripts];
413
- if (!isNullOrWhitespace(folder.preRequestScript)) {
414
- folderPreScripts.push(folder.preRequestScript!);
415
- }
416
-
417
- const folderPostScripts = [...inheritedPostScripts];
418
- if (!isNullOrWhitespace(folder.postRequestScript)) {
419
- // Use unshift for LIFO (inner runs before outer)
420
- folderPostScripts.unshift(folder.postRequestScript!);
421
- }
422
-
423
- // Determine which node children connect to (enter or pre-script)
424
- const childrenParentPreId = !isNullOrWhitespace(folder.folderPreScript) ? folderPreId : folderEnterId;
425
- // Determine which node receives children completion (post-script or exit)
426
- const childrenParentPostId = !isNullOrWhitespace(folder.folderPostScript) ? folderPostId : folderExitId;
427
-
428
- // If condition is statically false, skip children
429
- if (this.isConditionFalse(folder.condition)) {
430
- // Direct edge: folder-enter → folder-exit (bypassing children and scripts)
431
- this.addEdge(folderEnterId, folderExitId, 'structural');
432
- return { startId: folderEnterId, endId: folderExitId };
433
- }
434
-
435
- // Compute effective auth (folder auth overrides parent)
436
- const folderAuth = folder.auth ?? parentAuth;
437
-
438
- // Build children (preserve declaration order)
439
- for (const child of folder.items) {
440
- this.buildItem(
441
- child,
442
- folderPath,
443
- childrenParentPreId,
444
- childrenParentPostId,
445
- folderPreScripts,
446
- folderPostScripts,
447
- folderAuth // Pass folder's effective auth to children
448
- );
449
- }
450
-
451
- // Barrier edge (ensures children completion point waits for all children)
452
- this.addEdge(childrenParentPreId, childrenParentPostId, 'structural');
453
-
454
- return { startId: folderEnterId, endId: folderExitId };
455
- }
456
-
457
- private buildRequest(
458
- request: Request,
459
- parentPath: PathType,
460
- parentPreId: string,
461
- parentPostId: string,
462
- inheritedPreScripts: string[],
463
- inheritedPostScripts: string[],
464
- parentAuth?: Auth
465
- ): { startId: string; endId: string } {
466
- const requestPath = this.buildPath(parentPath, request.name, 'request');
467
- const requestId = `request:${requestPath}`;
468
-
469
- this.logger.trace(`buildRequest: ${request.name} (id=${requestId})`);
470
-
471
- // Build final script arrays for this request
472
- const requestPreScripts = [...inheritedPreScripts];
473
- if (!isNullOrWhitespace(request.preRequestScript)) {
474
- requestPreScripts.push(request.preRequestScript!);
475
- }
476
-
477
- const requestPostScripts = [...inheritedPostScripts];
478
- if (!isNullOrWhitespace(request.postRequestScript)) {
479
- // Use unshift for LIFO (request runs before inherited)
480
- requestPostScripts.unshift(request.postRequestScript!);
481
- }
482
-
483
- // Compute effective auth (request auth overrides folder/collection auth)
484
- const effectiveAuth = request.auth ?? parentAuth;
485
-
486
- // Add request node
487
- this.addNode({
488
- id: requestId,
489
- name: request.name,
490
- type: 'request',
491
- condition: request.condition,
492
- inheritedPreScripts: requestPreScripts,
493
- inheritedPostScripts: requestPostScripts,
494
- effectiveAuth, // Store computed effectiveAuth for request execution
495
- parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
496
- path: requestPath,
497
- item: request
498
- });
499
-
500
- // Structural edges
501
- this.addEdge(parentPreId, requestId, 'structural');
502
- this.addEdge(requestId, parentPostId, 'structural');
503
-
504
- // Plugin event scripts are NOT DAG nodes - they execute via emitEvent() callback
505
- // during plugin I/O phase. This ensures proper serialization and lifecycle.
506
- // Plugin events fire DURING request execution (e.g., WebSocket onMessage), not as separate tasks.
507
- // Removed: this.addEventScriptNodes(request, requestId, parentPostId, parentPath);
508
-
509
- // Register for dependsOn resolution
510
- this.startNodeByItemId.set(request.id, requestId);
511
- this.completionNodeByItemId.set(request.id, requestId);
512
-
513
- if (request.dependsOn !== undefined && request.dependsOn.length > 0) {
514
- for (const depId of request.dependsOn) {
515
- this.pendingDependsOn.push({ depId, targetItemId: request.id });
516
- }
517
- }
518
-
519
- return { startId: requestId, endId: requestId };
520
- }
521
-
522
- private addEventScriptNodes(
523
- request: Request,
524
- requestId: string,
525
- parentPostId: string,
526
- parentPath: PathType
527
- ): void {
528
- const eventScripts = request.data?.scripts;
529
- if (eventScripts === undefined || eventScripts.length === 0) {
530
- return;
531
- }
532
-
533
- for (let i = 0; i < eventScripts.length; i++) {
534
- const eventScript = eventScripts[i];
535
- const eventNodeId = `script:plugin-event:${requestId}:${i}:${eventScript.event}`;
536
-
537
- this.addNode({
538
- id: eventNodeId,
539
- name: `${request.name}-${eventScript.event}`,
540
- type: 'script',
541
- scriptType: 'plugin-event' as ScriptType,
542
- script: eventScript.script,
543
- parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
544
- path: `${requestId}:${eventScript.event}` as PathType,
545
- eventName: eventScript.event,
546
- parentRequestId: requestId
547
- });
548
-
549
- // Event edges: request → event → parent-post
550
- this.addEdge(requestId, eventNodeId, 'event');
551
- this.addEdge(eventNodeId, parentPostId, 'structural');
552
- }
553
- }
554
-
555
- private resolveDependsOnEdges(): void {
556
- for (const { depId, targetItemId } of this.pendingDependsOn) {
557
- const completionNodeId = this.completionNodeByItemId.get(depId);
558
- if (completionNodeId === undefined) {
559
- // Dependency not found - this can happen when filtering with excludeDeps=true
560
- // Skip this dependency edge instead of throwing
561
- this.logger.warn(`Skipping dependency: Item '${targetItemId}' depends on '${depId}' which is not in the filtered collection`);
562
- continue;
563
- }
564
-
565
- const targetStartNodeId = this.startNodeByItemId.get(targetItemId);
566
- if (targetStartNodeId === undefined) {
567
- throw new Error(`Internal error: No start node for item '${targetItemId}'`);
568
- }
569
-
570
- // Add edge: completionNode(dep) → startNode(target)
571
- this.addEdge(completionNodeId, targetStartNodeId, 'dependsOn');
572
- }
573
- }
574
-
575
-
576
- /**
577
- * Build path with proper type prefix (folder: or request:)
578
- * Similar to ExecutionNode path building
579
- */
580
- private buildPath(parent: string, name: string, type: 'folder' | 'request'): PathType {
581
- // If parent is collection:/
582
- if (parent === 'collection:/') {
583
- return `${type}:/${name}` as PathType;
584
- }
585
-
586
- // Remove type prefix from parent path
587
- const basePath = parent.replace(/^(folder|request):\//, '');
588
- return `${type}:/${basePath}/${name}` as PathType;
589
- }
590
-
591
- private toArray(script: string | undefined): string[] {
592
- return !isNullOrWhitespace(script) ? [script!] : [];
593
- }
594
-
595
- private isConditionFalse(condition: string | undefined): boolean {
596
- return condition?.toLowerCase() === 'false';
597
- }
598
- }
1
+ /**
2
+ * TaskGraph - DAG Builder for Parallel Execution
3
+ *
4
+ * Transforms a collection tree into a directed acyclic graph (DAG) where:
5
+ * - Nodes represent atomic tasks (script execution or request I/O)
6
+ * - Edges represent ordering dependencies (structural + explicit dependsOn)
7
+ *
8
+ * Design:
9
+ * - Script nodes: Serial execution through script queue (collection-pre/post, folder-pre/post, plugin events)
10
+ * - Request nodes: Parallel execution via request pool
11
+ * - Inherited pre/post scripts execute INSIDE request through script queue
12
+ * - These are stored in node metadata, not as separate DAG nodes
13
+ *
14
+ * Edge types:
15
+ * - Structural: Parent-child hierarchy (folder-pre → children → folder-post)
16
+ * - DependsOn: Explicit dependencies from request/folder dependsOn fields
17
+ * - Event: Request → plugin event scripts → parent folder-post
18
+ */
19
+
20
+ import type {
21
+ Collection,
22
+ CollectionItem,
23
+ Folder,
24
+ Request,
25
+ ProtocolScript,
26
+ ScriptType,
27
+ PathType,
28
+ Auth
29
+ } from '@apiquest/types';
30
+ import { isNullOrWhitespace } from './utils.js';
31
+ import { Logger } from './Logger.js';
32
+
33
+ /**
34
+ * TaskNode represents a node in the DAG
35
+ * Can be either a script node or a request node
36
+ */
37
+ export interface TaskNode {
38
+ // Identity
39
+ id: string;
40
+ name: string;
41
+ type: 'script' | 'request' | 'folder-enter' | 'folder-exit';
42
+ path: PathType;
43
+ parentFolderId?: PathType;
44
+
45
+ // Script node fields
46
+ scriptType?: ScriptType;
47
+ script?: string;
48
+
49
+ // Request/Folder condition
50
+ condition?: string;
51
+
52
+ // Request node: inherited scripts (executed inside request through script queue)
53
+ // Array maintains order: outermost to innermost
54
+ inheritedPreScripts?: string[]; // Collection → Folders → Request
55
+ inheritedPostScripts?: string[]; // Request → Folders → Collection (LIFO)
56
+
57
+ // Auth inheritance: Request > Folder > Parent Folder > Collection
58
+ effectiveAuth?: Auth;
59
+
60
+ // Original item (for request/folder execution)
61
+ item?: Request | Folder;
62
+
63
+ // Plugin event metadata (for script nodes of type plugin-event)
64
+ eventName?: string;
65
+ parentRequestId?: string;
66
+ }
67
+
68
+ /**
69
+ * TaskEdge represents a dependency between two nodes
70
+ */
71
+ export interface TaskEdge {
72
+ from: string;
73
+ to: string;
74
+ type: 'structural' | 'dependsOn' | 'event';
75
+ }
76
+
77
+ /**
78
+ * Builds DAG from collection structure
79
+ */
80
+ export class TaskGraph {
81
+ private nodes: Map<string, TaskNode> = new Map();
82
+ private edges: TaskEdge[] = [];
83
+ private dependentsMap: Map<string, string[]> = new Map();
84
+ private inDegreeMap: Map<string, number> = new Map();
85
+ private parentByNodeId: Map<string, PathType> = new Map();
86
+ private childrenByFolderId: Map<PathType, string[]> = new Map();
87
+ private logger: Logger;
88
+ private allowParallel: boolean = false; // Default to sequential
89
+
90
+ // Mappings for dependsOn resolution
91
+ private startNodeByItemId: Map<string, string> = new Map();
92
+ private completionNodeByItemId: Map<string, string> = new Map();
93
+ private pendingDependsOn: Array<{ depId: string; targetItemId: string }> = [];
94
+
95
+ constructor(baseLogger?: Logger) {
96
+ this.logger = baseLogger?.createLogger('TaskGraph') ?? new Logger('TaskGraph');
97
+ }
98
+
99
+ /**
100
+ * Build DAG from collection
101
+ * @param collection - The collection to build
102
+ * @param allowParallel - Whether parallel execution is enabled (affects DAG structure)
103
+ */
104
+ public build(collection: Collection, allowParallel: boolean = false): void {
105
+ this.allowParallel = allowParallel;
106
+ this.logger.debug(`Building DAG: allowParallel=${allowParallel}, collection="${collection.info.name}"`);
107
+
108
+ const collectionPreId = 'script:collection-pre';
109
+ const collectionPostId = 'script:collection-post';
110
+
111
+ // Add collection-level script nodes
112
+ this.addNode({
113
+ id: collectionPreId,
114
+ name: 'collection-pre',
115
+ type: 'script',
116
+ scriptType: 'collection-pre' as ScriptType,
117
+ script: collection.collectionPreScript,
118
+ path: 'collection:/'
119
+ });
120
+
121
+ this.addNode({
122
+ id: collectionPostId,
123
+ name: 'collection-post',
124
+ type: 'script',
125
+ scriptType: 'collection-post' as ScriptType,
126
+ script: collection.collectionPostScript,
127
+ path: 'collection:/'
128
+ });
129
+
130
+ // Build inherited script arrays
131
+ const collectionPreScripts = this.toArray(collection.preRequestScript);
132
+ const collectionPostScripts = this.toArray(collection.postRequestScript);
133
+
134
+ // Start with collection-level auth (if present)
135
+ const collectionAuth = collection.auth;
136
+
137
+ // Build child nodes
138
+ // In sequential mode: add explicit edges to enforce declaration order
139
+ // In parallel mode: siblings can execute concurrently (no sequential edges)
140
+ let previousCompletionId: string = collectionPreId;
141
+
142
+ for (const item of collection.items) {
143
+ this.logger.trace(`Building item: ${item.type}:${item.name}, previousCompletionId=${previousCompletionId}`);
144
+ const { startId, endId } = this.buildItem(
145
+ item,
146
+ 'collection:/',
147
+ previousCompletionId, // Sequential: this item waits for previous
148
+ collectionPostId,
149
+ collectionPreScripts,
150
+ collectionPostScripts,
151
+ collectionAuth
152
+ );
153
+
154
+ this.logger.trace(`Built item: startId=${startId}, endId=${endId}`);
155
+
156
+ // Update previousCompletionId for next sibling
157
+ // Sequential mode: endId (strict ordering)
158
+ // Parallel mode: collectionPreId (all siblings start after collection-pre)
159
+ previousCompletionId = this.allowParallel ? collectionPreId : endId;
160
+ }
161
+
162
+ // Final barrier: last item → collection-post
163
+ if (!this.allowParallel) {
164
+ this.logger.trace(`Adding final sequential barrier: ${previousCompletionId} → ${collectionPostId}`);
165
+ this.addEdge(previousCompletionId, collectionPostId, 'structural');
166
+ }
167
+
168
+ this.logger.debug(`DAG built: ${this.nodes.size} nodes, ${this.edges.length} edges`);
169
+
170
+ // Resolve dependsOn edges
171
+ this.resolveDependsOnEdges();
172
+ }
173
+
174
+ /**
175
+ * Get all nodes
176
+ */
177
+ public getNodes(): Map<string, TaskNode> {
178
+ return this.nodes;
179
+ }
180
+
181
+ /**
182
+ * Get all edges
183
+ */
184
+ public getEdges(): TaskEdge[] {
185
+ return this.edges;
186
+ }
187
+
188
+ public getChildrenByFolderId(): Map<PathType, string[]> {
189
+ return this.childrenByFolderId;
190
+ }
191
+
192
+ public getParentByNodeId(): Map<string, PathType> {
193
+ return this.parentByNodeId;
194
+ }
195
+
196
+ /**
197
+ * Get dependents for a node
198
+ */
199
+ public getDependents(nodeId: string): string[] {
200
+ return this.dependentsMap.get(nodeId) ?? [];
201
+ }
202
+
203
+ /**
204
+ * Get in-degree for a node
205
+ */
206
+ public getInDegree(nodeId: string): number {
207
+ return this.inDegreeMap.get(nodeId) ?? 0;
208
+ }
209
+
210
+ /**
211
+ * Get nodes with zero in-degree (ready for execution)
212
+ */
213
+ public getReadyNodes(): TaskNode[] {
214
+ const ready: TaskNode[] = [];
215
+ for (const [nodeId, degree] of this.inDegreeMap) {
216
+ if (degree === 0) {
217
+ const node = this.nodes.get(nodeId);
218
+ if (node !== undefined) {
219
+ ready.push(node);
220
+ }
221
+ }
222
+ }
223
+
224
+ this.logger.trace(`getReadyNodes() found ${ready.length} nodes: ${ready.map(n => `${n.type}:${n.name ?? n.id}`).join(', ')}`);
225
+
226
+ return ready;
227
+ }
228
+
229
+ /**
230
+ * Mark node as complete and return newly ready nodes
231
+ */
232
+ public completeNode(nodeId: string): TaskNode[] {
233
+ const nowReady: TaskNode[] = [];
234
+ const deps = this.getDependents(nodeId);
235
+
236
+ for (const depId of deps) {
237
+ const currentDegree = this.inDegreeMap.get(depId) ?? 0;
238
+ const newDegree = currentDegree - 1;
239
+ this.inDegreeMap.set(depId, newDegree);
240
+
241
+ if (newDegree === 0) {
242
+ const node = this.nodes.get(depId);
243
+ if (node !== undefined) {
244
+ nowReady.push(node);
245
+ }
246
+ }
247
+ }
248
+
249
+ this.logger.trace(`completeNode(${nodeId}) made ${nowReady.length} nodes ready: ${nowReady.map(n => `${n.type}:${n.name ?? n.id}`).join(', ')}`);
250
+
251
+ return nowReady;
252
+ }
253
+
254
+ private addNode(node: TaskNode): void {
255
+ this.nodes.set(node.id, node);
256
+ if (node.parentFolderId !== undefined) {
257
+ this.parentByNodeId.set(node.id, node.parentFolderId);
258
+ const children = this.childrenByFolderId.get(node.parentFolderId) ?? [];
259
+ children.push(node.id);
260
+ this.childrenByFolderId.set(node.parentFolderId, children);
261
+ }
262
+ if (!this.dependentsMap.has(node.id)) {
263
+ this.dependentsMap.set(node.id, []);
264
+ }
265
+ if (!this.inDegreeMap.has(node.id)) {
266
+ this.inDegreeMap.set(node.id, 0);
267
+ }
268
+ }
269
+
270
+ private addEdge(from: string, to: string, type: 'structural' | 'dependsOn' | 'event'): void {
271
+ this.edges.push({ from, to, type });
272
+
273
+ // Update dependents
274
+ const deps = this.dependentsMap.get(from) ?? [];
275
+ deps.push(to);
276
+ this.dependentsMap.set(from, deps);
277
+
278
+ // Increment in-degree
279
+ const degree = this.inDegreeMap.get(to) ?? 0;
280
+ this.inDegreeMap.set(to, degree + 1);
281
+ }
282
+
283
+ private buildItem(
284
+ item: CollectionItem,
285
+ parentPath: PathType,
286
+ parentPreId: string,
287
+ parentPostId: string,
288
+ inheritedPreScripts: string[],
289
+ inheritedPostScripts: string[],
290
+ parentAuth?: Auth
291
+ ): { startId: string; endId: string } {
292
+ if (item.type === 'folder') {
293
+ return this.buildFolder(
294
+ item,
295
+ parentPath,
296
+ parentPreId,
297
+ parentPostId,
298
+ inheritedPreScripts,
299
+ inheritedPostScripts,
300
+ parentAuth
301
+ );
302
+ } else {
303
+ return this.buildRequest(
304
+ item,
305
+ parentPath,
306
+ parentPreId,
307
+ parentPostId,
308
+ inheritedPreScripts,
309
+ inheritedPostScripts,
310
+ parentAuth
311
+ );
312
+ }
313
+ }
314
+
315
+ private buildFolder(
316
+ folder: Folder,
317
+ parentPath: PathType,
318
+ parentPreId: string,
319
+ parentPostId: string,
320
+ inheritedPreScripts: string[],
321
+ inheritedPostScripts: string[],
322
+ parentAuth?: Auth
323
+ ): { startId: string; endId: string } {
324
+ const folderPath = this.buildPath(parentPath, folder.name, 'folder');
325
+ const folderEnterId = `folder-enter:${folderPath}`;
326
+ const folderPreId = `script:folder-pre:${folderPath}`;
327
+ const folderPostId = `script:folder-post:${folderPath}`;
328
+ const folderExitId = `folder-exit:${folderPath}`;
329
+
330
+ this.logger.trace(`buildFolder: ${folder.name} (enter=${folderEnterId}, exit=${folderExitId})`);
331
+
332
+ // Add folder-enter node (lifecycle: PUSH scope + beforeFolder event)
333
+ // ALWAYS executes regardless of script existence
334
+ this.addNode({
335
+ id: folderEnterId,
336
+ name: `${folder.name}-enter`,
337
+ type: 'folder-enter',
338
+ parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
339
+ condition: folder.condition,
340
+ path: folderPath,
341
+ item: folder
342
+ });
343
+
344
+ // Add folder-exit node (lifecycle: POP scope + afterFolder event)
345
+ // ALWAYS executes regardless of script existence
346
+ this.addNode({
347
+ id: folderExitId,
348
+ name: `${folder.name}-exit`,
349
+ type: 'folder-exit',
350
+ parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
351
+ path: folderPath,
352
+ item: folder
353
+ });
354
+
355
+ // Add folder-pre script node ONLY if script exists
356
+ if (!isNullOrWhitespace(folder.folderPreScript)) {
357
+ this.addNode({
358
+ id: folderPreId,
359
+ name: `${folder.name}-pre`,
360
+ type: 'script',
361
+ scriptType: 'folder-pre' as ScriptType,
362
+ script: folder.folderPreScript,
363
+ parentFolderId: folderPath,
364
+ path: folderPath,
365
+ item: folder
366
+ });
367
+ }
368
+
369
+ // Add folder-post script node ONLY if script exists
370
+ if (!isNullOrWhitespace(folder.folderPostScript)) {
371
+ this.addNode({
372
+ id: folderPostId,
373
+ name: `${folder.name}-post`,
374
+ type: 'script',
375
+ scriptType: 'folder-post' as ScriptType,
376
+ script: folder.folderPostScript,
377
+ parentFolderId: folderPath,
378
+ path: folderPath,
379
+ item: folder
380
+ });
381
+ }
382
+
383
+ // Structural edges: parent → folder-enter
384
+ this.addEdge(parentPreId, folderEnterId, 'structural');
385
+
386
+ // folder-enter → folder-pre (if pre-script exists)
387
+ if (!isNullOrWhitespace(folder.folderPreScript)) {
388
+ this.addEdge(folderEnterId, folderPreId, 'structural');
389
+ }
390
+
391
+ // folder-post → folder-exit (if post-script exists)
392
+ if (!isNullOrWhitespace(folder.folderPostScript)) {
393
+ this.addEdge(folderPostId, folderExitId, 'structural');
394
+ }
395
+
396
+ // folder-exit → parent
397
+ this.addEdge(folderExitId, parentPostId, 'structural');
398
+
399
+ // Register for dependsOn resolution
400
+ // Start node is folder-enter (lifecycle), completion is folder-exit (lifecycle)
401
+ this.startNodeByItemId.set(folder.id, folderEnterId);
402
+ this.completionNodeByItemId.set(folder.id, folderExitId);
403
+
404
+ const folderDeps = folder.dependsOn;
405
+ if (folderDeps !== undefined && folderDeps.length > 0) {
406
+ for (const depId of folderDeps) {
407
+ this.pendingDependsOn.push({ depId, targetItemId: folder.id });
408
+ }
409
+ }
410
+
411
+ // Build inherited scripts for children
412
+ const folderPreScripts = [...inheritedPreScripts];
413
+ if (!isNullOrWhitespace(folder.preRequestScript)) {
414
+ folderPreScripts.push(folder.preRequestScript!);
415
+ }
416
+
417
+ const folderPostScripts = [...inheritedPostScripts];
418
+ if (!isNullOrWhitespace(folder.postRequestScript)) {
419
+ // Use unshift for LIFO (inner runs before outer)
420
+ folderPostScripts.unshift(folder.postRequestScript!);
421
+ }
422
+
423
+ // Determine which node children connect to (enter or pre-script)
424
+ const childrenParentPreId = !isNullOrWhitespace(folder.folderPreScript) ? folderPreId : folderEnterId;
425
+ // Determine which node receives children completion (post-script or exit)
426
+ const childrenParentPostId = !isNullOrWhitespace(folder.folderPostScript) ? folderPostId : folderExitId;
427
+
428
+ // If condition is statically false, skip children
429
+ if (this.isConditionFalse(folder.condition)) {
430
+ // Direct edge: folder-enter → folder-exit (bypassing children and scripts)
431
+ this.addEdge(folderEnterId, folderExitId, 'structural');
432
+ return { startId: folderEnterId, endId: folderExitId };
433
+ }
434
+
435
+ // Compute effective auth (folder auth overrides parent)
436
+ const folderAuth = folder.auth ?? parentAuth;
437
+
438
+ // Build children (preserve declaration order)
439
+ for (const child of folder.items) {
440
+ this.buildItem(
441
+ child,
442
+ folderPath,
443
+ childrenParentPreId,
444
+ childrenParentPostId,
445
+ folderPreScripts,
446
+ folderPostScripts,
447
+ folderAuth // Pass folder's effective auth to children
448
+ );
449
+ }
450
+
451
+ // Barrier edge (ensures children completion point waits for all children)
452
+ this.addEdge(childrenParentPreId, childrenParentPostId, 'structural');
453
+
454
+ return { startId: folderEnterId, endId: folderExitId };
455
+ }
456
+
457
+ private buildRequest(
458
+ request: Request,
459
+ parentPath: PathType,
460
+ parentPreId: string,
461
+ parentPostId: string,
462
+ inheritedPreScripts: string[],
463
+ inheritedPostScripts: string[],
464
+ parentAuth?: Auth
465
+ ): { startId: string; endId: string } {
466
+ const requestPath = this.buildPath(parentPath, request.name, 'request');
467
+ const requestId = `request:${requestPath}`;
468
+
469
+ this.logger.trace(`buildRequest: ${request.name} (id=${requestId})`);
470
+
471
+ // Build final script arrays for this request
472
+ const requestPreScripts = [...inheritedPreScripts];
473
+ if (!isNullOrWhitespace(request.preRequestScript)) {
474
+ requestPreScripts.push(request.preRequestScript!);
475
+ }
476
+
477
+ const requestPostScripts = [...inheritedPostScripts];
478
+ if (!isNullOrWhitespace(request.postRequestScript)) {
479
+ // Use unshift for LIFO (request runs before inherited)
480
+ requestPostScripts.unshift(request.postRequestScript!);
481
+ }
482
+
483
+ // Compute effective auth (request auth overrides folder/collection auth)
484
+ const effectiveAuth = request.auth ?? parentAuth;
485
+
486
+ // Add request node
487
+ this.addNode({
488
+ id: requestId,
489
+ name: request.name,
490
+ type: 'request',
491
+ condition: request.condition,
492
+ inheritedPreScripts: requestPreScripts,
493
+ inheritedPostScripts: requestPostScripts,
494
+ effectiveAuth, // Store computed effectiveAuth for request execution
495
+ parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
496
+ path: requestPath,
497
+ item: request
498
+ });
499
+
500
+ // Structural edges
501
+ this.addEdge(parentPreId, requestId, 'structural');
502
+ this.addEdge(requestId, parentPostId, 'structural');
503
+
504
+ // Plugin event scripts are NOT DAG nodes - they execute via emitEvent() callback
505
+ // during plugin I/O phase. This ensures proper serialization and lifecycle.
506
+ // Plugin events fire DURING request execution (e.g., WebSocket onMessage), not as separate tasks.
507
+ // Removed: this.addEventScriptNodes(request, requestId, parentPostId, parentPath);
508
+
509
+ // Register for dependsOn resolution
510
+ this.startNodeByItemId.set(request.id, requestId);
511
+ this.completionNodeByItemId.set(request.id, requestId);
512
+
513
+ if (request.dependsOn !== undefined && request.dependsOn.length > 0) {
514
+ for (const depId of request.dependsOn) {
515
+ this.pendingDependsOn.push({ depId, targetItemId: request.id });
516
+ }
517
+ }
518
+
519
+ return { startId: requestId, endId: requestId };
520
+ }
521
+
522
+ private addEventScriptNodes(
523
+ request: Request,
524
+ requestId: string,
525
+ parentPostId: string,
526
+ parentPath: PathType
527
+ ): void {
528
+ const eventScripts = request.data?.scripts;
529
+ if (eventScripts === undefined || eventScripts.length === 0) {
530
+ return;
531
+ }
532
+
533
+ for (let i = 0; i < eventScripts.length; i++) {
534
+ const eventScript = eventScripts[i];
535
+ const eventNodeId = `script:plugin-event:${requestId}:${i}:${eventScript.event}`;
536
+
537
+ this.addNode({
538
+ id: eventNodeId,
539
+ name: `${request.name}-${eventScript.event}`,
540
+ type: 'script',
541
+ scriptType: 'plugin-event' as ScriptType,
542
+ script: eventScript.script,
543
+ parentFolderId: parentPath.startsWith('folder:/') ? parentPath : undefined,
544
+ path: `${requestId}:${eventScript.event}` as PathType,
545
+ eventName: eventScript.event,
546
+ parentRequestId: requestId
547
+ });
548
+
549
+ // Event edges: request → event → parent-post
550
+ this.addEdge(requestId, eventNodeId, 'event');
551
+ this.addEdge(eventNodeId, parentPostId, 'structural');
552
+ }
553
+ }
554
+
555
+ private resolveDependsOnEdges(): void {
556
+ for (const { depId, targetItemId } of this.pendingDependsOn) {
557
+ const completionNodeId = this.completionNodeByItemId.get(depId);
558
+ if (completionNodeId === undefined) {
559
+ // Dependency not found - this can happen when filtering with excludeDeps=true
560
+ // Skip this dependency edge instead of throwing
561
+ this.logger.warn(`Skipping dependency: Item '${targetItemId}' depends on '${depId}' which is not in the filtered collection`);
562
+ continue;
563
+ }
564
+
565
+ const targetStartNodeId = this.startNodeByItemId.get(targetItemId);
566
+ if (targetStartNodeId === undefined) {
567
+ throw new Error(`Internal error: No start node for item '${targetItemId}'`);
568
+ }
569
+
570
+ // Add edge: completionNode(dep) → startNode(target)
571
+ this.addEdge(completionNodeId, targetStartNodeId, 'dependsOn');
572
+ }
573
+ }
574
+
575
+
576
+ /**
577
+ * Build path with proper type prefix (folder: or request:)
578
+ * Similar to ExecutionNode path building
579
+ */
580
+ private buildPath(parent: string, name: string, type: 'folder' | 'request'): PathType {
581
+ // If parent is collection:/
582
+ if (parent === 'collection:/') {
583
+ return `${type}:/${name}` as PathType;
584
+ }
585
+
586
+ // Remove type prefix from parent path
587
+ const basePath = parent.replace(/^(folder|request):\//, '');
588
+ return `${type}:/${basePath}/${name}` as PathType;
589
+ }
590
+
591
+ private toArray(script: string | undefined): string[] {
592
+ return !isNullOrWhitespace(script) ? [script!] : [];
593
+ }
594
+
595
+ private isConditionFalse(condition: string | undefined): boolean {
596
+ return condition?.toLowerCase() === 'false';
597
+ }
598
+ }