@auto-engineer/pipeline 0.14.0 → 0.16.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 (316) hide show
  1. package/.turbo/turbo-build.log +5 -6
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +279 -0
  4. package/dist/src/builder/define.d.ts +6 -2
  5. package/dist/src/builder/define.d.ts.map +1 -1
  6. package/dist/src/builder/define.js +17 -7
  7. package/dist/src/builder/define.js.map +1 -1
  8. package/dist/src/core/descriptors.d.ts +6 -2
  9. package/dist/src/core/descriptors.d.ts.map +1 -1
  10. package/dist/src/graph/filter-graph.d.ts +3 -0
  11. package/dist/src/graph/filter-graph.d.ts.map +1 -0
  12. package/dist/src/graph/filter-graph.js +80 -0
  13. package/dist/src/graph/filter-graph.js.map +1 -0
  14. package/dist/src/graph/types.d.ts +8 -0
  15. package/dist/src/graph/types.d.ts.map +1 -1
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/projections/await-tracker-projection.d.ts +31 -0
  20. package/dist/src/projections/await-tracker-projection.d.ts.map +1 -0
  21. package/dist/src/projections/await-tracker-projection.js +35 -0
  22. package/dist/src/projections/await-tracker-projection.js.map +1 -0
  23. package/dist/src/projections/index.d.ts +4 -0
  24. package/dist/src/projections/index.d.ts.map +1 -0
  25. package/dist/src/projections/index.js +4 -0
  26. package/dist/src/projections/index.js.map +1 -0
  27. package/dist/src/projections/item-status-projection.d.ts +22 -0
  28. package/dist/src/projections/item-status-projection.d.ts.map +1 -0
  29. package/dist/src/projections/item-status-projection.js +11 -0
  30. package/dist/src/projections/item-status-projection.js.map +1 -0
  31. package/dist/src/projections/latest-run-projection.d.ts +15 -0
  32. package/dist/src/projections/latest-run-projection.d.ts.map +1 -0
  33. package/dist/src/projections/latest-run-projection.js +7 -0
  34. package/dist/src/projections/latest-run-projection.js.map +1 -0
  35. package/dist/src/projections/message-log-projection.d.ts +51 -0
  36. package/dist/src/projections/message-log-projection.d.ts.map +1 -0
  37. package/dist/src/projections/message-log-projection.js +51 -0
  38. package/dist/src/projections/message-log-projection.js.map +1 -0
  39. package/dist/src/projections/node-status-projection.d.ts +23 -0
  40. package/dist/src/projections/node-status-projection.d.ts.map +1 -0
  41. package/dist/src/projections/node-status-projection.js +10 -0
  42. package/dist/src/projections/node-status-projection.js.map +1 -0
  43. package/dist/src/projections/phased-execution-projection.d.ts +77 -0
  44. package/dist/src/projections/phased-execution-projection.d.ts.map +1 -0
  45. package/dist/src/projections/phased-execution-projection.js +54 -0
  46. package/dist/src/projections/phased-execution-projection.js.map +1 -0
  47. package/dist/src/projections/settled-instance-projection.d.ts +67 -0
  48. package/dist/src/projections/settled-instance-projection.d.ts.map +1 -0
  49. package/dist/src/projections/settled-instance-projection.js +66 -0
  50. package/dist/src/projections/settled-instance-projection.js.map +1 -0
  51. package/dist/src/projections/stats-projection.d.ts +9 -0
  52. package/dist/src/projections/stats-projection.d.ts.map +1 -0
  53. package/dist/src/projections/stats-projection.js +16 -0
  54. package/dist/src/projections/stats-projection.js.map +1 -0
  55. package/dist/src/runtime/await-tracker.d.ts +17 -7
  56. package/dist/src/runtime/await-tracker.d.ts.map +1 -1
  57. package/dist/src/runtime/await-tracker.js +32 -29
  58. package/dist/src/runtime/await-tracker.js.map +1 -1
  59. package/dist/src/runtime/context.d.ts +1 -1
  60. package/dist/src/runtime/context.d.ts.map +1 -1
  61. package/dist/src/runtime/event-command-map.d.ts +3 -3
  62. package/dist/src/runtime/event-command-map.d.ts.map +1 -1
  63. package/dist/src/runtime/event-command-map.js +6 -2
  64. package/dist/src/runtime/event-command-map.js.map +1 -1
  65. package/dist/src/runtime/phased-executor.d.ts +14 -9
  66. package/dist/src/runtime/phased-executor.d.ts.map +1 -1
  67. package/dist/src/runtime/phased-executor.js +113 -105
  68. package/dist/src/runtime/phased-executor.js.map +1 -1
  69. package/dist/src/runtime/pipeline-runtime.d.ts.map +1 -1
  70. package/dist/src/runtime/pipeline-runtime.js +2 -2
  71. package/dist/src/runtime/pipeline-runtime.js.map +1 -1
  72. package/dist/src/runtime/settled-tracker.d.ts +12 -10
  73. package/dist/src/runtime/settled-tracker.d.ts.map +1 -1
  74. package/dist/src/runtime/settled-tracker.js +89 -80
  75. package/dist/src/runtime/settled-tracker.js.map +1 -1
  76. package/dist/src/server/pipeline-server.d.ts +31 -9
  77. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  78. package/dist/src/server/pipeline-server.js +424 -123
  79. package/dist/src/server/pipeline-server.js.map +1 -1
  80. package/dist/src/server/sse-manager.d.ts +0 -1
  81. package/dist/src/server/sse-manager.d.ts.map +1 -1
  82. package/dist/src/server/sse-manager.js +0 -3
  83. package/dist/src/server/sse-manager.js.map +1 -1
  84. package/dist/src/store/index.d.ts +3 -0
  85. package/dist/src/store/index.d.ts.map +1 -0
  86. package/dist/src/store/index.js +3 -0
  87. package/dist/src/store/index.js.map +1 -0
  88. package/dist/src/store/pipeline-event-store.d.ts +10 -0
  89. package/dist/src/store/pipeline-event-store.d.ts.map +1 -0
  90. package/dist/src/store/pipeline-event-store.js +112 -0
  91. package/dist/src/store/pipeline-event-store.js.map +1 -0
  92. package/dist/src/store/pipeline-read-model.d.ts +49 -0
  93. package/dist/src/store/pipeline-read-model.d.ts.map +1 -0
  94. package/dist/src/store/pipeline-read-model.js +156 -0
  95. package/dist/src/store/pipeline-read-model.js.map +1 -0
  96. package/dist/src/store/sqlite-pipeline-event-store.d.ts +14 -0
  97. package/dist/src/store/sqlite-pipeline-event-store.d.ts.map +1 -0
  98. package/dist/src/store/sqlite-pipeline-event-store.js +20 -0
  99. package/dist/src/store/sqlite-pipeline-event-store.js.map +1 -0
  100. package/dist/src/testing/fixtures/kanban-full.pipeline.js +2 -2
  101. package/dist/src/testing/fixtures/kanban-full.pipeline.js.map +1 -1
  102. package/dist/src/testing/fixtures/kanban.pipeline.js +2 -2
  103. package/dist/src/testing/fixtures/kanban.pipeline.js.map +1 -1
  104. package/dist/tsconfig.tsbuildinfo +1 -1
  105. package/ketchup-plan.md +1216 -0
  106. package/package.json +6 -4
  107. package/src/builder/define.specs.ts +5 -4
  108. package/src/builder/define.ts +24 -11
  109. package/src/config/pipeline-config.specs.ts +32 -0
  110. package/src/core/descriptors.ts +7 -2
  111. package/src/graph/filter-graph.specs.ts +267 -0
  112. package/src/graph/filter-graph.ts +111 -0
  113. package/src/graph/types.specs.ts +0 -14
  114. package/src/graph/types.ts +10 -0
  115. package/src/index.ts +1 -0
  116. package/src/projections/await-tracker-projection.specs.ts +24 -0
  117. package/src/projections/await-tracker-projection.ts +68 -0
  118. package/src/projections/index.ts +11 -0
  119. package/src/projections/item-status-projection.specs.ts +130 -0
  120. package/src/projections/item-status-projection.ts +32 -0
  121. package/src/projections/latest-run-projection.ts +20 -0
  122. package/src/projections/message-log-projection.ts +113 -0
  123. package/src/projections/node-status-projection.ts +33 -0
  124. package/src/projections/phased-execution-projection.specs.ts +202 -0
  125. package/src/projections/phased-execution-projection.ts +146 -0
  126. package/src/projections/settled-instance-projection.specs.ts +296 -0
  127. package/src/projections/settled-instance-projection.ts +160 -0
  128. package/src/projections/stats-projection.ts +26 -0
  129. package/src/runtime/await-tracker.specs.ts +57 -34
  130. package/src/runtime/await-tracker.ts +43 -31
  131. package/src/runtime/context.ts +1 -1
  132. package/src/runtime/event-command-map.ts +11 -4
  133. package/src/runtime/phased-executor.specs.ts +357 -81
  134. package/src/runtime/phased-executor.ts +134 -128
  135. package/src/runtime/pipeline-runtime.specs.ts +65 -0
  136. package/src/runtime/pipeline-runtime.ts +6 -4
  137. package/src/runtime/settled-tracker.specs.ts +716 -120
  138. package/src/runtime/settled-tracker.ts +100 -102
  139. package/src/server/pipeline-server.e2e.specs.ts +10 -16
  140. package/src/server/pipeline-server.specs.ts +1441 -211
  141. package/src/server/pipeline-server.ts +535 -144
  142. package/src/server/sse-manager.specs.ts +67 -36
  143. package/src/server/sse-manager.ts +0 -4
  144. package/src/store/index.ts +2 -0
  145. package/src/store/pipeline-event-store.specs.ts +357 -0
  146. package/src/store/pipeline-event-store.ts +156 -0
  147. package/src/store/pipeline-read-model.specs.ts +1170 -0
  148. package/src/store/pipeline-read-model.ts +223 -0
  149. package/src/store/sqlite-pipeline-event-store.specs.ts +13 -0
  150. package/src/store/sqlite-pipeline-event-store.ts +36 -0
  151. package/src/testing/fixtures/kanban-full.pipeline.ts +2 -2
  152. package/src/testing/fixtures/kanban.pipeline.ts +2 -2
  153. package/tsconfig.json +1 -1
  154. package/vitest.config.ts +1 -8
  155. package/claude.md +0 -160
  156. package/dist/src/__tests__/e2e/helpers.d.ts +0 -48
  157. package/dist/src/__tests__/e2e/helpers.d.ts.map +0 -1
  158. package/dist/src/__tests__/e2e/helpers.js +0 -253
  159. package/dist/src/__tests__/e2e/helpers.js.map +0 -1
  160. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.d.ts +0 -2
  161. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.d.ts.map +0 -1
  162. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.js +0 -195
  163. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.js.map +0 -1
  164. package/dist/src/__tests__/e2e/types.d.ts +0 -107
  165. package/dist/src/__tests__/e2e/types.d.ts.map +0 -1
  166. package/dist/src/__tests__/e2e/types.js +0 -2
  167. package/dist/src/__tests__/e2e/types.js.map +0 -1
  168. package/dist/src/builder/define.specs.d.ts +0 -2
  169. package/dist/src/builder/define.specs.d.ts.map +0 -1
  170. package/dist/src/builder/define.specs.js +0 -435
  171. package/dist/src/builder/define.specs.js.map +0 -1
  172. package/dist/src/core/descriptors.specs.d.ts +0 -2
  173. package/dist/src/core/descriptors.specs.d.ts.map +0 -1
  174. package/dist/src/core/descriptors.specs.js +0 -24
  175. package/dist/src/core/descriptors.specs.js.map +0 -1
  176. package/dist/src/core/types.specs.d.ts +0 -2
  177. package/dist/src/core/types.specs.d.ts.map +0 -1
  178. package/dist/src/core/types.specs.js +0 -40
  179. package/dist/src/core/types.specs.js.map +0 -1
  180. package/dist/src/file-syncer/crypto/jwe-encryptor.d.ts +0 -15
  181. package/dist/src/file-syncer/crypto/jwe-encryptor.d.ts.map +0 -1
  182. package/dist/src/file-syncer/crypto/jwe-encryptor.js +0 -64
  183. package/dist/src/file-syncer/crypto/jwe-encryptor.js.map +0 -1
  184. package/dist/src/file-syncer/crypto/provider-resolver.d.ts +0 -24
  185. package/dist/src/file-syncer/crypto/provider-resolver.d.ts.map +0 -1
  186. package/dist/src/file-syncer/crypto/provider-resolver.js +0 -71
  187. package/dist/src/file-syncer/crypto/provider-resolver.js.map +0 -1
  188. package/dist/src/file-syncer/discovery/bareImports.d.ts +0 -3
  189. package/dist/src/file-syncer/discovery/bareImports.d.ts.map +0 -1
  190. package/dist/src/file-syncer/discovery/bareImports.js +0 -36
  191. package/dist/src/file-syncer/discovery/bareImports.js.map +0 -1
  192. package/dist/src/file-syncer/discovery/dts.d.ts +0 -8
  193. package/dist/src/file-syncer/discovery/dts.d.ts.map +0 -1
  194. package/dist/src/file-syncer/discovery/dts.js +0 -99
  195. package/dist/src/file-syncer/discovery/dts.js.map +0 -1
  196. package/dist/src/file-syncer/index.d.ts +0 -46
  197. package/dist/src/file-syncer/index.d.ts.map +0 -1
  198. package/dist/src/file-syncer/index.js +0 -392
  199. package/dist/src/file-syncer/index.js.map +0 -1
  200. package/dist/src/file-syncer/sync/resolveSyncFileSet.d.ts +0 -7
  201. package/dist/src/file-syncer/sync/resolveSyncFileSet.d.ts.map +0 -1
  202. package/dist/src/file-syncer/sync/resolveSyncFileSet.js +0 -86
  203. package/dist/src/file-syncer/sync/resolveSyncFileSet.js.map +0 -1
  204. package/dist/src/file-syncer/types/wire.d.ts +0 -14
  205. package/dist/src/file-syncer/types/wire.d.ts.map +0 -1
  206. package/dist/src/file-syncer/types/wire.js +0 -2
  207. package/dist/src/file-syncer/types/wire.js.map +0 -1
  208. package/dist/src/file-syncer/utils/hash.d.ts +0 -5
  209. package/dist/src/file-syncer/utils/hash.d.ts.map +0 -1
  210. package/dist/src/file-syncer/utils/hash.js +0 -19
  211. package/dist/src/file-syncer/utils/hash.js.map +0 -1
  212. package/dist/src/file-syncer/utils/path.d.ts +0 -13
  213. package/dist/src/file-syncer/utils/path.d.ts.map +0 -1
  214. package/dist/src/file-syncer/utils/path.js +0 -74
  215. package/dist/src/file-syncer/utils/path.js.map +0 -1
  216. package/dist/src/graph/types.specs.d.ts +0 -2
  217. package/dist/src/graph/types.specs.d.ts.map +0 -1
  218. package/dist/src/graph/types.specs.js +0 -148
  219. package/dist/src/graph/types.specs.js.map +0 -1
  220. package/dist/src/logging/event-logger.specs.d.ts +0 -2
  221. package/dist/src/logging/event-logger.specs.d.ts.map +0 -1
  222. package/dist/src/logging/event-logger.specs.js +0 -81
  223. package/dist/src/logging/event-logger.specs.js.map +0 -1
  224. package/dist/src/plugins/handler-adapter.specs.d.ts +0 -2
  225. package/dist/src/plugins/handler-adapter.specs.d.ts.map +0 -1
  226. package/dist/src/plugins/handler-adapter.specs.js +0 -129
  227. package/dist/src/plugins/handler-adapter.specs.js.map +0 -1
  228. package/dist/src/plugins/plugin-loader.specs.d.ts +0 -2
  229. package/dist/src/plugins/plugin-loader.specs.d.ts.map +0 -1
  230. package/dist/src/plugins/plugin-loader.specs.js +0 -246
  231. package/dist/src/plugins/plugin-loader.specs.js.map +0 -1
  232. package/dist/src/runtime/await-tracker.specs.d.ts +0 -2
  233. package/dist/src/runtime/await-tracker.specs.d.ts.map +0 -1
  234. package/dist/src/runtime/await-tracker.specs.js +0 -46
  235. package/dist/src/runtime/await-tracker.specs.js.map +0 -1
  236. package/dist/src/runtime/context.specs.d.ts +0 -2
  237. package/dist/src/runtime/context.specs.d.ts.map +0 -1
  238. package/dist/src/runtime/context.specs.js +0 -26
  239. package/dist/src/runtime/context.specs.js.map +0 -1
  240. package/dist/src/runtime/event-command-map.specs.d.ts +0 -2
  241. package/dist/src/runtime/event-command-map.specs.d.ts.map +0 -1
  242. package/dist/src/runtime/event-command-map.specs.js +0 -108
  243. package/dist/src/runtime/event-command-map.specs.js.map +0 -1
  244. package/dist/src/runtime/phased-executor.specs.d.ts +0 -2
  245. package/dist/src/runtime/phased-executor.specs.d.ts.map +0 -1
  246. package/dist/src/runtime/phased-executor.specs.js +0 -256
  247. package/dist/src/runtime/phased-executor.specs.js.map +0 -1
  248. package/dist/src/runtime/pipeline-runtime.specs.d.ts +0 -2
  249. package/dist/src/runtime/pipeline-runtime.specs.d.ts.map +0 -1
  250. package/dist/src/runtime/pipeline-runtime.specs.js +0 -192
  251. package/dist/src/runtime/pipeline-runtime.specs.js.map +0 -1
  252. package/dist/src/runtime/settled-tracker.specs.d.ts +0 -2
  253. package/dist/src/runtime/settled-tracker.specs.d.ts.map +0 -1
  254. package/dist/src/runtime/settled-tracker.specs.js +0 -361
  255. package/dist/src/runtime/settled-tracker.specs.js.map +0 -1
  256. package/dist/src/server/full-orchestration.e2e.specs.d.ts +0 -2
  257. package/dist/src/server/full-orchestration.e2e.specs.d.ts.map +0 -1
  258. package/dist/src/server/full-orchestration.e2e.specs.js +0 -561
  259. package/dist/src/server/full-orchestration.e2e.specs.js.map +0 -1
  260. package/dist/src/server/pipeline-server.e2e.specs.d.ts +0 -2
  261. package/dist/src/server/pipeline-server.e2e.specs.d.ts.map +0 -1
  262. package/dist/src/server/pipeline-server.e2e.specs.js +0 -381
  263. package/dist/src/server/pipeline-server.e2e.specs.js.map +0 -1
  264. package/dist/src/server/pipeline-server.specs.d.ts +0 -2
  265. package/dist/src/server/pipeline-server.specs.d.ts.map +0 -1
  266. package/dist/src/server/pipeline-server.specs.js +0 -662
  267. package/dist/src/server/pipeline-server.specs.js.map +0 -1
  268. package/dist/src/server/sse-manager.specs.d.ts +0 -2
  269. package/dist/src/server/sse-manager.specs.d.ts.map +0 -1
  270. package/dist/src/server/sse-manager.specs.js +0 -158
  271. package/dist/src/server/sse-manager.specs.js.map +0 -1
  272. package/dist/src/testing/event-capture.specs.d.ts +0 -2
  273. package/dist/src/testing/event-capture.specs.d.ts.map +0 -1
  274. package/dist/src/testing/event-capture.specs.js +0 -114
  275. package/dist/src/testing/event-capture.specs.js.map +0 -1
  276. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.d.ts +0 -2
  277. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.d.ts.map +0 -1
  278. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.js +0 -263
  279. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.js.map +0 -1
  280. package/dist/src/testing/fixtures/kanban.pipeline.specs.d.ts +0 -2
  281. package/dist/src/testing/fixtures/kanban.pipeline.specs.d.ts.map +0 -1
  282. package/dist/src/testing/fixtures/kanban.pipeline.specs.js +0 -29
  283. package/dist/src/testing/fixtures/kanban.pipeline.specs.js.map +0 -1
  284. package/dist/src/testing/kanban-todo.e2e.specs.d.ts +0 -2
  285. package/dist/src/testing/kanban-todo.e2e.specs.d.ts.map +0 -1
  286. package/dist/src/testing/kanban-todo.e2e.specs.js +0 -160
  287. package/dist/src/testing/kanban-todo.e2e.specs.js.map +0 -1
  288. package/dist/src/testing/mock-handlers.specs.d.ts +0 -2
  289. package/dist/src/testing/mock-handlers.specs.d.ts.map +0 -1
  290. package/dist/src/testing/mock-handlers.specs.js +0 -193
  291. package/dist/src/testing/mock-handlers.specs.js.map +0 -1
  292. package/dist/src/testing/real-execution.e2e.specs.d.ts +0 -2
  293. package/dist/src/testing/real-execution.e2e.specs.d.ts.map +0 -1
  294. package/dist/src/testing/real-execution.e2e.specs.js +0 -140
  295. package/dist/src/testing/real-execution.e2e.specs.js.map +0 -1
  296. package/dist/src/testing/real-plugin.e2e.specs.d.ts +0 -2
  297. package/dist/src/testing/real-plugin.e2e.specs.d.ts.map +0 -1
  298. package/dist/src/testing/real-plugin.e2e.specs.js +0 -65
  299. package/dist/src/testing/real-plugin.e2e.specs.js.map +0 -1
  300. package/dist/src/testing/server-startup.e2e.specs.d.ts +0 -2
  301. package/dist/src/testing/server-startup.e2e.specs.d.ts.map +0 -1
  302. package/dist/src/testing/server-startup.e2e.specs.js +0 -104
  303. package/dist/src/testing/server-startup.e2e.specs.js.map +0 -1
  304. package/dist/src/testing/snapshot-compare.specs.d.ts +0 -2
  305. package/dist/src/testing/snapshot-compare.specs.d.ts.map +0 -1
  306. package/dist/src/testing/snapshot-compare.specs.js +0 -112
  307. package/dist/src/testing/snapshot-compare.specs.js.map +0 -1
  308. package/dist/src/testing/snapshot-sanitize.specs.d.ts +0 -2
  309. package/dist/src/testing/snapshot-sanitize.specs.d.ts.map +0 -1
  310. package/dist/src/testing/snapshot-sanitize.specs.js +0 -104
  311. package/dist/src/testing/snapshot-sanitize.specs.js.map +0 -1
  312. package/docs/testing-analysis.md +0 -395
  313. package/pomodoro-plan.md +0 -651
  314. package/src/core/descriptors.specs.ts +0 -28
  315. package/src/core/types.specs.ts +0 -44
  316. package/src/runtime/context.specs.ts +0 -28
