@provos/ironcurtain 0.9.1 → 0.11.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 (502) hide show
  1. package/README.md +69 -10
  2. package/dist/auth/oauth-provider.js +1 -4
  3. package/dist/auth/oauth-provider.js.map +1 -1
  4. package/dist/cli.js +40 -2
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config/config-command.js +174 -10
  7. package/dist/config/config-command.js.map +1 -1
  8. package/dist/config/first-start.js +61 -0
  9. package/dist/config/first-start.js.map +1 -1
  10. package/dist/config/generated/compiled-policy.json +528 -161
  11. package/dist/config/generated/test-scenarios.json +4810 -170
  12. package/dist/config/generated/tool-annotations.json +34 -47
  13. package/dist/config/index.d.ts +58 -0
  14. package/dist/config/index.js +97 -15
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/mcp-servers.json +2 -2
  17. package/dist/config/model-provider.d.ts +8 -2
  18. package/dist/config/model-provider.js +52 -9
  19. package/dist/config/model-provider.js.map +1 -1
  20. package/dist/config/paths.d.ts +181 -0
  21. package/dist/config/paths.js +326 -23
  22. package/dist/config/paths.js.map +1 -1
  23. package/dist/config/types.d.ts +7 -1
  24. package/dist/config/user-config.d.ts +61 -4
  25. package/dist/config/user-config.js +108 -21
  26. package/dist/config/user-config.js.map +1 -1
  27. package/dist/config/validate-policy-dir.d.ts +10 -0
  28. package/dist/config/validate-policy-dir.js +59 -0
  29. package/dist/config/validate-policy-dir.js.map +1 -0
  30. package/dist/cron/compile-task-policy.js +1 -0
  31. package/dist/cron/compile-task-policy.js.map +1 -1
  32. package/dist/cron/job-commands.js +22 -2
  33. package/dist/cron/job-commands.js.map +1 -1
  34. package/dist/cron/types.d.ts +16 -2
  35. package/dist/cron/types.js +1 -3
  36. package/dist/cron/types.js.map +1 -1
  37. package/dist/daemon/daemon-command.js +16 -2
  38. package/dist/daemon/daemon-command.js.map +1 -1
  39. package/dist/daemon/ironcurtain-daemon.d.ts +44 -1
  40. package/dist/daemon/ironcurtain-daemon.js +210 -8
  41. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  42. package/dist/docker/adapters/claude-code.d.ts +2 -1
  43. package/dist/docker/adapters/claude-code.js +320 -131
  44. package/dist/docker/adapters/claude-code.js.map +1 -1
  45. package/dist/docker/adapters/goose.d.ts +12 -11
  46. package/dist/docker/adapters/goose.js +80 -27
  47. package/dist/docker/adapters/goose.js.map +1 -1
  48. package/dist/docker/agent-adapter.d.ts +178 -4
  49. package/dist/docker/agent-adapter.js +14 -0
  50. package/dist/docker/agent-adapter.js.map +1 -1
  51. package/dist/docker/agent-registry.js +3 -2
  52. package/dist/docker/agent-registry.js.map +1 -1
  53. package/dist/docker/code-mode-proxy.d.ts +27 -0
  54. package/dist/docker/code-mode-proxy.js +3 -0
  55. package/dist/docker/code-mode-proxy.js.map +1 -1
  56. package/dist/docker/container-lifecycle.d.ts +15 -0
  57. package/dist/docker/container-lifecycle.js +32 -0
  58. package/dist/docker/container-lifecycle.js.map +1 -0
  59. package/dist/docker/docker-agent-session.d.ts +113 -62
  60. package/dist/docker/docker-agent-session.js +241 -300
  61. package/dist/docker/docker-agent-session.js.map +1 -1
  62. package/dist/docker/docker-infrastructure.d.ts +256 -21
  63. package/dist/docker/docker-infrastructure.js +585 -30
  64. package/dist/docker/docker-infrastructure.js.map +1 -1
  65. package/dist/docker/docker-manager.d.ts +36 -1
  66. package/dist/docker/docker-manager.js +168 -26
  67. package/dist/docker/docker-manager.js.map +1 -1
  68. package/dist/docker/docker-progress-sink.d.ts +43 -0
  69. package/dist/docker/docker-progress-sink.js +299 -0
  70. package/dist/docker/docker-progress-sink.js.map +1 -0
  71. package/dist/docker/mitm-proxy.d.ts +90 -1
  72. package/dist/docker/mitm-proxy.js +720 -40
  73. package/dist/docker/mitm-proxy.js.map +1 -1
  74. package/dist/docker/oauth-credentials.d.ts +46 -3
  75. package/dist/docker/oauth-credentials.js +56 -14
  76. package/dist/docker/oauth-credentials.js.map +1 -1
  77. package/dist/docker/oauth-token-manager.js +2 -2
  78. package/dist/docker/oauth-token-manager.js.map +1 -1
  79. package/dist/docker/orientation.d.ts +1 -1
  80. package/dist/docker/provider-config.d.ts +81 -2
  81. package/dist/docker/provider-config.js +222 -28
  82. package/dist/docker/provider-config.js.map +1 -1
  83. package/dist/docker/proxy-tools.d.ts +0 -2
  84. package/dist/docker/proxy-tools.js +2 -7
  85. package/dist/docker/proxy-tools.js.map +1 -1
  86. package/dist/docker/pty-session.d.ts +49 -1
  87. package/dist/docker/pty-session.js +212 -90
  88. package/dist/docker/pty-session.js.map +1 -1
  89. package/dist/docker/pty-types.d.ts +0 -2
  90. package/dist/docker/pty-types.js +0 -2
  91. package/dist/docker/pty-types.js.map +1 -1
  92. package/dist/docker/resource-limits.d.ts +125 -0
  93. package/dist/docker/resource-limits.js +200 -0
  94. package/dist/docker/resource-limits.js.map +1 -0
  95. package/dist/docker/spawn-with-idle-timeout.d.ts +39 -0
  96. package/dist/docker/spawn-with-idle-timeout.js +152 -0
  97. package/dist/docker/spawn-with-idle-timeout.js.map +1 -0
  98. package/dist/docker/sse-extractor.d.ts +51 -0
  99. package/dist/docker/sse-extractor.js +303 -0
  100. package/dist/docker/sse-extractor.js.map +1 -0
  101. package/dist/docker/token-stream-bus.d.ts +83 -0
  102. package/dist/docker/token-stream-bus.js +98 -0
  103. package/dist/docker/token-stream-bus.js.map +1 -0
  104. package/dist/docker/token-stream-types.d.ts +71 -0
  105. package/dist/docker/token-stream-types.js +8 -0
  106. package/dist/docker/token-stream-types.js.map +1 -0
  107. package/dist/docker/types.d.ts +61 -6
  108. package/dist/doctor/checks.d.ts +138 -0
  109. package/dist/doctor/checks.js +511 -0
  110. package/dist/doctor/checks.js.map +1 -0
  111. package/dist/doctor/doctor-command.d.ts +29 -0
  112. package/dist/doctor/doctor-command.js +160 -0
  113. package/dist/doctor/doctor-command.js.map +1 -0
  114. package/dist/doctor/mcp-liveness.d.ts +28 -0
  115. package/dist/doctor/mcp-liveness.js +84 -0
  116. package/dist/doctor/mcp-liveness.js.map +1 -0
  117. package/dist/doctor/oauth-checks.d.ts +23 -0
  118. package/dist/doctor/oauth-checks.js +157 -0
  119. package/dist/doctor/oauth-checks.js.map +1 -0
  120. package/dist/doctor/output.d.ts +26 -0
  121. package/dist/doctor/output.js +82 -0
  122. package/dist/doctor/output.js.map +1 -0
  123. package/dist/escalation/listener-command.js +0 -3
  124. package/dist/escalation/listener-command.js.map +1 -1
  125. package/dist/event-bus/typed-event-bus.d.ts +26 -0
  126. package/dist/event-bus/typed-event-bus.js +35 -0
  127. package/dist/event-bus/typed-event-bus.js.map +1 -0
  128. package/dist/index.js +32 -9
  129. package/dist/index.js.map +1 -1
  130. package/dist/logger.js +31 -2
  131. package/dist/logger.js.map +1 -1
  132. package/dist/memory/auto-save.d.ts +18 -4
  133. package/dist/memory/auto-save.js +46 -6
  134. package/dist/memory/auto-save.js.map +1 -1
  135. package/dist/memory/memory-policy.d.ts +41 -0
  136. package/dist/memory/memory-policy.js +36 -0
  137. package/dist/memory/memory-policy.js.map +1 -0
  138. package/dist/mux/mux-app.d.ts +2 -0
  139. package/dist/mux/mux-app.js +30 -34
  140. package/dist/mux/mux-app.js.map +1 -1
  141. package/dist/mux/mux-command.d.ts +10 -1
  142. package/dist/mux/mux-command.js +62 -35
  143. package/dist/mux/mux-command.js.map +1 -1
  144. package/dist/mux/mux-renderer.js +22 -17
  145. package/dist/mux/mux-renderer.js.map +1 -1
  146. package/dist/mux/pty-bridge.d.ts +2 -0
  147. package/dist/mux/pty-bridge.js +3 -0
  148. package/dist/mux/pty-bridge.js.map +1 -1
  149. package/dist/mux/session-scanner.d.ts +7 -0
  150. package/dist/mux/session-scanner.js +21 -2
  151. package/dist/mux/session-scanner.js.map +1 -1
  152. package/dist/mux/types.d.ts +0 -4
  153. package/dist/mux/types.js.map +1 -1
  154. package/dist/{pipeline → observability}/llm-logger.d.ts +9 -3
  155. package/dist/{pipeline → observability}/llm-logger.js +16 -4
  156. package/dist/observability/llm-logger.js.map +1 -0
  157. package/dist/observe/observe-command.d.ts +12 -0
  158. package/dist/observe/observe-command.js +298 -0
  159. package/dist/observe/observe-command.js.map +1 -0
  160. package/dist/observe/observe-renderer.d.ts +24 -0
  161. package/dist/observe/observe-renderer.js +87 -0
  162. package/dist/observe/observe-renderer.js.map +1 -0
  163. package/dist/observe/observe-tui-rain.d.ts +42 -0
  164. package/dist/observe/observe-tui-rain.js +531 -0
  165. package/dist/observe/observe-tui-rain.js.map +1 -0
  166. package/dist/observe/observe-tui-text-panel.d.ts +46 -0
  167. package/dist/observe/observe-tui-text-panel.js +525 -0
  168. package/dist/observe/observe-tui-text-panel.js.map +1 -0
  169. package/dist/observe/observe-tui-types.d.ts +270 -0
  170. package/dist/observe/observe-tui-types.js +200 -0
  171. package/dist/observe/observe-tui-types.js.map +1 -0
  172. package/dist/observe/observe-tui-word-scorer.d.ts +143 -0
  173. package/dist/observe/observe-tui-word-scorer.js +572 -0
  174. package/dist/observe/observe-tui-word-scorer.js.map +1 -0
  175. package/dist/observe/observe-tui.d.ts +59 -0
  176. package/dist/observe/observe-tui.js +592 -0
  177. package/dist/observe/observe-tui.js.map +1 -0
  178. package/dist/persona/compile-persona-policy.js +1 -0
  179. package/dist/persona/compile-persona-policy.js.map +1 -1
  180. package/dist/persona/memory-gate.d.ts +19 -0
  181. package/dist/persona/memory-gate.js +33 -0
  182. package/dist/persona/memory-gate.js.map +1 -0
  183. package/dist/persona/persona-command.js +41 -6
  184. package/dist/persona/persona-command.js.map +1 -1
  185. package/dist/persona/resolve.d.ts +22 -0
  186. package/dist/persona/resolve.js +45 -2
  187. package/dist/persona/resolve.js.map +1 -1
  188. package/dist/persona/types.d.ts +16 -0
  189. package/dist/pipeline/annotate.d.ts +7 -1
  190. package/dist/pipeline/annotate.js +92 -2
  191. package/dist/pipeline/annotate.js.map +1 -1
  192. package/dist/pipeline/compile.js +2 -0
  193. package/dist/pipeline/compile.js.map +1 -1
  194. package/dist/pipeline/constitution-compiler.d.ts +25 -14
  195. package/dist/pipeline/constitution-compiler.js +107 -38
  196. package/dist/pipeline/constitution-compiler.js.map +1 -1
  197. package/dist/pipeline/dynamic-list-types.d.ts +3 -6
  198. package/dist/pipeline/dynamic-list-types.js +3 -16
  199. package/dist/pipeline/dynamic-list-types.js.map +1 -1
  200. package/dist/pipeline/parallel-progress.d.ts +59 -0
  201. package/dist/pipeline/parallel-progress.js +263 -0
  202. package/dist/pipeline/parallel-progress.js.map +1 -0
  203. package/dist/pipeline/pipeline-runner.d.ts +49 -8
  204. package/dist/pipeline/pipeline-runner.js +419 -231
  205. package/dist/pipeline/pipeline-runner.js.map +1 -1
  206. package/dist/pipeline/pipeline-shared.d.ts +59 -1
  207. package/dist/pipeline/pipeline-shared.js +127 -12
  208. package/dist/pipeline/pipeline-shared.js.map +1 -1
  209. package/dist/pipeline/policy-verifier.js +16 -3
  210. package/dist/pipeline/policy-verifier.js.map +1 -1
  211. package/dist/pipeline/proxy-mcp-connections.js +17 -2
  212. package/dist/pipeline/proxy-mcp-connections.js.map +1 -1
  213. package/dist/pipeline/scenario-generator.d.ts +4 -8
  214. package/dist/pipeline/scenario-generator.js +13 -46
  215. package/dist/pipeline/scenario-generator.js.map +1 -1
  216. package/dist/pipeline/server-prefilter.d.ts +31 -0
  217. package/dist/pipeline/server-prefilter.js +115 -0
  218. package/dist/pipeline/server-prefilter.js.map +1 -0
  219. package/dist/pipeline/types.d.ts +5 -0
  220. package/dist/sandbox/index.d.ts +14 -0
  221. package/dist/sandbox/index.js +335 -116
  222. package/dist/sandbox/index.js.map +1 -1
  223. package/dist/sandbox/ironcurtain-protocol.d.ts +52 -0
  224. package/dist/sandbox/ironcurtain-protocol.js +185 -0
  225. package/dist/sandbox/ironcurtain-protocol.js.map +1 -0
  226. package/dist/session/agent-session.d.ts +6 -0
  227. package/dist/session/agent-session.js +16 -5
  228. package/dist/session/agent-session.js.map +1 -1
  229. package/dist/session/cli-transport.js +0 -6
  230. package/dist/session/cli-transport.js.map +1 -1
  231. package/dist/session/index.d.ts +35 -14
  232. package/dist/session/index.js +324 -130
  233. package/dist/session/index.js.map +1 -1
  234. package/dist/session/preflight.d.ts +50 -13
  235. package/dist/session/preflight.js +252 -80
  236. package/dist/session/preflight.js.map +1 -1
  237. package/dist/session/prompts.d.ts +2 -5
  238. package/dist/session/prompts.js.map +1 -1
  239. package/dist/session/session-manager.d.ts +61 -6
  240. package/dist/session/session-manager.js +72 -10
  241. package/dist/session/session-manager.js.map +1 -1
  242. package/dist/session/session-metadata.d.ts +21 -5
  243. package/dist/session/session-metadata.js +34 -12
  244. package/dist/session/session-metadata.js.map +1 -1
  245. package/dist/session/types.d.ts +311 -5
  246. package/dist/session/types.js +57 -1
  247. package/dist/session/types.js.map +1 -1
  248. package/dist/signal/setup-signal.js +13 -21
  249. package/dist/signal/setup-signal.js.map +1 -1
  250. package/dist/signal/signal-bot-daemon.d.ts +16 -1
  251. package/dist/signal/signal-bot-daemon.js +39 -27
  252. package/dist/signal/signal-bot-daemon.js.map +1 -1
  253. package/dist/signal/signal-container.js +7 -1
  254. package/dist/signal/signal-container.js.map +1 -1
  255. package/dist/skills/discovery.d.ts +77 -0
  256. package/dist/skills/discovery.js +206 -0
  257. package/dist/skills/discovery.js.map +1 -0
  258. package/dist/skills/index.d.ts +4 -0
  259. package/dist/skills/index.js +3 -0
  260. package/dist/skills/index.js.map +1 -0
  261. package/dist/skills/staging.d.ts +28 -0
  262. package/dist/skills/staging.js +68 -0
  263. package/dist/skills/staging.js.map +1 -0
  264. package/dist/skills/types.d.ts +46 -0
  265. package/dist/skills/types.js +2 -0
  266. package/dist/skills/types.js.map +1 -0
  267. package/dist/trusted-process/approval-whitelist.d.ts +0 -2
  268. package/dist/trusted-process/approval-whitelist.js +17 -17
  269. package/dist/trusted-process/approval-whitelist.js.map +1 -1
  270. package/dist/trusted-process/async-mutex.d.ts +32 -0
  271. package/dist/trusted-process/async-mutex.js +50 -0
  272. package/dist/trusted-process/async-mutex.js.map +1 -0
  273. package/dist/trusted-process/audit-log.d.ts +38 -1
  274. package/dist/trusted-process/audit-log.js +82 -13
  275. package/dist/trusted-process/audit-log.js.map +1 -1
  276. package/dist/trusted-process/call-circuit-breaker.js +2 -1
  277. package/dist/trusted-process/call-circuit-breaker.js.map +1 -1
  278. package/dist/trusted-process/control-server.d.ts +101 -0
  279. package/dist/trusted-process/control-server.js +282 -0
  280. package/dist/trusted-process/control-server.js.map +1 -0
  281. package/dist/trusted-process/domain-utils.d.ts +6 -6
  282. package/dist/trusted-process/domain-utils.js +7 -23
  283. package/dist/trusted-process/domain-utils.js.map +1 -1
  284. package/dist/trusted-process/error-prefixes.d.ts +16 -0
  285. package/dist/trusted-process/error-prefixes.js +17 -0
  286. package/dist/trusted-process/error-prefixes.js.map +1 -0
  287. package/dist/trusted-process/index.d.ts +40 -23
  288. package/dist/trusted-process/index.js +131 -225
  289. package/dist/trusted-process/index.js.map +1 -1
  290. package/dist/trusted-process/list-matcher.d.ts +17 -0
  291. package/dist/trusted-process/list-matcher.js +31 -0
  292. package/dist/trusted-process/list-matcher.js.map +1 -0
  293. package/dist/trusted-process/mcp-client-manager.d.ts +35 -6
  294. package/dist/trusted-process/mcp-client-manager.js +40 -45
  295. package/dist/trusted-process/mcp-client-manager.js.map +1 -1
  296. package/dist/trusted-process/mcp-proxy-server.d.ts +31 -120
  297. package/dist/trusted-process/mcp-proxy-server.js +166 -657
  298. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  299. package/dist/trusted-process/policy-engine.d.ts +16 -4
  300. package/dist/trusted-process/policy-engine.js +90 -45
  301. package/dist/trusted-process/policy-engine.js.map +1 -1
  302. package/dist/trusted-process/policy-roots.d.ts +6 -0
  303. package/dist/trusted-process/policy-roots.js +20 -0
  304. package/dist/trusted-process/policy-roots.js.map +1 -1
  305. package/dist/trusted-process/sandbox-integration.d.ts +12 -0
  306. package/dist/trusted-process/sandbox-integration.js +26 -0
  307. package/dist/trusted-process/sandbox-integration.js.map +1 -1
  308. package/dist/trusted-process/tool-call-coordinator.d.ts +269 -0
  309. package/dist/trusted-process/tool-call-coordinator.js +508 -0
  310. package/dist/trusted-process/tool-call-coordinator.js.map +1 -0
  311. package/dist/trusted-process/tool-call-pipeline.d.ts +205 -0
  312. package/dist/trusted-process/tool-call-pipeline.js +798 -0
  313. package/dist/trusted-process/tool-call-pipeline.js.map +1 -0
  314. package/dist/trusted-process/tool-description-hints.d.ts +1 -1
  315. package/dist/types/argument-roles.d.ts +1 -1
  316. package/dist/types/argument-roles.js +14 -1
  317. package/dist/types/argument-roles.js.map +1 -1
  318. package/dist/types/audit.d.ts +9 -0
  319. package/dist/{session → types}/errors.d.ts +8 -2
  320. package/dist/{session → types}/errors.js +8 -2
  321. package/dist/types/errors.js.map +1 -0
  322. package/dist/types/reserved-servers.d.ts +11 -0
  323. package/dist/types/reserved-servers.js +12 -0
  324. package/dist/types/reserved-servers.js.map +1 -0
  325. package/dist/types/server-listing.d.ts +14 -0
  326. package/dist/types/server-listing.js +2 -0
  327. package/dist/types/server-listing.js.map +1 -0
  328. package/dist/types/slug.d.ts +2 -0
  329. package/dist/types/slug.js +2 -0
  330. package/dist/types/slug.js.map +1 -1
  331. package/dist/types/url-normalize.d.ts +16 -0
  332. package/dist/types/url-normalize.js +33 -0
  333. package/dist/types/url-normalize.js.map +1 -0
  334. package/dist/utils/error-message.d.ts +8 -0
  335. package/dist/utils/error-message.js +11 -0
  336. package/dist/utils/error-message.js.map +1 -0
  337. package/dist/utils/exec-error.d.ts +19 -0
  338. package/dist/utils/exec-error.js +16 -0
  339. package/dist/utils/exec-error.js.map +1 -0
  340. package/dist/utils/is-plain-object.d.ts +7 -0
  341. package/dist/utils/is-plain-object.js +12 -0
  342. package/dist/utils/is-plain-object.js.map +1 -0
  343. package/dist/utils/preflight-checks.d.ts +16 -0
  344. package/dist/utils/preflight-checks.js +178 -0
  345. package/dist/utils/preflight-checks.js.map +1 -0
  346. package/dist/web-ui/__tests__/json-rpc-dispatch.test.d.ts +7 -0
  347. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js +725 -0
  348. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js.map +1 -0
  349. package/dist/web-ui/dispatch/escalation-dispatch.d.ts +7 -0
  350. package/dist/web-ui/dispatch/escalation-dispatch.js +52 -0
  351. package/dist/web-ui/dispatch/escalation-dispatch.js.map +1 -0
  352. package/dist/web-ui/dispatch/job-dispatch.d.ts +8 -0
  353. package/dist/web-ui/dispatch/job-dispatch.js +83 -0
  354. package/dist/web-ui/dispatch/job-dispatch.js.map +1 -0
  355. package/dist/web-ui/dispatch/persona-dispatch.d.ts +6 -0
  356. package/dist/web-ui/dispatch/persona-dispatch.js +116 -0
  357. package/dist/web-ui/dispatch/persona-dispatch.js.map +1 -0
  358. package/dist/web-ui/dispatch/session-dispatch.d.ts +9 -0
  359. package/dist/web-ui/dispatch/session-dispatch.js +201 -0
  360. package/dist/web-ui/dispatch/session-dispatch.js.map +1 -0
  361. package/dist/web-ui/dispatch/token-stream-dispatch.d.ts +15 -0
  362. package/dist/web-ui/dispatch/token-stream-dispatch.js +55 -0
  363. package/dist/web-ui/dispatch/token-stream-dispatch.js.map +1 -0
  364. package/dist/web-ui/dispatch/types.d.ts +31 -0
  365. package/dist/web-ui/dispatch/types.js +68 -0
  366. package/dist/web-ui/dispatch/types.js.map +1 -0
  367. package/dist/web-ui/dispatch/workflow-dispatch.d.ts +103 -0
  368. package/dist/web-ui/dispatch/workflow-dispatch.js +925 -0
  369. package/dist/web-ui/dispatch/workflow-dispatch.js.map +1 -0
  370. package/dist/web-ui/json-rpc-dispatch.d.ts +12 -0
  371. package/dist/web-ui/json-rpc-dispatch.js +34 -0
  372. package/dist/web-ui/json-rpc-dispatch.js.map +1 -0
  373. package/dist/web-ui/state-graph.d.ts +13 -0
  374. package/dist/web-ui/state-graph.js +82 -0
  375. package/dist/web-ui/state-graph.js.map +1 -0
  376. package/dist/web-ui/token-stream-bridge.d.ts +76 -0
  377. package/dist/web-ui/token-stream-bridge.js +265 -0
  378. package/dist/web-ui/token-stream-bridge.js.map +1 -0
  379. package/dist/web-ui/web-event-bus.d.ts +152 -0
  380. package/dist/web-ui/web-event-bus.js +19 -0
  381. package/dist/web-ui/web-event-bus.js.map +1 -0
  382. package/dist/web-ui/web-session-transport.d.ts +31 -0
  383. package/dist/web-ui/web-session-transport.js +109 -0
  384. package/dist/web-ui/web-session-transport.js.map +1 -0
  385. package/dist/web-ui/web-ui-server.d.ts +76 -0
  386. package/dist/web-ui/web-ui-server.js +461 -0
  387. package/dist/web-ui/web-ui-server.js.map +1 -0
  388. package/dist/web-ui/web-ui-types.d.ts +290 -0
  389. package/dist/web-ui/web-ui-types.js +32 -0
  390. package/dist/web-ui/web-ui-types.js.map +1 -0
  391. package/dist/web-ui/ws-utils.d.ts +5 -0
  392. package/dist/web-ui/ws-utils.js +12 -0
  393. package/dist/web-ui/ws-utils.js.map +1 -0
  394. package/dist/web-ui-static/assets/index-BFO5z1js.js +76 -0
  395. package/dist/web-ui-static/assets/index-CCUwrkIL.css +1 -0
  396. package/dist/web-ui-static/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  397. package/dist/web-ui-static/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  398. package/dist/web-ui-static/index.html +14 -0
  399. package/dist/workflow/artifacts.d.ts +59 -0
  400. package/dist/workflow/artifacts.js +188 -0
  401. package/dist/workflow/artifacts.js.map +1 -0
  402. package/dist/workflow/checkpoint-selection.d.ts +34 -0
  403. package/dist/workflow/checkpoint-selection.js +48 -0
  404. package/dist/workflow/checkpoint-selection.js.map +1 -0
  405. package/dist/workflow/checkpoint.d.ts +35 -0
  406. package/dist/workflow/checkpoint.js +84 -0
  407. package/dist/workflow/checkpoint.js.map +1 -0
  408. package/dist/workflow/cli-support.d.ts +92 -0
  409. package/dist/workflow/cli-support.js +423 -0
  410. package/dist/workflow/cli-support.js.map +1 -0
  411. package/dist/workflow/definition-loader.d.ts +32 -0
  412. package/dist/workflow/definition-loader.js +56 -0
  413. package/dist/workflow/definition-loader.js.map +1 -0
  414. package/dist/workflow/discovery.d.ts +78 -0
  415. package/dist/workflow/discovery.js +203 -0
  416. package/dist/workflow/discovery.js.map +1 -0
  417. package/dist/workflow/errors.d.ts +66 -0
  418. package/dist/workflow/errors.js +49 -0
  419. package/dist/workflow/errors.js.map +1 -0
  420. package/dist/workflow/guards.d.ts +8 -0
  421. package/dist/workflow/guards.js +38 -0
  422. package/dist/workflow/guards.js.map +1 -0
  423. package/dist/workflow/lint-integration.d.ts +64 -0
  424. package/dist/workflow/lint-integration.js +56 -0
  425. package/dist/workflow/lint-integration.js.map +1 -0
  426. package/dist/workflow/lint.d.ts +66 -0
  427. package/dist/workflow/lint.js +410 -0
  428. package/dist/workflow/lint.js.map +1 -0
  429. package/dist/workflow/machine-builder.d.ts +90 -0
  430. package/dist/workflow/machine-builder.js +485 -0
  431. package/dist/workflow/machine-builder.js.map +1 -0
  432. package/dist/workflow/message-log.d.ts +85 -0
  433. package/dist/workflow/message-log.js +40 -0
  434. package/dist/workflow/message-log.js.map +1 -0
  435. package/dist/workflow/orchestrator.d.ts +450 -0
  436. package/dist/workflow/orchestrator.js +1941 -0
  437. package/dist/workflow/orchestrator.js.map +1 -0
  438. package/dist/workflow/prompt-builder.d.ts +39 -0
  439. package/dist/workflow/prompt-builder.js +242 -0
  440. package/dist/workflow/prompt-builder.js.map +1 -0
  441. package/dist/workflow/run-state-command.d.ts +9 -0
  442. package/dist/workflow/run-state-command.js +414 -0
  443. package/dist/workflow/run-state-command.js.map +1 -0
  444. package/dist/workflow/run-state-debug-capture.d.ts +40 -0
  445. package/dist/workflow/run-state-debug-capture.js +101 -0
  446. package/dist/workflow/run-state-debug-capture.js.map +1 -0
  447. package/dist/workflow/status-parser.d.ts +68 -0
  448. package/dist/workflow/status-parser.js +259 -0
  449. package/dist/workflow/status-parser.js.map +1 -0
  450. package/dist/workflow/types.d.ts +498 -0
  451. package/dist/workflow/types.js +83 -0
  452. package/dist/workflow/types.js.map +1 -0
  453. package/dist/workflow/validate.d.ts +58 -0
  454. package/dist/workflow/validate.js +499 -0
  455. package/dist/workflow/validate.js.map +1 -0
  456. package/dist/workflow/workflow-command.d.ts +26 -0
  457. package/dist/workflow/workflow-command.js +648 -0
  458. package/dist/workflow/workflow-command.js.map +1 -0
  459. package/dist/workflow/workflow-discovery.d.ts +25 -0
  460. package/dist/workflow/workflow-discovery.js +100 -0
  461. package/dist/workflow/workflow-discovery.js.map +1 -0
  462. package/dist/workflow/workflow-manager.d.ts +122 -0
  463. package/dist/workflow/workflow-manager.js +308 -0
  464. package/dist/workflow/workflow-manager.js.map +1 -0
  465. package/dist/workflow/workflows/design-and-code/workflow.yaml +169 -0
  466. package/dist/workflow/workflows/test-email-summary/skills/email-formatting/SKILL.md +55 -0
  467. package/dist/workflow/workflows/test-email-summary/skills/gmail-query-syntax/SKILL.md +63 -0
  468. package/dist/workflow/workflows/test-email-summary/skills/json-schema-emails/SKILL.md +41 -0
  469. package/dist/workflow/workflows/test-email-summary/skills/json-schema-emails/schema.json +34 -0
  470. package/dist/workflow/workflows/test-email-summary/skills/summary-style-guide/SKILL.md +51 -0
  471. package/dist/workflow/workflows/test-email-summary/workflow.yaml +89 -0
  472. package/dist/workflow/workflows/vuln-discovery/skills/harness-design-fuzzing/SKILL.md +201 -0
  473. package/dist/workflow/workflows/vuln-discovery/skills/memory-safety-c-cpp/SKILL.md +166 -0
  474. package/dist/workflow/workflows/vuln-discovery/skills/vulnerability-triage/SKILL.md +185 -0
  475. package/dist/workflow/workflows/vuln-discovery/workflow.yaml +920 -0
  476. package/dist/workflow/worktree.d.ts +43 -0
  477. package/dist/workflow/worktree.js +106 -0
  478. package/dist/workflow/worktree.js.map +1 -0
  479. package/docker/Dockerfile.base +12 -1
  480. package/docker/Dockerfile.base.arm64 +17 -1
  481. package/docker/entrypoint-claude-code.sh +46 -0
  482. package/docker/entrypoint-goose.sh +25 -0
  483. package/package.json +22 -8
  484. package/packages/web-ui/README.md +113 -0
  485. package/packages/web-ui/docs/workflow-state-machine.png +0 -0
  486. package/src/config/generated/compiled-policy.json +528 -161
  487. package/src/config/generated/test-scenarios.json +4810 -170
  488. package/src/config/generated/tool-annotations.json +34 -47
  489. package/src/config/mcp-servers.json +2 -2
  490. package/src/workflow/workflows/design-and-code/workflow.yaml +169 -0
  491. package/src/workflow/workflows/test-email-summary/skills/email-formatting/SKILL.md +55 -0
  492. package/src/workflow/workflows/test-email-summary/skills/gmail-query-syntax/SKILL.md +63 -0
  493. package/src/workflow/workflows/test-email-summary/skills/json-schema-emails/SKILL.md +41 -0
  494. package/src/workflow/workflows/test-email-summary/skills/json-schema-emails/schema.json +34 -0
  495. package/src/workflow/workflows/test-email-summary/skills/summary-style-guide/SKILL.md +51 -0
  496. package/src/workflow/workflows/test-email-summary/workflow.yaml +89 -0
  497. package/src/workflow/workflows/vuln-discovery/skills/harness-design-fuzzing/SKILL.md +201 -0
  498. package/src/workflow/workflows/vuln-discovery/skills/memory-safety-c-cpp/SKILL.md +166 -0
  499. package/src/workflow/workflows/vuln-discovery/skills/vulnerability-triage/SKILL.md +185 -0
  500. package/src/workflow/workflows/vuln-discovery/workflow.yaml +920 -0
  501. package/dist/pipeline/llm-logger.js.map +0 -1
  502. package/dist/session/errors.js.map +0 -1