@@ -25,20 +25,15 @@ interface GraphNode {
25
25
  id: string;
26
26
  type: string;
27
27
  label: string;
28
- }
29
-
30
- interface PipelineNode {
31
- id: string;
32
- name: string;
33
- title: string;
34
- status: string;
28
+ status?: string;
29
+ pendingCount?: number;
30
+ endedCount?: number;
35
31
  }
36
32
 
37
33
  interface PipelineResponse {
38
- nodes: PipelineNode[];
39
- edges: Array<{ from: string; to: string }>;
40
- commandToEvents: Record<string, string[]>;
41
- eventToCommand: Record<string, string>;
34
+ nodes: GraphNode[];
35
+ edges: Array<{ from: string; to: string; backLink?: boolean }>;
36
+ latestRun?: string;
42
37
  }
43
38
 
44
39
  interface GraphResponse {
@@ -162,6 +157,28 @@ describe('PipelineServer', () => {
162
157
  expect(data.folds).toEqual([]);
163
158
  await server.stop();
164
159
  });
160
+
161
+ it('should exclude settled handlers from eventHandlers list', async () => {
162
+ const handler = {
163
+ name: 'CheckTests',
164
+ events: ['TestsPassed'],
165
+ handle: async () => ({ type: 'TestsPassed', data: {} }),
166
+ };
167
+ const pipeline = define('test')
168
+ .on('Start')
169
+ .emit('CheckTests', {})
170
+ .settled(['CheckTests'])
171
+ .dispatch({ dispatches: [] }, () => {})
172
+ .build();
173
+ const server = new PipelineServer({ port: 0 });
174
+ server.registerCommandHandlers([handler]);
175
+ server.registerPipeline(pipeline);
176
+ await server.start();
177
+ const data = await fetchAs<RegistryResponse>(`http://localhost:${server.port}/registry`);
178
+ expect(data.eventHandlers).toContain('Start');
179
+ expect(data.eventHandlers).not.toContain('settled:CheckTests');
180
+ await server.stop();
181
+ });
165
182
  });
166
183
 
167
184
  describe('GET /pipeline', () => {
@@ -175,234 +192,773 @@ describe('PipelineServer', () => {
175
192
  await server.stop();
176
193
  });
177
194
 
178
- it('should return pipeline response with commandToEvents', async () => {
195
+ it('should use displayName as label for command graph nodes', async () => {
179
196
  const handler = {
180
- name: 'Gen',
181
- events: ['GenDone', 'GenProgress'],
182
- handle: async () => ({ type: 'GenDone', data: {} }),
197
+ name: 'Cmd',
198
+ displayName: 'My Command',
199
+ handle: async () => ({ type: 'Done', data: {} }),
183
200
  };
201
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
184
202
  const server = new PipelineServer({ port: 0 });
185
203
  server.registerCommandHandlers([handler]);
204
+ server.registerPipeline(pipeline);
186
205
  await server.start();
187
- const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
188
- expect(data.commandToEvents).toEqual({ Gen: ['GenDone', 'GenProgress'] });
206
+ const data = await fetchAs<GraphResponse>(`http://localhost:${server.port}/pipeline`);
207
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:Cmd');
208
+ expect(cmdNode?.label).toBe('My Command');
209
+ await server.stop();
210
+ });
211
+
212
+ it('should use command name as graph node label when displayName not provided', async () => {
213
+ const handler = {
214
+ name: 'SimpleCmd',
215
+ handle: async () => ({ type: 'Done', data: {} }),
216
+ };
217
+ const pipeline = define('test').on('Start').emit('SimpleCmd', {}).build();
218
+ const server = new PipelineServer({ port: 0 });
219
+ server.registerCommandHandlers([handler]);
220
+ server.registerPipeline(pipeline);
221
+ await server.start();
222
+ const data = await fetchAs<GraphResponse>(`http://localhost:${server.port}/pipeline`);
223
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SimpleCmd');
224
+ expect(cmdNode?.label).toBe('SimpleCmd');
225
+ await server.stop();
226
+ });
227
+
228
+ it('should filter out event nodes when excludeTypes=event', async () => {
229
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
230
+ const server = new PipelineServer({ port: 0 });
231
+ server.registerPipeline(pipeline);
232
+ await server.start();
233
+ const data = await fetchAs<GraphResponse>(`http://localhost:${server.port}/pipeline?excludeTypes=event`);
234
+ expect(data.nodes.every((n) => n.type !== 'event')).toBe(true);
189
235
  await server.stop();
190
236
  });
191
237
 
192
- it('should return pipeline response with eventToCommand', async () => {
238
+ it('should reconnect edges when maintainEdges=true and filter commands', async () => {
193
239
  const pipeline = define('test').on('Start').emit('Process', {}).build();
194
240
  const server = new PipelineServer({ port: 0 });
195
241
  server.registerPipeline(pipeline);
196
242
  await server.start();
197
- const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
198
- expect(data.eventToCommand).toEqual({ Start: 'Process' });
243
+ const data = await fetchAs<GraphResponse>(
244
+ `http://localhost:${server.port}/pipeline?excludeTypes=command&maintainEdges=true`,
245
+ );
246
+ expect(data.nodes.every((n) => n.type !== 'command')).toBe(true);
247
+ expect(data.edges).toHaveLength(0);
248
+ await server.stop();
249
+ });
250
+
251
+ it('should filter multiple node types', async () => {
252
+ const pipeline = define('test')
253
+ .on('Start')
254
+ .emit('CheckA', {})
255
+ .emit('CheckB', {})
256
+ .settled(['CheckA', 'CheckB'])
257
+ .dispatch({ dispatches: [] }, () => {})
258
+ .build();
259
+ const server = new PipelineServer({ port: 0 });
260
+ server.registerPipeline(pipeline);
261
+ await server.start();
262
+ const data = await fetchAs<GraphResponse>(`http://localhost:${server.port}/pipeline?excludeTypes=event,settled`);
263
+ expect(data.nodes.every((n) => n.type !== 'event' && n.type !== 'settled')).toBe(true);
264
+ await server.stop();
265
+ });
266
+
267
+ it('should reconnect commands through events when filtering events with maintainEdges=true', async () => {
268
+ const generateHandler = {
269
+ name: 'Generate',
270
+ events: ['Generated'],
271
+ handle: async () => ({ type: 'Generated', data: {} }),
272
+ };
273
+ const pipeline = define('test').on('Start').emit('Generate', {}).on('Generated').emit('Process', {}).build();
274
+ const server = new PipelineServer({ port: 0 });
275
+ server.registerCommandHandlers([generateHandler]);
276
+ server.registerPipeline(pipeline);
277
+ await server.start();
278
+ const data = await fetchAs<GraphResponse>(
279
+ `http://localhost:${server.port}/pipeline?excludeTypes=event&maintainEdges=true`,
280
+ );
281
+ expect(data.nodes.every((n) => n.type !== 'event')).toBe(true);
282
+ expect(data.edges.some((e) => e.from === 'cmd:Generate' && e.to === 'cmd:Process')).toBe(true);
199
283
  await server.stop();
200
284
  });
201
285
 
202
- it('should return pipeline nodes with name, title, and status', async () => {
286
+ it('should have status idle on command nodes by default', async () => {
203
287
  const handler = {
204
288
  name: 'Cmd',
205
- alias: 'cmd',
206
- description: 'Test command',
289
+ events: ['Done'],
207
290
  handle: async () => ({ type: 'Done', data: {} }),
208
291
  };
292
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
209
293
  const server = new PipelineServer({ port: 0 });
210
294
  server.registerCommandHandlers([handler]);
295
+ server.registerPipeline(pipeline);
211
296
  await server.start();
212
297
  const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
213
- const cmdNode = data.nodes.find((n) => n.id === 'Cmd');
214
- expect(cmdNode).toBeDefined();
215
- expect(cmdNode?.name).toBe('Cmd');
216
- expect(cmdNode?.title).toBe('Test command');
217
- expect(cmdNode?.status).toBe('None');
298
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:Cmd');
299
+ expect(cmdNode?.status).toBe('idle');
218
300
  await server.stop();
219
301
  });
220
- });
221
302
 
222
- describe('POST /command', () => {
223
- it('should accept command', async () => {
303
+ it('should not have status on event nodes', async () => {
224
304
  const handler = {
225
305
  name: 'Cmd',
306
+ events: ['Done'],
226
307
  handle: async () => ({ type: 'Done', data: {} }),
227
308
  };
309
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
228
310
  const server = new PipelineServer({ port: 0 });
229
311
  server.registerCommandHandlers([handler]);
312
+ server.registerPipeline(pipeline);
230
313
  await server.start();
231
- const data = await fetchAs<CommandResponse>(`http://localhost:${server.port}/command`, {
232
- method: 'POST',
233
- headers: { 'Content-Type': 'application/json' },
234
- body: JSON.stringify({ type: 'Cmd', data: {} }),
235
- });
236
- expect(data.status).toBe('ack');
314
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
315
+ const eventNode = data.nodes.find((n) => n.id === 'evt:Start');
316
+ expect(eventNode?.status).toBeUndefined();
237
317
  await server.stop();
238
318
  });
239
319
 
240
- it('should return 404 for unknown command', async () => {
320
+ it('should have idle status on settled nodes when no correlationId provided', async () => {
321
+ const handler = {
322
+ name: 'CheckTests',
323
+ events: ['TestsPassed'],
324
+ handle: async () => ({ type: 'TestsPassed', data: {} }),
325
+ };
326
+ const pipeline = define('test')
327
+ .on('Start')
328
+ .emit('CheckTests', {})
329
+ .settled(['CheckTests'])
330
+ .dispatch({ dispatches: [] }, () => {})
331
+ .build();
241
332
  const server = new PipelineServer({ port: 0 });
333
+ server.registerCommandHandlers([handler]);
334
+ server.registerPipeline(pipeline);
242
335
  await server.start();
243
- const res = await fetchWithStatus(`http://localhost:${server.port}/command`, {
244
- method: 'POST',
245
- headers: { 'Content-Type': 'application/json' },
246
- body: JSON.stringify({ type: 'UnknownCmd', data: {} }),
247
- });
248
- expect(res.status).toBe(404);
249
- const data = await res.json<CommandResponse>();
250
- expect(data.status).toBe('nack');
336
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
337
+ const settledNode = data.nodes.find((n) => n.id === 'settled:CheckTests');
338
+ expect(settledNode?.status).toBe('idle');
339
+ expect(settledNode?.pendingCount).toBe(0);
340
+ expect(settledNode?.endedCount).toBe(0);
251
341
  await server.stop();
252
342
  });
253
343
 
254
- it('should handle command that returns multiple events', async () => {
344
+ it('should have status from computeSettledStats on settled nodes when correlationId provided', async () => {
255
345
  const handler = {
256
- name: 'Multi',
257
- handle: async () => [
258
- { type: 'EventA', data: { a: 1 } },
259
- { type: 'EventB', data: { b: 2 } },
260
- ],
346
+ name: 'CheckTests',
347
+ events: ['TestsPassed'],
348
+ handle: async () => ({ type: 'TestsPassed', data: {} }),
261
349
  };
350
+ const pipeline = define('test')
351
+ .on('Start')
352
+ .emit('CheckTests', {})
353
+ .settled(['CheckTests'])
354
+ .dispatch({ dispatches: [] }, () => {})
355
+ .build();
262
356
  const server = new PipelineServer({ port: 0 });
263
357
  server.registerCommandHandlers([handler]);
358
+ server.registerPipeline(pipeline);
264
359
  await server.start();
265
- await fetch(`http://localhost:${server.port}/command`, {
360
+
361
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
266
362
  method: 'POST',
267
363
  headers: { 'Content-Type': 'application/json' },
268
- body: JSON.stringify({ type: 'Multi', data: {} }),
364
+ body: JSON.stringify({ type: 'CheckTests', data: {} }),
269
365
  });
366
+
270
367
  await new Promise((r) => setTimeout(r, 100));
271
- const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
272
- expect(msgs.some((m) => m.message.type === 'EventA')).toBe(true);
273
- expect(msgs.some((m) => m.message.type === 'EventB')).toBe(true);
274
- await server.stop();
275
- });
276
- });
277
368
 
278
- describe('GET /messages', () => {
279
- it('should return messages', async () => {
280
- const server = new PipelineServer({ port: 0 });
281
- await server.start();
282
- const data = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
283
- expect(Array.isArray(data)).toBe(true);
369
+ const data = await fetchAs<PipelineResponse>(
370
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
371
+ );
372
+ const settledNode = data.nodes.find((n) => n.id === 'settled:CheckTests');
373
+ expect(settledNode?.status).toBeDefined();
374
+ expect(settledNode?.pendingCount).toBeDefined();
375
+ expect(settledNode?.endedCount).toBeDefined();
284
376
  await server.stop();
285
377
  });
286
- });
287
378
 
288
- describe('GET /stats', () => {
289
- it('should return stats', async () => {
379
+ it('should show running status for command being executed', async () => {
380
+ let resolveHandler: () => void = () => {};
381
+ const handlerPromise = new Promise<void>((resolve) => {
382
+ resolveHandler = resolve;
383
+ });
384
+ const handler = {
385
+ name: 'SlowCmd',
386
+ events: ['Done'],
387
+ handle: async () => {
388
+ await handlerPromise;
389
+ return { type: 'Done', data: {} };
390
+ },
391
+ };
392
+ const pipeline = define('test').on('Start').emit('SlowCmd', {}).build();
290
393
  const server = new PipelineServer({ port: 0 });
394
+ server.registerCommandHandlers([handler]);
395
+ server.registerPipeline(pipeline);
291
396
  await server.start();
292
- const data = await fetchAs<StatsResponse>(`http://localhost:${server.port}/stats`);
293
- expect(data.totalMessages).toBeDefined();
397
+
398
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
399
+ method: 'POST',
400
+ headers: { 'Content-Type': 'application/json' },
401
+ body: JSON.stringify({ type: 'SlowCmd', data: {} }),
402
+ });
403
+
404
+ await new Promise((r) => setTimeout(r, 50));
405
+
406
+ const data = await fetchAs<PipelineResponse>(
407
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
408
+ );
409
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SlowCmd');
410
+ expect(cmdNode?.status).toBe('running');
411
+
412
+ resolveHandler();
294
413
  await server.stop();
295
414
  });
296
- });
297
415
 
298
- describe('GET /sessions', () => {
299
- it('should return sessions', async () => {
416
+ it('should show success status after command completes with success event', async () => {
417
+ const handler = {
418
+ name: 'SuccessCmd',
419
+ events: ['CmdCompleted'],
420
+ handle: async () => ({ type: 'CmdCompleted', data: {} }),
421
+ };
422
+ const pipeline = define('test').on('Start').emit('SuccessCmd', {}).build();
300
423
  const server = new PipelineServer({ port: 0 });
424
+ server.registerCommandHandlers([handler]);
425
+ server.registerPipeline(pipeline);
301
426
  await server.start();
302
- const data = await fetchAs<unknown[]>(`http://localhost:${server.port}/sessions`);
303
- expect(Array.isArray(data)).toBe(true);
427
+
428
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
429
+ method: 'POST',
430
+ headers: { 'Content-Type': 'application/json' },
431
+ body: JSON.stringify({ type: 'SuccessCmd', data: {} }),
432
+ });
433
+
434
+ await new Promise((r) => setTimeout(r, 100));
435
+
436
+ const data = await fetchAs<PipelineResponse>(
437
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
438
+ );
439
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SuccessCmd');
440
+ expect(cmdNode?.status).toBe('success');
304
441
  await server.stop();
305
442
  });
306
- });
307
443
 
308
- describe('event routing', () => {
309
- it('should route events through pipeline', async () => {
444
+ it('should show error status after command completes with failed event', async () => {
310
445
  const handler = {
311
- name: 'Init',
312
- handle: async () => ({ type: 'Ready', data: {} }),
446
+ name: 'FailCmd',
447
+ events: ['CmdFailed'],
448
+ handle: async () => ({ type: 'CmdFailed', data: { error: 'Something went wrong' } }),
313
449
  };
314
- const pipeline = define('test').on('Ready').emit('Next', {}).build();
450
+ const pipeline = define('test').on('Start').emit('FailCmd', {}).build();
315
451
  const server = new PipelineServer({ port: 0 });
316
452
  server.registerCommandHandlers([handler]);
317
453
  server.registerPipeline(pipeline);
318
454
  await server.start();
319
- await fetch(`http://localhost:${server.port}/command`, {
455
+
456
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
320
457
  method: 'POST',
321
458
  headers: { 'Content-Type': 'application/json' },
322
- body: JSON.stringify({ type: 'Init', data: {} }),
459
+ body: JSON.stringify({ type: 'FailCmd', data: {} }),
323
460
  });
461
+
324
462
  await new Promise((r) => setTimeout(r, 100));
325
- const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
326
- expect(msgs.some((m) => m.message.type === 'Next')).toBe(true);
463
+
464
+ const data = await fetchAs<PipelineResponse>(
465
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
466
+ );
467
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:FailCmd');
468
+ expect(cmdNode?.status).toBe('error');
327
469
  await server.stop();
328
470
  });
329
471
 
330
- it('should handle custom handler that emits events', async () => {
472
+ it('should broadcast PipelineRunStarted event when new correlationId is first seen', async () => {
331
473
  const handler = {
332
- name: 'Start',
474
+ name: 'StartCmd',
475
+ events: ['Started'],
333
476
  handle: async () => ({ type: 'Started', data: {} }),
334
477
  };
335
- const pipeline = define('test')
336
- .on('Started')
337
- .handle(async (_e, ctx) => {
338
- await ctx.emit('CustomEvent', { emitted: true });
339
- })
340
- .build();
478
+ const pipeline = define('test').on('Trigger').emit('StartCmd', {}).build();
341
479
  const server = new PipelineServer({ port: 0 });
342
480
  server.registerCommandHandlers([handler]);
343
481
  server.registerPipeline(pipeline);
344
482
  await server.start();
345
- await fetch(`http://localhost:${server.port}/command`, {
483
+
484
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
346
485
  method: 'POST',
347
486
  headers: { 'Content-Type': 'application/json' },
348
- body: JSON.stringify({ type: 'Start', data: {} }),
487
+ body: JSON.stringify({ type: 'StartCmd', data: {} }),
349
488
  });
489
+
350
490
  await new Promise((r) => setTimeout(r, 100));
491
+
351
492
  const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
352
- expect(msgs.some((m) => m.message.type === 'CustomEvent')).toBe(true);
493
+ const pipelineRunStarted = msgs.find((m) => m.message.type === 'PipelineRunStarted');
494
+ expect(pipelineRunStarted).toBeDefined();
495
+ expect((pipelineRunStarted?.message as { correlationId?: string }).correlationId).toBe(
496
+ commandResponse.correlationId,
497
+ );
498
+ expect((pipelineRunStarted?.message as { data?: { triggerCommand?: string } }).data?.triggerCommand).toBe(
499
+ 'StartCmd',
500
+ );
353
501
  await server.stop();
354
502
  });
355
- });
356
503
 
357
- describe('GET /pipeline/mermaid', () => {
358
- it('should return mermaid diagram as text', async () => {
359
- const pipeline = define('test').on('Start').emit('Process', {}).build();
504
+ it('should broadcast NodeStatusChanged event when command starts running', async () => {
505
+ const handler = {
506
+ name: 'RunCmd',
507
+ events: ['RunDone'],
508
+ handle: async () => ({ type: 'RunDone', data: {} }),
509
+ };
510
+ const pipeline = define('test').on('Trigger').emit('RunCmd', {}).build();
360
511
  const server = new PipelineServer({ port: 0 });
512
+ server.registerCommandHandlers([handler]);
361
513
  server.registerPipeline(pipeline);
362
514
  await server.start();
363
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
364
- expect(res.headers.get('content-type')).toContain('text/plain');
365
- const mermaid = await res.text();
366
- expect(mermaid).toContain('flowchart LR');
515
+
516
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
517
+ method: 'POST',
518
+ headers: { 'Content-Type': 'application/json' },
519
+ body: JSON.stringify({ type: 'RunCmd', data: {} }),
520
+ });
521
+
522
+ await new Promise((r) => setTimeout(r, 100));
523
+
524
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
525
+ type NodeStatusChangedMessage = {
526
+ type: string;
527
+ correlationId?: string;
528
+ data?: { nodeId?: string; status?: string; previousStatus?: string };
529
+ };
530
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
531
+ const runningEvent = nodeStatusChanged.find(
532
+ (m) => (m.message as NodeStatusChangedMessage).data?.status === 'running',
533
+ );
534
+ expect(runningEvent).toBeDefined();
535
+ expect((runningEvent?.message as NodeStatusChangedMessage).data?.nodeId).toBe('cmd:RunCmd');
536
+ expect((runningEvent?.message as NodeStatusChangedMessage).data?.previousStatus).toBe('idle');
537
+ expect((runningEvent?.message as NodeStatusChangedMessage).correlationId).toBe(commandResponse.correlationId);
367
538
  await server.stop();
368
539
  });
369
540
 
370
- it('should include event nodes in mermaid diagram', async () => {
371
- const pipeline = define('test').on('Start').emit('Process', {}).build();
541
+ it('should broadcast NodeStatusChanged event when command completes', async () => {
542
+ const handler = {
543
+ name: 'CompleteCmd',
544
+ events: ['CompleteDone'],
545
+ handle: async () => ({ type: 'CompleteDone', data: {} }),
546
+ };
547
+ const pipeline = define('test').on('Trigger').emit('CompleteCmd', {}).build();
372
548
  const server = new PipelineServer({ port: 0 });
549
+ server.registerCommandHandlers([handler]);
373
550
  server.registerPipeline(pipeline);
374
551
  await server.start();
375
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
376
- const mermaid = await res.text();
377
- expect(mermaid).toContain('evt_Start');
552
+
553
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
554
+ method: 'POST',
555
+ headers: { 'Content-Type': 'application/json' },
556
+ body: JSON.stringify({ type: 'CompleteCmd', data: {} }),
557
+ });
558
+
559
+ await new Promise((r) => setTimeout(r, 100));
560
+
561
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
562
+ type NodeStatusChangedMessage = {
563
+ type: string;
564
+ correlationId?: string;
565
+ data?: { nodeId?: string; status?: string; previousStatus?: string };
566
+ };
567
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
568
+ const successEvent = nodeStatusChanged.find(
569
+ (m) => (m.message as NodeStatusChangedMessage).data?.status === 'success',
570
+ );
571
+ expect(successEvent).toBeDefined();
572
+ expect((successEvent?.message as NodeStatusChangedMessage).data?.nodeId).toBe('cmd:CompleteCmd');
573
+ expect((successEvent?.message as NodeStatusChangedMessage).data?.previousStatus).toBe('running');
574
+ expect((successEvent?.message as NodeStatusChangedMessage).correlationId).toBe(commandResponse.correlationId);
378
575
  await server.stop();
379
576
  });
380
577
 
381
- it('should include command nodes in mermaid diagram', async () => {
382
- const pipeline = define('test').on('Start').emit('Process', {}).build();
578
+ it('should persist status across multiple /pipeline calls', async () => {
579
+ const handler = {
580
+ name: 'PersistCmd',
581
+ events: ['PersistDone'],
582
+ handle: async () => ({ type: 'PersistDone', data: {} }),
583
+ };
584
+ const pipeline = define('test').on('Trigger').emit('PersistCmd', {}).build();
383
585
  const server = new PipelineServer({ port: 0 });
586
+ server.registerCommandHandlers([handler]);
384
587
  server.registerPipeline(pipeline);
385
588
  await server.start();
386
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
387
- const mermaid = await res.text();
388
- expect(mermaid).toContain('Process[Process]');
589
+
590
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
591
+ method: 'POST',
592
+ headers: { 'Content-Type': 'application/json' },
593
+ body: JSON.stringify({ type: 'PersistCmd', data: {} }),
594
+ });
595
+
596
+ await new Promise((r) => setTimeout(r, 100));
597
+
598
+ const firstCall = await fetchAs<PipelineResponse>(
599
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
600
+ );
601
+ expect(firstCall.nodes.find((n) => n.id === 'cmd:PersistCmd')?.status).toBe('success');
602
+
603
+ const secondCall = await fetchAs<PipelineResponse>(
604
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
605
+ );
606
+ expect(secondCall.nodes.find((n) => n.id === 'cmd:PersistCmd')?.status).toBe('success');
607
+
389
608
  await server.stop();
390
609
  });
391
610
 
392
- it('should include edges in mermaid diagram', async () => {
393
- const pipeline = define('test').on('Start').emit('Process', {}).build();
611
+ it('should track status independently for different correlationIds', async () => {
612
+ const handler = {
613
+ name: 'IndependentCmd',
614
+ events: ['IndependentDone'],
615
+ handle: async () => ({ type: 'IndependentDone', data: {} }),
616
+ };
617
+ const pipeline = define('test').on('Trigger').emit('IndependentCmd', {}).build();
394
618
  const server = new PipelineServer({ port: 0 });
619
+ server.registerCommandHandlers([handler]);
395
620
  server.registerPipeline(pipeline);
396
621
  await server.start();
397
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
398
- const mermaid = await res.text();
399
- expect(mermaid).toContain('-->');
400
- await server.stop();
401
- });
402
622
 
403
- it('should style commands as blue and events as orange', async () => {
404
- const pipeline = define('test').on('Start').emit('Process', {}).build();
405
- const server = new PipelineServer({ port: 0 });
623
+ const run1 = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
624
+ method: 'POST',
625
+ headers: { 'Content-Type': 'application/json' },
626
+ body: JSON.stringify({ type: 'IndependentCmd', data: {} }),
627
+ });
628
+
629
+ const run2 = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
630
+ method: 'POST',
631
+ headers: { 'Content-Type': 'application/json' },
632
+ body: JSON.stringify({ type: 'IndependentCmd', data: {} }),
633
+ });
634
+
635
+ await new Promise((r) => setTimeout(r, 100));
636
+
637
+ expect(run1.correlationId).not.toBe(run2.correlationId);
638
+
639
+ const pipeline1 = await fetchAs<PipelineResponse>(
640
+ `http://localhost:${server.port}/pipeline?correlationId=${run1.correlationId}`,
641
+ );
642
+ const pipeline2 = await fetchAs<PipelineResponse>(
643
+ `http://localhost:${server.port}/pipeline?correlationId=${run2.correlationId}`,
644
+ );
645
+
646
+ expect(pipeline1.nodes.find((n) => n.id === 'cmd:IndependentCmd')?.status).toBe('success');
647
+ expect(pipeline2.nodes.find((n) => n.id === 'cmd:IndependentCmd')?.status).toBe('success');
648
+
649
+ await server.stop();
650
+ });
651
+
652
+ it('should show idle status for all command nodes when no correlationId provided', async () => {
653
+ const handler = {
654
+ name: 'IdleCmd',
655
+ events: ['IdleDone'],
656
+ handle: async () => ({ type: 'IdleDone', data: {} }),
657
+ };
658
+ const pipeline = define('test').on('Trigger').emit('IdleCmd', {}).build();
659
+ const server = new PipelineServer({ port: 0 });
660
+ server.registerCommandHandlers([handler]);
661
+ server.registerPipeline(pipeline);
662
+ await server.start();
663
+
664
+ await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
665
+ method: 'POST',
666
+ headers: { 'Content-Type': 'application/json' },
667
+ body: JSON.stringify({ type: 'IdleCmd', data: {} }),
668
+ });
669
+
670
+ await new Promise((r) => setTimeout(r, 100));
671
+
672
+ const pipelineWithoutCorrelation = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
673
+ const cmdNode = pipelineWithoutCorrelation.nodes.find((n) => n.id === 'cmd:IdleCmd');
674
+ expect(cmdNode?.status).toBe('idle');
675
+
676
+ await server.stop();
677
+ });
678
+
679
+ it('should return latestRun with the most recent correlationId', async () => {
680
+ const handler = {
681
+ name: 'LatestCmd',
682
+ events: ['LatestDone'],
683
+ handle: async () => ({ type: 'LatestDone', data: {} }),
684
+ };
685
+ const pipeline = define('test').on('Trigger').emit('LatestCmd', {}).build();
686
+ const server = new PipelineServer({ port: 0 });
687
+ server.registerCommandHandlers([handler]);
688
+ server.registerPipeline(pipeline);
689
+ await server.start();
690
+
691
+ const run1 = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
692
+ method: 'POST',
693
+ headers: { 'Content-Type': 'application/json' },
694
+ body: JSON.stringify({ type: 'LatestCmd', data: {} }),
695
+ });
696
+
697
+ await new Promise((r) => setTimeout(r, 50));
698
+
699
+ const run2 = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
700
+ method: 'POST',
701
+ headers: { 'Content-Type': 'application/json' },
702
+ body: JSON.stringify({ type: 'LatestCmd', data: {} }),
703
+ });
704
+
705
+ await new Promise((r) => setTimeout(r, 50));
706
+
707
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
708
+ expect(data.latestRun).toBe(run2.correlationId);
709
+ expect(data.latestRun).not.toBe(run1.correlationId);
710
+
711
+ await server.stop();
712
+ });
713
+ });
714
+
715
+ describe('POST /command', () => {
716
+ it('should accept command', async () => {
717
+ const handler = {
718
+ name: 'Cmd',
719
+ handle: async () => ({ type: 'Done', data: {} }),
720
+ };
721
+ const server = new PipelineServer({ port: 0 });
722
+ server.registerCommandHandlers([handler]);
723
+ await server.start();
724
+ const data = await fetchAs<CommandResponse>(`http://localhost:${server.port}/command`, {
725
+ method: 'POST',
726
+ headers: { 'Content-Type': 'application/json' },
727
+ body: JSON.stringify({ type: 'Cmd', data: {} }),
728
+ });
729
+ expect(data.status).toBe('ack');
730
+ await server.stop();
731
+ });
732
+
733
+ it('should return 404 for unknown command', async () => {
734
+ const server = new PipelineServer({ port: 0 });
735
+ await server.start();
736
+ const res = await fetchWithStatus(`http://localhost:${server.port}/command`, {
737
+ method: 'POST',
738
+ headers: { 'Content-Type': 'application/json' },
739
+ body: JSON.stringify({ type: 'UnknownCmd', data: {} }),
740
+ });
741
+ expect(res.status).toBe(404);
742
+ const data = await res.json<CommandResponse>();
743
+ expect(data.status).toBe('nack');
744
+ await server.stop();
745
+ });
746
+
747
+ it('should handle command that returns multiple events', async () => {
748
+ const handler = {
749
+ name: 'Multi',
750
+ handle: async () => [
751
+ { type: 'EventA', data: { a: 1 } },
752
+ { type: 'EventB', data: { b: 2 } },
753
+ ],
754
+ };
755
+ const server = new PipelineServer({ port: 0 });
756
+ server.registerCommandHandlers([handler]);
757
+ await server.start();
758
+ await fetch(`http://localhost:${server.port}/command`, {
759
+ method: 'POST',
760
+ headers: { 'Content-Type': 'application/json' },
761
+ body: JSON.stringify({ type: 'Multi', data: {} }),
762
+ });
763
+ await new Promise((r) => setTimeout(r, 100));
764
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
765
+ expect(msgs.some((m) => m.message.type === 'EventA')).toBe(true);
766
+ expect(msgs.some((m) => m.message.type === 'EventB')).toBe(true);
767
+ await server.stop();
768
+ });
769
+ });
770
+
771
+ describe('GET /messages', () => {
772
+ it('should return messages', async () => {
773
+ const server = new PipelineServer({ port: 0 });
774
+ await server.start();
775
+ const data = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
776
+ expect(Array.isArray(data)).toBe(true);
777
+ await server.stop();
778
+ });
779
+ });
780
+
781
+ describe('GET /stats', () => {
782
+ it('should return stats', async () => {
783
+ const server = new PipelineServer({ port: 0 });
784
+ await server.start();
785
+ const data = await fetchAs<StatsResponse>(`http://localhost:${server.port}/stats`);
786
+ expect(data.totalMessages).toBeDefined();
787
+ await server.stop();
788
+ });
789
+ });
790
+
791
+ describe('event routing', () => {
792
+ it('should route events through pipeline', async () => {
793
+ const handler = {
794
+ name: 'Init',
795
+ handle: async () => ({ type: 'Ready', data: {} }),
796
+ };
797
+ const pipeline = define('test').on('Ready').emit('Next', {}).build();
798
+ const server = new PipelineServer({ port: 0 });
799
+ server.registerCommandHandlers([handler]);
800
+ server.registerPipeline(pipeline);
801
+ await server.start();
802
+ await fetch(`http://localhost:${server.port}/command`, {
803
+ method: 'POST',
804
+ headers: { 'Content-Type': 'application/json' },
805
+ body: JSON.stringify({ type: 'Init', data: {} }),
806
+ });
807
+ await new Promise((r) => setTimeout(r, 100));
808
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
809
+ expect(msgs.some((m) => m.message.type === 'Next')).toBe(true);
810
+ await server.stop();
811
+ });
812
+
813
+ it('should handle custom handler that emits events', async () => {
814
+ const handler = {
815
+ name: 'Start',
816
+ handle: async () => ({ type: 'Started', data: {} }),
817
+ };
818
+ const pipeline = define('test')
819
+ .on('Started')
820
+ .handle(async (_e, ctx) => {
821
+ await ctx.emit('CustomEvent', { emitted: true });
822
+ })
823
+ .build();
824
+ const server = new PipelineServer({ port: 0 });
825
+ server.registerCommandHandlers([handler]);
826
+ server.registerPipeline(pipeline);
827
+ await server.start();
828
+ await fetch(`http://localhost:${server.port}/command`, {
829
+ method: 'POST',
830
+ headers: { 'Content-Type': 'application/json' },
831
+ body: JSON.stringify({ type: 'Start', data: {} }),
832
+ });
833
+ await new Promise((r) => setTimeout(r, 100));
834
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
835
+ expect(msgs.some((m) => m.message.type === 'CustomEvent')).toBe(true);
836
+ await server.stop();
837
+ });
838
+ });
839
+
840
+ describe('GET /pipeline/mermaid', () => {
841
+ it('should return mermaid diagram as text', async () => {
842
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
843
+ const server = new PipelineServer({ port: 0 });
844
+ server.registerPipeline(pipeline);
845
+ await server.start();
846
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
847
+ expect(res.headers.get('content-type')).toContain('text/plain');
848
+ const mermaid = await res.text();
849
+ expect(mermaid).toContain('flowchart LR');
850
+ await server.stop();
851
+ });
852
+
853
+ it('should filter out event nodes when excludeTypes=event', async () => {
854
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
855
+ const server = new PipelineServer({ port: 0 });
856
+ server.registerPipeline(pipeline);
857
+ await server.start();
858
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid?excludeTypes=event`);
859
+ const mermaid = await res.text();
860
+ expect(mermaid).not.toContain('evt_Start');
861
+ expect(mermaid).toContain('Process[Process]');
862
+ await server.stop();
863
+ });
864
+
865
+ it('should filter out settled nodes when excludeTypes=settled', async () => {
866
+ const checkAHandler = {
867
+ name: 'CheckA',
868
+ events: ['CheckAPassed', 'CheckAFailed'],
869
+ handle: async () => ({ type: 'CheckAPassed', data: {} }),
870
+ };
871
+ const pipeline = define('test')
872
+ .on('Start')
873
+ .emit('CheckA', {})
874
+ .settled(['CheckA'])
875
+ .dispatch({ dispatches: [] }, () => {})
876
+ .build();
877
+ const server = new PipelineServer({ port: 0 });
878
+ server.registerCommandHandlers([checkAHandler]);
879
+ server.registerPipeline(pipeline);
880
+ await server.start();
881
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid?excludeTypes=settled`);
882
+ const mermaid = await res.text();
883
+ expect(mermaid).not.toContain('settled_');
884
+ expect(mermaid).toContain('CheckA');
885
+ await server.stop();
886
+ });
887
+
888
+ it('should use displayName as label for command nodes in mermaid diagram', async () => {
889
+ const handler = {
890
+ name: 'Cmd',
891
+ displayName: 'My Command',
892
+ handle: async () => ({ type: 'Done', data: {} }),
893
+ };
894
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
895
+ const server = new PipelineServer({ port: 0 });
896
+ server.registerCommandHandlers([handler]);
897
+ server.registerPipeline(pipeline);
898
+ await server.start();
899
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
900
+ const mermaid = await res.text();
901
+ expect(mermaid).toContain('Cmd[My Command]');
902
+ await server.stop();
903
+ });
904
+
905
+ it('should include event nodes in mermaid diagram', async () => {
906
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
907
+ const server = new PipelineServer({ port: 0 });
908
+ server.registerPipeline(pipeline);
909
+ await server.start();
910
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
911
+ const mermaid = await res.text();
912
+ expect(mermaid).toContain('evt_Start');
913
+ await server.stop();
914
+ });
915
+
916
+ it('should use displayName as label for event nodes in mermaid diagram', async () => {
917
+ const handler = {
918
+ name: 'Cmd',
919
+ events: [{ name: 'CmdCompleted', displayName: 'Command Completed' }],
920
+ handle: async () => ({ type: 'CmdCompleted', data: {} }),
921
+ };
922
+ const nextHandler = {
923
+ name: 'NextCmd',
924
+ handle: async () => ({ type: 'Done', data: {} }),
925
+ };
926
+ const pipeline = define('test').on('Start').emit('Cmd', {}).on('CmdCompleted').emit('NextCmd', {}).build();
927
+ const server = new PipelineServer({ port: 0 });
928
+ server.registerCommandHandlers([handler, nextHandler]);
929
+ server.registerPipeline(pipeline);
930
+ await server.start();
931
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
932
+ const mermaid = await res.text();
933
+ expect(mermaid).toContain('evt_CmdCompleted([Command Completed])');
934
+ await server.stop();
935
+ });
936
+
937
+ it('should include command nodes in mermaid diagram', async () => {
938
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
939
+ const server = new PipelineServer({ port: 0 });
940
+ server.registerPipeline(pipeline);
941
+ await server.start();
942
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
943
+ const mermaid = await res.text();
944
+ expect(mermaid).toContain('Process[Process]');
945
+ await server.stop();
946
+ });
947
+
948
+ it('should include edges in mermaid diagram', async () => {
949
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
950
+ const server = new PipelineServer({ port: 0 });
951
+ server.registerPipeline(pipeline);
952
+ await server.start();
953
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
954
+ const mermaid = await res.text();
955
+ expect(mermaid).toContain('-->');
956
+ await server.stop();
957
+ });
958
+
959
+ it('should style commands as blue and events as orange', async () => {
960
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
961
+ const server = new PipelineServer({ port: 0 });
406
962
  server.registerPipeline(pipeline);
407
963
  await server.start();
408
964
  const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
@@ -560,7 +1116,7 @@ describe('PipelineServer', () => {
560
1116
  await server.stop();
561
1117
  });
562
1118
 
563
- it('should show edges from command events to settled node, not from commands', async () => {
1119
+ it('should show edges from commands to settled node', async () => {
564
1120
  const checkAHandler = {
565
1121
  name: 'CheckA',
566
1122
  events: ['CheckAPassed', 'CheckAFailed'],
@@ -584,12 +1140,8 @@ describe('PipelineServer', () => {
584
1140
  await server.start();
585
1141
  const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
586
1142
  const mermaid = await res.text();
587
- expect(mermaid).toContain('evt_CheckAPassed --> settled_CheckA_CheckB');
588
- expect(mermaid).toContain('evt_CheckAFailed --> settled_CheckA_CheckB');
589
- expect(mermaid).toContain('evt_CheckBPassed --> settled_CheckA_CheckB');
590
- expect(mermaid).toContain('evt_CheckBFailed --> settled_CheckA_CheckB');
591
- expect(mermaid).not.toMatch(/CheckA --> settled_/);
592
- expect(mermaid).not.toMatch(/CheckB --> settled_/);
1143
+ expect(mermaid).toContain('CheckA --> settled_CheckA_CheckB');
1144
+ expect(mermaid).toContain('CheckB --> settled_CheckA_CheckB');
593
1145
  await server.stop();
594
1146
  });
595
1147
 
@@ -604,126 +1156,642 @@ describe('PipelineServer', () => {
604
1156
  events: ['RetryDone'],
605
1157
  handle: async () => ({ type: 'RetryDone', data: {} }),
606
1158
  };
607
- const pipeline = define('test')
608
- .on('Start')
609
- .emit('CheckA', {})
610
- .settled(['CheckA'])
611
- .dispatch({ dispatches: ['RetryCommand'] }, () => {})
612
- .build();
1159
+ const pipeline = define('test')
1160
+ .on('Start')
1161
+ .emit('CheckA', {})
1162
+ .settled(['CheckA'])
1163
+ .dispatch({ dispatches: ['RetryCommand'] }, () => {})
1164
+ .build();
1165
+ const server = new PipelineServer({ port: 0 });
1166
+ server.registerCommandHandlers([checkHandler, retryHandler]);
1167
+ server.registerPipeline(pipeline);
1168
+ await server.start();
1169
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
1170
+ const mermaid = await res.text();
1171
+ expect(mermaid).toContain('settled_CheckA -.->|retry| RetryCommand');
1172
+ await server.stop();
1173
+ });
1174
+
1175
+ it('should style backLink edges in red', async () => {
1176
+ const checkHandler = {
1177
+ name: 'CheckA',
1178
+ events: ['CheckAPassed', 'CheckAFailed'],
1179
+ handle: async () => ({ type: 'CheckAPassed', data: {} }),
1180
+ };
1181
+ const retryHandler = {
1182
+ name: 'RetryCommand',
1183
+ events: ['RetryDone'],
1184
+ handle: async () => ({ type: 'RetryDone', data: {} }),
1185
+ };
1186
+ const pipeline = define('test')
1187
+ .on('Start')
1188
+ .emit('CheckA', {})
1189
+ .settled(['CheckA'])
1190
+ .dispatch({ dispatches: ['RetryCommand'] }, () => {})
1191
+ .build();
1192
+ const server = new PipelineServer({ port: 0 });
1193
+ server.registerCommandHandlers([checkHandler, retryHandler]);
1194
+ server.registerPipeline(pipeline);
1195
+ await server.start();
1196
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
1197
+ const mermaid = await res.text();
1198
+ expect(mermaid).toContain('linkStyle');
1199
+ expect(mermaid).toMatch(/stroke:#[a-fA-F0-9]{6}|stroke:red/);
1200
+ await server.stop();
1201
+ });
1202
+
1203
+ it('should mark event-to-command edges as backLink when they create cycles', async () => {
1204
+ const generateHandler = {
1205
+ name: 'GenerateIA',
1206
+ events: ['IAGenerated', 'IAValidationFailed'],
1207
+ handle: async () => ({ type: 'IAGenerated', data: {} }),
1208
+ };
1209
+ const pipeline = define('test')
1210
+ .on('Start')
1211
+ .emit('GenerateIA', {})
1212
+ .on('IAValidationFailed')
1213
+ .emit('GenerateIA', {})
1214
+ .build();
1215
+ const server = new PipelineServer({ port: 0 });
1216
+ server.registerCommandHandlers([generateHandler]);
1217
+ server.registerPipeline(pipeline);
1218
+ await server.start();
1219
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
1220
+ const backLinkEdge = data.edges.find((e) => e.from === 'evt:IAValidationFailed' && e.to === 'cmd:GenerateIA');
1221
+ expect(backLinkEdge).toBeDefined();
1222
+ expect(backLinkEdge?.backLink).toBe(true);
1223
+ await server.stop();
1224
+ });
1225
+
1226
+ it('should NOT mark forward edges as backLink when cycle is broken by settled dispatch', async () => {
1227
+ const implHandler = {
1228
+ name: 'ImplementSlice',
1229
+ events: ['SliceImplemented'],
1230
+ handle: async () => ({ type: 'SliceImplemented', data: {} }),
1231
+ };
1232
+ const checkHandler = {
1233
+ name: 'CheckTests',
1234
+ events: ['TestsCheckPassed', 'TestsCheckFailed'],
1235
+ handle: async () => ({ type: 'TestsCheckPassed', data: {} }),
1236
+ };
1237
+ const pipeline = define('test')
1238
+ .on('Start')
1239
+ .emit('ImplementSlice', {})
1240
+ .on('SliceImplemented')
1241
+ .emit('CheckTests', {})
1242
+ .settled(['CheckTests'])
1243
+ .dispatch({ dispatches: ['ImplementSlice'] }, () => {})
1244
+ .build();
1245
+ const server = new PipelineServer({ port: 0 });
1246
+ server.registerCommandHandlers([implHandler, checkHandler]);
1247
+ server.registerPipeline(pipeline);
1248
+ await server.start();
1249
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
1250
+ const forwardEdge = data.edges.find((e) => e.from === 'evt:SliceImplemented' && e.to === 'cmd:CheckTests');
1251
+ expect(forwardEdge).toBeDefined();
1252
+ expect(forwardEdge?.backLink).toBeUndefined();
1253
+ await server.stop();
1254
+ });
1255
+
1256
+ it('should handle diamond graph patterns when detecting backlinks', async () => {
1257
+ const cmdAHandler = {
1258
+ name: 'CmdA',
1259
+ events: ['EventA'],
1260
+ handle: async () => ({ type: 'EventA', data: {} }),
1261
+ };
1262
+ const cmdBHandler = {
1263
+ name: 'CmdB',
1264
+ events: ['EventB'],
1265
+ handle: async () => ({ type: 'EventB', data: {} }),
1266
+ };
1267
+ const cmdCHandler = {
1268
+ name: 'CmdC',
1269
+ events: ['EventC'],
1270
+ handle: async () => ({ type: 'EventC', data: {} }),
1271
+ };
1272
+ const cmdDHandler = {
1273
+ name: 'CmdD',
1274
+ events: ['EventD'],
1275
+ handle: async () => ({ type: 'EventD', data: {} }),
1276
+ };
1277
+ const pipeline = define('test')
1278
+ .on('Start')
1279
+ .emit('CmdA', {})
1280
+ .on('EventA')
1281
+ .emit('CmdB', {})
1282
+ .on('EventA')
1283
+ .emit('CmdC', {})
1284
+ .on('EventB')
1285
+ .emit('CmdD', {})
1286
+ .on('EventC')
1287
+ .emit('CmdD', {})
1288
+ .on('EventD')
1289
+ .emit('CmdA', {})
1290
+ .build();
1291
+ const server = new PipelineServer({ port: 0 });
1292
+ server.registerCommandHandlers([cmdAHandler, cmdBHandler, cmdCHandler, cmdDHandler]);
1293
+ server.registerPipeline(pipeline);
1294
+ await server.start();
1295
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
1296
+ const backLinkEdge = data.edges.find((e) => e.from === 'evt:EventD' && e.to === 'cmd:CmdA');
1297
+ expect(backLinkEdge).toBeDefined();
1298
+ expect(backLinkEdge?.backLink).toBe(true);
1299
+ await server.stop();
1300
+ });
1301
+
1302
+ it('should add event nodes from settled handler commandToEvents when not already added', async () => {
1303
+ const checkAHandler = {
1304
+ name: 'CheckA',
1305
+ events: ['CheckAPassed', 'CheckAFailed'],
1306
+ handle: async () => ({ type: 'CheckAPassed', data: {} }),
1307
+ };
1308
+ const checkBHandler = {
1309
+ name: 'CheckB',
1310
+ events: ['CheckBPassed', 'CheckBFailed'],
1311
+ handle: async () => ({ type: 'CheckBPassed', data: {} }),
1312
+ };
1313
+ const pipeline = define('test')
1314
+ .on('Start')
1315
+ .emit('CheckA', {})
1316
+ .emit('CheckB', {})
1317
+ .settled(['CheckA', 'CheckB'])
1318
+ .dispatch({ dispatches: [] }, () => {})
1319
+ .build();
1320
+ const server = new PipelineServer({ port: 0 });
1321
+ server.registerCommandHandlers([checkAHandler, checkBHandler]);
1322
+ server.registerPipeline(pipeline);
1323
+ await server.start();
1324
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
1325
+ const mermaid = await res.text();
1326
+ expect(mermaid).toContain('evt_CheckAPassed');
1327
+ expect(mermaid).toContain('evt_CheckAFailed');
1328
+ expect(mermaid).toContain('evt_CheckBPassed');
1329
+ expect(mermaid).toContain('evt_CheckBFailed');
1330
+ await server.stop();
1331
+ });
1332
+ });
1333
+
1334
+ describe('GET /pipeline/diagram', () => {
1335
+ it('should return HTML content type', async () => {
1336
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
1337
+ const server = new PipelineServer({ port: 0 });
1338
+ server.registerPipeline(pipeline);
1339
+ await server.start();
1340
+ const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
1341
+ expect(res.headers.get('content-type')).toContain('text/html');
1342
+ await server.stop();
1343
+ });
1344
+
1345
+ it('should filter nodes when excludeTypes is provided', async () => {
1346
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
1347
+ const server = new PipelineServer({ port: 0 });
1348
+ server.registerPipeline(pipeline);
1349
+ await server.start();
1350
+ const res = await fetch(`http://localhost:${server.port}/pipeline/diagram?excludeTypes=event`);
1351
+ const html = await res.text();
1352
+ expect(html).not.toContain('evt_Start');
1353
+ expect(html).toContain('Process');
1354
+ await server.stop();
1355
+ });
1356
+
1357
+ it('should include mermaid.js script', async () => {
1358
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
1359
+ const server = new PipelineServer({ port: 0 });
1360
+ server.registerPipeline(pipeline);
1361
+ await server.start();
1362
+ const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
1363
+ const html = await res.text();
1364
+ expect(html).toContain('mermaid');
1365
+ await server.stop();
1366
+ });
1367
+
1368
+ it('should include the pipeline mermaid definition', async () => {
1369
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
1370
+ const server = new PipelineServer({ port: 0 });
1371
+ server.registerPipeline(pipeline);
1372
+ await server.start();
1373
+ const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
1374
+ const html = await res.text();
1375
+ expect(html).toContain('flowchart LR');
1376
+ expect(html).toContain('evt_Start');
1377
+ await server.stop();
1378
+ });
1379
+
1380
+ it('should have a valid HTML structure', async () => {
1381
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
1382
+ const server = new PipelineServer({ port: 0 });
1383
+ server.registerPipeline(pipeline);
1384
+ await server.start();
1385
+ const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
1386
+ const html = await res.text();
1387
+ expect(html).toContain('<!DOCTYPE html>');
1388
+ expect(html).toContain('<html');
1389
+ expect(html).toContain('</html>');
1390
+ await server.stop();
1391
+ });
1392
+ });
1393
+
1394
+ describe('item-level tracking', () => {
1395
+ it('should extract itemKey from command data using registered extractor', async () => {
1396
+ const handler = {
1397
+ name: 'ImplementSlice',
1398
+ events: ['SliceImplemented'],
1399
+ handle: async () => ({ type: 'SliceImplemented', data: {} }),
1400
+ };
1401
+ const pipeline = define('test').on('Trigger').emit('ImplementSlice', {}).build();
1402
+ const server = new PipelineServer({ port: 0 });
1403
+ server.registerCommandHandlers([handler]);
1404
+ server.registerPipeline(pipeline);
1405
+ server.registerItemKeyExtractor('ImplementSlice', (d) => (d as { slicePath?: string }).slicePath);
1406
+ await server.start();
1407
+
1408
+ const commandResponse = await fetchAs<{ correlationId: string }>(`http://localhost:${server.port}/command`, {
1409
+ method: 'POST',
1410
+ headers: { 'Content-Type': 'application/json' },
1411
+ body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-1' } }),
1412
+ });
1413
+
1414
+ await new Promise((r) => setTimeout(r, 100));
1415
+
1416
+ const data = await fetchAs<PipelineResponse>(
1417
+ `http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`,
1418
+ );
1419
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:ImplementSlice');
1420
+ expect(cmdNode?.pendingCount).toBe(0);
1421
+ expect(cmdNode?.endedCount).toBe(1);
1422
+
1423
+ await server.stop();
1424
+ });
1425
+
1426
+ it('should count multiple parallel items correctly', async () => {
1427
+ const handler = {
1428
+ name: 'ImplementSlice',
1429
+ events: ['SliceImplemented'],
1430
+ handle: async () => ({ type: 'SliceImplemented', data: {} }),
1431
+ };
1432
+ const pipeline = define('test').on('Trigger').emit('ImplementSlice', {}).build();
1433
+ const server = new PipelineServer({ port: 0 });
1434
+ server.registerCommandHandlers([handler]);
1435
+ server.registerPipeline(pipeline);
1436
+ server.registerItemKeyExtractor('ImplementSlice', (d) => (d as { slicePath?: string }).slicePath);
1437
+ await server.start();
1438
+
1439
+ const correlationId = `corr-parallel-test`;
1440
+
1441
+ await Promise.all([
1442
+ fetch(`http://localhost:${server.port}/command`, {
1443
+ method: 'POST',
1444
+ headers: { 'Content-Type': 'application/json' },
1445
+ body: JSON.stringify({
1446
+ type: 'ImplementSlice',
1447
+ data: { slicePath: '/server/slice-1' },
1448
+ correlationId,
1449
+ }),
1450
+ }),
1451
+ fetch(`http://localhost:${server.port}/command`, {
1452
+ method: 'POST',
1453
+ headers: { 'Content-Type': 'application/json' },
1454
+ body: JSON.stringify({
1455
+ type: 'ImplementSlice',
1456
+ data: { slicePath: '/server/slice-2' },
1457
+ correlationId,
1458
+ }),
1459
+ }),
1460
+ fetch(`http://localhost:${server.port}/command`, {
1461
+ method: 'POST',
1462
+ headers: { 'Content-Type': 'application/json' },
1463
+ body: JSON.stringify({
1464
+ type: 'ImplementSlice',
1465
+ data: { slicePath: '/server/slice-3' },
1466
+ correlationId,
1467
+ }),
1468
+ }),
1469
+ ]);
1470
+
1471
+ await new Promise((r) => setTimeout(r, 100));
1472
+
1473
+ const data = await fetchAs<PipelineResponse>(
1474
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1475
+ );
1476
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:ImplementSlice');
1477
+ expect(cmdNode?.pendingCount).toBe(0);
1478
+ expect(cmdNode?.endedCount).toBe(3);
1479
+
1480
+ await server.stop();
1481
+ });
1482
+
1483
+ it('should show pending count while commands are running', async () => {
1484
+ const resolveHandlers: Array<() => void> = [];
1485
+ const handler = {
1486
+ name: 'SlowSlice',
1487
+ events: ['SlowSliceDone'],
1488
+ handle: async () => {
1489
+ await new Promise<void>((resolve) => {
1490
+ resolveHandlers.push(resolve);
1491
+ });
1492
+ return { type: 'SlowSliceDone', data: {} };
1493
+ },
1494
+ };
1495
+ const pipeline = define('test').on('Trigger').emit('SlowSlice', {}).build();
613
1496
  const server = new PipelineServer({ port: 0 });
614
- server.registerCommandHandlers([checkHandler, retryHandler]);
1497
+ server.registerCommandHandlers([handler]);
615
1498
  server.registerPipeline(pipeline);
1499
+ server.registerItemKeyExtractor('SlowSlice', (d) => (d as { id?: string }).id);
616
1500
  await server.start();
617
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
618
- const mermaid = await res.text();
619
- expect(mermaid).toContain('settled_CheckA --> RetryCommand');
1501
+
1502
+ const correlationId = `corr-slow-test`;
1503
+
1504
+ void fetch(`http://localhost:${server.port}/command`, {
1505
+ method: 'POST',
1506
+ headers: { 'Content-Type': 'application/json' },
1507
+ body: JSON.stringify({ type: 'SlowSlice', data: { id: 'item-1' }, correlationId }),
1508
+ });
1509
+ void fetch(`http://localhost:${server.port}/command`, {
1510
+ method: 'POST',
1511
+ headers: { 'Content-Type': 'application/json' },
1512
+ body: JSON.stringify({ type: 'SlowSlice', data: { id: 'item-2' }, correlationId }),
1513
+ });
1514
+ void fetch(`http://localhost:${server.port}/command`, {
1515
+ method: 'POST',
1516
+ headers: { 'Content-Type': 'application/json' },
1517
+ body: JSON.stringify({ type: 'SlowSlice', data: { id: 'item-3' }, correlationId }),
1518
+ });
1519
+
1520
+ await new Promise((r) => setTimeout(r, 50));
1521
+
1522
+ const midwayData = await fetchAs<PipelineResponse>(
1523
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1524
+ );
1525
+ const midwayNode = midwayData.nodes.find((n) => n.id === 'cmd:SlowSlice');
1526
+ expect(midwayNode?.pendingCount).toBe(3);
1527
+ expect(midwayNode?.endedCount).toBe(0);
1528
+ expect(midwayNode?.status).toBe('running');
1529
+
1530
+ resolveHandlers.forEach((r) => r());
1531
+ await new Promise((r) => setTimeout(r, 50));
1532
+
1533
+ const finalData = await fetchAs<PipelineResponse>(
1534
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1535
+ );
1536
+ const finalNode = finalData.nodes.find((n) => n.id === 'cmd:SlowSlice');
1537
+ expect(finalNode?.pendingCount).toBe(0);
1538
+ expect(finalNode?.endedCount).toBe(3);
1539
+ expect(finalNode?.status).toBe('success');
1540
+
620
1541
  await server.stop();
621
1542
  });
622
1543
 
623
- it('should style backLink edges in red', async () => {
624
- const checkHandler = {
625
- name: 'CheckA',
626
- events: ['CheckAPassed', 'CheckAFailed'],
627
- handle: async () => ({ type: 'CheckAPassed', data: {} }),
628
- };
629
- const retryHandler = {
630
- name: 'RetryCommand',
631
- events: ['RetryDone'],
632
- handle: async () => ({ type: 'RetryDone', data: {} }),
1544
+ it('should show error status when any item fails', async () => {
1545
+ const handler = {
1546
+ name: 'MixedSlice',
1547
+ events: ['MixedSliceDone', 'MixedSliceFailed'],
1548
+ handle: async (cmd: { data: { shouldFail?: boolean } }) => {
1549
+ if (cmd.data.shouldFail === true) {
1550
+ return { type: 'MixedSliceFailed', data: {} };
1551
+ }
1552
+ return { type: 'MixedSliceDone', data: {} };
1553
+ },
633
1554
  };
634
- const pipeline = define('test')
635
- .on('Start')
636
- .emit('CheckA', {})
637
- .settled(['CheckA'])
638
- .dispatch({ dispatches: ['RetryCommand'] }, () => {})
639
- .build();
1555
+ const pipeline = define('test').on('Trigger').emit('MixedSlice', {}).build();
640
1556
  const server = new PipelineServer({ port: 0 });
641
- server.registerCommandHandlers([checkHandler, retryHandler]);
1557
+ server.registerCommandHandlers([handler]);
642
1558
  server.registerPipeline(pipeline);
1559
+ server.registerItemKeyExtractor('MixedSlice', (d) => (d as { id?: string }).id);
643
1560
  await server.start();
644
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
645
- const mermaid = await res.text();
646
- expect(mermaid).toContain('linkStyle');
647
- expect(mermaid).toMatch(/stroke:#[a-fA-F0-9]{6}|stroke:red/);
1561
+
1562
+ const correlationId = `corr-mixed-test`;
1563
+
1564
+ await Promise.all([
1565
+ fetch(`http://localhost:${server.port}/command`, {
1566
+ method: 'POST',
1567
+ headers: { 'Content-Type': 'application/json' },
1568
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-1' }, correlationId }),
1569
+ }),
1570
+ fetch(`http://localhost:${server.port}/command`, {
1571
+ method: 'POST',
1572
+ headers: { 'Content-Type': 'application/json' },
1573
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'fail-1', shouldFail: true }, correlationId }),
1574
+ }),
1575
+ fetch(`http://localhost:${server.port}/command`, {
1576
+ method: 'POST',
1577
+ headers: { 'Content-Type': 'application/json' },
1578
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-2' }, correlationId }),
1579
+ }),
1580
+ ]);
1581
+
1582
+ await new Promise((r) => setTimeout(r, 100));
1583
+
1584
+ const data = await fetchAs<PipelineResponse>(
1585
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1586
+ );
1587
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:MixedSlice');
1588
+ expect(cmdNode?.pendingCount).toBe(0);
1589
+ expect(cmdNode?.endedCount).toBe(3);
1590
+ expect(cmdNode?.status).toBe('error');
1591
+
648
1592
  await server.stop();
649
1593
  });
650
1594
 
651
- it('should add event nodes from settled handler commandToEvents when not already added', async () => {
652
- const checkAHandler = {
653
- name: 'CheckA',
654
- events: ['CheckAPassed', 'CheckAFailed'],
655
- handle: async () => ({ type: 'CheckAPassed', data: {} }),
656
- };
657
- const checkBHandler = {
658
- name: 'CheckB',
659
- events: ['CheckBPassed', 'CheckBFailed'],
660
- handle: async () => ({ type: 'CheckBPassed', data: {} }),
1595
+ it('should reset item to running when retry command arrives for same itemKey', async () => {
1596
+ let attemptCount = 0;
1597
+ const handler = {
1598
+ name: 'RetrySlice',
1599
+ events: ['RetrySliceDone', 'RetrySliceFailed'],
1600
+ handle: async () => {
1601
+ attemptCount++;
1602
+ if (attemptCount === 1) {
1603
+ return { type: 'RetrySliceFailed', data: {} };
1604
+ }
1605
+ return { type: 'RetrySliceDone', data: {} };
1606
+ },
661
1607
  };
662
- const pipeline = define('test')
663
- .on('Start')
664
- .emit('CheckA', {})
665
- .emit('CheckB', {})
666
- .settled(['CheckA', 'CheckB'])
667
- .dispatch({ dispatches: [] }, () => {})
668
- .build();
1608
+ const pipeline = define('test').on('Trigger').emit('RetrySlice', {}).build();
669
1609
  const server = new PipelineServer({ port: 0 });
670
- server.registerCommandHandlers([checkAHandler, checkBHandler]);
1610
+ server.registerCommandHandlers([handler]);
671
1611
  server.registerPipeline(pipeline);
1612
+ server.registerItemKeyExtractor('RetrySlice', (d) => (d as { slicePath?: string }).slicePath);
672
1613
  await server.start();
673
- const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
674
- const mermaid = await res.text();
675
- expect(mermaid).toContain('evt_CheckAPassed');
676
- expect(mermaid).toContain('evt_CheckAFailed');
677
- expect(mermaid).toContain('evt_CheckBPassed');
678
- expect(mermaid).toContain('evt_CheckBFailed');
1614
+
1615
+ const correlationId = `corr-retry-test`;
1616
+ const slicePath = '/server/retry-slice';
1617
+
1618
+ await fetch(`http://localhost:${server.port}/command`, {
1619
+ method: 'POST',
1620
+ headers: { 'Content-Type': 'application/json' },
1621
+ body: JSON.stringify({ type: 'RetrySlice', data: { slicePath }, correlationId }),
1622
+ });
1623
+ await new Promise((r) => setTimeout(r, 50));
1624
+
1625
+ const afterFailure = await fetchAs<PipelineResponse>(
1626
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1627
+ );
1628
+ expect(afterFailure.nodes.find((n) => n.id === 'cmd:RetrySlice')?.status).toBe('error');
1629
+
1630
+ await fetch(`http://localhost:${server.port}/command`, {
1631
+ method: 'POST',
1632
+ headers: { 'Content-Type': 'application/json' },
1633
+ body: JSON.stringify({ type: 'RetrySlice', data: { slicePath }, correlationId }),
1634
+ });
1635
+ await new Promise((r) => setTimeout(r, 50));
1636
+
1637
+ const afterRetry = await fetchAs<PipelineResponse>(
1638
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1639
+ );
1640
+ const node = afterRetry.nodes.find((n) => n.id === 'cmd:RetrySlice');
1641
+ expect(node?.status).toBe('success');
1642
+ expect(node?.pendingCount).toBe(0);
1643
+ expect(node?.endedCount).toBe(1);
1644
+
679
1645
  await server.stop();
680
1646
  });
681
- });
682
1647
 
683
- describe('GET /pipeline/diagram', () => {
684
- it('should return HTML content type', async () => {
685
- const pipeline = define('test').on('Start').emit('Process', {}).build();
1648
+ it('should include pendingCount and endedCount in NodeStatusChanged events', async () => {
1649
+ const handler = {
1650
+ name: 'CountSlice',
1651
+ events: ['CountSliceDone'],
1652
+ handle: async () => ({ type: 'CountSliceDone', data: {} }),
1653
+ };
1654
+ const pipeline = define('test').on('Trigger').emit('CountSlice', {}).build();
686
1655
  const server = new PipelineServer({ port: 0 });
1656
+ server.registerCommandHandlers([handler]);
687
1657
  server.registerPipeline(pipeline);
1658
+ server.registerItemKeyExtractor('CountSlice', (d) => (d as { id?: string }).id);
688
1659
  await server.start();
689
- const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
690
- expect(res.headers.get('content-type')).toContain('text/html');
1660
+
1661
+ const correlationId = `corr-counts-event-test`;
1662
+
1663
+ await fetch(`http://localhost:${server.port}/command`, {
1664
+ method: 'POST',
1665
+ headers: { 'Content-Type': 'application/json' },
1666
+ body: JSON.stringify({ type: 'CountSlice', data: { id: 'item-1' }, correlationId }),
1667
+ });
1668
+
1669
+ await new Promise((r) => setTimeout(r, 100));
1670
+
1671
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
1672
+ type NodeStatusChangedMessage = {
1673
+ type: string;
1674
+ correlationId?: string;
1675
+ data?: {
1676
+ nodeId?: string;
1677
+ status?: string;
1678
+ previousStatus?: string;
1679
+ pendingCount?: number;
1680
+ endedCount?: number;
1681
+ };
1682
+ };
1683
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
1684
+ const successEvent = nodeStatusChanged.find(
1685
+ (m) => (m.message as NodeStatusChangedMessage).data?.status === 'success',
1686
+ );
1687
+ expect(successEvent).toBeDefined();
1688
+ expect((successEvent?.message as NodeStatusChangedMessage).data?.pendingCount).toBe(0);
1689
+ expect((successEvent?.message as NodeStatusChangedMessage).data?.endedCount).toBe(1);
1690
+
691
1691
  await server.stop();
692
1692
  });
693
1693
 
694
- it('should include mermaid.js script', async () => {
695
- const pipeline = define('test').on('Start').emit('Process', {}).build();
1694
+ it('should use requestId as fallback when no itemKey extractor is registered', async () => {
1695
+ const handler = {
1696
+ name: 'NoExtractorCmd',
1697
+ events: ['NoExtractorDone'],
1698
+ handle: async () => ({ type: 'NoExtractorDone', data: {} }),
1699
+ };
1700
+ const pipeline = define('test').on('Trigger').emit('NoExtractorCmd', {}).build();
696
1701
  const server = new PipelineServer({ port: 0 });
1702
+ server.registerCommandHandlers([handler]);
697
1703
  server.registerPipeline(pipeline);
698
1704
  await server.start();
699
- const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
700
- const html = await res.text();
701
- expect(html).toContain('mermaid');
1705
+
1706
+ const correlationId = `corr-no-extractor-test`;
1707
+
1708
+ await fetch(`http://localhost:${server.port}/command`, {
1709
+ method: 'POST',
1710
+ headers: { 'Content-Type': 'application/json' },
1711
+ body: JSON.stringify({ type: 'NoExtractorCmd', data: {}, correlationId }),
1712
+ });
1713
+
1714
+ await new Promise((r) => setTimeout(r, 100));
1715
+
1716
+ const data = await fetchAs<PipelineResponse>(
1717
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1718
+ );
1719
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:NoExtractorCmd');
1720
+ expect(cmdNode?.pendingCount).toBe(0);
1721
+ expect(cmdNode?.endedCount).toBe(1);
1722
+ expect(cmdNode?.status).toBe('success');
1723
+
702
1724
  await server.stop();
703
1725
  });
704
1726
 
705
- it('should include the pipeline mermaid definition', async () => {
706
- const pipeline = define('test').on('Start').emit('Process', {}).build();
1727
+ it('should show idle status with zero counts when no correlationId provided', async () => {
1728
+ const handler = {
1729
+ name: 'IdleCountCmd',
1730
+ events: ['IdleCountDone'],
1731
+ handle: async () => ({ type: 'IdleCountDone', data: {} }),
1732
+ };
1733
+ const pipeline = define('test').on('Trigger').emit('IdleCountCmd', {}).build();
707
1734
  const server = new PipelineServer({ port: 0 });
1735
+ server.registerCommandHandlers([handler]);
708
1736
  server.registerPipeline(pipeline);
709
1737
  await server.start();
710
- const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
711
- const html = await res.text();
712
- expect(html).toContain('flowchart LR');
713
- expect(html).toContain('evt_Start');
1738
+
1739
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
1740
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:IdleCountCmd');
1741
+ expect(cmdNode?.status).toBe('idle');
1742
+ expect(cmdNode?.pendingCount).toBe(0);
1743
+ expect(cmdNode?.endedCount).toBe(0);
1744
+
714
1745
  await server.stop();
715
1746
  });
716
1747
 
717
- it('should have a valid HTML structure', async () => {
718
- const pipeline = define('test').on('Start').emit('Process', {}).build();
1748
+ it('documents behavior: status remains error after retry without itemKey extractor (fix: register extractor)', async () => {
1749
+ let callCount = 0;
1750
+ const handler = {
1751
+ name: 'RetryNoExtractor',
1752
+ events: ['RetryNoExtractorDone', 'RetryNoExtractorFailed'],
1753
+ handle: async () => {
1754
+ callCount++;
1755
+ if (callCount === 1) {
1756
+ return { type: 'RetryNoExtractorFailed', data: {} };
1757
+ }
1758
+ return { type: 'RetryNoExtractorDone', data: {} };
1759
+ },
1760
+ };
1761
+ const pipeline = define('test').on('Trigger').emit('RetryNoExtractor', {}).build();
719
1762
  const server = new PipelineServer({ port: 0 });
1763
+ server.registerCommandHandlers([handler]);
720
1764
  server.registerPipeline(pipeline);
721
1765
  await server.start();
722
- const res = await fetch(`http://localhost:${server.port}/pipeline/diagram`);
723
- const html = await res.text();
724
- expect(html).toContain('<!DOCTYPE html>');
725
- expect(html).toContain('<html');
726
- expect(html).toContain('</html>');
1766
+
1767
+ const correlationId = `corr-retry-no-extractor-bug`;
1768
+
1769
+ await fetch(`http://localhost:${server.port}/command`, {
1770
+ method: 'POST',
1771
+ headers: { 'Content-Type': 'application/json' },
1772
+ body: JSON.stringify({ type: 'RetryNoExtractor', data: { targetDir: '/slice1' }, correlationId }),
1773
+ });
1774
+ await new Promise((r) => setTimeout(r, 50));
1775
+
1776
+ const afterFailure = await fetchAs<PipelineResponse>(
1777
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1778
+ );
1779
+ expect(afterFailure.nodes.find((n) => n.id === 'cmd:RetryNoExtractor')?.status).toBe('error');
1780
+
1781
+ await fetch(`http://localhost:${server.port}/command`, {
1782
+ method: 'POST',
1783
+ headers: { 'Content-Type': 'application/json' },
1784
+ body: JSON.stringify({ type: 'RetryNoExtractor', data: { targetDir: '/slice1' }, correlationId }),
1785
+ });
1786
+ await new Promise((r) => setTimeout(r, 50));
1787
+
1788
+ const afterRetry = await fetchAs<PipelineResponse>(
1789
+ `http://localhost:${server.port}/pipeline?correlationId=${correlationId}`,
1790
+ );
1791
+ const node = afterRetry.nodes.find((n) => n.id === 'cmd:RetryNoExtractor');
1792
+ expect(node?.status).toBe('error');
1793
+ expect(node?.endedCount).toBe(2);
1794
+
727
1795
  await server.stop();
728
1796
  });
729
1797
  });
@@ -758,4 +1826,166 @@ describe('PipelineServer', () => {
758
1826
  await server.stop();
759
1827
  });
760
1828
  });
1829
+
1830
+ describe('GET /events', () => {
1831
+ it('should accept SSE connections', async () => {
1832
+ const server = new PipelineServer({ port: 0 });
1833
+ await server.start();
1834
+
1835
+ const controller = new AbortController();
1836
+ const responsePromise = fetch(`http://localhost:${server.port}/events`, {
1837
+ signal: controller.signal,
1838
+ });
1839
+
1840
+ await new Promise((r) => setTimeout(r, 50));
1841
+ controller.abort();
1842
+
1843
+ try {
1844
+ await responsePromise;
1845
+ } catch {
1846
+ // AbortError expected
1847
+ }
1848
+
1849
+ await server.stop();
1850
+ });
1851
+
1852
+ it('should accept SSE connections with correlationId filter', async () => {
1853
+ const server = new PipelineServer({ port: 0 });
1854
+ await server.start();
1855
+
1856
+ const controller = new AbortController();
1857
+ const responsePromise = fetch(`http://localhost:${server.port}/events?correlationId=test-123`, {
1858
+ signal: controller.signal,
1859
+ });
1860
+
1861
+ await new Promise((r) => setTimeout(r, 50));
1862
+ controller.abort();
1863
+
1864
+ try {
1865
+ await responsePromise;
1866
+ } catch {
1867
+ // AbortError expected
1868
+ }
1869
+
1870
+ await server.stop();
1871
+ });
1872
+ });
1873
+
1874
+ describe('phased execution', () => {
1875
+ it('should emit phased execution events when foreach-phased handler runs', async () => {
1876
+ type Component = { path: string; priority: 'high' | 'medium' | 'low' };
1877
+ type ComponentEvent = { data: { components: Component[] } };
1878
+ type ResultEvent = { data: { componentPath: string } };
1879
+
1880
+ const generateHandler = {
1881
+ name: 'GenerateComponents',
1882
+ events: ['ComponentsGenerated'],
1883
+ handle: async () => ({
1884
+ type: 'ComponentsGenerated',
1885
+ data: { components: [{ path: '/comp/a.tsx', priority: 'high' }] },
1886
+ }),
1887
+ };
1888
+
1889
+ const implementHandler = {
1890
+ name: 'ImplementComponent',
1891
+ events: ['ComponentImplemented'],
1892
+ handle: async (cmd: { data: { componentPath: string } }) => ({
1893
+ type: 'ComponentImplemented',
1894
+ data: { componentPath: cmd.data.componentPath },
1895
+ }),
1896
+ };
1897
+
1898
+ const pipeline = define('test')
1899
+ .on('ComponentsGenerated')
1900
+ .forEach((e: ComponentEvent) => e.data.components)
1901
+ .groupInto(['high', 'medium', 'low'], (c: Component) => c.priority)
1902
+ .process('ImplementComponent', (c: Component) => ({ componentPath: c.path }))
1903
+ .onComplete({
1904
+ success: 'AllComponentsImplemented',
1905
+ failure: 'ComponentImplementationFailed',
1906
+ itemKey: (e: ResultEvent) => e.data.componentPath,
1907
+ })
1908
+ .build();
1909
+
1910
+ const server = new PipelineServer({ port: 0 });
1911
+ server.registerCommandHandlers([generateHandler, implementHandler]);
1912
+ server.registerPipeline(pipeline);
1913
+ await server.start();
1914
+
1915
+ await fetch(`http://localhost:${server.port}/command`, {
1916
+ method: 'POST',
1917
+ headers: { 'Content-Type': 'application/json' },
1918
+ body: JSON.stringify({ type: 'GenerateComponents', data: {} }),
1919
+ });
1920
+
1921
+ await new Promise((r) => setTimeout(r, 300));
1922
+ await server.stop();
1923
+ });
1924
+ });
1925
+
1926
+ describe('POST /execute', () => {
1927
+ it('should call handler and return event directly', async () => {
1928
+ const handler = {
1929
+ name: 'TestCmd',
1930
+ handle: async () => ({ type: 'TestDone', data: { result: 'success' } }),
1931
+ };
1932
+ const server = new PipelineServer({ port: 0 });
1933
+ server.registerCommandHandlers([handler]);
1934
+ await server.start();
1935
+
1936
+ const response = await fetch(`http://localhost:${server.port}/execute`, {
1937
+ method: 'POST',
1938
+ headers: { 'Content-Type': 'application/json' },
1939
+ body: JSON.stringify({ command: 'TestCmd', payload: { input: 'test' } }),
1940
+ });
1941
+
1942
+ const data = (await response.json()) as { event: string; data: Record<string, unknown> };
1943
+ expect(response.status).toBe(200);
1944
+ expect(data).toEqual({ event: 'TestDone', data: { result: 'success' } });
1945
+
1946
+ await server.stop();
1947
+ });
1948
+
1949
+ it('should return 400 for unknown command', async () => {
1950
+ const server = new PipelineServer({ port: 0 });
1951
+ await server.start();
1952
+
1953
+ const response = await fetch(`http://localhost:${server.port}/execute`, {
1954
+ method: 'POST',
1955
+ headers: { 'Content-Type': 'application/json' },
1956
+ body: JSON.stringify({ command: 'NonExistentCmd', payload: {} }),
1957
+ });
1958
+
1959
+ const data = (await response.json()) as { error: string };
1960
+ expect(response.status).toBe(400);
1961
+ expect(data).toEqual({ error: 'Unknown command: NonExistentCmd' });
1962
+
1963
+ await server.stop();
1964
+ });
1965
+
1966
+ it('should return first event when handler returns array', async () => {
1967
+ const handler = {
1968
+ name: 'MultiEventCmd',
1969
+ handle: async () => [
1970
+ { type: 'FirstEvent', data: { order: 1 } },
1971
+ { type: 'SecondEvent', data: { order: 2 } },
1972
+ ],
1973
+ };
1974
+ const server = new PipelineServer({ port: 0 });
1975
+ server.registerCommandHandlers([handler]);
1976
+ await server.start();
1977
+
1978
+ const response = await fetch(`http://localhost:${server.port}/execute`, {
1979
+ method: 'POST',
1980
+ headers: { 'Content-Type': 'application/json' },
1981
+ body: JSON.stringify({ command: 'MultiEventCmd', payload: {} }),
1982
+ });
1983
+
1984
+ const data = (await response.json()) as { event: string; data: Record<string, unknown> };
1985
+ expect(response.status).toBe(200);
1986
+ expect(data).toEqual({ event: 'FirstEvent', data: { order: 1 } });
1987
+
1988
+ await server.stop();
1989
+ });
1990
+ });
761
1991
  });