@@ -0,0 +1,1941 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync, readdirSync, statSync, unlinkSync, cpSync, } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { MessageLog } from './message-log.js';
4
+ import { createHash } from 'node:crypto';
5
+ import { execFile as execFileCb } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import * as http from 'node:http';
8
+ import { createActor, fromPromise } from 'xstate';
9
+ import { createWorkflowId, WORKFLOW_ARTIFACT_DIR, GLOBAL_PERSONA, DEFAULT_CONTAINER_SCOPE, resolveWorkflowSkillsOptions, } from './types.js';
10
+ import { getBundleAuditLogPath, getBundleBundleDir, getBundleControlSocketPath, getBundleRuntimeRoot, getBundleStatesDir, getInvocationDir, nextStateSlug, } from '../config/paths.js';
11
+ import { POLICY_LOAD_PATH } from '../trusted-process/control-server.js';
12
+ import { loadConfig, loadPersonaPolicyArtifacts } from '../config/index.js';
13
+ import { validatePolicyDir } from '../config/validate-policy-dir.js';
14
+ import { getTokenStreamBus } from '../docker/token-stream-bus.js';
15
+ import { getPersonaDefinitionPath, resolvePersona } from '../persona/resolve.js';
16
+ import { isMemoryEnabledForPersonaName } from '../persona/memory-gate.js';
17
+ import { extractRequiredServers } from '../trusted-process/policy-roots.js';
18
+ import { createPersonaName } from '../persona/types.js';
19
+ import { MEMORY_SERVER_NAME } from '../memory/memory-annotations.js';
20
+ import { createAgentConversationId, createBundleId } from '../session/types.js';
21
+ import { describeTransientFailureKind } from '../docker/agent-adapter.js';
22
+ import { ensureSecureBundleDir } from '../docker/docker-infrastructure.js';
23
+ import { buildWorkflowMachine, } from './machine-builder.js';
24
+ import { parseAgentStatus, AgentStatusParseError, buildStatusBlockReprompt, getValidVerdicts, buildInvalidVerdictReprompt, } from './status-parser.js';
25
+ import { buildAgentCommand, buildArtifactReprompt, buildStatusInstructions } from './prompt-builder.js';
26
+ import { collectFilesRecursive, hasAnyFiles, snapshotArtifacts } from './artifacts.js';
27
+ import { parseArtifactRef, validateDefinition, validateWorkflowSkillReferences } from './validate.js';
28
+ import { parseDefinitionFile, getWorkflowPackageDir } from './discovery.js';
29
+ import { resolveSkillsForSession } from '../skills/discovery.js';
30
+ import { discoverWorkflowRuns } from './workflow-discovery.js';
31
+ import { isCheckpointResumable } from './checkpoint.js';
32
+ import { AgentInvocationError, WorkflowQuotaExhaustedError, WorkflowTransientFailureError, isWorkflowQuotaExhaustedError, isWorkflowTransientFailureError, } from './errors.js';
33
+ const execFileAsync = promisify(execFileCb);
34
+ // ---------------------------------------------------------------------------
35
+ // Transition message truncation
36
+ // ---------------------------------------------------------------------------
37
+ const MAX_TRANSITION_MESSAGE_BYTES = 4096;
38
+ const TRANSITION_TRUNCATION_NOTICE = '\n\n[... truncated]';
39
+ /**
40
+ * Default timeout for a `loadPolicy` RPC to the workflow coordinator.
41
+ * Generous because the coordinator's mutex may be held briefly by an
42
+ * in-flight tool call; tight because "control socket unreachable" must
43
+ * surface fast rather than wedge the workflow.
44
+ */
45
+ const LOAD_POLICY_RPC_TIMEOUT_MS = 10_000;
46
+ function truncateForTransition(text) {
47
+ if (!text)
48
+ return undefined;
49
+ if (Buffer.byteLength(text, 'utf-8') <= MAX_TRANSITION_MESSAGE_BYTES) {
50
+ return text;
51
+ }
52
+ const noticeBudget = MAX_TRANSITION_MESSAGE_BYTES - Buffer.byteLength(TRANSITION_TRUNCATION_NOTICE, 'utf-8');
53
+ let truncated = text;
54
+ while (Buffer.byteLength(truncated, 'utf-8') > noticeBudget) {
55
+ truncated = truncated.slice(0, Math.floor(truncated.length * 0.9));
56
+ }
57
+ return truncated + TRANSITION_TRUNCATION_NOTICE;
58
+ }
59
+ function toErrorMessage(err) {
60
+ return err instanceof Error ? err.message : String(err);
61
+ }
62
+ function tryParseAgentStatus(responseText) {
63
+ try {
64
+ const output = parseAgentStatus(responseText);
65
+ return output ? { kind: 'ok', output } : { kind: 'missing' };
66
+ }
67
+ catch (err) {
68
+ if (err instanceof AgentStatusParseError) {
69
+ return { kind: 'malformed', error: err };
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+ /**
75
+ * Writes directly to process.stderr, bypassing any console hijacking
76
+ * (e.g., logger.setup() redirecting console.error to a file).
77
+ * Used for critical error messages that MUST reach the terminal.
78
+ */
79
+ function writeStderr(message) {
80
+ process.stderr.write(`${message}\n`);
81
+ }
82
+ /** Per-run subdirectory name for the staged copy of the workflow's bundled `skills/` tree. */
83
+ const STAGED_WORKFLOW_SKILLS_SUBDIR = 'workflow-skills';
84
+ /**
85
+ * Stages the workflow package's `skills/` tree into the run directory
86
+ * so the workflow becomes self-contained at start time. After this call
87
+ * the per-run path (`<runMetaDir>/workflow-skills/`) is the canonical
88
+ * source of workflow-bundled skills for both initial-set staging and
89
+ * per-state restage; the original package path under
90
+ * `getWorkflowPackageDir(definitionPath)` is no longer consulted at
91
+ * runtime.
92
+ *
93
+ * Returns the absolute path to the staged copy when source `skills/`
94
+ * exists and was copied, `undefined` when the source has no `skills/`
95
+ * directory (the workflow simply ships none — not an error). Throws
96
+ * only on copy I/O failure, since a half-copied tree would be worse
97
+ * than a missing one.
98
+ *
99
+ * Why copy at start (not symlink): the package directory may be
100
+ * deleted, moved, or rebuilt between start and resume — symlink chases
101
+ * lead to "skills silently empty on resume" without diagnostic. A
102
+ * shallow-recursive copy decouples the run from its source-of-record
103
+ * location.
104
+ */
105
+ export function stageWorkflowSkillsAtStart(packageDir, runMetaDir) {
106
+ const sourceSkillsDir = resolve(packageDir, 'skills');
107
+ if (!existsSync(sourceSkillsDir))
108
+ return undefined;
109
+ const stagedDir = resolve(runMetaDir, STAGED_WORKFLOW_SKILLS_SUBDIR);
110
+ cpSync(sourceSkillsDir, stagedDir, { recursive: true });
111
+ return stagedDir;
112
+ }
113
+ /**
114
+ * Resolves the workflow-skills directory to use for a resumed workflow.
115
+ *
116
+ * Priority order, from most-trusted to most-degraded:
117
+ * 1. The checkpointed staged path, if it still exists. The orchestrator
118
+ * writes this at start under the run dir; we own the lifecycle so
119
+ * it should normally still be there.
120
+ * 2. A fresh stage from the original package dir, when the staged
121
+ * copy is gone but the source is reachable. Recovers from manual
122
+ * pruning of the run dir without losing skills on resume.
123
+ * 3. `undefined`, with a stderr warning. Both the staged copy and
124
+ * the source are gone; degrade gracefully but make the loss
125
+ * visible (the original bug — silent empty discovery — is what
126
+ * this whole resolver exists to prevent).
127
+ *
128
+ * Pre-feature checkpoints have no `workflowSkillsDir` field; they
129
+ * trigger the case-(2) fresh stage transparently.
130
+ */
131
+ function resolveWorkflowSkillsDirOnResume(opts) {
132
+ const { workflowId, checkpointedStagedDir, packageDir, runMetaDir } = opts;
133
+ if (checkpointedStagedDir !== undefined && existsSync(checkpointedStagedDir)) {
134
+ return checkpointedStagedDir;
135
+ }
136
+ try {
137
+ const restaged = stageWorkflowSkillsAtStart(packageDir, runMetaDir);
138
+ if (restaged !== undefined) {
139
+ writeStderr(`[workflow] Resume ${workflowId}: workflow-skills staged copy missing, re-staged from ${packageDir}.`);
140
+ return restaged;
141
+ }
142
+ }
143
+ catch (err) {
144
+ writeStderr(`[workflow] Resume ${workflowId}: failed to re-stage workflow skills from ${packageDir}: ${toErrorMessage(err)}`);
145
+ return undefined;
146
+ }
147
+ // Both paths gone. Without this warning the resumed run silently
148
+ // loses every workflow-bundled skill — the exact failure mode this
149
+ // function was added to prevent.
150
+ writeStderr(`[workflow] Resume ${workflowId}: no workflow-skills available (staged copy and ${packageDir}/skills both missing). Workflow-bundled skills will not be available in this run.`);
151
+ return undefined;
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Orchestrator
155
+ // ---------------------------------------------------------------------------
156
+ export class WorkflowOrchestrator {
157
+ deps;
158
+ workflows = new Map();
159
+ lifecycleCallbacks = [];
160
+ constructor(deps) {
161
+ this.deps = deps;
162
+ }
163
+ /** Build a partial log entry with shared fields for the given workflow instance. */
164
+ logBase(instance) {
165
+ return {
166
+ ts: new Date().toISOString(),
167
+ workflowId: instance.id,
168
+ state: instance.currentState,
169
+ };
170
+ }
171
+ // -----------------------------------------------------------------------
172
+ // Pre-flight validation
173
+ // -----------------------------------------------------------------------
174
+ /**
175
+ * Validates that all non-"global" personas referenced in the workflow
176
+ * definition actually exist on disk. Fails fast with a clear error
177
+ * listing all missing personas.
178
+ *
179
+ * Only checks for persona.json existence -- does NOT verify compiled
180
+ * policy (that happens at session creation time via resolvePersona).
181
+ */
182
+ validatePersonas(definition) {
183
+ const missing = [];
184
+ for (const [stateId, state] of Object.entries(definition.states)) {
185
+ if (state.type !== 'agent')
186
+ continue;
187
+ const persona = state.persona;
188
+ if (persona === GLOBAL_PERSONA)
189
+ continue;
190
+ const defPath = getPersonaDefinitionPath(createPersonaName(persona));
191
+ if (!existsSync(defPath)) {
192
+ missing.push(`"${persona}" (used by state "${stateId}")`);
193
+ }
194
+ }
195
+ if (missing.length > 0) {
196
+ throw new Error(`Workflow references personas that do not exist:\n` +
197
+ ` ${missing.join('\n ')}\n` +
198
+ `Create them with: ironcurtain persona create <name>`);
199
+ }
200
+ }
201
+ // -----------------------------------------------------------------------
202
+ // Workflow-scoped Docker infrastructure lifecycle
203
+ // -----------------------------------------------------------------------
204
+ /**
205
+ * Determines whether a workflow should run in shared-container mode.
206
+ * Requires both an explicit opt-in via `settings.sharedContainer` and
207
+ * a Docker-backed agent. Builtin workflows ignore the flag entirely.
208
+ */
209
+ shouldUseSharedContainer(definition) {
210
+ const settings = definition.settings ?? {};
211
+ if (settings.sharedContainer !== true)
212
+ return false;
213
+ if (settings.mode === 'builtin')
214
+ return false;
215
+ return true;
216
+ }
217
+ /**
218
+ * Returns the bundle for `scope`, minting a new one on first use.
219
+ *
220
+ * Each unique `containerScope` used across the workflow's states gets
221
+ * exactly one bundle: the first `executeAgentState` call under a given
222
+ * scope creates the bundle, attaches its coordinator control server,
223
+ * and inserts the result into `instance.bundlesByScope`. Later states
224
+ * with the same scope borrow the existing entry (no factory call).
225
+ *
226
+ * Replaces the pre-Step-6 eager workflow-start mint. Only invoked when
227
+ * `shouldUseSharedContainer(definition)` returns true; callers must
228
+ * gate on that check.
229
+ *
230
+ * On control-server attach failure, the just-created bundle is torn
231
+ * down before the error propagates so we do not leak Docker resources
232
+ * on partial initialization. The map is **not** populated in that case
233
+ * — the scope entry remains absent so a later state can retry.
234
+ *
235
+ * Concurrency: relies on serial XState invocations — there is no
236
+ * parallel agent-state fan-out today. If that ever lands, add an
237
+ * in-flight-promise guard to prevent concurrent lazy-mint races for
238
+ * the same scope.
239
+ *
240
+ * Abort race: if `destroyWorkflowInfrastructure` begins while a mint
241
+ * is suspended at either `await factory(...)` or `await startServer(...)`,
242
+ * the mint completes but must not publish into `bundlesByScope` —
243
+ * destroy already snapshot-cleared the map and would miss the late
244
+ * insertion. `instance.aborted` is the barrier, consulted at both
245
+ * `await` points; on observed abort we tear down the just-built
246
+ * bundle and throw without updating the map.
247
+ */
248
+ async ensureBundleForScope(instance, scope) {
249
+ if (instance.aborted) {
250
+ throw new Error(`Workflow ${instance.id} is aborting; cannot mint bundle for scope "${scope}"`);
251
+ }
252
+ const existing = instance.bundlesByScope.get(scope);
253
+ if (existing)
254
+ return existing;
255
+ const settings = instance.definition.settings ?? {};
256
+ const agentId = (settings.dockerAgent ?? 'claude-code');
257
+ // Mint a fresh UUID per scope: each bundle needs its own on-disk
258
+ // directory tree, coordinator control socket, and Docker container
259
+ // name. Unlike single-session CLI (`bundleId === sessionId`),
260
+ // workflow scopes produce genuine standalone `BundleId`s.
261
+ const bundleId = createBundleId();
262
+ const controlSocketPath = getBundleControlSocketPath(bundleId);
263
+ // Ensure the per-bundle runtime root exists BEFORE the coordinator
264
+ // tries to bind its UDS there. Routes through `ensureSecureBundleDir`
265
+ // so the same symlink-rejection + 0o700-enforcement hardening that
266
+ // guards `sockets/` and `host/` (in `prepareDockerInfrastructure`)
267
+ // also guards the root the coordinator's `ctrl.sock` binds into.
268
+ ensureSecureBundleDir(getBundleRuntimeRoot(bundleId));
269
+ const requiredServers = this.getRequiredServersForScope(instance, scope);
270
+ // Persona-less initial set; per-state restaging fills in persona skills later.
271
+ // Reads the cached per-run staged path (set at start / resume) so
272
+ // the bundle never depends on the original package directory still
273
+ // being on disk.
274
+ const resolvedSkills = resolveSkillsForSession({ workflowSkillsDir: instance.workflowSkillsDir });
275
+ const factory = this.deps.createWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureFactory());
276
+ const infra = await factory({
277
+ workflowId: instance.id,
278
+ bundleId,
279
+ agentId,
280
+ controlSocketPath,
281
+ workspacePath: instance.workspacePath,
282
+ scope,
283
+ requiredServers,
284
+ resolvedSkills,
285
+ });
286
+ // Abort may have landed while `factory()` was suspended. Publishing
287
+ // into `bundlesByScope` now would leak past the destroy's leak
288
+ // assertion; destroy the orphan inline and throw. ESLint cannot see
289
+ // that `instance.aborted` is mutated by the concurrent
290
+ // `destroyWorkflowInfrastructure` call between the two reads.
291
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
292
+ if (instance.aborted) {
293
+ const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
294
+ await destroy(infra).catch((teardownErr) => {
295
+ writeStderr(`[workflow] destroyDockerInfrastructure during abort recovery for ${instance.id} (bundle ${bundleId}): ${toErrorMessage(teardownErr)}`);
296
+ });
297
+ throw new Error(`Workflow ${instance.id} was aborted during bundle mint for scope "${scope}"`);
298
+ }
299
+ // Attach the control server. On failure, tear down the bundle so
300
+ // we don't leak containers/proxies; the error propagates so the
301
+ // caller (executeAgentState) can fail the state invoke.
302
+ try {
303
+ const startServer = this.deps.startWorkflowControlServer ?? defaultStartWorkflowControlServer;
304
+ await startServer({ infra, socketPath: controlSocketPath });
305
+ }
306
+ catch (err) {
307
+ const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
308
+ await destroy(infra).catch((teardownErr) => {
309
+ writeStderr(`[workflow] destroyDockerInfrastructure during control-server recovery for ${instance.id}: ${toErrorMessage(teardownErr)}`);
310
+ });
311
+ throw err;
312
+ }
313
+ // Abort check #2: abort may have landed while `startServer()` was
314
+ // suspended. Publishing into `bundlesByScope` now would leak past
315
+ // the destroy's leak assertion; tear down the just-built bundle
316
+ // and throw. ESLint cannot see that `instance.aborted` is mutated
317
+ // by the concurrent `destroyWorkflowInfrastructure` call between
318
+ // the two reads.
319
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
320
+ if (instance.aborted) {
321
+ const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
322
+ await destroy(infra).catch((teardownErr) => {
323
+ writeStderr(`[workflow] destroyDockerInfrastructure during abort recovery (post-startServer) for ${instance.id} (bundle ${bundleId}): ${toErrorMessage(teardownErr)}`);
324
+ });
325
+ throw new Error(`Workflow ${instance.id} was aborted during control-server attach for scope "${scope}"`);
326
+ }
327
+ instance.bundlesByScope.set(scope, infra);
328
+ instance.mintedServersByBundle.set(bundleId, requiredServers);
329
+ return infra;
330
+ }
331
+ /**
332
+ * Returns the cached policyDir for `persona`, canonicalizing on first
333
+ * lookup so required-server derivation and the coordinator's
334
+ * `loadPolicy` RPC operate on the same realpath-resolved file (any
335
+ * symlink under the persona dir is collapsed once, here, instead of
336
+ * re-resolved by every reader).
337
+ */
338
+ getPolicyDir(instance, persona) {
339
+ const cached = instance.policyDirByPersona.get(persona);
340
+ if (cached !== undefined)
341
+ return cached;
342
+ const dir = validatePolicyDir(resolvePersonaPolicyDir(persona));
343
+ instance.policyDirByPersona.set(persona, dir);
344
+ return dir;
345
+ }
346
+ /**
347
+ * Computes the union of MCP server names required by every agent state
348
+ * sharing `scope`. Threaded into the workflow infrastructure factory so
349
+ * the bundle only spawns proxies the policies actually reference.
350
+ */
351
+ getRequiredServersForScope(instance, scope) {
352
+ const union = new Set();
353
+ const seenPersonas = new Set();
354
+ for (const stateConfig of Object.values(instance.definition.states)) {
355
+ if (stateConfig.type !== 'agent')
356
+ continue;
357
+ const stateScope = stateConfig.containerScope ?? DEFAULT_CONTAINER_SCOPE;
358
+ if (stateScope !== scope)
359
+ continue;
360
+ if (seenPersonas.has(stateConfig.persona))
361
+ continue;
362
+ seenPersonas.add(stateConfig.persona);
363
+ const { compiledPolicy } = loadPersonaPolicyArtifacts(this.getPolicyDir(instance, stateConfig.persona));
364
+ for (const server of extractRequiredServers(compiledPolicy)) {
365
+ union.add(server);
366
+ }
367
+ // Memory is bolt-on (not in compiled policy). `Set.add` is
368
+ // idempotent, so adding here per-persona preserves any-wants-it.
369
+ if (isMemoryEnabledForPersonaName(stateConfig.persona, this.deps.userConfig)) {
370
+ union.add(MEMORY_SERVER_NAME);
371
+ }
372
+ }
373
+ return union;
374
+ }
375
+ /**
376
+ * Reloads the coordinator's policy for the given persona on the given
377
+ * bundle. Called once per agent state invocation (including re-entries)
378
+ * in shared-container mode. The coordinator stamps `persona` onto every
379
+ * subsequent audit entry, so consumers can reconstruct per-persona /
380
+ * per-re-entry slices from the bundle's `audit.jsonl` file.
381
+ *
382
+ * Each scope owns its own bundle, coordinator, and control socket, so
383
+ * the bundle is passed explicitly — cycle the scope the state is
384
+ * about to enter, not some other scope's bundle.
385
+ *
386
+ * On failure (control socket unreachable, coordinator reports a load
387
+ * error) this throws — the workflow must not proceed under the
388
+ * previous persona's policy.
389
+ */
390
+ async cyclePolicy(instance, persona, bundle) {
391
+ // Skip the RPC when the bundle's coordinator already has this
392
+ // persona loaded. Consecutive states on the same scope+persona
393
+ // would otherwise re-send an identical `loadPolicy` — a no-op on
394
+ // the coordinator that still costs a UDS round-trip and a policy
395
+ // engine rebuild.
396
+ if (instance.currentPersonaByBundle.get(bundle.bundleId) === persona)
397
+ return;
398
+ const policyDir = this.getPolicyDir(instance, persona);
399
+ // Guard against mid-workflow recompiles that add a server: the bundle
400
+ // was minted with a fixed required-server set, so a new policy that
401
+ // expands the set would have the coordinator allow tools no relay was
402
+ // spawned for. Fail fast with a clear error instead.
403
+ const minted = instance.mintedServersByBundle.get(bundle.bundleId);
404
+ if (minted) {
405
+ const { compiledPolicy } = loadPersonaPolicyArtifacts(policyDir);
406
+ const newServers = extractRequiredServers(compiledPolicy);
407
+ const added = [];
408
+ for (const s of newServers)
409
+ if (!minted.has(s))
410
+ added.push(s);
411
+ if (added.length > 0) {
412
+ throw new Error(`Persona "${persona}" requires server(s) [${added.join(', ')}] that were not spawned for this scope ` +
413
+ `(bundle minted with [${[...minted].sort().join(', ')}]). ` +
414
+ `A persona recompile that expands the server set requires restarting the workflow.`);
415
+ }
416
+ }
417
+ // Target this bundle's coordinator. Under bifurcated workflows every
418
+ // bundle has its own socket, so the bundle argument fully determines
419
+ // the route.
420
+ const socketPath = getBundleControlSocketPath(bundle.bundleId);
421
+ // Invalidate the cache entry BEFORE the RPC. If `loadPolicy` fails
422
+ // after the coordinator already applied the new policy (e.g., the
423
+ // RPC times out during the response), leaving the cache pointed at
424
+ // the previous persona would silently skip the next cycle and run
425
+ // a subsequent state under the wrong policy. Clear-then-rewrite
426
+ // ensures the cache is never stale relative to the coordinator's
427
+ // actual state — at worst we re-send an identical `loadPolicy`.
428
+ instance.currentPersonaByBundle.delete(bundle.bundleId);
429
+ const loadPolicy = this.deps.loadPolicyRpc ?? defaultLoadPolicyRpc;
430
+ await loadPolicy({
431
+ socketPath,
432
+ persona,
433
+ policyDir,
434
+ timeoutMs: LOAD_POLICY_RPC_TIMEOUT_MS,
435
+ });
436
+ instance.currentPersonaByBundle.set(bundle.bundleId, persona);
437
+ }
438
+ /**
439
+ * Tears down every bundle this workflow owns. Walks
440
+ * `instance.bundlesByScope.values()` in parallel via `Promise.allSettled`
441
+ * so failures in one bundle's teardown do not block the others. The
442
+ * map is drained before the awaits so a second concurrent caller (e.g.
443
+ * `shutdownAll` racing the fire-and-forget destroy in
444
+ * `handleWorkflowComplete`) sees an empty map and returns without
445
+ * re-entering `destroy`.
446
+ *
447
+ * Callers in recovery paths rely on **expected** failure modes being
448
+ * swallowed so abort/shutdown flows can complete:
449
+ * - Failures in `destroy(infra)` are logged to stderr and swallowed.
450
+ * - Failures in socket unlinks are logged and swallowed.
451
+ *
452
+ * **One exception — intentional throw on internal invariant violation.**
453
+ * After teardown the helper asserts `bundlesByScope.size === 0`. A
454
+ * non-empty map at that point is a bug: something added an entry back
455
+ * while teardown was in flight. The invariant is enforced by the
456
+ * abort-guard in `ensureBundleForScope` (post-`factory` flag check);
457
+ * reaching this throw means the guard has a hole and we want it to
458
+ * surface loudly rather than silently strand a bundle. Callers are
459
+ * `abort()`, `shutdownAll()`, and the fire-and-forget `.catch()` in
460
+ * `handleWorkflowComplete` — in the expected invariant-holds case
461
+ * none of them see an exception. A thrown leak assertion in
462
+ * `abort()` / `shutdownAll()` will propagate to their own caller (the
463
+ * test or CLI layer), which is the intended signal.
464
+ */
465
+ async destroyWorkflowInfrastructure(instance) {
466
+ // Set BEFORE any short-circuit: an in-flight `ensureBundleForScope`
467
+ // that resumes from its `await factory(...)` must observe this flag
468
+ // and tear down its own orphan rather than publishing into the map.
469
+ instance.aborted = true;
470
+ if (instance.bundlesByScope.size === 0)
471
+ return;
472
+ // Snapshot and clear BEFORE the awaits so a concurrent caller sees
473
+ // an empty map and bails out. See JSDoc for the rationale.
474
+ const bundles = [...instance.bundlesByScope.values()];
475
+ instance.bundlesByScope.clear();
476
+ const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
477
+ await Promise.allSettled(bundles.map(async (infra) => {
478
+ try {
479
+ await destroy(infra);
480
+ }
481
+ catch (err) {
482
+ writeStderr(`[workflow] destroyDockerInfrastructure failed for ${instance.id} (bundle ${infra.bundleId}): ${toErrorMessage(err)}`);
483
+ }
484
+ // Best-effort: unlink the coordinator control socket. Swallow
485
+ // ENOENT because the socket may never have been bound (e.g.,
486
+ // when destroy runs after a failure before control-server attach).
487
+ try {
488
+ unlinkSync(getBundleControlSocketPath(infra.bundleId));
489
+ }
490
+ catch (err) {
491
+ const code = err.code;
492
+ if (code !== 'ENOENT') {
493
+ writeStderr(`[workflow] Failed to unlink control socket for ${instance.id} (bundle ${infra.bundleId}): ${toErrorMessage(err)}`);
494
+ }
495
+ }
496
+ }));
497
+ // Leak assertion: after snapshot-and-clear + parallel destroy, the
498
+ // map MUST be empty. A non-empty map means a destroy path silently
499
+ // added an entry back after teardown began — a real bug we surface
500
+ // as a synchronous error so it cannot be swallowed.
501
+ if (instance.bundlesByScope.size !== 0) {
502
+ const leaked = [...instance.bundlesByScope.keys()];
503
+ throw new Error(`Leaked workflow bundle scopes after teardown: ${JSON.stringify(leaked)}`);
504
+ }
505
+ }
506
+ /**
507
+ * Lazy-loads the default `createDockerInfrastructure` wrapper. The real
508
+ * helper requires Docker dependencies and the session config, so we
509
+ * construct them here rather than eagerly at orchestrator construction
510
+ * time (which would pay the import cost for every workflow run,
511
+ * including builtin ones that don't need Docker).
512
+ */
513
+ async loadDefaultInfrastructureFactory() {
514
+ const { createDockerInfrastructure } = await import('../docker/docker-infrastructure.js');
515
+ const { loadConfig, applyAllowedDirectoryToMcpArgs } = await import('../config/index.js');
516
+ const { filterMcpServersByPolicy } = await import('../persona/resolve.js');
517
+ return async (input) => {
518
+ const config = loadConfig();
519
+ // Bundle dir holds the bundle's shared Docker artifacts
520
+ // (orientation, sockets, claude-state, escalations, system
521
+ // prompt). It lives under the bundle's per-bundleId subtree so
522
+ // bifurcated workflows can host multiple bundles side by side
523
+ // without key collisions.
524
+ // Audit entries go to the per-bundle audit file (one file per
525
+ // bundle, with per-entry persona tagging).
526
+ const bundleDir = getBundleBundleDir(input.workflowId, input.bundleId);
527
+ const bundleEscalationDir = resolve(bundleDir, 'escalations');
528
+ // All sessions in a workflow share the workflow's workspace dir
529
+ // as their allowed directory. Do NOT use a session-scoped sandbox
530
+ // here: the orchestrator's artifact checks look inside the
531
+ // workspace, so artifacts the agent writes must land in the same
532
+ // tree.
533
+ mkdirSync(bundleDir, { recursive: true });
534
+ mkdirSync(bundleEscalationDir, { recursive: true });
535
+ mkdirSync(input.workspacePath, { recursive: true });
536
+ // Rewrite the allowed directory and audit path onto the loaded
537
+ // config. `config.allowedDirectory` drives the filesystem MCP
538
+ // server's --allowed-directory; `config.auditLogPath` is what the
539
+ // ToolCallCoordinator (via Sandbox) reads when creating the
540
+ // AuditLog. Without the audit rewrite, audit entries fall through
541
+ // to the default `./audit.jsonl` against process.cwd().
542
+ config.allowedDirectory = input.workspacePath;
543
+ config.auditLogPath = getBundleAuditLogPath(input.workflowId, input.bundleId);
544
+ config.mcpServers = filterMcpServersByPolicy(config.mcpServers, input.requiredServers);
545
+ applyAllowedDirectoryToMcpArgs(config.mcpServers, input.workspacePath);
546
+ return createDockerInfrastructure(config, { kind: 'docker', agent: input.agentId }, bundleDir, input.workspacePath, bundleEscalationDir, input.bundleId,
547
+ // Threading the workflowId + scope here drives the
548
+ // `ironcurtain.workflow` and `ironcurtain.scope` Docker labels
549
+ // emitted by `createSessionContainers()` via `buildBundleLabels`.
550
+ input.workflowId, input.scope, input.resolvedSkills);
551
+ };
552
+ }
553
+ /** Lazy-loads the default `destroyDockerInfrastructure` helper. */
554
+ async loadDefaultInfrastructureTeardown() {
555
+ const { destroyDockerInfrastructure } = await import('../docker/docker-infrastructure.js');
556
+ return destroyDockerInfrastructure;
557
+ }
558
+ // -----------------------------------------------------------------------
559
+ // Token-stream accumulation (workflow-scoped totalTokens counter)
560
+ // -----------------------------------------------------------------------
561
+ //
562
+ // The token-stream bus is global (see `getTokenStreamBus()` in
563
+ // `src/docker/token-stream-bus.ts`), so the orchestrator subscribes with
564
+ // `subscribeAll()` and filters inside the listener by the workflow's own
565
+ // session-ID set. Per-workflow rather than per-agent: `ctx.totalTokens`
566
+ // is a workflow-level cumulative sum, so one long-lived subscription is
567
+ // cheaper (one bus listener, one unsubscribe on completion) than
568
+ // subscribing and unsubscribing on every `agent_started` /
569
+ // `agent_session_ended` pair.
570
+ /**
571
+ * Subscribes the workflow to the global token-stream bus. Accumulates
572
+ * `message_end.outputTokens` into `instance.tokens.outputTokens` for any
573
+ * event whose session ID is in `instance.tokens.sessionIds`. The set is managed
574
+ * by `executeAgentState` (added before `agent_started`, removed in the
575
+ * `finally` block after `agent_session_ended`).
576
+ *
577
+ * Installed synchronously so the subscription is live before any agent
578
+ * state runs — no window where bus events go unobserved.
579
+ */
580
+ setupTokenSubscription(instance) {
581
+ // Defensive: re-entry by resume-after-crash should not double-subscribe.
582
+ if (instance.tokens.unsubscribe)
583
+ return;
584
+ instance.tokens.unsubscribe = getTokenStreamBus().subscribeAll((sessionId, event) => {
585
+ if (event.kind !== 'message_end')
586
+ return;
587
+ if (!instance.tokens.sessionIds.has(sessionId))
588
+ return;
589
+ instance.tokens.outputTokens += event.outputTokens;
590
+ });
591
+ }
592
+ /**
593
+ * Tears down the token-stream bus subscription. Idempotent: safe to
594
+ * call from both normal completion and abort paths.
595
+ */
596
+ teardownTokenSubscription(instance) {
597
+ if (instance.tokens.unsubscribe) {
598
+ try {
599
+ instance.tokens.unsubscribe();
600
+ }
601
+ catch {
602
+ // Unsubscribe is best-effort; errors here should never prevent
603
+ // workflow teardown from completing.
604
+ }
605
+ instance.tokens.unsubscribe = undefined;
606
+ }
607
+ instance.tokens.sessionIds.clear();
608
+ }
609
+ // -----------------------------------------------------------------------
610
+ // WorkflowController implementation
611
+ // -----------------------------------------------------------------------
612
+ // Keeps the `async` signature even though the body no longer awaits:
613
+ // (a) preserves the `WorkflowController.start()` contract (Promise<WorkflowId>),
614
+ // (b) leaves a hook for future async bootstrap work without another
615
+ // interface change.
616
+ // eslint-disable-next-line @typescript-eslint/require-await
617
+ async start(definitionPath, taskDescription, workspacePath) {
618
+ const raw = parseDefinitionFile(definitionPath);
619
+ const definition = validateDefinition(raw);
620
+ this.validatePersonas(definition);
621
+ validateWorkflowSkillReferences(definition, getWorkflowPackageDir(definitionPath));
622
+ const workflowId = createWorkflowId();
623
+ const resolvedWorkspace = workspacePath ?? resolve(this.deps.baseDir, workflowId, 'workspace');
624
+ // Reject if any active workflow already uses this workspace path
625
+ for (const instance of this.workflows.values()) {
626
+ if (instance.workspacePath === resolvedWorkspace && !instance.finalStatus) {
627
+ throw new Error(`Workspace ${resolvedWorkspace} is already in use by workflow ${instance.id}`);
628
+ }
629
+ }
630
+ mkdirSync(resolvedWorkspace, { recursive: true });
631
+ const artifactDir = resolve(resolvedWorkspace, WORKFLOW_ARTIFACT_DIR);
632
+ mkdirSync(artifactDir, { recursive: true });
633
+ const taskDir = resolve(artifactDir, 'task');
634
+ mkdirSync(taskDir, { recursive: true });
635
+ writeFileSync(resolve(taskDir, 'description.md'), taskDescription);
636
+ ensureWorkflowGitignored(resolvedWorkspace);
637
+ // Copy definition to baseDir for resume portability
638
+ const metaDir = resolve(this.deps.baseDir, workflowId);
639
+ mkdirSync(metaDir, { recursive: true });
640
+ writeFileSync(resolve(metaDir, 'definition.json'), JSON.stringify(definition, null, 2));
641
+ // Stage the workflow package's `skills/` tree into the run dir so
642
+ // resume is independent of whether the original package path still
643
+ // exists. Cached on the instance and persisted in every checkpoint
644
+ // so all subsequent reads point at this stable per-run copy.
645
+ const workflowSkillsDir = stageWorkflowSkillsAtStart(getWorkflowPackageDir(definitionPath), metaDir);
646
+ const { machine, gateStateNames, terminalStateNames } = buildWorkflowMachine(definition, taskDescription);
647
+ const providedMachine = this.provideActors(machine, workflowId, definition);
648
+ const actor = createActor(providedMachine);
649
+ const tab = this.deps.createWorkflowTab(definition.name, workflowId);
650
+ const messageLog = new MessageLog(resolve(this.deps.baseDir, workflowId, 'messages.jsonl'));
651
+ const instance = {
652
+ id: workflowId,
653
+ definition,
654
+ definitionPath,
655
+ workflowSkillsDir,
656
+ actor,
657
+ gateStateNames,
658
+ terminalStateNames,
659
+ activeSessions: new Set(),
660
+ artifactDir,
661
+ workspacePath: resolvedWorkspace,
662
+ tab,
663
+ transitionHistory: [],
664
+ currentState: definition.initial,
665
+ stateEnteredAt: Date.now(),
666
+ messageLog,
667
+ bundlesByScope: new Map(),
668
+ policyDirByPersona: new Map(),
669
+ currentPersonaByBundle: new Map(),
670
+ mintedServersByBundle: new Map(),
671
+ aborted: false,
672
+ tokens: {
673
+ outputTokens: 0,
674
+ sessionIds: new Set(),
675
+ },
676
+ };
677
+ // Under `sharedContainer: true`, bundles are minted lazily by
678
+ // `ensureBundleForScope` on the first `executeAgentState` call for
679
+ // each scope. There is no eager workflow-start mint — the first
680
+ // state pays the cost of spinning up the bundle it needs. This also
681
+ // removes a latent "Docker not available" failure mode at workflow
682
+ // registration time: the workflow enters the actor's first state
683
+ // before any Docker resource is touched.
684
+ this.workflows.set(workflowId, instance);
685
+ this.setupTokenSubscription(instance);
686
+ this.emitLifecycleEvent({ kind: 'started', workflowId, name: definition.name, taskDescription });
687
+ this.subscribeToActor(instance);
688
+ actor.start();
689
+ return workflowId;
690
+ }
691
+ async resume(workflowId) {
692
+ const checkpoint = await Promise.resolve(this.deps.checkpointStore.load(workflowId));
693
+ if (!checkpoint) {
694
+ throw new Error(`No checkpoint found for workflow ${workflowId}`);
695
+ }
696
+ const definitionCopyPath = resolve(this.deps.baseDir, workflowId, 'definition.json');
697
+ const definitionPath = existsSync(definitionCopyPath) ? definitionCopyPath : checkpoint.definitionPath;
698
+ // Runtime copies are always JSON; original user files may be YAML
699
+ const raw = parseDefinitionFile(definitionPath);
700
+ const definition = validateDefinition(raw);
701
+ const { machine, gateStateNames, terminalStateNames } = buildWorkflowMachine(definition, checkpoint.context.taskDescription);
702
+ const providedMachine = this.provideActors(machine, workflowId, definition);
703
+ const restoredSnapshot = providedMachine.resolveState({
704
+ value: checkpoint.machineState,
705
+ context: checkpoint.context,
706
+ });
707
+ const actor = createActor(providedMachine, { snapshot: restoredSnapshot });
708
+ const workspacePath = checkpoint.workspacePath ?? resolve(this.deps.baseDir, workflowId, 'workspace');
709
+ const artifactDir = resolve(workspacePath, WORKFLOW_ARTIFACT_DIR);
710
+ // Ensure artifact dir exists (backward compat with pre-workspace checkpoints)
711
+ mkdirSync(artifactDir, { recursive: true });
712
+ const tab = this.deps.createWorkflowTab(definition.name, workflowId);
713
+ // Append to existing log file (resume must not overwrite)
714
+ const messageLog = new MessageLog(resolve(this.deps.baseDir, workflowId, 'messages.jsonl'));
715
+ // Resolve workflow-skills with the run-dir staged copy as primary,
716
+ // a fresh re-stage from the original package dir as recovery, and
717
+ // a logged `undefined` as the last-resort degradation. See
718
+ // `resolveWorkflowSkillsDirOnResume` for the full priority order.
719
+ //
720
+ // Recovery uses `checkpoint.definitionPath` (the user's original
721
+ // package), not `definitionPath` — the latter resolves to the
722
+ // run-dir copy of `definition.json` whose `getWorkflowPackageDir`
723
+ // is the run dir itself, which never contains a sibling `skills/`.
724
+ const runMetaDir = resolve(this.deps.baseDir, workflowId);
725
+ const workflowSkillsDir = resolveWorkflowSkillsDirOnResume({
726
+ workflowId,
727
+ checkpointedStagedDir: checkpoint.workflowSkillsDir,
728
+ packageDir: getWorkflowPackageDir(checkpoint.definitionPath),
729
+ runMetaDir,
730
+ });
731
+ const instance = {
732
+ id: workflowId,
733
+ definition,
734
+ definitionPath: checkpoint.definitionPath,
735
+ workflowSkillsDir,
736
+ actor,
737
+ gateStateNames,
738
+ terminalStateNames,
739
+ activeSessions: new Set(),
740
+ artifactDir,
741
+ workspacePath,
742
+ tab,
743
+ transitionHistory: [...checkpoint.transitionHistory],
744
+ currentState: String(checkpoint.machineState),
745
+ stateEnteredAt: Date.now(),
746
+ messageLog,
747
+ bundlesByScope: new Map(),
748
+ policyDirByPersona: new Map(),
749
+ currentPersonaByBundle: new Map(),
750
+ mintedServersByBundle: new Map(),
751
+ aborted: false,
752
+ tokens: {
753
+ // Resume picks up the checkpointed totalTokens as the accumulator's
754
+ // starting point so post-resume message_end events keep adding to
755
+ // the running total instead of resetting it.
756
+ outputTokens: checkpoint.context.totalTokens,
757
+ sessionIds: new Set(),
758
+ },
759
+ };
760
+ // Resume does NOT reclaim the original containers — any
761
+ // dependencies installed in the previous run are lost. Under the
762
+ // lazy-mint model there is nothing to do at resume time:
763
+ // `bundlesByScope` starts empty and the first state to execute
764
+ // mints its bundle afresh. Container reclamation across resume is
765
+ // tracked as a follow-up.
766
+ if (this.shouldUseSharedContainer(definition)) {
767
+ writeStderr(`[workflow] Resuming ${workflowId} in shared-container mode: bundles will be re-created lazily per scope. ` +
768
+ `Any dependencies installed in pre-resume containers are lost.`);
769
+ }
770
+ this.workflows.set(workflowId, instance);
771
+ this.setupTokenSubscription(instance);
772
+ this.emitLifecycleEvent({
773
+ kind: 'started',
774
+ workflowId,
775
+ name: definition.name,
776
+ taskDescription: checkpoint.context.taskDescription,
777
+ });
778
+ this.subscribeToActor(instance);
779
+ actor.start();
780
+ // XState v5's resolveState() restores *to* a state but does not *enter* it.
781
+ // Invoke services (agent/deterministic) only start on state entry via transition.
782
+ // For invoke states, we must manually execute the service and feed the result
783
+ // back to the actor as an xstate.done.actor / xstate.error.actor event.
784
+ const restoredState = String(checkpoint.machineState);
785
+ const stateDef = definition.states[restoredState];
786
+ if (stateDef.type === 'agent' || stateDef.type === 'deterministic') {
787
+ this.replayInvokeForRestoredState(workflowId, restoredState, stateDef, definition);
788
+ }
789
+ }
790
+ /**
791
+ * Manually executes the invoke service for a state that was restored from
792
+ * a checkpoint. XState does not re-trigger invocations when an actor is
793
+ * started from a persisted snapshot, so we run the service ourselves and
794
+ * send the result event to the actor.
795
+ */
796
+ replayInvokeForRestoredState(workflowId, stateId, stateDef, definition) {
797
+ const instance = this.workflows.get(workflowId);
798
+ if (!instance)
799
+ return;
800
+ const snapshot = instance.actor.getSnapshot();
801
+ const context = snapshot.context;
802
+ // Build the service promise based on state type
803
+ const servicePromise = stateDef.type === 'agent'
804
+ ? this.executeAgentState(workflowId, { stateId, stateConfig: stateDef, context }, definition)
805
+ : this.executeDeterministicState({ stateId, commands: stateDef.run, context });
806
+ // Feed the result back to the actor as an XState internal invoke event.
807
+ // These event types don't exist in our WorkflowEvent union, so we cast
808
+ // through unknown to satisfy TypeScript while matching XState's internal
809
+ // event format that onDone/onError handlers expect.
810
+ servicePromise
811
+ .then((output) => {
812
+ instance.actor.send({ type: `xstate.done.actor.${stateId}`, output });
813
+ })
814
+ .catch((err) => {
815
+ instance.actor.send({ type: `xstate.error.actor.${stateId}`, error: err });
816
+ });
817
+ }
818
+ listResumable() {
819
+ const runs = discoverWorkflowRuns(this.deps.baseDir);
820
+ const activeIds = new Set(this.listActive());
821
+ const resumable = [];
822
+ for (const run of runs) {
823
+ if (!run.hasCheckpoint || activeIds.has(run.workflowId))
824
+ continue;
825
+ // FileCheckpointStore.load() re-throws on JSON.parse failure (it only
826
+ // swallows ENOENT). A single corrupt checkpoint.json would otherwise
827
+ // poison the whole list and break `workflow inspect` discovery + the
828
+ // web UI past-runs panel. Skip and log instead.
829
+ let cp;
830
+ try {
831
+ cp = this.deps.checkpointStore.load(run.workflowId);
832
+ }
833
+ catch (err) {
834
+ writeStderr(`[workflow] Failed to load checkpoint for ${run.workflowId}: ${toErrorMessage(err)}; skipping in resumable list`);
835
+ continue;
836
+ }
837
+ if (cp !== undefined && isCheckpointResumable(cp)) {
838
+ resumable.push(run.workflowId);
839
+ }
840
+ }
841
+ return resumable;
842
+ }
843
+ getStatus(id) {
844
+ const instance = this.workflows.get(id);
845
+ if (!instance)
846
+ return undefined;
847
+ if (instance.finalStatus)
848
+ return instance.finalStatus;
849
+ if (instance.activeGateId) {
850
+ const gateName = instance.currentState;
851
+ const stateDef = instance.definition.states[gateName];
852
+ if (stateDef.type === 'human_gate') {
853
+ return {
854
+ phase: 'waiting_human',
855
+ gate: this.buildGateRequest(id, gateName, stateDef),
856
+ };
857
+ }
858
+ }
859
+ return {
860
+ phase: 'running',
861
+ currentState: instance.currentState,
862
+ activeAgents: [],
863
+ };
864
+ }
865
+ getDetail(id) {
866
+ const instance = this.workflows.get(id);
867
+ if (!instance)
868
+ return undefined;
869
+ const snapshot = instance.actor.getSnapshot();
870
+ const ctx = snapshot.context;
871
+ return {
872
+ definition: instance.definition,
873
+ transitionHistory: [...instance.transitionHistory],
874
+ workspacePath: instance.workspacePath,
875
+ context: {
876
+ taskDescription: ctx.taskDescription,
877
+ round: ctx.round,
878
+ maxRounds: ctx.maxRounds,
879
+ totalTokens: ctx.totalTokens,
880
+ visitCounts: { ...ctx.visitCounts },
881
+ },
882
+ };
883
+ }
884
+ listActive() {
885
+ return [...this.workflows.keys()].filter((id) => {
886
+ const instance = this.workflows.get(id);
887
+ return instance && !instance.finalStatus;
888
+ });
889
+ }
890
+ resolveGate(id, event) {
891
+ // FORCE_REVISION and REPLAN loop back to an earlier state with the
892
+ // feedback injected into the next agent's prompt (via context.humanPrompt).
893
+ // Without feedback the agent has no signal for what to change and the
894
+ // re-entry prompt ("Revise it to address the human feedback above")
895
+ // references content that does not exist. Require non-empty feedback at
896
+ // the source so every entry point (CLI, web UI, programmatic) fails fast.
897
+ if (event.type === 'FORCE_REVISION' || event.type === 'REPLAN') {
898
+ if (!event.prompt || event.prompt.trim().length === 0) {
899
+ throw new Error(`Feedback is required for ${event.type} events`);
900
+ }
901
+ }
902
+ const instance = this.workflows.get(id);
903
+ if (!instance)
904
+ return;
905
+ const gateId = instance.activeGateId;
906
+ if (!gateId) {
907
+ // No active gate -- prevent double-resolution
908
+ return;
909
+ }
910
+ // Clear before sending to prevent concurrent double-resolution
911
+ instance.activeGateId = undefined;
912
+ this.deps.dismissGate(instance.id, gateId);
913
+ instance.messageLog.append({
914
+ ...this.logBase(instance),
915
+ type: 'gate_resolved',
916
+ event: event.type,
917
+ prompt: event.prompt ?? null,
918
+ });
919
+ const xstateEventName = `HUMAN_${event.type}`;
920
+ instance.actor.send({
921
+ type: xstateEventName,
922
+ prompt: event.prompt,
923
+ });
924
+ }
925
+ async abort(id) {
926
+ const instance = this.workflows.get(id);
927
+ if (!instance)
928
+ return;
929
+ if (instance.finalStatus?.phase === 'completed' ||
930
+ instance.finalStatus?.phase === 'aborted' ||
931
+ instance.finalStatus?.phase === 'failed') {
932
+ return;
933
+ }
934
+ const closePromises = [];
935
+ for (const session of instance.activeSessions) {
936
+ closePromises.push(session.close().catch(() => { }));
937
+ }
938
+ await Promise.allSettled(closePromises);
939
+ instance.activeSessions.clear();
940
+ instance.actor.stop();
941
+ instance.finalStatus = {
942
+ phase: 'aborted',
943
+ reason: 'Workflow aborted by user',
944
+ };
945
+ // Release the token-stream bus subscription before the async infra
946
+ // teardown so no late events land against a finalized workflow.
947
+ this.teardownTokenSubscription(instance);
948
+ // Tear down workflow-scoped Docker infrastructure after all sessions
949
+ // are closed. Error-tolerant (see destroyWorkflowInfrastructure).
950
+ await this.destroyWorkflowInfrastructure(instance);
951
+ // Intentionally leave the checkpoint in place: a user-triggered abort
952
+ // should remain resumable via `workflow resume`. The checkpoint is only
953
+ // removed on successful completion (see handleWorkflowComplete).
954
+ instance.tab.write('[aborted]');
955
+ instance.tab.close();
956
+ this.emitLifecycleEvent({
957
+ kind: 'failed',
958
+ workflowId: id,
959
+ error: 'Workflow aborted by user',
960
+ });
961
+ }
962
+ onEvent(callback) {
963
+ this.lifecycleCallbacks.push(callback);
964
+ }
965
+ async shutdownAll() {
966
+ const ids = [...this.workflows.keys()];
967
+ // Abort runs full teardown (including destroyWorkflowInfrastructure) for
968
+ // workflows not yet in a terminal state. For already-terminal workflows,
969
+ // abort() early-returns — so we follow up with an explicit destroy pass
970
+ // to defend against any case where a terminal transition skipped infra
971
+ // teardown (e.g., tests forcing finalStatus directly). The destroy call
972
+ // is idempotent when instance.bundlesByScope is empty.
973
+ await Promise.allSettled(ids.map((id) => this.abort(id)));
974
+ await Promise.allSettled(ids.map((id) => {
975
+ const instance = this.workflows.get(id);
976
+ return instance ? this.destroyWorkflowInfrastructure(instance) : Promise.resolve();
977
+ }));
978
+ }
979
+ // -----------------------------------------------------------------------
980
+ // Actor setup helpers
981
+ // -----------------------------------------------------------------------
982
+ /** Injects concrete agent/deterministic service implementations into the machine. */
983
+ provideActors(machine, workflowId, definition) {
984
+ return machine.provide({
985
+ actors: {
986
+ agentService: fromPromise(async ({ input }) => {
987
+ try {
988
+ return await this.executeAgentState(workflowId, input, definition);
989
+ }
990
+ catch (err) {
991
+ writeStderr(`[workflow] agentService invoke rejected for "${input.stateId}": ${toErrorMessage(err)}`);
992
+ throw err;
993
+ }
994
+ }),
995
+ deterministicService: fromPromise(async ({ input }) => {
996
+ try {
997
+ return await this.executeDeterministicState(input);
998
+ }
999
+ catch (err) {
1000
+ writeStderr(`[workflow] deterministicService invoke rejected for "${input.stateId}": ${toErrorMessage(err)}`);
1001
+ throw err;
1002
+ }
1003
+ }),
1004
+ },
1005
+ });
1006
+ }
1007
+ /** Subscribes to actor snapshot changes for lifecycle events, gates, and checkpointing. */
1008
+ subscribeToActor(instance) {
1009
+ const { actor, gateStateNames, definition, id: workflowId } = instance;
1010
+ actor.subscribe((rawSnapshot) => {
1011
+ const snapshot = rawSnapshot;
1012
+ const stateValue = String(snapshot.value);
1013
+ const previousState = instance.currentState;
1014
+ const now = Date.now();
1015
+ // Record transition and checkpoint only on actual state changes
1016
+ if (stateValue !== previousState) {
1017
+ const duration = instance.stateEnteredAt ? now - instance.stateEnteredAt : 0;
1018
+ const stateType = definition.states[previousState].type;
1019
+ let agentMessage;
1020
+ if (stateType === 'human_gate') {
1021
+ agentMessage = truncateForTransition(snapshot.context.humanPrompt ?? snapshot.context.previousAgentOutput);
1022
+ }
1023
+ else if (stateType === 'agent') {
1024
+ agentMessage = truncateForTransition(snapshot.context.previousAgentOutput);
1025
+ }
1026
+ // deterministic and terminal: no agentMessage (leave undefined)
1027
+ instance.transitionHistory.push({
1028
+ from: previousState,
1029
+ to: stateValue,
1030
+ event: 'transition',
1031
+ timestamp: new Date(now).toISOString(),
1032
+ duration_ms: duration,
1033
+ agentMessage,
1034
+ });
1035
+ instance.stateEnteredAt = now;
1036
+ // Skip checkpointing transitions into terminal states. On successful
1037
+ // completion `handleWorkflowComplete` removes the checkpoint anyway;
1038
+ // on abort we want the surviving checkpoint to point at the last
1039
+ // non-terminal state so `resume()` can restart the run meaningfully.
1040
+ if (!this.isTerminalStateValue(definition, stateValue)) {
1041
+ this.saveCheckpoint(instance, snapshot);
1042
+ }
1043
+ instance.messageLog.append({
1044
+ ...this.logBase(instance),
1045
+ type: 'state_transition',
1046
+ from: previousState,
1047
+ event: stateValue,
1048
+ });
1049
+ }
1050
+ instance.currentState = stateValue;
1051
+ instance.tab.write(`[state] ${stateValue}`);
1052
+ // Surface errors from invoke failures (storeError action sets lastError)
1053
+ const ctx = snapshot.context;
1054
+ if (ctx.lastError && ctx.lastError !== instance.lastSurfacedError) {
1055
+ instance.lastSurfacedError = ctx.lastError;
1056
+ writeStderr(`[workflow] Error in context after state "${stateValue}": ${ctx.lastError}`);
1057
+ instance.tab.write(`[error] Agent invoke failed: ${ctx.lastError}`);
1058
+ this.emitLifecycleEvent({
1059
+ kind: 'failed',
1060
+ workflowId,
1061
+ error: `Agent "${previousState}" failed: ${ctx.lastError}`,
1062
+ });
1063
+ }
1064
+ else if (!ctx.lastError && instance.lastSurfacedError) {
1065
+ instance.lastSurfacedError = undefined;
1066
+ }
1067
+ for (const gateName of gateStateNames) {
1068
+ if (snapshot.matches(gateName)) {
1069
+ const stateDef = definition.states[gateName];
1070
+ if (stateDef.type === 'human_gate') {
1071
+ this.handleGateEntry(workflowId, gateName, stateDef);
1072
+ }
1073
+ break;
1074
+ }
1075
+ }
1076
+ this.emitLifecycleEvent({
1077
+ kind: 'state_entered',
1078
+ workflowId,
1079
+ state: stateValue,
1080
+ });
1081
+ // Check for terminal states
1082
+ if (snapshot.status === 'done') {
1083
+ this.handleWorkflowComplete(workflowId, snapshot.context);
1084
+ }
1085
+ });
1086
+ }
1087
+ // -----------------------------------------------------------------------
1088
+ // Checkpointing
1089
+ // -----------------------------------------------------------------------
1090
+ /**
1091
+ * Returns true if `stateValue` names a terminal state in the workflow
1092
+ * definition. Used to skip checkpointing on the final transition so the
1093
+ * last on-disk checkpoint points at the last non-terminal state (important
1094
+ * for resume-after-abort, which would otherwise reload an `aborted`
1095
+ * snapshot and immediately re-terminate).
1096
+ */
1097
+ isTerminalStateValue(definition, stateValue) {
1098
+ return definition.states[stateValue].type === 'terminal';
1099
+ }
1100
+ // `waiting_human` would smuggle a ReadonlyMap (gate.presentedArtifacts) into
1101
+ // JSON.stringify, which silently emits `{}`. handleWorkflowComplete only
1102
+ // assigns `completed` or `aborted`, so the cycle is safe today.
1103
+ buildCheckpoint(instance, snapshot, finalStatus) {
1104
+ return {
1105
+ machineState: snapshot.value,
1106
+ context: snapshot.context,
1107
+ timestamp: new Date().toISOString(),
1108
+ transitionHistory: [...instance.transitionHistory],
1109
+ definitionPath: instance.definitionPath,
1110
+ workspacePath: instance.workspacePath,
1111
+ // Persisted so resume can read the run-dir staged copy directly
1112
+ // instead of recomputing from the (possibly moved/deleted)
1113
+ // package path. Skipped when the workflow shipped no skills, to
1114
+ // keep the checkpoint shape tight for the common case.
1115
+ ...(instance.workflowSkillsDir !== undefined ? { workflowSkillsDir: instance.workflowSkillsDir } : {}),
1116
+ ...(finalStatus !== undefined ? { finalStatus } : {}),
1117
+ };
1118
+ }
1119
+ saveCheckpoint(instance, snapshot) {
1120
+ const checkpoint = this.buildCheckpoint(instance, snapshot);
1121
+ try {
1122
+ this.deps.checkpointStore.save(instance.id, checkpoint);
1123
+ }
1124
+ catch (err) {
1125
+ writeStderr(`[workflow] Failed to save checkpoint for ${instance.id}: ${toErrorMessage(err)}`);
1126
+ }
1127
+ }
1128
+ // -----------------------------------------------------------------------
1129
+ // Agent state execution
1130
+ // -----------------------------------------------------------------------
1131
+ // TODO(refactor): this method is ~230 lines and carries three
1132
+ // invocation-scoped closures (`logReceived`, `logAgentRetry`,
1133
+ // `sendAgentTurn`) that all capture the same bundle of state —
1134
+ // `session`, `instance`, `stateConfig`, `stateId`, `messageLog`, and
1135
+ // `this`. The closure pattern is idiomatic here only because every
1136
+ // alternative (methods, helpers) would propagate that bundle through
1137
+ // 5–6 parameters per site.
1138
+ //
1139
+ // The right fix is to extract the whole turn-handling pipeline
1140
+ // (send/parse/retry/reprompt + the three log helpers) into a
1141
+ // dedicated class — e.g. `AgentTurnRunner` — constructed once per
1142
+ // invocation with the shared state injected. That shrinks this
1143
+ // method from ~230 lines to ~80, and makes turn handling
1144
+ // unit-testable in isolation (today it is reachable only through
1145
+ // end-to-end workflow tests). Pair the extraction with a review of
1146
+ // `AgentInvocationError` wrapping so the conversation-id recovery
1147
+ // semantics survive the move. Worth doing as its own PR; do NOT
1148
+ // fold it into a feature change.
1149
+ async executeAgentState(workflowId, input, definition) {
1150
+ const { stateId, stateConfig, context } = input;
1151
+ const instance = this.workflows.get(workflowId);
1152
+ if (!instance)
1153
+ throw new Error(`Workflow ${workflowId} not found`);
1154
+ const settings = definition.settings ?? {};
1155
+ // Version artifact directories before re-entering a state
1156
+ const visitCount = context.visitCounts[stateId] ?? 0;
1157
+ if (visitCount > 1) {
1158
+ const unversioned = new Set(settings.unversionedArtifacts ?? []);
1159
+ snapshotArtifacts(instance.artifactDir, stateConfig.outputs, visitCount, unversioned);
1160
+ }
1161
+ instance.tab.write(`[agent] Starting "${stateId}" (persona: ${stateConfig.persona})`);
1162
+ const command = buildAgentCommand(stateId, stateConfig, context, definition);
1163
+ const mode = settings.mode === 'builtin'
1164
+ ? { kind: 'builtin' }
1165
+ : { kind: 'docker', agent: (settings.dockerAgent ?? 'claude-code') };
1166
+ const effectiveModel = stateConfig.model ?? settings.model;
1167
+ // Shared-container mode only: resolve (or lazily mint) the bundle
1168
+ // for this state's scope, rotate the coordinator's audit stream, and
1169
+ // swap policy before constructing the borrowing session. In
1170
+ // per-state-container mode the new session builds its own
1171
+ // coordinator, so there is nothing to cycle.
1172
+ //
1173
+ // Scope lookup algorithm (docs/designs/workflow-session-identity.md §3):
1174
+ // 1. scope := stateConfig.containerScope ?? "primary"
1175
+ // 2. bundle := instance.bundlesByScope.get(scope)
1176
+ // 3. if absent: lazy-mint via ensureBundleForScope(instance, scope)
1177
+ // 4. borrow bundle via SessionOptions.workflow.infrastructure
1178
+ let bundle;
1179
+ if (this.shouldUseSharedContainer(definition)) {
1180
+ const scope = stateConfig.containerScope ?? DEFAULT_CONTAINER_SCOPE;
1181
+ try {
1182
+ bundle = await this.ensureBundleForScope(instance, scope);
1183
+ }
1184
+ catch (err) {
1185
+ const errMsg = toErrorMessage(err);
1186
+ writeStderr(`[workflow] ensureBundleForScope failed for "${stateId}" (scope=${scope}): ${errMsg}`);
1187
+ instance.tab.write(`[error] Infrastructure setup failed for "${stateId}": ${errMsg}`);
1188
+ instance.messageLog.append({
1189
+ ...this.logBase(instance),
1190
+ type: 'error',
1191
+ error: errMsg,
1192
+ context: `ensureBundleForScope "${stateId}" (scope: ${scope})`,
1193
+ });
1194
+ throw err;
1195
+ }
1196
+ try {
1197
+ await this.cyclePolicy(instance, stateConfig.persona, bundle);
1198
+ }
1199
+ catch (err) {
1200
+ const errMsg = toErrorMessage(err);
1201
+ writeStderr(`[workflow] cyclePolicy failed for "${stateId}": ${errMsg}`);
1202
+ instance.tab.write(`[error] Policy cycle failed for "${stateId}": ${errMsg}`);
1203
+ instance.messageLog.append({
1204
+ ...this.logBase(instance),
1205
+ type: 'error',
1206
+ error: errMsg,
1207
+ context: `cyclePolicy for "${stateId}" (persona: ${stateConfig.persona})`,
1208
+ });
1209
+ throw err;
1210
+ }
1211
+ }
1212
+ // Agent-CLI conversation identity for this invocation. Decision table
1213
+ // (docs/designs/workflow-session-identity.md §3):
1214
+ // - freshSession:false AND prior entry in agentConversationsByState -> reuse
1215
+ // - otherwise -> mint fresh via createAgentConversationId()
1216
+ // The Docker adapter decides --session-id vs --resume by probing
1217
+ // conversationStateDir for `<agentConversationId>.jsonl`; a minted-fresh
1218
+ // id has no prior file, a reused one does.
1219
+ const priorConversationId = stateConfig.freshSession === false ? context.agentConversationsByState[stateId] : undefined;
1220
+ const agentConversationId = priorConversationId ?? createAgentConversationId();
1221
+ // Mutable tracker updated by the hard-failure retry loop when the
1222
+ // session rotates its conversation id. The final value is what lands
1223
+ // in the invocation result / error and hence in the checkpoint's
1224
+ // `agentConversationsByState`, so a later `freshSession: false` visit
1225
+ // resumes the id whose transcript actually exists on disk.
1226
+ let currentConversationId = agentConversationId;
1227
+ // In borrow mode, route this invocation's per-state artifacts
1228
+ // (session.log, session-metadata.json) under a slug-keyed directory
1229
+ // inside the workflow run. The slug is the next available `.N` in
1230
+ // the bundle's states dir, so true logical re-visits AND resume legs
1231
+ // both get their own dir — never appending into a prior leg's logs.
1232
+ let stateSlug;
1233
+ let workflowStateDir;
1234
+ if (bundle) {
1235
+ stateSlug = nextStateSlug(getBundleStatesDir(instance.id, bundle.bundleId), stateId);
1236
+ workflowStateDir = getInvocationDir(instance.id, bundle.bundleId, stateSlug);
1237
+ mkdirSync(workflowStateDir, { recursive: true });
1238
+ }
1239
+ // Read the cached per-run staged path; never recompute from
1240
+ // `instance.definitionPath` here. Computing it per-state would
1241
+ // re-introduce the resume-fragility bug fixed by staging at start.
1242
+ const workflowSkillsDir = instance.workflowSkillsDir;
1243
+ // Workflow context for the session. Always emitted for workflow
1244
+ // runs (so the orchestrator's identity isn't ambiguous), even when
1245
+ // there's no bundle to borrow — `infrastructure` / `stateDir` /
1246
+ // `stateSlug` opt in only when shared-container mode applies.
1247
+ const workflowOptions = {
1248
+ ...(bundle ? { infrastructure: bundle } : {}),
1249
+ ...(workflowStateDir ? { stateDir: workflowStateDir, stateSlug } : {}),
1250
+ ...resolveWorkflowSkillsOptions(stateConfig.skills, workflowSkillsDir),
1251
+ };
1252
+ let session;
1253
+ try {
1254
+ session = await this.deps.createSession({
1255
+ persona: stateConfig.persona,
1256
+ mode,
1257
+ agentConversationId,
1258
+ workspacePath: instance.workspacePath,
1259
+ systemPromptAugmentation: definition.settings?.systemPrompt,
1260
+ ...(effectiveModel != null ? { agentModelOverride: effectiveModel } : {}),
1261
+ ...(settings.maxSessionSeconds != null
1262
+ ? { resourceBudgetOverrides: { maxSessionSeconds: settings.maxSessionSeconds } }
1263
+ : {}),
1264
+ // The nested record colocates the borrowed bundle, per-state
1265
+ // artifact dir, and workflow-bundled skills. `buildSessionConfig`
1266
+ // enforces the borrow-mode invariant (stateDir requires
1267
+ // infrastructure) at runtime.
1268
+ ...(Object.keys(workflowOptions).length > 0 ? { workflow: workflowOptions } : {}),
1269
+ });
1270
+ }
1271
+ catch (err) {
1272
+ const errMsg = toErrorMessage(err);
1273
+ writeStderr(`[workflow] Session creation failed for "${stateId}": ${errMsg}`);
1274
+ instance.tab.write(`[error] Session creation failed for "${stateId}": ${errMsg}`);
1275
+ instance.messageLog.append({
1276
+ ...this.logBase(instance),
1277
+ type: 'error',
1278
+ error: errMsg,
1279
+ context: `session creation for "${stateId}"`,
1280
+ });
1281
+ throw new AgentInvocationError({ stateId, agentConversationId: currentConversationId, cause: err });
1282
+ }
1283
+ instance.activeSessions.add(session);
1284
+ try {
1285
+ instance.tab.write(`[agent] Sending command to "${stateId}"...`);
1286
+ const { messageLog } = instance;
1287
+ const statusInstructions = buildStatusInstructions(stateConfig.transitions);
1288
+ const logReceived = (text, output) => {
1289
+ messageLog.append({
1290
+ ...this.logBase(instance),
1291
+ type: 'agent_received',
1292
+ role: stateConfig.persona,
1293
+ message: text,
1294
+ verdict: output?.verdict ?? null,
1295
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- logged for diagnostics
1296
+ confidence: output?.confidence ?? null,
1297
+ });
1298
+ };
1299
+ const logAgentRetry = (reason, details, retryMessage) => {
1300
+ messageLog.append({
1301
+ ...this.logBase(instance),
1302
+ type: 'agent_retry',
1303
+ role: stateConfig.persona,
1304
+ reason,
1305
+ details,
1306
+ retryMessage,
1307
+ });
1308
+ };
1309
+ // Uniform agent-turn entrypoint: every prompt and reprompt must flow
1310
+ // through here so the quota-exhaustion / transient-failure
1311
+ // short-circuits apply to ALL of them. Retrying or reprompting a turn
1312
+ // whose adapter reported either signal would only burn more of the
1313
+ // already-exhausted budget (quota) or hammer a stalled upstream
1314
+ // (transient) — instead we log the structured event once and throw
1315
+ // a dedicated error so higher layers can distinguish "paused by
1316
+ // provider" / "stalled upstream" from "aborted by bug" (today all
1317
+ // three abort; M4 turns these into a `paused` terminal phase without
1318
+ // touching here).
1319
+ //
1320
+ // Sessions without `sendMessageDetailed` (e.g., built-in in-process
1321
+ // sessions) degrade cleanly to `sendMessage`, which cannot produce
1322
+ // either signal — the short-circuits simply never fire for them.
1323
+ const sendAgentTurn = async (msg) => {
1324
+ const result = session.sendMessageDetailed
1325
+ ? await session.sendMessageDetailed(msg)
1326
+ : { text: await session.sendMessage(msg), hardFailure: false };
1327
+ if (result.quotaExhausted) {
1328
+ const { resetAt, rawMessage } = result.quotaExhausted;
1329
+ logReceived(result.text, undefined);
1330
+ messageLog.append({
1331
+ ...this.logBase(instance),
1332
+ type: 'quota_exhausted',
1333
+ role: stateConfig.persona,
1334
+ resetAt: resetAt?.toISOString(),
1335
+ rawMessage,
1336
+ });
1337
+ // Stamp the instance before throwing so `handleWorkflowComplete`
1338
+ // can force an abort-preserving terminal regardless of which
1339
+ // state the onError target resolves to — protects checkpoint
1340
+ // retention for workflow definitions whose error target happens
1341
+ // to be a normal terminal (e.g. `done`).
1342
+ instance.quotaExhausted = { resetAt, rawMessage };
1343
+ throw new WorkflowQuotaExhaustedError({ stateId, resetAt, rawMessage });
1344
+ }
1345
+ if (result.transientFailure) {
1346
+ const { kind, rawMessage } = result.transientFailure;
1347
+ // Same shape as the quota branch — append a structured log
1348
+ // entry, stamp the instance, throw a dedicated error — but
1349
+ // deliberately skip `logReceived`. Quota envelopes carry the
1350
+ // provider's actual rate-limit message in `result.text`, which
1351
+ // is worth recording as the agent's turn. Transient envelopes
1352
+ // carry only the agent's preamble with no real assistant
1353
+ // content, so logging it as `agent_received` would falsely
1354
+ // imply the agent produced a turn.
1355
+ messageLog.append({
1356
+ ...this.logBase(instance),
1357
+ type: 'transient_failure',
1358
+ role: stateConfig.persona,
1359
+ kind,
1360
+ rawMessage,
1361
+ });
1362
+ instance.transientFailure = { kind, rawMessage };
1363
+ throw new WorkflowTransientFailureError({ stateId, kind, rawMessage });
1364
+ }
1365
+ return result;
1366
+ };
1367
+ messageLog.append({
1368
+ ...this.logBase(instance),
1369
+ type: 'agent_sent',
1370
+ role: stateConfig.persona,
1371
+ message: command,
1372
+ });
1373
+ // Shared-container mode: the MITM is long-lived across agents. Flip
1374
+ // its routing id so this agent's events land under its own session id
1375
+ // (matching what the bridge registers below). Optional-chain because
1376
+ // builtin/per-state-container workflows have no shared `infra`.
1377
+ const agentSessionId = session.getInfo().id;
1378
+ bundle?.setTokenSessionId(agentSessionId);
1379
+ instance.tokens.sessionIds.add(agentSessionId);
1380
+ this.emitLifecycleEvent({
1381
+ kind: 'agent_started',
1382
+ workflowId,
1383
+ state: stateId,
1384
+ persona: stateConfig.persona,
1385
+ sessionId: agentSessionId,
1386
+ });
1387
+ // Two-phase retry: (1) hard-failure retries re-send the ORIGINAL
1388
+ // command with a rotated conversation id, (2) soft-failure reprompt
1389
+ // asks the agent to fix a missing/malformed agent_status block.
1390
+ //
1391
+ // Hard failures (exitCode != 0 with empty output, e.g., upstream
1392
+ // provider stall that kills the CLI mid-stream) leave the agent's
1393
+ // session id consumed but no resumable transcript on disk — a retry
1394
+ // with the same id is rejected by the CLI. Rotating and resending the
1395
+ // original prompt is the correct recovery path; a missing-status-block
1396
+ // reprompt into a dead session cannot possibly succeed.
1397
+ const MAX_HARD_RETRIES = 2;
1398
+ let responseText = '';
1399
+ for (let attempt = 0; attempt <= MAX_HARD_RETRIES; attempt++) {
1400
+ const result = await sendAgentTurn(command);
1401
+ responseText = result.text;
1402
+ if (!result.hardFailure)
1403
+ break;
1404
+ logReceived(responseText, undefined);
1405
+ logAgentRetry('upstream_stall', `Agent exited without producing output (attempt ${attempt + 1}/${MAX_HARD_RETRIES + 1})`, command);
1406
+ if (attempt === MAX_HARD_RETRIES) {
1407
+ throw new Error(`Agent failed to produce output after ${MAX_HARD_RETRIES + 1} attempts (upstream stall)`);
1408
+ }
1409
+ const rotated = session.rotateAgentConversationId?.();
1410
+ if (rotated)
1411
+ currentConversationId = rotated;
1412
+ }
1413
+ let parseResult = tryParseAgentStatus(responseText);
1414
+ logReceived(responseText, parseResult.kind === 'ok' ? parseResult.output : undefined);
1415
+ let agentOutput;
1416
+ if (parseResult.kind === 'ok') {
1417
+ agentOutput = parseResult.output;
1418
+ }
1419
+ else {
1420
+ const malformed = parseResult.kind === 'malformed' ? parseResult.error : undefined;
1421
+ const retryMsg = buildStatusBlockReprompt(statusInstructions, malformed);
1422
+ logAgentRetry(malformed ? 'malformed_status_block' : 'missing_status_block', malformed
1423
+ ? `Malformed agent_status block: ${malformed.message}`
1424
+ : 'Response did not contain an agent_status block', retryMsg);
1425
+ responseText = (await sendAgentTurn(retryMsg)).text;
1426
+ parseResult = tryParseAgentStatus(responseText);
1427
+ logReceived(responseText, parseResult.kind === 'ok' ? parseResult.output : undefined);
1428
+ if (parseResult.kind !== 'ok') {
1429
+ throw new Error(parseResult.kind === 'malformed'
1430
+ ? `Agent produced a malformed agent_status block after retry: ${parseResult.error.message}`
1431
+ : 'Agent failed to provide agent_status block after retry');
1432
+ }
1433
+ agentOutput = parseResult.output;
1434
+ }
1435
+ // Validate verdict against valid transitions before the result reaches XState.
1436
+ // This prevents silent deadlocks when the agent returns a verdict that no
1437
+ // transition's `when` clause matches.
1438
+ const validVerdicts = getValidVerdicts(stateConfig.transitions);
1439
+ if (validVerdicts && !validVerdicts.has(agentOutput.verdict)) {
1440
+ const retryMsg = buildInvalidVerdictReprompt(agentOutput.verdict, stateConfig.transitions);
1441
+ logAgentRetry('invalid_verdict', `Verdict "${agentOutput.verdict}" not in valid set: ${[...validVerdicts].join(', ')}`, retryMsg);
1442
+ responseText = (await sendAgentTurn(retryMsg)).text;
1443
+ const retryParse = tryParseAgentStatus(responseText);
1444
+ logReceived(responseText, retryParse.kind === 'ok' ? retryParse.output : undefined);
1445
+ if (retryParse.kind !== 'ok') {
1446
+ const suffix = retryParse.kind === 'malformed'
1447
+ ? `retry produced a malformed status block: ${retryParse.error.message}`
1448
+ : 'retry did not include a status block';
1449
+ throw new Error(`Agent verdict "${agentOutput.verdict}" is not valid for this state ` +
1450
+ `(expected one of: ${[...validVerdicts].join(', ')}), and ${suffix}`);
1451
+ }
1452
+ if (!validVerdicts.has(retryParse.output.verdict)) {
1453
+ throw new Error(`Agent returned invalid verdict "${retryParse.output.verdict}" after retry ` +
1454
+ `(expected one of: ${[...validVerdicts].join(', ')})`);
1455
+ }
1456
+ agentOutput = retryParse.output;
1457
+ }
1458
+ const missingArtifacts = this.findMissingArtifacts(stateConfig, instance.artifactDir);
1459
+ if (missingArtifacts.length > 0) {
1460
+ const artifactRetryMsg = buildArtifactReprompt(missingArtifacts, stateConfig.transitions);
1461
+ logAgentRetry('missing_artifacts', `Missing: ${missingArtifacts.join(', ')}`, artifactRetryMsg);
1462
+ const retryResponse = (await sendAgentTurn(artifactRetryMsg)).text;
1463
+ const retryParse = tryParseAgentStatus(retryResponse);
1464
+ if (retryParse.kind === 'ok') {
1465
+ logReceived(retryResponse, retryParse.output);
1466
+ agentOutput = retryParse.output;
1467
+ }
1468
+ else {
1469
+ logReceived(retryResponse, undefined);
1470
+ }
1471
+ const stillMissing = this.findMissingArtifacts(stateConfig, instance.artifactDir);
1472
+ if (stillMissing.length > 0) {
1473
+ throw new Error(`Missing artifacts after retry: ${stillMissing.join(', ')}`);
1474
+ }
1475
+ }
1476
+ const outputHash = computeOutputHash(stateConfig.outputs, instance.artifactDir, instance.workspacePath);
1477
+ const artifacts = collectArtifactPaths(stateConfig.outputs, instance.artifactDir);
1478
+ instance.tab.write(`[agent] "${stateId}" completed: verdict=${agentOutput.verdict}, artifacts=${Object.keys(artifacts).join(',') || 'none'}`);
1479
+ this.emitLifecycleEvent({
1480
+ kind: 'agent_completed',
1481
+ workflowId,
1482
+ state: stateId,
1483
+ persona: stateConfig.persona,
1484
+ verdict: agentOutput.verdict,
1485
+ });
1486
+ return {
1487
+ output: agentOutput,
1488
+ agentConversationId: currentConversationId,
1489
+ artifacts,
1490
+ outputHash,
1491
+ responseText,
1492
+ // Snapshot the workflow's cumulative output-token count AT THE
1493
+ // END of this agent's turn. The XState assign action uses this
1494
+ // value to update ctx.totalTokens; it reflects every `message_end`
1495
+ // the bus subscriber has seen so far, including all earlier
1496
+ // agents in the workflow.
1497
+ totalTokens: instance.tokens.outputTokens,
1498
+ };
1499
+ }
1500
+ catch (err) {
1501
+ // Quota exhaustion / transient upstream failure already produced
1502
+ // their own structured log entries inside `sendAgentTurn`;
1503
+ // appending a generic `error` entry here would double-log the same
1504
+ // event and make `workflow inspect` surface a duplicate red error
1505
+ // line alongside the structured signal. Suppress the generic entry
1506
+ // when the cause is a `WorkflowQuotaExhaustedError` or
1507
+ // `WorkflowTransientFailureError`.
1508
+ if (!isWorkflowQuotaExhaustedError(err) && !isWorkflowTransientFailureError(err)) {
1509
+ instance.messageLog.append({
1510
+ ...this.logBase(instance),
1511
+ type: 'error',
1512
+ error: toErrorMessage(err),
1513
+ context: `agent "${stateId}" (persona: ${stateConfig.persona})`,
1514
+ });
1515
+ }
1516
+ throw new AgentInvocationError({ stateId, agentConversationId: currentConversationId, cause: err });
1517
+ }
1518
+ finally {
1519
+ instance.activeSessions.delete(session);
1520
+ const endedSessionId = session.getInfo().id;
1521
+ await session.close().catch((closeErr) => {
1522
+ writeStderr(`[workflow] session.close() failed for "${stateId}": ${toErrorMessage(closeErr)}`);
1523
+ });
1524
+ // session.close() drains in-flight streams that captured the
1525
+ // agent's session id at attach time; they keep posting under it
1526
+ // until they finish, regardless of setTokenSessionId(undefined).
1527
+ // The clear here only governs future attachments.
1528
+ bundle?.setTokenSessionId(undefined);
1529
+ instance.tokens.sessionIds.delete(endedSessionId);
1530
+ // Pairs 1:1 with agent_started; emitted unconditionally in finally
1531
+ // so success, failure, and abort paths all clean up the bridge.
1532
+ this.emitLifecycleEvent({
1533
+ kind: 'agent_session_ended',
1534
+ workflowId,
1535
+ state: stateId,
1536
+ sessionId: endedSessionId,
1537
+ });
1538
+ }
1539
+ }
1540
+ // -----------------------------------------------------------------------
1541
+ // Deterministic state execution
1542
+ // -----------------------------------------------------------------------
1543
+ async executeDeterministicState(input) {
1544
+ const { commands } = input;
1545
+ let totalTestCount = 0;
1546
+ const allErrors = [];
1547
+ for (const cmdArray of commands) {
1548
+ if (cmdArray.length === 0)
1549
+ continue;
1550
+ const [binary, ...args] = cmdArray;
1551
+ try {
1552
+ const { stdout } = await execFileAsync(binary, args);
1553
+ // Try to extract test count from stdout (simple heuristic)
1554
+ const testMatch = /(\d+)\s+(?:tests?|specs?)\s+pass/i.exec(stdout);
1555
+ if (testMatch) {
1556
+ totalTestCount += parseInt(testMatch[1], 10);
1557
+ }
1558
+ }
1559
+ catch (err) {
1560
+ const execErr = err;
1561
+ allErrors.push(execErr.stderr ?? execErr.stdout ?? String(err));
1562
+ }
1563
+ }
1564
+ return {
1565
+ passed: allErrors.length === 0,
1566
+ testCount: totalTestCount > 0 ? totalTestCount : undefined,
1567
+ errors: allErrors.length > 0 ? allErrors.join('\n') : undefined,
1568
+ };
1569
+ }
1570
+ // -----------------------------------------------------------------------
1571
+ // Gate handling
1572
+ // -----------------------------------------------------------------------
1573
+ handleGateEntry(workflowId, gateName, stateDef) {
1574
+ const instance = this.workflows.get(workflowId);
1575
+ if (!instance)
1576
+ return;
1577
+ const gateRequest = this.buildGateRequest(workflowId, gateName, stateDef);
1578
+ instance.activeGateId = gateRequest.gateId;
1579
+ instance.messageLog.append({
1580
+ ...this.logBase(instance),
1581
+ type: 'gate_raised',
1582
+ acceptedEvents: stateDef.acceptedEvents,
1583
+ });
1584
+ this.deps.raiseGate(gateRequest);
1585
+ this.emitLifecycleEvent({
1586
+ kind: 'gate_raised',
1587
+ workflowId,
1588
+ gate: gateRequest,
1589
+ });
1590
+ }
1591
+ buildGateRequest(workflowId, gateName, stateDef) {
1592
+ const instance = this.workflows.get(workflowId);
1593
+ const presentedArtifacts = new Map();
1594
+ for (const artifactRef of stateDef.present ?? []) {
1595
+ if (instance) {
1596
+ const { name } = parseArtifactRef(artifactRef);
1597
+ const dir = resolve(instance.artifactDir, name);
1598
+ if (existsSync(dir)) {
1599
+ presentedArtifacts.set(name, dir);
1600
+ }
1601
+ }
1602
+ }
1603
+ // Surface context.lastError in the gate summary so callers know
1604
+ // whether this gate was reached normally or via an invoke error.
1605
+ const snapshot = instance?.actor.getSnapshot();
1606
+ const errorContext = snapshot?.context?.lastError ? ` (error: ${snapshot.context.lastError})` : '';
1607
+ return {
1608
+ gateId: `${workflowId}-${gateName}`,
1609
+ workflowId,
1610
+ stateName: gateName,
1611
+ acceptedEvents: stateDef.acceptedEvents,
1612
+ presentedArtifacts,
1613
+ summary: `Waiting for human review at ${gateName}${errorContext}`,
1614
+ };
1615
+ }
1616
+ // -----------------------------------------------------------------------
1617
+ // Completion handling
1618
+ // -----------------------------------------------------------------------
1619
+ handleWorkflowComplete(workflowId, context) {
1620
+ const instance = this.workflows.get(workflowId);
1621
+ if (!instance)
1622
+ return;
1623
+ // Guard against XState emitting a terminal transition multiple times.
1624
+ // `finalStatus` being set is the canonical "already completed" signal;
1625
+ // if it's set, skip re-entering terminal handling so we don't call
1626
+ // destroyWorkflowInfrastructure twice (the teardown itself is idempotent,
1627
+ // but duplicate tab.close() / checkpoint.remove() / lifecycle events
1628
+ // are noisy).
1629
+ if (instance.finalStatus)
1630
+ return;
1631
+ // Check if this is a normal completion or an aborted terminal.
1632
+ // Quota exhaustion takes precedence: the error target may have
1633
+ // resolved to any terminal (including `done`-like ones), but a run
1634
+ // that died on upstream quota MUST be treated as aborted so the
1635
+ // checkpoint is preserved and the user can resume once the provider
1636
+ // window reopens. (M4 will upgrade this to a dedicated `paused`
1637
+ // phase; for now `aborted` gives us the same checkpoint retention.)
1638
+ const stateValue = instance.currentState;
1639
+ if (instance.quotaExhausted) {
1640
+ const resetHint = instance.quotaExhausted.resetAt
1641
+ ? ` (resets at ${instance.quotaExhausted.resetAt.toISOString()})`
1642
+ : '';
1643
+ instance.finalStatus = {
1644
+ phase: 'aborted',
1645
+ reason: `Upstream quota exhausted${resetHint}`,
1646
+ };
1647
+ }
1648
+ else if (instance.transientFailure) {
1649
+ // Mirror the quota branch: a transient upstream failure must force
1650
+ // an abort-preserving terminal regardless of which state
1651
+ // `findErrorTarget` resolved to. Without this, a workflow whose
1652
+ // only terminal is `done` would land on `done` and
1653
+ // `handleWorkflowComplete` would mark the run `completed`, breaking
1654
+ // resume.
1655
+ const excerpt = instance.transientFailure.rawMessage.slice(0, 200);
1656
+ instance.finalStatus = {
1657
+ phase: 'aborted',
1658
+ reason: `Transient upstream failure: ${describeTransientFailureKind(instance.transientFailure.kind)} ` +
1659
+ `(resumable — run "ironcurtain workflow resume <baseDir>" once upstream is healthy)\n${excerpt}`,
1660
+ };
1661
+ }
1662
+ else if (stateValue === 'aborted' || stateValue.includes('abort')) {
1663
+ instance.finalStatus = {
1664
+ phase: 'aborted',
1665
+ reason: 'Workflow reached aborted state',
1666
+ };
1667
+ }
1668
+ else {
1669
+ const result = { finalArtifacts: { ...context.artifacts } };
1670
+ instance.finalStatus = {
1671
+ phase: 'completed',
1672
+ result,
1673
+ };
1674
+ }
1675
+ // Persist finalStatus so isCheckpointResumable can later distinguish
1676
+ // completed (excluded) from aborted/failed (still resumable). Preserve
1677
+ // the existing on-disk machineState/context (which points at the last
1678
+ // non-terminal state) so resume-after-abort can re-enter that state
1679
+ // instead of immediately re-completing on a terminal snapshot. The
1680
+ // fallback path covers the unusual case where no prior checkpoint
1681
+ // exists (e.g. a workflow that transitioned straight to terminal
1682
+ // without ever passing through a non-terminal save point).
1683
+ try {
1684
+ const existing = this.deps.checkpointStore.load(workflowId);
1685
+ const terminalCheckpoint = this.buildCheckpoint(instance, instance.actor.getSnapshot(), instance.finalStatus);
1686
+ const checkpoint = existing
1687
+ ? {
1688
+ ...terminalCheckpoint,
1689
+ machineState: existing.machineState,
1690
+ context: existing.context,
1691
+ }
1692
+ : terminalCheckpoint;
1693
+ this.deps.checkpointStore.save(workflowId, checkpoint);
1694
+ }
1695
+ catch (err) {
1696
+ writeStderr(`[workflow] Failed to save terminal checkpoint for ${workflowId}: ${toErrorMessage(err)}`);
1697
+ }
1698
+ instance.tab.write(`[done] ${instance.finalStatus.phase}`);
1699
+ instance.tab.close();
1700
+ // Release the token-stream bus subscription. Done eagerly (before the
1701
+ // async destroyWorkflowInfrastructure) because it's a synchronous
1702
+ // callback — idempotent if already cleared by abort().
1703
+ this.teardownTokenSubscription(instance);
1704
+ // Tear down workflow-scoped Docker infrastructure. Runs asynchronously
1705
+ // because the actor subscription is sync; destroyWorkflowInfrastructure
1706
+ // is error-tolerant so unhandled rejections should not occur, but we
1707
+ // still catch defensively for a belt-and-suspenders guarantee.
1708
+ void this.destroyWorkflowInfrastructure(instance).catch((err) => {
1709
+ writeStderr(`[workflow] destroyWorkflowInfrastructure unexpectedly threw for ${workflowId}: ${toErrorMessage(err)}`);
1710
+ });
1711
+ this.emitLifecycleEvent({
1712
+ kind: 'completed',
1713
+ workflowId,
1714
+ });
1715
+ }
1716
+ // -----------------------------------------------------------------------
1717
+ // Artifact helpers
1718
+ // -----------------------------------------------------------------------
1719
+ findMissingArtifacts(stateConfig, artifactDir) {
1720
+ const missing = [];
1721
+ for (const output of stateConfig.outputs) {
1722
+ const dir = resolve(artifactDir, output);
1723
+ if (!hasAnyFiles(dir)) {
1724
+ missing.push(output);
1725
+ }
1726
+ }
1727
+ return missing;
1728
+ }
1729
+ // -----------------------------------------------------------------------
1730
+ // Lifecycle event emission
1731
+ // -----------------------------------------------------------------------
1732
+ emitLifecycleEvent(event) {
1733
+ for (const cb of this.lifecycleCallbacks) {
1734
+ try {
1735
+ cb(event);
1736
+ }
1737
+ catch (cbErr) {
1738
+ // Lifecycle callbacks should not crash the orchestrator, but log the failure
1739
+ console.error(`[workflow] Lifecycle callback threw for "${event.kind}":`, cbErr);
1740
+ }
1741
+ }
1742
+ }
1743
+ }
1744
+ // ---------------------------------------------------------------------------
1745
+ // Standalone helpers (exported for testing)
1746
+ // ---------------------------------------------------------------------------
1747
+ // ---------------------------------------------------------------------------
1748
+ // Policy cycling helpers (workflow shared-container mode)
1749
+ // ---------------------------------------------------------------------------
1750
+ /**
1751
+ * Resolves the compiled-policy directory for the given persona name.
1752
+ *
1753
+ * The reserved sentinel `GLOBAL_PERSONA` maps to the package-bundled
1754
+ * generated dir (same one `loadConfig().generatedDir` resolves to for
1755
+ * CLI runs). All other values go through `resolvePersona()`, which
1756
+ * validates the persona exists and has a compiled policy.
1757
+ */
1758
+ function resolvePersonaPolicyDir(persona) {
1759
+ if (persona === GLOBAL_PERSONA) {
1760
+ return loadConfig().generatedDir;
1761
+ }
1762
+ return resolvePersona(persona).policyDir;
1763
+ }
1764
+ /**
1765
+ * Default `startWorkflowControlServer`: reaches through the infra
1766
+ * bundle's proxy for its policy-swap target and binds a control server
1767
+ * at the workflow-scoped UDS path.
1768
+ */
1769
+ async function defaultStartWorkflowControlServer(input) {
1770
+ const target = input.infra.proxy.getPolicySwapTarget();
1771
+ if (!target) {
1772
+ throw new Error('Cannot attach workflow control server: the proxy is not yet started. ' +
1773
+ 'Call proxy.start() before attaching a control server.');
1774
+ }
1775
+ await target.startControlServer({ socketPath: input.socketPath });
1776
+ }
1777
+ /**
1778
+ * Default `loadPolicyRpc`: speaks HTTP/1.1 over the supplied UDS using
1779
+ * Node's built-in client. Resolves on a 2xx response; rejects on any
1780
+ * non-2xx status, request timeout, or socket error. The coordinator's
1781
+ * reply body is read only on the error path (to surface the error
1782
+ * text) — 2xx bodies are discarded with `res.resume()`.
1783
+ *
1784
+ * Exported so integration tests can drive the wire format without
1785
+ * standing up a full `WorkflowOrchestrator`. Keeping the HTTP request
1786
+ * construction in one place guarantees tests exercise the same bytes
1787
+ * production emits.
1788
+ */
1789
+ export async function defaultLoadPolicyRpc(input) {
1790
+ const body = JSON.stringify({
1791
+ persona: input.persona,
1792
+ policyDir: input.policyDir,
1793
+ });
1794
+ const timeoutMs = input.timeoutMs ?? LOAD_POLICY_RPC_TIMEOUT_MS;
1795
+ return new Promise((resolveFn, rejectFn) => {
1796
+ let settled = false;
1797
+ const settle = (err) => {
1798
+ if (settled)
1799
+ return;
1800
+ settled = true;
1801
+ if (err)
1802
+ rejectFn(err);
1803
+ else
1804
+ resolveFn();
1805
+ };
1806
+ const req = http.request({
1807
+ socketPath: input.socketPath,
1808
+ method: 'POST',
1809
+ path: POLICY_LOAD_PATH,
1810
+ headers: {
1811
+ 'Content-Type': 'application/json',
1812
+ 'Content-Length': Buffer.byteLength(body, 'utf-8'),
1813
+ },
1814
+ timeout: timeoutMs,
1815
+ }, (res) => {
1816
+ const status = res.statusCode ?? 0;
1817
+ if (status >= 200 && status < 300) {
1818
+ res.resume();
1819
+ res.on('end', () => settle());
1820
+ res.on('error', settle);
1821
+ return;
1822
+ }
1823
+ const chunks = [];
1824
+ res.on('data', (chunk) => chunks.push(chunk));
1825
+ res.on('end', () => {
1826
+ const text = Buffer.concat(chunks).toString('utf-8');
1827
+ settle(new Error(`loadPolicy RPC failed (status ${status}): ${text.slice(0, 200)}`));
1828
+ });
1829
+ res.on('error', settle);
1830
+ });
1831
+ req.on('timeout', () => {
1832
+ req.destroy(new Error(`loadPolicy RPC timed out after ${timeoutMs}ms`));
1833
+ });
1834
+ req.on('error', settle);
1835
+ req.end(body);
1836
+ });
1837
+ }
1838
+ // ---------------------------------------------------------------------------
1839
+ // Workspace root hashing (file listing + mtime, no content reads)
1840
+ // ---------------------------------------------------------------------------
1841
+ const WORKSPACE_HASH_EXCLUDED_DIRS = new Set([
1842
+ '.git',
1843
+ WORKFLOW_ARTIFACT_DIR,
1844
+ 'node_modules',
1845
+ '__pycache__',
1846
+ '.next',
1847
+ 'dist',
1848
+ 'build',
1849
+ '.cache',
1850
+ '.venv',
1851
+ 'venv',
1852
+ ]);
1853
+ /**
1854
+ * Recursively collects `relativePath:mtime` entries for all files
1855
+ * under `basePath`, excluding directories in WORKSPACE_HASH_EXCLUDED_DIRS.
1856
+ */
1857
+ function collectFileEntries(basePath, relativeTo, out) {
1858
+ const dirents = readdirSync(resolve(basePath, relativeTo), { withFileTypes: true });
1859
+ for (const dirent of dirents) {
1860
+ const relPath = relativeTo ? `${relativeTo}/${dirent.name}` : dirent.name;
1861
+ if (dirent.isDirectory()) {
1862
+ if (WORKSPACE_HASH_EXCLUDED_DIRS.has(dirent.name))
1863
+ continue;
1864
+ collectFileEntries(basePath, relPath, out);
1865
+ }
1866
+ else if (dirent.isFile()) {
1867
+ const fullPath = resolve(basePath, relPath);
1868
+ const mtime = statSync(fullPath).mtimeMs;
1869
+ out.push(`${relPath}:${mtime}`);
1870
+ }
1871
+ }
1872
+ }
1873
+ /** Hashes the workspace root file listing (paths + mtimes) into the given hash. */
1874
+ function hashWorkspaceRoot(hash, workspacePath) {
1875
+ const entries = [];
1876
+ collectFileEntries(workspacePath, '', entries);
1877
+ entries.sort();
1878
+ for (const entry of entries) {
1879
+ hash.update(entry);
1880
+ }
1881
+ }
1882
+ /**
1883
+ * Computes a SHA-256 hash of output artifacts or workspace root file metadata.
1884
+ *
1885
+ * Hashes file metadata (path + size + mtime) — never contents. This bounds cost
1886
+ * regardless of artifact size (fuzz logs, captured stderr, etc. can be many GiB
1887
+ * and would otherwise trip Node's 2 GiB `readFile` ceiling). Any agent rewrite
1888
+ * via fs ops bumps mtime, and same-mtime-same-size collisions in a single ms
1889
+ * window are not a concern for workflow change detection.
1890
+ */
1891
+ export function computeOutputHash(outputNames, artifactDir, workspacePath) {
1892
+ const hash = createHash('sha256');
1893
+ if (outputNames.length > 0) {
1894
+ for (const output of outputNames) {
1895
+ const dir = resolve(artifactDir, output);
1896
+ const files = collectFilesRecursive(dir);
1897
+ for (const file of files) {
1898
+ const { size, mtimeMs } = statSync(file.fullPath);
1899
+ hash.update(`${file.relativePath}:${size}:${mtimeMs}`);
1900
+ }
1901
+ }
1902
+ }
1903
+ else {
1904
+ hashWorkspaceRoot(hash, workspacePath);
1905
+ }
1906
+ return hash.digest('hex');
1907
+ }
1908
+ // ---------------------------------------------------------------------------
1909
+ // .gitignore management
1910
+ // ---------------------------------------------------------------------------
1911
+ /** Ensures the workflow artifact directory is listed in the workspace root's .gitignore. */
1912
+ function ensureWorkflowGitignored(workspacePath) {
1913
+ const gitignorePath = resolve(workspacePath, '.gitignore');
1914
+ const dirEntry = `${WORKFLOW_ARTIFACT_DIR}/`;
1915
+ if (existsSync(gitignorePath)) {
1916
+ const content = readFileSync(gitignorePath, 'utf-8');
1917
+ const lines = content.split('\n');
1918
+ const alreadyListed = lines.some((line) => line.trim() === dirEntry || line.trim() === WORKFLOW_ARTIFACT_DIR);
1919
+ if (!alreadyListed) {
1920
+ const suffix = content.endsWith('\n') ? '' : '\n';
1921
+ appendFileSync(gitignorePath, `${suffix}${dirEntry}\n`);
1922
+ }
1923
+ }
1924
+ else {
1925
+ writeFileSync(gitignorePath, `${dirEntry}\n`);
1926
+ }
1927
+ }
1928
+ /**
1929
+ * Collects artifact paths: maps output name -> directory path.
1930
+ */
1931
+ export function collectArtifactPaths(outputNames, artifactDir) {
1932
+ const result = {};
1933
+ for (const output of outputNames) {
1934
+ const dir = resolve(artifactDir, output);
1935
+ if (existsSync(dir)) {
1936
+ result[output] = dir;
1937
+ }
1938
+ }
1939
+ return result;
1940
+ }
1941
+ //# sourceMappingURL=orchestrator.js.map