specrails-desktop 2.0.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 (455) hide show
  1. package/.claude/commands/specrails/batch-implement.md +287 -0
  2. package/.claude/commands/specrails/compat-check.md +271 -0
  3. package/.claude/commands/specrails/doctor.md +62 -0
  4. package/.claude/commands/specrails/enrich.md +1635 -0
  5. package/.claude/commands/specrails/explore-spec.md +173 -0
  6. package/.claude/commands/specrails/health-check.md +527 -0
  7. package/.claude/commands/specrails/implement.md +1457 -0
  8. package/.claude/commands/specrails/memory-inspect.md +259 -0
  9. package/.claude/commands/specrails/opsx-diff.md +419 -0
  10. package/.claude/commands/specrails/propose-spec.md +102 -0
  11. package/.claude/commands/specrails/reconfig.md +89 -0
  12. package/.claude/commands/specrails/refactor-recommender.md +212 -0
  13. package/.claude/commands/specrails/retry.md +363 -0
  14. package/.claude/commands/specrails/telemetry.md +552 -0
  15. package/.claude/commands/specrails/why.md +96 -0
  16. package/LICENSE +21 -0
  17. package/README.md +290 -0
  18. package/cli/dist/specrails-desktop.js +1098 -0
  19. package/client/dist/assets/ActivityFeedPage-Gy4x8dBt.js +1 -0
  20. package/client/dist/assets/AgentsPage-CPgu--Fb.js +86 -0
  21. package/client/dist/assets/AnalyticsPage-B5sJEee2.js +1 -0
  22. package/client/dist/assets/BarChart-7IMQ8HY1.js +33 -0
  23. package/client/dist/assets/CodePage-CBdFvbwe.js +2 -0
  24. package/client/dist/assets/DesktopAnalyticsPage-w0rdTq4w.js +1 -0
  25. package/client/dist/assets/DocsDialog-BZUYM7wm.js +11 -0
  26. package/client/dist/assets/DocsPage-9QglWl46.js +11 -0
  27. package/client/dist/assets/ExportDropdown-BLZFXtNi.js +1 -0
  28. package/client/dist/assets/IntegrationsPage-BxBE4y99.js +3 -0
  29. package/client/dist/assets/JobDetailPage-DydWx_5S.js +16 -0
  30. package/client/dist/assets/JobsPage-20ibw0IO.js +1 -0
  31. package/client/dist/assets/abap-Bw6f2wDG.js +1 -0
  32. package/client/dist/assets/activity-BEIp_Y1A.js +1 -0
  33. package/client/dist/assets/activity-BdrPln96.js +1 -0
  34. package/client/dist/assets/activity-CpkRS8Sx.js +1 -0
  35. package/client/dist/assets/activity-DKCpESPt.js +1 -0
  36. package/client/dist/assets/activity-DOUVEjJi.js +1 -0
  37. package/client/dist/assets/activity-DRwkql_y.js +1 -0
  38. package/client/dist/assets/activity-DcDQ7tjw.js +1 -0
  39. package/client/dist/assets/activity-Dv6H7wEr.js +1 -0
  40. package/client/dist/assets/addon-image-3WCl5Vhd.js +1 -0
  41. package/client/dist/assets/addon-ligatures-C5OdliKs.js +2 -0
  42. package/client/dist/assets/addon-webgl-BbX6pSjl.js +44 -0
  43. package/client/dist/assets/addspec-B5yl4Loj.js +1 -0
  44. package/client/dist/assets/addspec-BEeF5-zc.js +1 -0
  45. package/client/dist/assets/addspec-D33ocMxf.js +1 -0
  46. package/client/dist/assets/addspec-DFswZ0jK.js +1 -0
  47. package/client/dist/assets/addspec-DRE-jZv7.js +1 -0
  48. package/client/dist/assets/addspec-DVZ15Jp8.js +1 -0
  49. package/client/dist/assets/addspec-Fkv91Opc.js +1 -0
  50. package/client/dist/assets/addspec-GWm4ffKl.js +1 -0
  51. package/client/dist/assets/agents-1nCDWRmP.js +1 -0
  52. package/client/dist/assets/agents-Bm9rPqnt.js +1 -0
  53. package/client/dist/assets/agents-CMxtJMLD.js +1 -0
  54. package/client/dist/assets/agents-DK-Dlc0i.js +1 -0
  55. package/client/dist/assets/agents-Q6Ldfpxx.js +1 -0
  56. package/client/dist/assets/agents-TeOSy-ax.js +1 -0
  57. package/client/dist/assets/agents-iTqjRajS.js +1 -0
  58. package/client/dist/assets/agents-s87sMGzL.js +1 -0
  59. package/client/dist/assets/agentstudio-B6Wb59E7.js +1 -0
  60. package/client/dist/assets/agentstudio-BADhZ41e.js +1 -0
  61. package/client/dist/assets/agentstudio-BSnWLR63.js +1 -0
  62. package/client/dist/assets/agentstudio-BdidyBzZ.js +1 -0
  63. package/client/dist/assets/agentstudio-CxlUllqI.js +1 -0
  64. package/client/dist/assets/agentstudio-D3I62TLJ.js +1 -0
  65. package/client/dist/assets/agentstudio-DuH9TogZ.js +1 -0
  66. package/client/dist/assets/agentstudio-Kw88_dUF.js +1 -0
  67. package/client/dist/assets/aiedit-BWxHGsYA.js +1 -0
  68. package/client/dist/assets/aiedit-D2ji6Qy0.js +1 -0
  69. package/client/dist/assets/aiedit-DAhZTvtk.js +1 -0
  70. package/client/dist/assets/aiedit-DJMny-D5.js +1 -0
  71. package/client/dist/assets/aiedit-DOcxERkU.js +1 -0
  72. package/client/dist/assets/aiedit-DvrcbwGv.js +1 -0
  73. package/client/dist/assets/aiedit-TTwzL1TS.js +1 -0
  74. package/client/dist/assets/aiedit-WBSjT_C1.js +1 -0
  75. package/client/dist/assets/analytics-BIdr0YfL.js +1 -0
  76. package/client/dist/assets/analytics-C6EzgtdE.js +1 -0
  77. package/client/dist/assets/analytics-C9Zc-rkM.js +1 -0
  78. package/client/dist/assets/analytics-CVx3YOc0.js +1 -0
  79. package/client/dist/assets/analytics-CYj0tfj7.js +1 -0
  80. package/client/dist/assets/analytics-CnY4kNG3.js +1 -0
  81. package/client/dist/assets/analytics-CrPCZRJ-.js +1 -0
  82. package/client/dist/assets/analytics-DMCto-TF.js +1 -0
  83. package/client/dist/assets/apex-Cw8_REBo.js +1 -0
  84. package/client/dist/assets/atom-one-dark-B-oHczHB.css +1 -0
  85. package/client/dist/assets/attachments-BIsSSnHJ.js +1 -0
  86. package/client/dist/assets/attachments-BW4L3l2L.js +1 -0
  87. package/client/dist/assets/attachments-Bcf6BG6V.js +1 -0
  88. package/client/dist/assets/attachments-Bke8sCU4.js +1 -0
  89. package/client/dist/assets/attachments-COcrGRFz.js +1 -0
  90. package/client/dist/assets/attachments-DYHGA2Dj.js +1 -0
  91. package/client/dist/assets/attachments-Dd92KpUH.js +1 -0
  92. package/client/dist/assets/attachments-DzdU6DV6.js +1 -0
  93. package/client/dist/assets/azcli-Cz6HAoOw.js +1 -0
  94. package/client/dist/assets/bat-CcJ-xyqL.js +1 -0
  95. package/client/dist/assets/bicep-z1WDCKYz.js +2 -0
  96. package/client/dist/assets/browser-5ErDlJoR.js +1 -0
  97. package/client/dist/assets/browser-Bc-YdlVg.js +1 -0
  98. package/client/dist/assets/browser-BlYF4OOq.js +1 -0
  99. package/client/dist/assets/browser-CT-ReZGt.js +1 -0
  100. package/client/dist/assets/browser-DGITz3fC.js +1 -0
  101. package/client/dist/assets/browser-JsAIGCEW.js +1 -0
  102. package/client/dist/assets/browser-M5-rbPlw.js +1 -0
  103. package/client/dist/assets/browser-Qya9cARy.js +1 -0
  104. package/client/dist/assets/cameligo-BRewOpfa.js +1 -0
  105. package/client/dist/assets/chat-BEGuC03z.js +1 -0
  106. package/client/dist/assets/chat-BEW60P_u.js +1 -0
  107. package/client/dist/assets/chat-BQNMD0PL.js +1 -0
  108. package/client/dist/assets/chat-BsbNGPW9.js +1 -0
  109. package/client/dist/assets/chat-CboQguCi.js +1 -0
  110. package/client/dist/assets/chat-DRCa9pOt.js +1 -0
  111. package/client/dist/assets/chat-DwUm6W9z.js +1 -0
  112. package/client/dist/assets/chat-yoXwguQu.js +1 -0
  113. package/client/dist/assets/chunk-CilyBKbf.js +1 -0
  114. package/client/dist/assets/clojure-DBjRWN6g.js +1 -0
  115. package/client/dist/assets/clsx-DnqN-uhr.js +1 -0
  116. package/client/dist/assets/code-AL1rVIMb.js +1 -0
  117. package/client/dist/assets/code-C0BKpkht.js +1 -0
  118. package/client/dist/assets/code-C0FTS3ew.js +1 -0
  119. package/client/dist/assets/code-CPcHxzxw.js +1 -0
  120. package/client/dist/assets/code-D3ryDniw.js +1 -0
  121. package/client/dist/assets/code-D3zVVQTj.js +1 -0
  122. package/client/dist/assets/code-PCmfS3dn.js +1 -0
  123. package/client/dist/assets/code-exI0G5Wd.js +1 -0
  124. package/client/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  125. package/client/dist/assets/coffee-Cfk_XHGR.js +1 -0
  126. package/client/dist/assets/commands-B772IyDa.js +1 -0
  127. package/client/dist/assets/commands-BDDp6xFG.js +1 -0
  128. package/client/dist/assets/commands-CJxCry-o.js +1 -0
  129. package/client/dist/assets/commands-CfgY-_of.js +1 -0
  130. package/client/dist/assets/commands-DLrvnPNg.js +1 -0
  131. package/client/dist/assets/commands-IXMOKBYt.js +1 -0
  132. package/client/dist/assets/commands-UD1NzmwX.js +1 -0
  133. package/client/dist/assets/commands-sqrqsxyE.js +1 -0
  134. package/client/dist/assets/common-DCr6VzJ7.js +1 -0
  135. package/client/dist/assets/common-Dard9UNH.js +1 -0
  136. package/client/dist/assets/common-DeDELLZJ.js +1 -0
  137. package/client/dist/assets/common-DltqHaAe.js +1 -0
  138. package/client/dist/assets/common-Dmm1GhdD.js +1 -0
  139. package/client/dist/assets/common-DnjcgkPH.js +1 -0
  140. package/client/dist/assets/common-GbpxfPG8.js +1 -0
  141. package/client/dist/assets/common-wA36jmj1.js +1 -0
  142. package/client/dist/assets/cpp-BVob6BaP.js +1 -0
  143. package/client/dist/assets/csharp-C4fbRuOu.js +1 -0
  144. package/client/dist/assets/csp-DthFP_vT.js +1 -0
  145. package/client/dist/assets/css-CGMH0hcW.js +3 -0
  146. package/client/dist/assets/css.worker-Wv5dxAWO.js +89 -0
  147. package/client/dist/assets/cssMode-Cc6ozl-J.js +1 -0
  148. package/client/dist/assets/cypher-Pnf68BRV.js +1 -0
  149. package/client/dist/assets/dart-PMMOtxZX.js +1 -0
  150. package/client/dist/assets/dashboard-B4ixDVk8.js +1 -0
  151. package/client/dist/assets/dashboard-BZBADHSj.js +1 -0
  152. package/client/dist/assets/dashboard-C1MfeUHs.js +1 -0
  153. package/client/dist/assets/dashboard-C7SK6xu5.js +1 -0
  154. package/client/dist/assets/dashboard-CB6Le1yN.js +1 -0
  155. package/client/dist/assets/dashboard-CoTpMOBM.js +1 -0
  156. package/client/dist/assets/dashboard-Duo4DDCW.js +1 -0
  157. package/client/dist/assets/dashboard-I19DXBxw.js +1 -0
  158. package/client/dist/assets/dist-js-BY-Fv_fg.js +1 -0
  159. package/client/dist/assets/dist-js-Bakc4uxT.js +1 -0
  160. package/client/dist/assets/dockerfile-di1nsJCc.js +1 -0
  161. package/client/dist/assets/ecl-D_WVcB5M.js +1 -0
  162. package/client/dist/assets/editor-Br_kD0ds.css +1 -0
  163. package/client/dist/assets/editor.api2-XLGzZfbc.js +872 -0
  164. package/client/dist/assets/editor.main-CfXxHimg.js +6 -0
  165. package/client/dist/assets/editor.worker-Bd9IXS8d.js +26 -0
  166. package/client/dist/assets/elixir-OAdJEMOn.js +1 -0
  167. package/client/dist/assets/explore-4mFpnrKU.js +1 -0
  168. package/client/dist/assets/explore-A8Ltoblq.js +1 -0
  169. package/client/dist/assets/explore-B9A3iN2W.js +1 -0
  170. package/client/dist/assets/explore-BV5Xxlsn.js +1 -0
  171. package/client/dist/assets/explore-BrBJvfjP.js +1 -0
  172. package/client/dist/assets/explore-C3FSE42C.js +1 -0
  173. package/client/dist/assets/explore-D2EFgt8J.js +1 -0
  174. package/client/dist/assets/explore-hFc3HFcp.js +1 -0
  175. package/client/dist/assets/flow9-D3QEZjgn.js +1 -0
  176. package/client/dist/assets/format-command-CwGuwzGA.js +1 -0
  177. package/client/dist/assets/freemarker2-DP7J1gG3.js +3 -0
  178. package/client/dist/assets/fsharp-BF0k_8N8.js +1 -0
  179. package/client/dist/assets/go-BAQO5Jsz.js +1 -0
  180. package/client/dist/assets/graphql-hdFVFkiV.js +1 -0
  181. package/client/dist/assets/handlebars-BjRlucw6.js +1 -0
  182. package/client/dist/assets/hcl-DWnl1o-X.js +1 -0
  183. package/client/dist/assets/html-OumBQJ-U.js +1 -0
  184. package/client/dist/assets/html.worker-CQP8QQsS.js +502 -0
  185. package/client/dist/assets/htmlMode-CStc3zXM.js +1 -0
  186. package/client/dist/assets/index-CimDRRi7.css +2 -0
  187. package/client/dist/assets/index-XGZaKl_u.js +142 -0
  188. package/client/dist/assets/ini-CB-6OVu3.js +1 -0
  189. package/client/dist/assets/integrations-C3p12Ms6.js +1 -0
  190. package/client/dist/assets/integrations-Cr6hH7XR.js +1 -0
  191. package/client/dist/assets/integrations-Cublz3m6.js +1 -0
  192. package/client/dist/assets/integrations-D28q1kF6.js +1 -0
  193. package/client/dist/assets/integrations-DRdbki5W.js +1 -0
  194. package/client/dist/assets/integrations-DaC4SzzL.js +1 -0
  195. package/client/dist/assets/integrations-DmQYCUvN.js +1 -0
  196. package/client/dist/assets/integrations-HIlUxXVs.js +1 -0
  197. package/client/dist/assets/java-d1CmfiHX.js +1 -0
  198. package/client/dist/assets/javascript-CMk--e7g.js +1 -0
  199. package/client/dist/assets/jobs-BE1siB0M.js +1 -0
  200. package/client/dist/assets/jobs-BHcQ_Faf.js +1 -0
  201. package/client/dist/assets/jobs-CFfc2dNX.js +1 -0
  202. package/client/dist/assets/jobs-CSi5n8X_.js +1 -0
  203. package/client/dist/assets/jobs-Dc3X86PY.js +1 -0
  204. package/client/dist/assets/jobs-De5tASex.js +1 -0
  205. package/client/dist/assets/jobs-DsoXEdo7.js +1 -0
  206. package/client/dist/assets/jobs-Wl-ApPMb.js +1 -0
  207. package/client/dist/assets/json.worker-DzV-CpCQ.js +58 -0
  208. package/client/dist/assets/jsonMode-C2h3ZcjZ.js +7 -0
  209. package/client/dist/assets/julia-Bgv08lKa.js +1 -0
  210. package/client/dist/assets/kotlin-u98kaVTf.js +1 -0
  211. package/client/dist/assets/less-CjYwpgg5.js +2 -0
  212. package/client/dist/assets/lexon-YTjaAFBB.js +1 -0
  213. package/client/dist/assets/lib-CPxTMOAq.js +7 -0
  214. package/client/dist/assets/liquid-mI3KJrBE.js +1 -0
  215. package/client/dist/assets/lspLanguageFeatures-DU09ggWi.js +4 -0
  216. package/client/dist/assets/lua-BzmkWv27.js +1 -0
  217. package/client/dist/assets/m3-CFwk9fw0.js +1 -0
  218. package/client/dist/assets/markdown-CR5iMpSZ.js +1 -0
  219. package/client/dist/assets/mdx-C41VDTR_.js +1 -0
  220. package/client/dist/assets/mips-CcEalc17.js +1 -0
  221. package/client/dist/assets/monaco.contribution-CPObAXMC.js +2 -0
  222. package/client/dist/assets/msdax-BQbkawnr.js +1 -0
  223. package/client/dist/assets/mysql-GTlaaW_P.js +1 -0
  224. package/client/dist/assets/nav-0fwkrgHt.js +1 -0
  225. package/client/dist/assets/nav-BEL3MTwK.js +1 -0
  226. package/client/dist/assets/nav-B_G-TJDW.js +1 -0
  227. package/client/dist/assets/nav-C2YXcbZS.js +1 -0
  228. package/client/dist/assets/nav-ClzOE4mA.js +1 -0
  229. package/client/dist/assets/nav-CtYwmMgu.js +1 -0
  230. package/client/dist/assets/nav-D2bOGSEg.js +1 -0
  231. package/client/dist/assets/nav-iH1V5j6o.js +1 -0
  232. package/client/dist/assets/objective-c-Byu1T5if.js +1 -0
  233. package/client/dist/assets/pascal-BrfzBfRm.js +1 -0
  234. package/client/dist/assets/pascaligo-BXXKFUeo.js +1 -0
  235. package/client/dist/assets/perl-B3OikKq-.js +1 -0
  236. package/client/dist/assets/pgsql-CTsa0Acc.js +1 -0
  237. package/client/dist/assets/php-DiQh3FUW.js +1 -0
  238. package/client/dist/assets/pla-92uH8Fzm.js +1 -0
  239. package/client/dist/assets/postiats-BbeWkKUr.js +1 -0
  240. package/client/dist/assets/powerquery-DgDMzpsm.js +1 -0
  241. package/client/dist/assets/powershell-BfdUUzaG.js +1 -0
  242. package/client/dist/assets/preload-helper-DSXbuxSR.js +1 -0
  243. package/client/dist/assets/protobuf-BojW2ftW.js +2 -0
  244. package/client/dist/assets/pug-BxqTg3IU.js +1 -0
  245. package/client/dist/assets/python-Y27rKQtk.js +1 -0
  246. package/client/dist/assets/qsharp-BX_A-MW9.js +1 -0
  247. package/client/dist/assets/r-D9BMnxvJ.js +1 -0
  248. package/client/dist/assets/razor-Cd5-q9Bp.js +1 -0
  249. package/client/dist/assets/redis-5cJqEQJJ.js +1 -0
  250. package/client/dist/assets/redshift-d8BBqiwb.js +1 -0
  251. package/client/dist/assets/restructuredtext-C8a6yIcZ.js +1 -0
  252. package/client/dist/assets/ruby-egeh-6KX.js +1 -0
  253. package/client/dist/assets/rust-a3r9IInB.js +1 -0
  254. package/client/dist/assets/sb-y8iRIDei.js +1 -0
  255. package/client/dist/assets/scala-BPDK2AmK.js +1 -0
  256. package/client/dist/assets/scheme-BIWUEoOs.js +1 -0
  257. package/client/dist/assets/scss-CA-PSzwg.js +3 -0
  258. package/client/dist/assets/settings-55oDcbSh.js +1 -0
  259. package/client/dist/assets/settings-Bd4Tq1RB.js +1 -0
  260. package/client/dist/assets/settings-CCSM-Fhn.js +1 -0
  261. package/client/dist/assets/settings-D3e_bDoW.js +1 -0
  262. package/client/dist/assets/settings-DKbTkbn7.js +1 -0
  263. package/client/dist/assets/settings-Dxpo6_w7.js +1 -0
  264. package/client/dist/assets/settings-bt84e3Aa.js +1 -0
  265. package/client/dist/assets/settings-nu68QukM.js +1 -0
  266. package/client/dist/assets/setup-BMqwfbW9.js +1 -0
  267. package/client/dist/assets/setup-Bb5LcG28.js +1 -0
  268. package/client/dist/assets/setup-BeEx2_da.js +1 -0
  269. package/client/dist/assets/setup-CCCrB53Q.js +1 -0
  270. package/client/dist/assets/setup-CJA0ATmd.js +1 -0
  271. package/client/dist/assets/setup-CeiDbZcb.js +1 -0
  272. package/client/dist/assets/setup-Cus7TApA.js +1 -0
  273. package/client/dist/assets/setup-D9qOs2Xo.js +1 -0
  274. package/client/dist/assets/shell--LiT1Bja.js +1 -0
  275. package/client/dist/assets/solidity-DdqZccZg.js +1 -0
  276. package/client/dist/assets/sophia-S6-YxNG_.js +1 -0
  277. package/client/dist/assets/sparql-BSf5kMp2.js +1 -0
  278. package/client/dist/assets/specs-BFfu3u-a.js +1 -0
  279. package/client/dist/assets/specs-B__C8-8a.js +1 -0
  280. package/client/dist/assets/specs-CZ1PsXsC.js +1 -0
  281. package/client/dist/assets/specs-D2FzlLn9.js +1 -0
  282. package/client/dist/assets/specs-DaUTrNF9.js +1 -0
  283. package/client/dist/assets/specs-Dyc5hYeE.js +1 -0
  284. package/client/dist/assets/specs-cKEh2LXt.js +1 -0
  285. package/client/dist/assets/specs-k0PyLDVt.js +1 -0
  286. package/client/dist/assets/sql-D7KgjR8G.js +1 -0
  287. package/client/dist/assets/st-BnoDa-Ml.js +1 -0
  288. package/client/dist/assets/swift-DEUHTkUX.js +1 -0
  289. package/client/dist/assets/systemverilog-Tqb_KPnW.js +1 -0
  290. package/client/dist/assets/tcl-BmBFS2qq.js +1 -0
  291. package/client/dist/assets/terminal-80yDMgMF.js +1 -0
  292. package/client/dist/assets/terminal-Bje4ziIa.js +1 -0
  293. package/client/dist/assets/terminal-C2WYcFHF.js +1 -0
  294. package/client/dist/assets/terminal-CSONJOex.js +1 -0
  295. package/client/dist/assets/terminal-DEqzGtcr.js +1 -0
  296. package/client/dist/assets/terminal-DeWzh6ys.js +1 -0
  297. package/client/dist/assets/terminal-YOlsJCQj.js +1 -0
  298. package/client/dist/assets/terminal-lkZYR4wJ.js +1 -0
  299. package/client/dist/assets/tickets-CB7N30gm.js +1 -0
  300. package/client/dist/assets/tickets-CF2PYelu.js +1 -0
  301. package/client/dist/assets/tickets-DNOANUXr.js +1 -0
  302. package/client/dist/assets/tickets-DU1aqsbr.js +1 -0
  303. package/client/dist/assets/tickets-DYvafSaY.js +1 -0
  304. package/client/dist/assets/tickets-DlpC_iTg.js +1 -0
  305. package/client/dist/assets/tickets-DucYgtdl.js +1 -0
  306. package/client/dist/assets/tickets-clefmXLv.js +1 -0
  307. package/client/dist/assets/ts.worker-METxwbDZ.js +67719 -0
  308. package/client/dist/assets/tsMode-B0y_xEci.js +11 -0
  309. package/client/dist/assets/twig-BQV8igWC.js +1 -0
  310. package/client/dist/assets/typescript-BzK0OgwW.js +1 -0
  311. package/client/dist/assets/typespec-DlFroUGY.js +1 -0
  312. package/client/dist/assets/useProjectCache-DSaiGFjV.js +1 -0
  313. package/client/dist/assets/vb-BlrJpIMX.js +1 -0
  314. package/client/dist/assets/wgsl-BWgIc6FZ.js +298 -0
  315. package/client/dist/assets/workers-rt--R2Qy.js +1 -0
  316. package/client/dist/assets/xml-eX9QXAmI.js +1 -0
  317. package/client/dist/assets/yaml-fcsNkpOt.js +1 -0
  318. package/client/dist/index.html +246 -0
  319. package/docs/README.md +54 -0
  320. package/docs/cli.md +198 -0
  321. package/docs/codex.md +210 -0
  322. package/docs/creating-specs.md +197 -0
  323. package/docs/customizing.md +197 -0
  324. package/docs/getting-started.md +140 -0
  325. package/docs/internals/README.md +25 -0
  326. package/docs/internals/adding-a-provider.md +238 -0
  327. package/docs/internals/api-reference.md +634 -0
  328. package/docs/internals/architecture.md +332 -0
  329. package/docs/internals/configuration.md +172 -0
  330. package/docs/internals/openspec-workflow.md +282 -0
  331. package/docs/internals/operations-runbook.md +198 -0
  332. package/docs/internals/profiles.md +152 -0
  333. package/docs/platforms/macos.md +130 -0
  334. package/docs/platforms/windows.md +81 -0
  335. package/docs/running-pipelines.md +240 -0
  336. package/docs/terminal.md +138 -0
  337. package/docs/tracking-cost.md +155 -0
  338. package/package.json +82 -0
  339. package/server/dist/agent-generator.js +232 -0
  340. package/server/dist/agent-refine-db.js +124 -0
  341. package/server/dist/agent-refine-manager.js +526 -0
  342. package/server/dist/ai-invocations.js +111 -0
  343. package/server/dist/attachment-manager.js +299 -0
  344. package/server/dist/auth.js +207 -0
  345. package/server/dist/binary-probe.js +35 -0
  346. package/server/dist/browser-capture-manager.js +576 -0
  347. package/server/dist/browser-capture-types.js +28 -0
  348. package/server/dist/browser-network.js +149 -0
  349. package/server/dist/browser-playwright.js +888 -0
  350. package/server/dist/build-dirs.js +44 -0
  351. package/server/dist/changes-reader.js +120 -0
  352. package/server/dist/chat-manager.js +1060 -0
  353. package/server/dist/chromium-resolver.js +311 -0
  354. package/server/dist/code-explorer-router.js +788 -0
  355. package/server/dist/codex-otel-bridge.js +235 -0
  356. package/server/dist/command-resolver.js +102 -0
  357. package/server/dist/config.js +306 -0
  358. package/server/dist/context-budget.js +113 -0
  359. package/server/dist/context-scope.js +279 -0
  360. package/server/dist/contract-refine-runner.js +521 -0
  361. package/server/dist/core-compat.js +207 -0
  362. package/server/dist/core-package.js +14 -0
  363. package/server/dist/db.js +1034 -0
  364. package/server/dist/desktop-analytics.js +156 -0
  365. package/server/dist/desktop-db.js +456 -0
  366. package/server/dist/desktop-router.js +735 -0
  367. package/server/dist/docs-router.js +207 -0
  368. package/server/dist/explore-contract-refine.js +421 -0
  369. package/server/dist/explore-cwd-manager.js +242 -0
  370. package/server/dist/explore-draft-title.js +47 -0
  371. package/server/dist/explore-smash.js +450 -0
  372. package/server/dist/feature-flags.js +17 -0
  373. package/server/dist/file-provenance.js +382 -0
  374. package/server/dist/file-summary-generator.js +221 -0
  375. package/server/dist/file-summary-manager.js +689 -0
  376. package/server/dist/hooks.js +102 -0
  377. package/server/dist/ids.js +7 -0
  378. package/server/dist/index.js +586 -0
  379. package/server/dist/metrics.js +136 -0
  380. package/server/dist/mobile/index.js +16 -0
  381. package/server/dist/mobile/mobile-admin-router.js +84 -0
  382. package/server/dist/mobile/mobile-auth.js +67 -0
  383. package/server/dist/mobile/mobile-devices.js +80 -0
  384. package/server/dist/mobile/mobile-event-bus.js +39 -0
  385. package/server/dist/mobile/mobile-gateway.js +285 -0
  386. package/server/dist/mobile/mobile-mdns.js +81 -0
  387. package/server/dist/mobile/mobile-pairing.js +179 -0
  388. package/server/dist/mobile/mobile-redact.js +53 -0
  389. package/server/dist/mobile/mobile-router.js +411 -0
  390. package/server/dist/mobile/mobile-tls.js +86 -0
  391. package/server/dist/mobile/mobile-types.js +9 -0
  392. package/server/dist/mobile/mobile-ws.js +275 -0
  393. package/server/dist/path-resolver.js +298 -0
  394. package/server/dist/plugin-manager.js +617 -0
  395. package/server/dist/plugins/claude-approval.js +179 -0
  396. package/server/dist/plugins/claude-md-mutation.js +146 -0
  397. package/server/dist/plugins/codex-mcp.js +108 -0
  398. package/server/dist/plugins/contributors.js +72 -0
  399. package/server/dist/plugins/drift.js +58 -0
  400. package/server/dist/plugins/index.js +14 -0
  401. package/server/dist/plugins/json-mutation.js +120 -0
  402. package/server/dist/plugins/manager.js +32 -0
  403. package/server/dist/plugins/ownership.js +86 -0
  404. package/server/dist/plugins/paths.js +37 -0
  405. package/server/dist/plugins/prereq-installer.js +104 -0
  406. package/server/dist/plugins/rail-integration.js +79 -0
  407. package/server/dist/plugins/serena/index.js +13 -0
  408. package/server/dist/plugins/serena/install.js +91 -0
  409. package/server/dist/plugins/serena/instructions-content.js +21 -0
  410. package/server/dist/plugins/serena/manifest.js +111 -0
  411. package/server/dist/plugins/serena/verify.js +78 -0
  412. package/server/dist/plugins-router.js +215 -0
  413. package/server/dist/pricing.js +89 -0
  414. package/server/dist/profile-manager.js +310 -0
  415. package/server/dist/profiles-router.js +759 -0
  416. package/server/dist/project-registry.js +443 -0
  417. package/server/dist/project-router.js +4016 -0
  418. package/server/dist/proposal-manager.js +291 -0
  419. package/server/dist/provider-selection.js +69 -0
  420. package/server/dist/providers/claude-adapter.js +281 -0
  421. package/server/dist/providers/codex-adapter.js +264 -0
  422. package/server/dist/providers/index.js +23 -0
  423. package/server/dist/providers/registry.js +37 -0
  424. package/server/dist/providers/types.js +22 -0
  425. package/server/dist/queue-manager.js +1511 -0
  426. package/server/dist/rails-router.js +362 -0
  427. package/server/dist/rails-store.js +116 -0
  428. package/server/dist/result-event.js +106 -0
  429. package/server/dist/schemas/profile.v1.json +151 -0
  430. package/server/dist/setup-manager.js +1165 -0
  431. package/server/dist/setup-prerequisites.js +372 -0
  432. package/server/dist/smash-runner.js +663 -0
  433. package/server/dist/spec-draft-parser.js +133 -0
  434. package/server/dist/spec-launcher-manager.js +174 -0
  435. package/server/dist/spec-models.js +32 -0
  436. package/server/dist/specrails-tech-client.js +82 -0
  437. package/server/dist/spending.js +448 -0
  438. package/server/dist/telemetry-compactor.js +180 -0
  439. package/server/dist/telemetry-export.js +317 -0
  440. package/server/dist/telemetry-receiver.js +224 -0
  441. package/server/dist/terminal-manager.js +633 -0
  442. package/server/dist/terminal-marks-store.js +117 -0
  443. package/server/dist/terminal-osc-parser.js +159 -0
  444. package/server/dist/terminal-settings.js +282 -0
  445. package/server/dist/terminal-shell-integration.js +196 -0
  446. package/server/dist/ticket-broadcast.js +47 -0
  447. package/server/dist/ticket-store.js +397 -0
  448. package/server/dist/ticket-watcher.js +117 -0
  449. package/server/dist/types.js +10 -0
  450. package/server/dist/user-mcp-config.js +117 -0
  451. package/server/dist/util/cli-prompt.js +181 -0
  452. package/server/dist/util/secure-fs.js +50 -0
  453. package/server/dist/util/win-spawn.js +43 -0
  454. package/server/dist/webhook-manager.js +89 -0
  455. package/server/dist/ws-routing.js +47 -0
@@ -0,0 +1,1511 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.QueueManager = exports.ULTRACODE_COMMAND_RE = exports.JobAlreadyTerminalError = exports.JobNotFoundError = exports.CodexNotFoundError = exports.ClaudeNotFoundError = exports.DEFAULT_ZOMBIE_TIMEOUT_MS = void 0;
7
+ exports.buildTelemetryEnv = buildTelemetryEnv;
8
+ exports.projectSupportsProfiles = projectSupportsProfiles;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const readline_1 = require("readline");
12
+ const ids_1 = require("./ids");
13
+ const tree_kill_1 = __importDefault(require("tree-kill"));
14
+ const types_1 = require("./types");
15
+ const command_resolver_1 = require("./command-resolver");
16
+ const cli_prompt_1 = require("./util/cli-prompt");
17
+ const hooks_1 = require("./hooks");
18
+ const ai_invocations_1 = require("./ai-invocations");
19
+ const feature_flags_1 = require("./feature-flags");
20
+ const file_provenance_1 = require("./file-provenance");
21
+ const result_event_1 = require("./result-event");
22
+ const crypto_1 = require("crypto");
23
+ const providers_1 = require("./providers");
24
+ const codex_otel_bridge_1 = require("./codex-otel-bridge");
25
+ const db_1 = require("./db");
26
+ const attachment_manager_1 = require("./attachment-manager");
27
+ const ticket_store_1 = require("./ticket-store");
28
+ const binary_probe_1 = require("./binary-probe");
29
+ // ─── Telemetry env helpers ────────────────────────────────────────────────────
30
+ /** Build the OTEL environment variable block for a spawned claude process.
31
+ * Extracted as a pure function so it is unit-testable without a full spawn. */
32
+ function buildTelemetryEnv(jobId, projectId, desktopPort, extraResourceAttributes = {}) {
33
+ const baseAttrs = [
34
+ ['specrails.job_id', jobId],
35
+ ['specrails.project_id', projectId],
36
+ ];
37
+ for (const [k, v] of Object.entries(extraResourceAttributes)) {
38
+ baseAttrs.push([k, String(v)]);
39
+ }
40
+ return {
41
+ CLAUDE_CODE_ENABLE_TELEMETRY: '1',
42
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${desktopPort}/otlp`,
43
+ OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json',
44
+ OTEL_METRICS_EXPORTER: 'otlp',
45
+ OTEL_LOGS_EXPORTER: 'otlp',
46
+ OTEL_TRACES_EXPORTER: 'otlp',
47
+ OTEL_RESOURCE_ATTRIBUTES: baseAttrs.map(([k, v]) => `${k}=${v}`).join(','),
48
+ };
49
+ }
50
+ /** Detect whether a project's installed specrails-core version supports the
51
+ * profile-aware pipeline (shipped in 4.1.0). Returns false when the version
52
+ * file is missing or unparseable so we default to legacy (safer). */
53
+ function projectSupportsProfiles(projectPath) {
54
+ const candidates = [
55
+ path_1.default.join(projectPath, '.specrails', 'specrails-version'),
56
+ path_1.default.join(projectPath, '.specrails-version'),
57
+ ];
58
+ for (const p of candidates) {
59
+ if (!fs_1.default.existsSync(p))
60
+ continue;
61
+ try {
62
+ const raw = fs_1.default.readFileSync(p, 'utf8').trim();
63
+ const [ma, mi, pa] = raw.split('.').map((n) => parseInt(n, 10));
64
+ if (isNaN(ma) || isNaN(mi) || isNaN(pa))
65
+ return false;
66
+ return ma > 4 || (ma === 4 && mi > 1) || (ma === 4 && mi === 1 && pa >= 0);
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ return false;
73
+ }
74
+ const LOG_BUFFER_MAX = 5000;
75
+ const LOG_BUFFER_DROP = 1000;
76
+ exports.DEFAULT_ZOMBIE_TIMEOUT_MS = 1_800_000; // 30 minutes
77
+ // ─── Error classes ────────────────────────────────────────────────────────────
78
+ class ClaudeNotFoundError extends Error {
79
+ constructor() {
80
+ super('claude binary not found');
81
+ this.name = 'ClaudeNotFoundError';
82
+ }
83
+ }
84
+ exports.ClaudeNotFoundError = ClaudeNotFoundError;
85
+ class CodexNotFoundError extends Error {
86
+ constructor() {
87
+ super('codex binary not found');
88
+ this.name = 'CodexNotFoundError';
89
+ }
90
+ }
91
+ exports.CodexNotFoundError = CodexNotFoundError;
92
+ class JobNotFoundError extends Error {
93
+ constructor() {
94
+ super('Job not found');
95
+ this.name = 'JobNotFoundError';
96
+ }
97
+ }
98
+ exports.JobNotFoundError = JobNotFoundError;
99
+ class JobAlreadyTerminalError extends Error {
100
+ constructor() {
101
+ super('Job is already in terminal state');
102
+ this.name = 'JobAlreadyTerminalError';
103
+ }
104
+ }
105
+ exports.JobAlreadyTerminalError = JobAlreadyTerminalError;
106
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
107
+ function extractDisplayText(event) {
108
+ const type = event.type;
109
+ // ── Claude `--output-format stream-json` ───────────────────────────────
110
+ if (type === 'assistant') {
111
+ const content = event.message;
112
+ const texts = (content?.content ?? [])
113
+ .filter((c) => c.type === 'text')
114
+ .map((c) => c.text ?? '');
115
+ return texts.join('') || null;
116
+ }
117
+ if (type === 'tool_use') {
118
+ const name = event.name;
119
+ const input = JSON.stringify(event.input ?? {});
120
+ return `[tool: ${name}] ${input.slice(0, 120)}`;
121
+ }
122
+ if (type === 'tool_result' || type === 'system_prompt' || type === 'user' || type === 'system' || type === 'result') {
123
+ return null;
124
+ }
125
+ // ── Codex `exec --json` event types ───────────────────────────────────
126
+ // Codex shape differs from claude: items are nested under `item` with a
127
+ // discriminator at `item.type`. Without explicit handling the Job Detail
128
+ // log shows only the spawn preamble and exit notice — exactly the
129
+ // "2 / 2 lines" symptom that masks 200k+ tokens of real work.
130
+ if (type === 'item.completed' || type === 'item.started') {
131
+ const item = event.item;
132
+ if (!item)
133
+ return null;
134
+ const itemType = item.type;
135
+ if (itemType === 'agent_message') {
136
+ const text = item.text?.trim();
137
+ return text && text.length > 0 ? text : null;
138
+ }
139
+ if (itemType === 'command_execution') {
140
+ // Only surface the completed line so the log isn't doubled with the
141
+ // matching `item.started` placeholder.
142
+ if (type !== 'item.completed')
143
+ return null;
144
+ const cmd = item.command ?? '';
145
+ const exitCode = item.exit_code;
146
+ const exitStr = typeof exitCode === 'number' ? ` → exit ${exitCode}` : '';
147
+ return `[exec]${exitStr} ${cmd.slice(0, 200)}`;
148
+ }
149
+ if (itemType === 'agent_reasoning') {
150
+ const text = item.text?.trim();
151
+ return text && text.length > 0 ? `[reasoning] ${text.slice(0, 200)}` : null;
152
+ }
153
+ return null;
154
+ }
155
+ if (type === 'thread.started' || type === 'turn.started' || type === 'turn.completed') {
156
+ return null;
157
+ }
158
+ return null;
159
+ }
160
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'zombie_terminated', 'skipped']);
161
+ /** Match an Ultracode rail command: `/specrails:ultracode #5 …` (or `/sr:…`). */
162
+ exports.ULTRACODE_COMMAND_RE = /^\/(specrails|sr):ultracode\b/;
163
+ // ─── QueueManager ─────────────────────────────────────────────────────────────
164
+ class QueueManager {
165
+ _queue;
166
+ _jobs;
167
+ _activeProcess;
168
+ _activeJobId;
169
+ _paused;
170
+ _killTimer;
171
+ _cancelingJobs;
172
+ _zombieJobs;
173
+ _broadcast;
174
+ _db;
175
+ _logBuffer;
176
+ _commands;
177
+ _cwd;
178
+ _zombieTimeoutMs;
179
+ _inactivityTimer;
180
+ /** Set by shutdown(); once disposed the manager spawns no new jobs and never
181
+ * touches the (now possibly closed) DB from late child 'close' callbacks. */
182
+ _disposed;
183
+ _getCostAlertThreshold;
184
+ _getDesktopDailyBudget;
185
+ _adapter;
186
+ /** Effective model to use when spawning processes. For Claude the adapter
187
+ * reads its own config; this is the override that gets passed via `--model`.
188
+ * For codex it controls the catalog model used at spawn time and as the
189
+ * fallback model name stamped onto the ai_invocations row. */
190
+ _resolvedModel;
191
+ _onJobFinished;
192
+ /** Project ID used for OTEL resource attributes (Super mode only) */
193
+ _projectId;
194
+ /** Server port used to construct the OTLP endpoint URL for env injection */
195
+ _desktopPort;
196
+ /** Project slug used for per-job profile snapshots (Super mode only) */
197
+ _projectSlug;
198
+ /** Pending profile selection keyed by jobId — read at spawn time */
199
+ _jobProfileSelection;
200
+ /** Pending per-job provider override keyed by jobId — read at spawn time.
201
+ * In-memory only (mirrors _jobProfileSelection): a queued job that survives a
202
+ * restart falls back to the project's primary provider. */
203
+ _jobProviderSelection;
204
+ /** Pending per-job model override keyed by jobId — read at spawn time.
205
+ * In-memory only (mirrors _jobProviderSelection). */
206
+ _jobModelSelection;
207
+ /** Pre-spawn working-tree snapshot refs keyed by jobId — read at exit time
208
+ * by the Code-Explorer provenance hook. Cleared on job exit. */
209
+ _snapshotRefs;
210
+ constructor(broadcast, db, commands, cwd, options) {
211
+ this._queue = [];
212
+ this._jobs = new Map();
213
+ this._activeProcess = null;
214
+ this._activeJobId = null;
215
+ this._paused = false;
216
+ this._killTimer = null;
217
+ this._cancelingJobs = new Set();
218
+ this._zombieJobs = new Set();
219
+ this._broadcast = broadcast;
220
+ this._db = db ?? null;
221
+ this._logBuffer = [];
222
+ this._commands = commands ?? [];
223
+ this._cwd = cwd;
224
+ this._inactivityTimer = null;
225
+ this._disposed = false;
226
+ this._getCostAlertThreshold = options?.getCostAlertThreshold ?? null;
227
+ this._getDesktopDailyBudget = options?.getDesktopDailyBudget ?? null;
228
+ this._adapter = (0, providers_1.getAdapter)(options?.provider ?? 'claude');
229
+ this._resolvedModel = options?.resolvedModel ?? null;
230
+ this._onJobFinished = options?.onJobFinished ?? null;
231
+ this._projectId = options?.projectId ?? null;
232
+ this._desktopPort = options?.desktopPort ?? 4200;
233
+ this._projectSlug = options?.projectSlug ?? null;
234
+ this._jobProfileSelection = new Map();
235
+ this._jobProviderSelection = new Map();
236
+ this._jobModelSelection = new Map();
237
+ this._snapshotRefs = new Map();
238
+ const envTimeout = process.env.WM_ZOMBIE_TIMEOUT_MS !== undefined
239
+ ? parseInt(process.env.WM_ZOMBIE_TIMEOUT_MS, 10)
240
+ : null;
241
+ this._zombieTimeoutMs = options?.zombieTimeoutMs
242
+ ?? (envTimeout !== null && !isNaN(envTimeout) ? envTimeout : exports.DEFAULT_ZOMBIE_TIMEOUT_MS);
243
+ if (this._db) {
244
+ this._restoreFromDb();
245
+ }
246
+ }
247
+ setCommands(commands) {
248
+ this._commands = commands;
249
+ }
250
+ setZombieTimeout(ms) {
251
+ this._zombieTimeoutMs = ms;
252
+ // If a job is currently running, reset the timer with the new value
253
+ if (this._activeJobId) {
254
+ this._resetZombieTimer();
255
+ }
256
+ }
257
+ /**
258
+ * Tear down the manager: clear pending timers, terminate any active child
259
+ * (SIGTERM, then SIGKILL after a grace period), and drop the DB handle so a
260
+ * late child 'close' event cannot run prepared statements against a closed
261
+ * connection (which would throw uncaught inside the EventEmitter listener and
262
+ * crash the whole app). Idempotent. Must be called BEFORE the per-project DB
263
+ * is closed (e.g. in ProjectRegistry.removeProject) and on graceful shutdown.
264
+ */
265
+ shutdown() {
266
+ if (this._disposed)
267
+ return;
268
+ this._disposed = true;
269
+ if (this._inactivityTimer !== null) {
270
+ clearTimeout(this._inactivityTimer);
271
+ this._inactivityTimer = null;
272
+ }
273
+ if (this._killTimer !== null) {
274
+ clearTimeout(this._killTimer);
275
+ this._killTimer = null;
276
+ }
277
+ const proc = this._activeProcess;
278
+ if (proc && proc.pid) {
279
+ const pid = proc.pid;
280
+ try {
281
+ (0, tree_kill_1.default)(pid, 'SIGTERM');
282
+ }
283
+ catch { /* best-effort */ }
284
+ const grace = setTimeout(() => {
285
+ try {
286
+ (0, tree_kill_1.default)(pid, 'SIGKILL', () => { });
287
+ }
288
+ catch { /* best-effort */ }
289
+ }, 5000);
290
+ // Do not let the grace timer keep the process alive on real shutdown.
291
+ if (typeof grace.unref === 'function')
292
+ grace.unref();
293
+ }
294
+ this._activeProcess = null;
295
+ this._activeJobId = null;
296
+ // Release any per-job provenance snapshots so teardown leaves no map entries.
297
+ this._snapshotRefs.clear();
298
+ // Drop the DB reference last so any in-flight 'close' callback sees null
299
+ // and skips all DB work via the existing `if (this._db)` guards.
300
+ this._db = null;
301
+ }
302
+ // ─── Public API ─────────────────────────────────────────────────────────────
303
+ enqueue(command, priorityOrOpts, opts) {
304
+ // Support both: enqueue(cmd, priority, opts) and enqueue(cmd, opts)
305
+ let priority = 'normal';
306
+ let resolvedOpts = opts;
307
+ if (typeof priorityOrOpts === 'string') {
308
+ priority = priorityOrOpts;
309
+ }
310
+ else if (priorityOrOpts && typeof priorityOrOpts === 'object') {
311
+ resolvedOpts = priorityOrOpts;
312
+ }
313
+ // Resolve the adapter for THIS job: the per-job provider override when set
314
+ // and installed, else the project's primary provider. The binary check
315
+ // below probes the chosen provider's CLI.
316
+ const enqueueAdapter = resolvedOpts?.provider ? (0, providers_1.getAdapter)(resolvedOpts.provider) : this._adapter;
317
+ if (enqueueAdapter.id === 'codex') {
318
+ if (!(0, binary_probe_1.binaryOnPath)('codex'))
319
+ throw new CodexNotFoundError();
320
+ }
321
+ else if (enqueueAdapter.id === 'claude') {
322
+ if (!(0, binary_probe_1.binaryOnPath)('claude'))
323
+ throw new ClaudeNotFoundError();
324
+ }
325
+ else if (!(0, binary_probe_1.binaryOnPath)(enqueueAdapter.binary)) {
326
+ // Future providers reuse the same pattern: a quick `which` probe via
327
+ // the adapter's binary. We don't throw a typed *NotFoundError because
328
+ // none has been declared; the adapter's id surfaces in the error.
329
+ throw new Error(`${enqueueAdapter.binary} binary not found`);
330
+ }
331
+ const id = (0, ids_1.newId)();
332
+ const job = {
333
+ id,
334
+ command,
335
+ status: 'queued',
336
+ queuePosition: null,
337
+ priority,
338
+ startedAt: null,
339
+ finishedAt: null,
340
+ exitCode: null,
341
+ dependsOnJobId: resolvedOpts?.dependsOnJobId ?? null,
342
+ pipelineId: resolvedOpts?.pipelineId ?? null,
343
+ skipReason: null,
344
+ resultText: null,
345
+ };
346
+ this._jobs.set(id, job);
347
+ // Record profile selection (if provided) so spawn time can pick it up.
348
+ // `undefined` means "use default resolution"; `null` means "force legacy".
349
+ if (resolvedOpts && 'profileName' in resolvedOpts) {
350
+ this._jobProfileSelection.set(id, resolvedOpts.profileName ?? null);
351
+ }
352
+ // Record per-job provider override so _startJob resolves the right adapter.
353
+ if (resolvedOpts?.provider) {
354
+ this._jobProviderSelection.set(id, resolvedOpts.provider);
355
+ }
356
+ // Record per-job model override (e.g. ultracode model picker).
357
+ if (resolvedOpts?.model) {
358
+ this._jobModelSelection.set(id, resolvedOpts.model);
359
+ }
360
+ // Insert at the correct position based on priority (higher priority first, FIFO within same level)
361
+ const weight = types_1.PRIORITY_WEIGHT[priority];
362
+ let insertIdx = this._queue.length;
363
+ for (let i = 0; i < this._queue.length; i++) {
364
+ const existing = this._jobs.get(this._queue[i]);
365
+ if (existing && types_1.PRIORITY_WEIGHT[existing.priority] < weight) {
366
+ insertIdx = i;
367
+ break;
368
+ }
369
+ }
370
+ this._queue.splice(insertIdx, 0, id);
371
+ this._recomputePositions();
372
+ this._persistJob(job);
373
+ this._broadcastQueueState();
374
+ this._drainQueue();
375
+ return job;
376
+ }
377
+ cancel(jobId) {
378
+ const job = this._jobs.get(jobId);
379
+ if (!job) {
380
+ throw new JobNotFoundError();
381
+ }
382
+ if (TERMINAL_STATUSES.has(job.status)) {
383
+ throw new JobAlreadyTerminalError();
384
+ }
385
+ if (job.status === 'queued') {
386
+ const idx = this._queue.indexOf(jobId);
387
+ if (idx !== -1) {
388
+ this._queue.splice(idx, 1);
389
+ }
390
+ job.status = 'canceled';
391
+ job.finishedAt = new Date().toISOString();
392
+ // B47: a queued job's per-job selection entries are consumed only when the
393
+ // job STARTS (_resolveJobAdapter et al.). Cancelling it while queued means
394
+ // it never starts, so drop them here to avoid leaking map entries forever.
395
+ this._jobProviderSelection.delete(jobId);
396
+ this._jobModelSelection.delete(jobId);
397
+ this._jobProfileSelection.delete(jobId);
398
+ this._skipDependents(jobId, `Parent job ${jobId} was canceled`);
399
+ this._recomputePositions();
400
+ this._persistJob(job);
401
+ this._broadcastQueueState();
402
+ // M20: a queued cancel never reached _onJobFinished (only the running path
403
+ // does, via _kill→_onJobExit), so a rail-launched queued job left its
404
+ // railJobs entry stuck 'running' forever and dropped the job.canceled
405
+ // webhook + rail.job_completed broadcast. Fire the callback here too; it is
406
+ // exit-status-driven and idempotent on tickets.
407
+ if (this._onJobFinished) {
408
+ try {
409
+ this._onJobFinished(jobId, 'canceled', undefined);
410
+ }
411
+ catch (err) {
412
+ console.error(`[QueueManager] onJobFinished(canceled) failed for ${jobId}: ${err.message}`);
413
+ }
414
+ }
415
+ return 'canceled';
416
+ }
417
+ // job.status === 'running'
418
+ this._kill(jobId);
419
+ return 'canceling';
420
+ }
421
+ pause() {
422
+ this._paused = true;
423
+ this._persistQueueState();
424
+ this._broadcastQueueState();
425
+ }
426
+ resume() {
427
+ this._paused = false;
428
+ this._persistQueueState();
429
+ this._broadcastQueueState();
430
+ this._drainQueue();
431
+ }
432
+ reorder(jobIds) {
433
+ const queuedSet = new Set(this._queue);
434
+ const incomingSet = new Set(jobIds);
435
+ if (queuedSet.size !== incomingSet.size) {
436
+ throw new Error('jobIds must contain exactly the IDs of all currently-queued jobs');
437
+ }
438
+ for (const id of jobIds) {
439
+ if (!queuedSet.has(id)) {
440
+ throw new Error(`Job ${id} is not in queued state`);
441
+ }
442
+ }
443
+ this._queue = [...jobIds];
444
+ this._recomputePositions();
445
+ if (this._db) {
446
+ for (const id of jobIds) {
447
+ const job = this._jobs.get(id);
448
+ if (job) {
449
+ this._persistJob(job);
450
+ }
451
+ }
452
+ }
453
+ this._broadcastQueueState();
454
+ }
455
+ updatePriority(jobId, priority) {
456
+ const job = this._jobs.get(jobId);
457
+ if (!job)
458
+ throw new JobNotFoundError();
459
+ if (job.status !== 'queued') {
460
+ throw new Error('Can only change priority of queued jobs');
461
+ }
462
+ job.priority = priority;
463
+ // Remove from queue and re-insert at correct position
464
+ const idx = this._queue.indexOf(jobId);
465
+ if (idx !== -1)
466
+ this._queue.splice(idx, 1);
467
+ const weight = types_1.PRIORITY_WEIGHT[priority];
468
+ let insertIdx = this._queue.length;
469
+ for (let i = 0; i < this._queue.length; i++) {
470
+ const existing = this._jobs.get(this._queue[i]);
471
+ if (existing && types_1.PRIORITY_WEIGHT[existing.priority] < weight) {
472
+ insertIdx = i;
473
+ break;
474
+ }
475
+ }
476
+ this._queue.splice(insertIdx, 0, jobId);
477
+ this._recomputePositions();
478
+ this._persistJob(job);
479
+ this._broadcastQueueState();
480
+ }
481
+ getJobs() {
482
+ return Array.from(this._jobs.values());
483
+ }
484
+ getActiveJobId() {
485
+ return this._activeJobId;
486
+ }
487
+ isPaused() {
488
+ return this._paused;
489
+ }
490
+ getLogBuffer() {
491
+ return [...this._logBuffer];
492
+ }
493
+ // ─── Private methods ────────────────────────────────────────────────────────
494
+ phasesForCommand(command) {
495
+ return this._phasesForCommand(command);
496
+ }
497
+ /**
498
+ * Resolve a slash command into a full prompt with $ARGUMENTS substituted.
499
+ * Delegates to the shared resolveCommand utility in command-resolver.ts.
500
+ */
501
+ _resolveCommand(command) {
502
+ return (0, command_resolver_1.resolveCommand)(command, this._cwd ?? process.cwd());
503
+ }
504
+ _phasesForCommand(command) {
505
+ // Extract slug from command strings like "/specrails:implement #5" or "implement"
506
+ const firstToken = command.trim().split(/\s+/)[0];
507
+ const slug = firstToken.includes(':') ? firstToken.split(':').pop() : firstToken.replace(/^\//, '');
508
+ const info = this._commands.find((c) => c.slug === slug);
509
+ return info?.phases ?? [];
510
+ }
511
+ _extractTicketIds(command) {
512
+ return (0, ticket_store_1.extractTicketIdsFromCommand)(command);
513
+ }
514
+ _buildImplementAttachmentContext(command) {
515
+ if (!this._cwd || !this._projectSlug)
516
+ return '';
517
+ const ticketIds = this._extractTicketIds(command);
518
+ if (ticketIds.length === 0)
519
+ return '';
520
+ try {
521
+ const store = (0, ticket_store_1.readStore)((0, ticket_store_1.resolveTicketStoragePath)(this._cwd));
522
+ const sections = [];
523
+ for (const ticketId of ticketIds) {
524
+ const storeAttachmentIds = new Set((store.tickets[String(ticketId)]?.attachments ?? []).map((attachment) => attachment.id));
525
+ const diskAttachmentIds = attachment_manager_1.attachmentManager
526
+ .list(this._projectSlug, ticketId)
527
+ .map((attachment) => attachment.id);
528
+ const attachmentIds = Array.from(new Set([...storeAttachmentIds, ...diskAttachmentIds]));
529
+ if (attachmentIds.length === 0)
530
+ continue;
531
+ const blocks = attachment_manager_1.attachmentManager.getPromptBlocksSync(this._projectSlug, ticketId, attachmentIds);
532
+ if (blocks.length === 0)
533
+ continue;
534
+ sections.push(`## Ticket #${ticketId} Attached Resources\n\n${blocks.join('\n\n')}`);
535
+ }
536
+ if (sections.length === 0)
537
+ return '';
538
+ return '\n\nIMPORTANT: Referenced ticket attachments are also part of the spec context. ' +
539
+ `You have explicit permission to read local attachment files stored under ~/.specrails/projects/${this._projectSlug}/attachments/<ticketId>/.\n\n` +
540
+ `${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}\n\n` +
541
+ 'If a <user-attachment> block contains only a local file path, open that file directly before implementing.\n\n' +
542
+ sections.join('\n\n');
543
+ }
544
+ catch (err) {
545
+ console.warn(`[queue-manager] failed to build attachment context: ${err.message}`);
546
+ return '';
547
+ }
548
+ }
549
+ /**
550
+ * Build the Claude prompt for an Ultracode job. Ultracode does NOT invoke
551
+ * a slash command: it sends the resolved pre-prompt followed by the full spec
552
+ * text of every ticket referenced in the command. Fully reconstructible from
553
+ * the command (`/specrails:ultracode #<id> …`) + the local ticket store, so a
554
+ * queued job survives a server restart without losing the prompt.
555
+ */
556
+ _buildUltracodePrompt(command) {
557
+ const pre = this._db ? (0, db_1.getUltracodePrePrompt)(this._db) : db_1.DEFAULT_ULTRACODE_PRE_PROMPT;
558
+ const ticketIds = this._extractTicketIds(command);
559
+ const specs = [];
560
+ if (this._cwd) {
561
+ try {
562
+ const store = (0, ticket_store_1.readStore)((0, ticket_store_1.resolveTicketStoragePath)(this._cwd));
563
+ for (const ticketId of ticketIds) {
564
+ const ticket = store.tickets[String(ticketId)];
565
+ if (!ticket)
566
+ continue;
567
+ const body = (ticket.description ?? '').trim();
568
+ specs.push(`# Spec #${ticketId}: ${ticket.title}\n\n${body || '_(no description)_'}`);
569
+ }
570
+ }
571
+ catch (err) {
572
+ console.warn(`[queue-manager] failed to read specs for ultracode: ${err.message}`);
573
+ }
574
+ }
575
+ const specBlock = specs.length > 0
576
+ ? specs.join('\n\n---\n\n')
577
+ : `(No spec content found for ${ticketIds.map((id) => `#${id}`).join(', ') || 'this rail'}.)`;
578
+ return `${pre}\n\n---\n\n${specBlock}`;
579
+ }
580
+ _drainQueue() {
581
+ if (this._disposed)
582
+ return;
583
+ if (this._activeJobId !== null)
584
+ return;
585
+ if (this._paused)
586
+ return;
587
+ if (this._queue.length === 0)
588
+ return;
589
+ const readyIndex = this._queue.findIndex(id => {
590
+ const job = this._jobs.get(id);
591
+ if (!job)
592
+ return true;
593
+ return this._isDependencyMet(job);
594
+ });
595
+ if (readyIndex === -1)
596
+ return;
597
+ const nextJobId = this._queue.splice(readyIndex, 1)[0];
598
+ // A3: reserve the active slot SYNCHRONOUSLY, before _startJob's awaits
599
+ // (plugin verify, profile snapshot). Otherwise a second _drainQueue triggered
600
+ // during those awaits (a concurrent /spawn, or the synchronous N-job loop of
601
+ // an Ultracode rail launch) still sees _activeJobId === null and starts a
602
+ // second job in the same working tree, with _activeProcess/_activeJobId then
603
+ // clobbered so cancel/zombie-kill hits the wrong child.
604
+ this._activeJobId = nextJobId;
605
+ this._recomputePositions();
606
+ void this._startJob(nextJobId).catch((err) => {
607
+ console.error(`[QueueManager] _startJob(${nextJobId}) threw before spawn: ${err?.message}`);
608
+ // Only release if we never established a child (else _onJobExit owns cleanup).
609
+ if (this._activeJobId === nextJobId && this._activeProcess === null) {
610
+ this._activeJobId = null;
611
+ this._drainQueue();
612
+ }
613
+ });
614
+ }
615
+ /**
616
+ * Resolve the adapter for a job at spawn time: the per-job provider override
617
+ * (consumed from `_jobProviderSelection`) when present and registered, else
618
+ * the project's primary adapter. Consuming the entry keeps the map bounded.
619
+ */
620
+ _resolveJobAdapter(jobId) {
621
+ const override = this._jobProviderSelection.get(jobId);
622
+ this._jobProviderSelection.delete(jobId);
623
+ if (override) {
624
+ try {
625
+ return (0, providers_1.getAdapter)(override);
626
+ }
627
+ catch {
628
+ /* fall through to primary */
629
+ }
630
+ }
631
+ return this._adapter;
632
+ }
633
+ async _startJob(jobId) {
634
+ const job = this._jobs.get(jobId);
635
+ if (!job) {
636
+ // Job vanished between the synchronous slot reservation in _drainQueue and
637
+ // here — release the reserved slot and move on (A3).
638
+ if (this._activeJobId === jobId)
639
+ this._activeJobId = null;
640
+ this._drainQueue();
641
+ return;
642
+ }
643
+ // Per-job adapter (multi-provider). `this._adapter` stays the project
644
+ // primary; everything in this spawn (binary, argv, model, profile, OTEL,
645
+ // plugins, result parsing, ai_invocations.provider) flows from `adapter`.
646
+ const adapter = this._resolveJobAdapter(jobId);
647
+ job.status = 'running';
648
+ job.startedAt = new Date().toISOString();
649
+ job.queuePosition = null;
650
+ this._recomputePositions();
651
+ this._persistJob(job);
652
+ const commandPhases = this._phasesForCommand(job.command);
653
+ if (commandPhases.length > 0) {
654
+ (0, hooks_1.setActivePhases)(commandPhases, this._broadcast);
655
+ }
656
+ else {
657
+ (0, hooks_1.resetPhases)(this._broadcast);
658
+ }
659
+ const commandToRun = job.command.trim();
660
+ // Build supplementary context (output chaining + headless mode) that goes
661
+ // into --append-system-prompt, keeping the user prompt clean.
662
+ let systemAppend = '';
663
+ // Output chaining: inject previous step's output as context for dependent jobs
664
+ if (job.dependsOnJobId) {
665
+ const parentJob = this._jobs.get(job.dependsOnJobId);
666
+ if (parentJob?.resultText) {
667
+ const prevOutput = parentJob.resultText;
668
+ const truncated = prevOutput.length > 10000
669
+ ? prevOutput.slice(0, 10000) + '\n\n[output truncated]'
670
+ : prevOutput;
671
+ systemAppend += `Previous step output:\n\n${truncated}\n\n---\n\nNow execute the following command.\n\n`;
672
+ }
673
+ }
674
+ // Headless mode: when --yes is in the command, instruct Claude to auto-proceed
675
+ // (stdin is ignored in spawned processes, so no user confirmation is possible)
676
+ if (job.command.includes('--yes')) {
677
+ systemAppend += '\n\nCRITICAL — FULLY AUTONOMOUS MODE (--yes flag):\n' +
678
+ 'This pipeline is running headless with NO human operator. stdin is disconnected — nobody can reply.\n' +
679
+ '- NEVER ask for approval, confirmation, review, or feedback. There is nobody to answer.\n' +
680
+ '- NEVER output prompts like "Reply with approved", "Do you want to proceed?", "Please confirm", or "Ready for review".\n' +
681
+ '- NEVER stop between pipeline phases to wait for input. Run ALL phases end-to-end without pausing.\n' +
682
+ '- When there are multiple options or decisions, always choose the RECOMMENDED option and proceed.\n' +
683
+ '- Auto-approve all proposals, designs, and artifacts. Treat everything as "approved" by default.\n' +
684
+ '- Skip any instructions that say "wait for user", "present for review", or "ask the user".\n' +
685
+ '- The pipeline must complete fully from start to finish in a single uninterrupted run.';
686
+ }
687
+ // Local ticket store: implement/batch-implement jobs must read specs from
688
+ // .specrails/local-tickets.json — never from external trackers like Jira/Linear.
689
+ if (/\/(specrails|sr):(implement|batch-implement)\b/.test(commandToRun)) {
690
+ systemAppend += '\n\nIMPORTANT: The ticket/spec data for this project is stored locally in .specrails/local-tickets.json. ' +
691
+ 'You MUST read specs from this file. Do NOT attempt to fetch tickets from Jira, Linear, GitHub Issues, or any other external tracker. ' +
692
+ 'The #<id> references in the command correspond to ticket IDs inside .specrails/local-tickets.json. ' +
693
+ 'Do NOT require jq to inspect this file; on Windows or when jq is unavailable, use PowerShell (`Get-Content .specrails/local-tickets.json -Raw | ConvertFrom-Json`) or Node.js built-ins. ' +
694
+ 'When running tests, use the project-defined scripts and package manager commands as-is; do NOT add Jest-only flags such as --runInBand to Vitest commands.';
695
+ const attachmentContext = this._buildImplementAttachmentContext(commandToRun);
696
+ if (attachmentContext) {
697
+ systemAppend += attachmentContext;
698
+ }
699
+ const prePrompt = this._db ? (0, db_1.getProjectSettings)(this._db).prePrompt.trim() : '';
700
+ if (prePrompt) {
701
+ systemAppend += '\n\nPROJECT PRE-PROMPT:\n' +
702
+ 'Apply the following project-specific instructions in addition to the ticket/spec and its attached resources.\n\n' +
703
+ prePrompt;
704
+ }
705
+ }
706
+ const binary = adapter.binary;
707
+ // Adapter-specific slash-command syntax:
708
+ // - claude: native `/specrails:foo` recognised by Claude CLI directly,
709
+ // so we pass the command verbatim and the system prompt rides along
710
+ // via `--system-prompt`.
711
+ // - codex: there is no `/namespace:cmd` parser; instead codex uses
712
+ // `$skill_name` to invoke a skill from `.codex/skills/<name>/SKILL.md`.
713
+ // Translate `/specrails:<name>` → `$<name>` so codex picks up the
714
+ // matching skill natively (which our scaffold writes for every
715
+ // claude slash command — propose-spec, implement, batch-implement,
716
+ // explore-spec, retry, …). This is the rail equivalent of the
717
+ // user typing `$implement #1 --yes` themselves in `codex`.
718
+ // Ultracode (Claude only): skip the slash command entirely and send the
719
+ // pre-prompt + spec text directly as the prompt. The server route guards
720
+ // that ultracode never reaches a non-claude adapter; defensively, a codex
721
+ // adapter still falls through to its skill-translation path below.
722
+ const isUltracode = adapter.id === 'claude' && exports.ULTRACODE_COMMAND_RE.test(commandToRun);
723
+ const railPrompt = isUltracode
724
+ ? this._buildUltracodePrompt(commandToRun)
725
+ : adapter.id === 'codex'
726
+ ? commandToRun.replace(/^\/(specrails|sr):([\w-]+)/, '$$$2')
727
+ : commandToRun;
728
+ // Per-job model override (consumed once) takes precedence — used by the
729
+ // ultracode model picker so the user can choose haiku/sonnet/opus per launch.
730
+ const modelOverride = this._jobModelSelection.get(jobId);
731
+ this._jobModelSelection.delete(jobId);
732
+ const railModel = modelOverride
733
+ ? modelOverride
734
+ : adapter.id === 'claude' && this._db
735
+ ? (0, db_1.getProjectSettings)(this._db).orchestratorModel
736
+ : (this._resolvedModel ?? adapter.defaultModel());
737
+ const args = adapter.buildArgs('rail-job', {
738
+ prompt: railPrompt,
739
+ systemPrompt: systemAppend || undefined,
740
+ model: railModel,
741
+ });
742
+ // Resolve agent profile (if any) and snapshot per-job before spawn.
743
+ // Super mode only (projectId + projectSlug + cwd all present).
744
+ // Skipped when the adapter does not honour `SPECRAILS_PROFILE_PATH` AND
745
+ // when the project's installed specrails-core is older than the
746
+ // provider's minimum core version (legacy fallback). Codex skill rails
747
+ // ship in specrails-core 4.6.0+; the projectSupportsProfiles probe today
748
+ // checks the claude minimum (4.1.0) — extending it per-provider is
749
+ // tracked in OpenSpec change task §13.
750
+ let profileSnapshotPath = null;
751
+ let profileName = null;
752
+ if (adapter.capabilities.profileEnvSupport && this._projectId && this._projectSlug && this._cwd) {
753
+ try {
754
+ const selection = this._jobProfileSelection.get(jobId); // undefined|null|string
755
+ this._jobProfileSelection.delete(jobId);
756
+ const coreSupports = projectSupportsProfiles(this._cwd);
757
+ if (selection !== null && coreSupports) {
758
+ // selection is string (explicit) or undefined (default resolution)
759
+ const { resolveProfile, snapshotForJob, persistJobProfile, } = require('./profile-manager');
760
+ const resolved = resolveProfile(this._cwd, selection ?? undefined, adapter.id);
761
+ if (resolved) {
762
+ profileSnapshotPath = snapshotForJob(this._projectSlug, jobId, resolved);
763
+ profileName = resolved.name;
764
+ if (this._db) {
765
+ persistJobProfile(this._db, jobId, resolved);
766
+ }
767
+ }
768
+ }
769
+ }
770
+ catch (err) {
771
+ // Profile resolution failures are non-fatal — rail falls back to
772
+ // legacy behavior. The error is visible in logs for debugging.
773
+ console.warn(`[queue-manager] profile resolution failed for job ${jobId}: ${err.message}`);
774
+ }
775
+ }
776
+ // Read pipelineTelemetryEnabled at spawn time (not constructor time) so
777
+ // toggling the setting takes effect on the next job without restarting.
778
+ // OTEL env injection is gated on `adapter.capabilities.nativeOtelEnv`:
779
+ // claude honours OTEL_* env vars natively; codex does not and instead
780
+ // gets signals synthesised by the codex-otel-bridge attached below.
781
+ let spawnEnv = process.env;
782
+ const telemetryEnabled = !!(this._projectId && this._db && (0, db_1.getProjectSettings)(this._db).pipelineTelemetryEnabled);
783
+ if (telemetryEnabled && adapter.capabilities.nativeOtelEnv && this._projectId) {
784
+ const extra = {};
785
+ if (profileName)
786
+ extra['specrails.profile_name'] = profileName;
787
+ if (profileName)
788
+ extra['specrails.profile_schema_version'] = '1';
789
+ spawnEnv = {
790
+ ...process.env,
791
+ ...buildTelemetryEnv(jobId, this._projectId, this._desktopPort, extra),
792
+ };
793
+ }
794
+ // Inject the profile path whenever the adapter honours it (was: claude-
795
+ // only). The codex skill rails read SPECRAILS_PROFILE_PATH the same way.
796
+ if (profileSnapshotPath) {
797
+ spawnEnv = { ...spawnEnv, SPECRAILS_PROFILE_PATH: profileSnapshotPath };
798
+ }
799
+ // ─── Plugin resolution + snapshot ──────────────────────────────────────
800
+ // Active = installed + verify ok; degraded = installed but verify failed
801
+ // or timed out. Degraded does NOT block spawn — rail proceeds, UI gets
802
+ // a `plugin.degraded` event so the user can reinstall.
803
+ //
804
+ // Today PluginManager only supports the `project-json` MCP registration
805
+ // (claude). Codex (`cli-add`) is covered by tasks §14 — until that lands
806
+ // we skip plugin resolution for non-`project-json` adapters so the rail
807
+ // spawns cleanly without errors.
808
+ let pluginActive = [];
809
+ let pluginDegraded = [];
810
+ let pluginSnapshotPath = null;
811
+ if (adapter.mcpRegistration === 'project-json' && this._projectId && this._projectSlug && this._cwd) {
812
+ try {
813
+ const { resolvePluginsForSpawn, snapshotPluginsForJob } = require('./plugins/rail-integration');
814
+ const resolution = await resolvePluginsForSpawn(this._cwd, this._projectId, jobId);
815
+ pluginActive = resolution.active;
816
+ pluginDegraded = resolution.degraded;
817
+ if (pluginActive.length > 0 || pluginDegraded.length > 0) {
818
+ pluginSnapshotPath = snapshotPluginsForJob(this._projectSlug, jobId, this._projectId, pluginActive, pluginDegraded);
819
+ }
820
+ for (const d of pluginDegraded) {
821
+ this._broadcast({
822
+ type: 'plugin.degraded',
823
+ projectId: this._projectId,
824
+ name: d.name,
825
+ reason: d.reason,
826
+ jobId,
827
+ timestamp: new Date().toISOString(),
828
+ });
829
+ }
830
+ }
831
+ catch (err) {
832
+ console.warn(`[queue-manager] plugin resolution failed for job ${jobId}: ${err.message}`);
833
+ }
834
+ }
835
+ if (pluginActive.length > 0 && pluginSnapshotPath) {
836
+ spawnEnv = {
837
+ ...spawnEnv,
838
+ SPECRAILS_PLUGINS_ACTIVE: pluginActive.map((p) => p.name).join(','),
839
+ SPECRAILS_PLUGINS_SNAPSHOT: pluginSnapshotPath,
840
+ };
841
+ }
842
+ // Add OTEL attrs when telemetry already on AND the adapter accepts env
843
+ // injection. Codex spawns receive these attributes via the bridge's
844
+ // resource attribute block instead (see codex-otel-bridge.ts).
845
+ if (adapter.capabilities.nativeOtelEnv && this._projectId && this._db) {
846
+ const settings = (0, db_1.getProjectSettings)(this._db);
847
+ if (settings.pipelineTelemetryEnabled && (pluginActive.length > 0 || pluginDegraded.length > 0)) {
848
+ const extra = {};
849
+ if (pluginActive.length > 0) {
850
+ extra['specrails.plugins.active'] = JSON.stringify(pluginActive.map((p) => p.name));
851
+ extra['specrails.plugins.versions'] = JSON.stringify(Object.fromEntries(pluginActive.map((p) => [p.name, p.version])));
852
+ }
853
+ if (pluginDegraded.length > 0) {
854
+ extra['specrails.plugins.degraded'] = JSON.stringify(pluginDegraded.map((d) => d.name));
855
+ }
856
+ spawnEnv = {
857
+ ...spawnEnv,
858
+ ...buildTelemetryEnv(jobId, this._projectId, this._desktopPort, extra),
859
+ };
860
+ }
861
+ }
862
+ // Code-Explorer pre-spawn snapshot. Captures the working-tree state via
863
+ // `git stash create --include-untracked` so the post-exit hook can diff
864
+ // against it. Gated by SPECRAILS_CODE_EXPLORER — when off, no-op.
865
+ if ((0, feature_flags_1.isCodeExplorerEnabled)() && this._cwd) {
866
+ try {
867
+ const snap = (0, file_provenance_1.snapshotWorkingTree)(this._cwd);
868
+ this._snapshotRefs.set(jobId, snap);
869
+ }
870
+ catch (err) {
871
+ console.warn(`[queue-manager] provenance snapshot failed: ${err.message}`);
872
+ }
873
+ }
874
+ // spawnAiCli reroutes multi-line argv values through stdin on Windows.
875
+ const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
876
+ env: spawnEnv,
877
+ stdio: ['ignore', 'pipe', 'pipe'],
878
+ cwd: this._cwd,
879
+ });
880
+ this._activeProcess = child;
881
+ this._activeJobId = jobId;
882
+ // Without this listener, an ENOENT (e.g. claude not on PATH) propagates
883
+ // as an unhandled 'error' event and crashes the entire app. Node still
884
+ // emits 'close' afterwards, so the existing close handler fails the job
885
+ // through the normal path — we only need to absorb the error event.
886
+ /* c8 ignore next 3 -- spawn-failure path; exercised manually, not in CI */
887
+ child.on('error', (err) => {
888
+ console.error(`[QueueManager] spawn failed for job ${jobId} (${binary}): ${err.message}`);
889
+ });
890
+ // Start zombie detection timer. Reset on any raw data from the process.
891
+ // Using 'data' events (not readline 'line') ensures the timer resets
892
+ // synchronously in test environments with fake timers.
893
+ this._resetZombieTimer();
894
+ child.stdout.on('data', () => { this._resetZombieTimer(); });
895
+ child.stderr.on('data', () => { this._resetZombieTimer(); });
896
+ let eventSeq = 0;
897
+ let lastResultEvent = null;
898
+ // Accumulator of parsed AdapterEvent for finaliseInvocationResult on close.
899
+ const adapterEvents = [];
900
+ // Synthetic OTEL bridge for providers whose CLI does not honour OTEL_*
901
+ // env vars (codex today). Lifecycle bound to the spawn's close handler.
902
+ let otelBridge = null;
903
+ if (telemetryEnabled && !adapter.capabilities.nativeOtelEnv && this._projectId) {
904
+ otelBridge = (0, codex_otel_bridge_1.createCodexOtelBridge)({
905
+ jobId,
906
+ projectId: this._projectId,
907
+ desktopPort: this._desktopPort,
908
+ model: railModel,
909
+ });
910
+ }
911
+ if (this._db) {
912
+ (0, db_1.createJob)(this._db, {
913
+ id: jobId,
914
+ command: job.command,
915
+ started_at: job.startedAt,
916
+ priority: job.priority,
917
+ depends_on_job_id: job.dependsOnJobId,
918
+ pipeline_id: job.pipelineId,
919
+ });
920
+ }
921
+ // ── Batched broadcast for high-frequency messages (log + event) ──────
922
+ // Collects messages and flushes every ~80ms instead of one WS send per line.
923
+ const pendingBroadcast = [];
924
+ let flushTimer = null;
925
+ const FLUSH_INTERVAL_MS = 80;
926
+ const batchedBroadcast = (msg) => {
927
+ pendingBroadcast.push(msg);
928
+ if (!flushTimer) {
929
+ flushTimer = setTimeout(() => {
930
+ flushTimer = null;
931
+ const batch = pendingBroadcast.splice(0);
932
+ for (const m of batch)
933
+ this._broadcast(m);
934
+ }, FLUSH_INTERVAL_MS);
935
+ }
936
+ };
937
+ const flushPending = () => {
938
+ if (flushTimer) {
939
+ clearTimeout(flushTimer);
940
+ flushTimer = null;
941
+ }
942
+ const batch = pendingBroadcast.splice(0);
943
+ for (const m of batch)
944
+ this._broadcast(m);
945
+ };
946
+ const emitLine = (source, line) => {
947
+ const msg = {
948
+ type: 'log',
949
+ source,
950
+ line,
951
+ timestamp: new Date().toISOString(),
952
+ processId: jobId,
953
+ };
954
+ this._logBuffer.push(msg);
955
+ if (this._logBuffer.length > LOG_BUFFER_MAX) {
956
+ this._logBuffer.splice(0, LOG_BUFFER_DROP);
957
+ }
958
+ batchedBroadcast(msg);
959
+ };
960
+ const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
961
+ const stderrReader = (0, readline_1.createInterface)({ input: child.stderr, crlfDelay: Infinity });
962
+ stdoutReader.on('line', (line) => {
963
+ let parsed = null;
964
+ try {
965
+ parsed = JSON.parse(line);
966
+ }
967
+ catch { /* plain text */ }
968
+ // Feed the adapter for the canonical event shape used by
969
+ // finaliseInvocationResult and (optionally) the OTEL bridge. Done
970
+ // alongside the raw event persistence below, NOT in place of it: the
971
+ // raw event log is what feeds the live Job Detail UI and the
972
+ // telemetry export ZIP for non-bridge providers.
973
+ const adapterEv = adapter.parseStreamLine(line);
974
+ if (adapterEv) {
975
+ adapterEvents.push(adapterEv);
976
+ otelBridge?.consumeEvent(adapterEv);
977
+ }
978
+ if (parsed) {
979
+ const eventType = parsed.type ?? 'unknown';
980
+ if (this._db) {
981
+ (0, db_1.appendEvent)(this._db, jobId, eventSeq++, {
982
+ event_type: eventType,
983
+ source: 'stdout',
984
+ payload: line,
985
+ });
986
+ }
987
+ batchedBroadcast({
988
+ type: 'event',
989
+ jobId,
990
+ event_type: eventType,
991
+ source: 'stdout',
992
+ payload: line,
993
+ timestamp: new Date().toISOString(),
994
+ seq: eventSeq - 1,
995
+ });
996
+ if (eventType === 'result') {
997
+ lastResultEvent = parsed;
998
+ }
999
+ const displayText = extractDisplayText(parsed);
1000
+ if (displayText !== null) {
1001
+ if (this._db) {
1002
+ (0, db_1.appendEvent)(this._db, jobId, eventSeq++, {
1003
+ event_type: 'log',
1004
+ source: 'stdout',
1005
+ payload: JSON.stringify({ line: displayText }),
1006
+ });
1007
+ }
1008
+ emitLine('stdout', displayText);
1009
+ }
1010
+ }
1011
+ else {
1012
+ if (this._db) {
1013
+ (0, db_1.appendEvent)(this._db, jobId, eventSeq++, {
1014
+ event_type: 'log',
1015
+ source: 'stdout',
1016
+ payload: JSON.stringify({ line }),
1017
+ });
1018
+ }
1019
+ // For adapters whose stream is JSONL (claude, codex), a non-parseable
1020
+ // line is unexpected noise. For future plain-text adapters this is
1021
+ // their normal output. emitLine surfaces it either way.
1022
+ if (adapterEv?.kind === 'text-delta') {
1023
+ emitLine('stdout', adapterEv.text);
1024
+ }
1025
+ else {
1026
+ emitLine('stdout', line);
1027
+ }
1028
+ }
1029
+ });
1030
+ stderrReader.on('line', (line) => {
1031
+ if (this._db) {
1032
+ (0, db_1.appendEvent)(this._db, jobId, eventSeq++, {
1033
+ event_type: 'log',
1034
+ source: 'stderr',
1035
+ payload: JSON.stringify({ line }),
1036
+ });
1037
+ }
1038
+ emitLine('stderr', line);
1039
+ });
1040
+ child.on('close', (code) => {
1041
+ flushPending(); // flush any remaining batched messages before job exit
1042
+ // Finalise the OTEL bridge (best-effort, async). The bridge POSTs to
1043
+ // the in-process OTLP receiver; failures are warned, not thrown.
1044
+ if (otelBridge) {
1045
+ otelBridge.finalize({ exitCode: code }).catch((err) => {
1046
+ console.warn('[queue-manager] otel bridge finalize failed:', err);
1047
+ });
1048
+ }
1049
+ this._onJobExit(jobId, code, lastResultEvent, emitLine, adapterEvents, railModel, adapter);
1050
+ });
1051
+ this._broadcastQueueState();
1052
+ }
1053
+ _onJobExit(jobId, code, lastResultEvent, emitLine, adapterEvents = [], spawnedModel,
1054
+ /** Per-job adapter resolved in _startJob; defaults to the project primary
1055
+ * for any caller that does not thread it (none today). */
1056
+ adapter = this._adapter) {
1057
+ this._clearZombieTimer();
1058
+ if (this._killTimer !== null) {
1059
+ clearTimeout(this._killTimer);
1060
+ this._killTimer = null;
1061
+ }
1062
+ // Reclaim the pre-spawn snapshot unconditionally, BEFORE any early return,
1063
+ // so a disposed/unknown job can't leak its entry in _snapshotRefs (the git
1064
+ // stash commit it references is dangling and git-GC'd on its own).
1065
+ const snapshot = this._snapshotRefs.get(jobId);
1066
+ this._snapshotRefs.delete(jobId);
1067
+ // A3: release the active slot for THIS job before any early return, so a
1068
+ // disposed/unknown-job exit can never leave the slot reserved (which would
1069
+ // wedge the queue). Guarded by identity in case a stale exit fires late.
1070
+ if (this._activeJobId === jobId) {
1071
+ this._activeProcess = null;
1072
+ this._activeJobId = null;
1073
+ }
1074
+ // The manager was torn down (e.g. project removed) while the child was
1075
+ // still running. The DB may be closed; skip all bookkeeping to avoid an
1076
+ // uncaught throw inside this EventEmitter 'close' listener.
1077
+ if (this._disposed)
1078
+ return;
1079
+ const job = this._jobs.get(jobId);
1080
+ if (!job)
1081
+ return;
1082
+ const wasZombie = this._zombieJobs.has(jobId);
1083
+ const wasCanceling = this._cancelingJobs.has(jobId);
1084
+ this._zombieJobs.delete(jobId);
1085
+ this._cancelingJobs.delete(jobId);
1086
+ let finalStatus;
1087
+ if (wasZombie) {
1088
+ finalStatus = 'zombie_terminated';
1089
+ }
1090
+ else if (wasCanceling) {
1091
+ finalStatus = 'canceled';
1092
+ }
1093
+ else if (code === 0) {
1094
+ finalStatus = 'completed';
1095
+ }
1096
+ else {
1097
+ finalStatus = 'failed';
1098
+ }
1099
+ job.status = finalStatus;
1100
+ job.finishedAt = new Date().toISOString();
1101
+ job.exitCode = code;
1102
+ // Capture result text for output chaining between pipeline steps
1103
+ if (lastResultEvent && typeof lastResultEvent.result === 'string') {
1104
+ job.resultText = lastResultEvent.result;
1105
+ }
1106
+ // (_activeProcess/_activeJobId already released above, before the early
1107
+ // returns, so the slot is freed on every exit path — A3.)
1108
+ if (this._db) {
1109
+ // Adapter-driven result finalisation handles tokens, cost (or pricing-
1110
+ // table estimate for non-native-cost providers), and session_id stamping.
1111
+ const { result: normalised, estimated } = (0, result_event_1.finaliseInvocationResult)(adapter, adapterEvents, { fallbackModel: spawnedModel });
1112
+ const tokenData = lastResultEvent || adapterEvents.length > 0
1113
+ ? {
1114
+ tokens_in: normalised.tokens_in,
1115
+ tokens_out: normalised.tokens_out,
1116
+ tokens_cache_read: normalised.tokens_cache_read,
1117
+ tokens_cache_create: normalised.tokens_cache_create,
1118
+ total_cost_usd: normalised.total_cost_usd,
1119
+ total_cost_usd_estimated: estimated,
1120
+ num_turns: normalised.num_turns,
1121
+ model: normalised.model,
1122
+ duration_ms: normalised.duration_ms,
1123
+ duration_api_ms: normalised.duration_api_ms,
1124
+ session_id: normalised.session_id,
1125
+ }
1126
+ : {};
1127
+ try {
1128
+ (0, db_1.finishJob)(this._db, jobId, {
1129
+ exit_code: code ?? -1,
1130
+ status: finalStatus,
1131
+ ...tokenData,
1132
+ });
1133
+ }
1134
+ catch (err) {
1135
+ // Defense-in-depth: the DB may have been closed underneath us mid-job.
1136
+ // Never let a write throw uncaught inside the child 'close' listener.
1137
+ console.error('[queue-manager] finishJob failed (db unavailable?):', err);
1138
+ }
1139
+ // ai_invocations capture (surface='job'). One row per job exit.
1140
+ if (this._projectId) {
1141
+ try {
1142
+ const invStatus = finalStatus === 'completed'
1143
+ ? 'success'
1144
+ : (finalStatus === 'canceled' || finalStatus === 'zombie_terminated')
1145
+ ? 'aborted'
1146
+ : 'failed';
1147
+ const ticketIds = this._extractTicketIds(job.command);
1148
+ (0, ai_invocations_1.recordInvocation)(this._db, {
1149
+ id: (0, crypto_1.randomUUID)(),
1150
+ project_id: this._projectId,
1151
+ provider: adapter.id,
1152
+ surface: 'job',
1153
+ surface_ref_id: jobId,
1154
+ ticket_id: ticketIds[0] ?? null,
1155
+ status: invStatus,
1156
+ started_at: job.startedAt ?? new Date().toISOString(),
1157
+ finished_at: job.finishedAt,
1158
+ total_cost_usd_estimated: estimated,
1159
+ ...normalised,
1160
+ });
1161
+ this._broadcast({ type: 'spending.invalidated', projectId: this._projectId });
1162
+ }
1163
+ catch (err) {
1164
+ console.error('[queue-manager] recordInvocation failed:', err);
1165
+ }
1166
+ }
1167
+ // Code-Explorer post-exit provenance hook. Diffs the working tree against
1168
+ // the pre-spawn snapshot and inserts one row per touched path. Gated by
1169
+ // SPECRAILS_CODE_EXPLORER (re-checked at each completion so the flag can
1170
+ // be flipped off mid-session without leaving partial writes).
1171
+ if ((0, feature_flags_1.isCodeExplorerEnabled)() && this._cwd && this._projectId) {
1172
+ const ref = snapshot?.ref ?? '';
1173
+ try {
1174
+ const diff = (0, file_provenance_1.diffAgainstSnapshot)(this._cwd, ref, snapshot?.untracked, snapshot?.headSha);
1175
+ const patches = (0, file_provenance_1.collectDiffPatches)(this._cwd, ref, diff, snapshot?.headSha);
1176
+ if (diff.length > 50) {
1177
+ console.warn(`[provenance.large_job] job=${jobId} files=${diff.length}`);
1178
+ }
1179
+ const ticketIds = this._extractTicketIds(job.command);
1180
+ const rows = (0, file_provenance_1.recordProvenanceForJob)(this._db, this._projectId, jobId, ticketIds[0] ?? null, diff, Date.now(), patches);
1181
+ for (const row of rows) {
1182
+ (0, file_provenance_1.broadcastProvenanceUpdated)(this._broadcast, this._projectId, row);
1183
+ }
1184
+ }
1185
+ catch (err) {
1186
+ console.warn(`[queue-manager] provenance recording failed: ${err.message}`);
1187
+ }
1188
+ }
1189
+ // Cost comes from the normalised result so providers without a native
1190
+ // total_cost_usd field (codex today) still trigger cost alerts based on
1191
+ // the pricing-table estimate. When `estimated`, the figure is best-
1192
+ // effort — alerts still fire because the user opted into the threshold
1193
+ // explicitly and a noisy alert is better than a missed one.
1194
+ const jobCost = normalised.total_cost_usd;
1195
+ const costStr = jobCost != null ? ` | cost: ${estimated ? '~' : ''}$${jobCost.toFixed(4)}` : '';
1196
+ emitLine('stdout', `[process exited with code ${code ?? 'unknown'}${costStr}]`);
1197
+ // Cost alert: check per-job threshold (app-level, then per-project).
1198
+ // These prepared statements touch the DB, which may have been closed
1199
+ // mid-job; guard so a throw never escapes the child 'close' listener.
1200
+ if (jobCost != null && finalStatus === 'completed') {
1201
+ try {
1202
+ const desktopThreshold = this._getCostAlertThreshold?.() ?? null;
1203
+ if (desktopThreshold != null && jobCost >= desktopThreshold) {
1204
+ this._broadcast({ type: 'cost_alert', projectId: '', jobId, cost: jobCost, threshold: desktopThreshold });
1205
+ }
1206
+ // Per-project job cost threshold (alerts independently of app threshold)
1207
+ const projectThresholdRow = this._db.prepare(`SELECT value FROM queue_state WHERE key = 'config.job_cost_threshold_usd'`).get();
1208
+ if (projectThresholdRow) {
1209
+ const projectThreshold = parseFloat(projectThresholdRow.value);
1210
+ if (projectThreshold > 0 && jobCost >= projectThreshold) {
1211
+ this._broadcast({ type: 'cost_alert', projectId: '', jobId, cost: jobCost, threshold: projectThreshold });
1212
+ }
1213
+ }
1214
+ // Per-project daily budget: check total spend for today
1215
+ const dailyBudgetRow = this._db.prepare(`SELECT value FROM queue_state WHERE key = 'config.daily_budget_usd'`).get();
1216
+ if (dailyBudgetRow) {
1217
+ const dailyBudget = parseFloat(dailyBudgetRow.value);
1218
+ if (dailyBudget > 0) {
1219
+ const spendRow = this._db.prepare(`SELECT COALESCE(SUM(total_cost_usd), 0) as total FROM jobs WHERE status = 'completed' AND total_cost_usd IS NOT NULL AND started_at >= date('now')`).get();
1220
+ const dailySpend = spendRow.total;
1221
+ if (dailySpend >= dailyBudget) {
1222
+ const wasPaused = this._paused;
1223
+ this._paused = true;
1224
+ if (!wasPaused) {
1225
+ this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', 'true')`).run();
1226
+ }
1227
+ this._broadcast({ type: 'daily_budget_exceeded', projectId: '', dailySpend, budget: dailyBudget, queuePaused: true });
1228
+ }
1229
+ }
1230
+ }
1231
+ // App-level daily budget enforcement
1232
+ if (this._getDesktopDailyBudget) {
1233
+ const { budget: desktopBudget, totalSpend: desktopTotalSpend } = this._getDesktopDailyBudget();
1234
+ if (desktopBudget != null && desktopBudget > 0 && desktopTotalSpend >= desktopBudget) {
1235
+ const wasPaused = this._paused;
1236
+ this._paused = true;
1237
+ if (!wasPaused) {
1238
+ this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', 'true')`).run();
1239
+ }
1240
+ this._broadcast({ type: 'desktop_daily_budget_exceeded', projectId: '', desktopDailySpend: desktopTotalSpend, desktopBudget, queuePaused: true });
1241
+ }
1242
+ }
1243
+ }
1244
+ catch (err) {
1245
+ console.error('[queue-manager] cost-alert bookkeeping failed (db unavailable?):', err);
1246
+ }
1247
+ }
1248
+ }
1249
+ else {
1250
+ emitLine('stdout', `[process exited with code ${code ?? 'unknown'}]`);
1251
+ }
1252
+ // Notify webhook handler (if any) about job completion/failure/cancellation.
1253
+ // zombie_terminated is included so a timed-out rail job still releases its
1254
+ // tickets (revert/flag) and clears its in-memory railJobs entry instead of
1255
+ // wedging the rail card in 'running' until a server restart.
1256
+ if (this._onJobFinished &&
1257
+ (finalStatus === 'completed' || finalStatus === 'failed' || finalStatus === 'canceled' || finalStatus === 'zombie_terminated')) {
1258
+ let costUsd;
1259
+ try {
1260
+ costUsd = this._db
1261
+ ? this._db.prepare('SELECT total_cost_usd FROM jobs WHERE id = ?').get(jobId)?.total_cost_usd ?? undefined
1262
+ : undefined;
1263
+ }
1264
+ catch (err) {
1265
+ console.error('[queue-manager] cost read for webhook failed (db unavailable?):', err);
1266
+ }
1267
+ this._onJobFinished(jobId, finalStatus, costUsd ?? undefined);
1268
+ }
1269
+ // Handle dependent jobs: skip them if parent did not complete successfully
1270
+ if (finalStatus !== 'completed') {
1271
+ this._skipDependents(jobId, `Parent job ${jobId} ${finalStatus}`);
1272
+ }
1273
+ // Check pipeline status
1274
+ if (job.pipelineId) {
1275
+ this._checkPipelineStatus(job.pipelineId);
1276
+ }
1277
+ this._broadcastQueueState();
1278
+ this._drainQueue();
1279
+ }
1280
+ _resetZombieTimer() {
1281
+ if (this._zombieTimeoutMs <= 0)
1282
+ return;
1283
+ if (this._inactivityTimer !== null) {
1284
+ clearTimeout(this._inactivityTimer);
1285
+ }
1286
+ const jobId = this._activeJobId;
1287
+ if (!jobId)
1288
+ return;
1289
+ this._inactivityTimer = setTimeout(() => {
1290
+ this._inactivityTimer = null;
1291
+ this._onZombieDetected(jobId);
1292
+ }, this._zombieTimeoutMs);
1293
+ }
1294
+ _clearZombieTimer() {
1295
+ if (this._inactivityTimer !== null) {
1296
+ clearTimeout(this._inactivityTimer);
1297
+ this._inactivityTimer = null;
1298
+ }
1299
+ }
1300
+ _onZombieDetected(jobId) {
1301
+ const job = this._jobs.get(jobId);
1302
+ if (!job || job.status !== 'running')
1303
+ return;
1304
+ this._clearZombieTimer();
1305
+ const timeoutSec = Math.round(this._zombieTimeoutMs / 1000);
1306
+ const line = `[zombie-detection] Job ${jobId} has been inactive for ${timeoutSec}s — auto-terminating`;
1307
+ console.error(line);
1308
+ // Emit directly without going through emitLine (which would reset the zombie timer)
1309
+ const msg = {
1310
+ type: 'log',
1311
+ source: 'stderr',
1312
+ line,
1313
+ timestamp: new Date().toISOString(),
1314
+ processId: jobId,
1315
+ };
1316
+ this._logBuffer.push(msg);
1317
+ if (this._logBuffer.length > LOG_BUFFER_MAX) {
1318
+ this._logBuffer.splice(0, LOG_BUFFER_DROP);
1319
+ }
1320
+ this._broadcast(msg);
1321
+ this._zombieJobs.add(jobId);
1322
+ this._kill(jobId);
1323
+ }
1324
+ _kill(jobId) {
1325
+ if (!this._activeProcess || !this._activeProcess.pid)
1326
+ return;
1327
+ this._clearZombieTimer();
1328
+ // A second cancel()/zombie-kill of the same still-running job would
1329
+ // otherwise overwrite (and leak) the in-flight SIGKILL timer, which could
1330
+ // later fire treeKill(SIGKILL) against a recycled PID. Clear it first.
1331
+ if (this._killTimer !== null) {
1332
+ clearTimeout(this._killTimer);
1333
+ this._killTimer = null;
1334
+ }
1335
+ this._cancelingJobs.add(jobId);
1336
+ (0, tree_kill_1.default)(this._activeProcess.pid, 'SIGTERM');
1337
+ const pid = this._activeProcess.pid;
1338
+ this._killTimer = setTimeout(() => {
1339
+ (0, tree_kill_1.default)(pid, 'SIGKILL', (err) => {
1340
+ if (err) {
1341
+ // SIGKILL failed — force cleanup so queue is not permanently blocked
1342
+ console.error(`[kill] SIGKILL failed for pid ${pid}: ${err.message}`);
1343
+ if (this._activeJobId === jobId) {
1344
+ const job = this._jobs.get(jobId);
1345
+ if (job && job.status === 'running') {
1346
+ job.status = 'failed';
1347
+ job.finishedAt = new Date().toISOString();
1348
+ if (this._db) {
1349
+ try {
1350
+ this._db.prepare(`UPDATE jobs SET status = 'failed', finished_at = CURRENT_TIMESTAMP WHERE id = ?`).run(jobId);
1351
+ }
1352
+ catch { /* ignore */ }
1353
+ }
1354
+ }
1355
+ this._activeProcess = null;
1356
+ this._activeJobId = null;
1357
+ this._cancelingJobs.delete(jobId);
1358
+ this._zombieJobs.delete(jobId);
1359
+ this._broadcastQueueState();
1360
+ this._drainQueue();
1361
+ }
1362
+ }
1363
+ });
1364
+ this._killTimer = null;
1365
+ }, 5000);
1366
+ }
1367
+ _broadcastQueueState() {
1368
+ this._broadcast({
1369
+ type: 'queue',
1370
+ jobs: this.getJobs(),
1371
+ activeJobId: this._activeJobId,
1372
+ paused: this._paused,
1373
+ timestamp: new Date().toISOString(),
1374
+ });
1375
+ }
1376
+ _persistJob(job) {
1377
+ if (!this._db)
1378
+ return;
1379
+ // For queued jobs, we use the DB to store queue position and priority for startup restore.
1380
+ // We only upsert queue_position + priority + dependency fields — the rest is handled by createJob/finishJob.
1381
+ // Since this method is called for all status transitions, we use a flexible upsert
1382
+ // that only touches queue_position, priority, and dependency fields (for queued jobs) — other fields are
1383
+ // managed by the existing createJob/finishJob API.
1384
+ try {
1385
+ this._db.prepare(`UPDATE jobs SET queue_position = ?, priority = ?, depends_on_job_id = ?, pipeline_id = ? WHERE id = ?`).run(job.queuePosition ?? null, job.priority, job.dependsOnJobId ?? null, job.pipelineId ?? null, job.id);
1386
+ }
1387
+ catch {
1388
+ // Job may not exist in DB yet
1389
+ }
1390
+ }
1391
+ _persistQueueState() {
1392
+ if (!this._db)
1393
+ return;
1394
+ try {
1395
+ this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', ?)`).run(this._paused ? 'true' : 'false');
1396
+ }
1397
+ catch {
1398
+ // queue_state table may not exist if migration hasn't run
1399
+ }
1400
+ }
1401
+ _restoreFromDb() {
1402
+ if (!this._db)
1403
+ return;
1404
+ try {
1405
+ // Fail any jobs that were running when the server last shut down
1406
+ this._db.prepare(`UPDATE jobs SET status = 'failed', finished_at = CURRENT_TIMESTAMP WHERE status = 'running'`).run();
1407
+ // Restore queued jobs in order (priority DESC then queue_position ASC)
1408
+ const rows = this._db.prepare(`SELECT id, command, queue_position, priority, depends_on_job_id, pipeline_id FROM jobs WHERE status = 'queued' ORDER BY queue_position ASC`).all();
1409
+ for (const row of rows) {
1410
+ const priority = (types_1.VALID_PRIORITIES.has(row.priority ?? '') ? row.priority : 'normal');
1411
+ const job = {
1412
+ id: row.id,
1413
+ command: row.command,
1414
+ status: 'queued',
1415
+ queuePosition: row.queue_position,
1416
+ priority,
1417
+ startedAt: null,
1418
+ finishedAt: null,
1419
+ exitCode: null,
1420
+ dependsOnJobId: row.depends_on_job_id ?? null,
1421
+ pipelineId: row.pipeline_id ?? null,
1422
+ skipReason: null,
1423
+ resultText: null,
1424
+ };
1425
+ this._jobs.set(row.id, job);
1426
+ this._queue.push(row.id);
1427
+ }
1428
+ // Re-sort queue by priority (higher first), preserving FIFO within same level
1429
+ this._queue.sort((a, b) => {
1430
+ const jobA = this._jobs.get(a);
1431
+ const jobB = this._jobs.get(b);
1432
+ return types_1.PRIORITY_WEIGHT[jobB.priority] - types_1.PRIORITY_WEIGHT[jobA.priority];
1433
+ });
1434
+ this._recomputePositions();
1435
+ // Restore pause state
1436
+ const pauseRow = this._db.prepare(`SELECT value FROM queue_state WHERE key = 'paused'`).get();
1437
+ this._paused = pauseRow?.value === 'true';
1438
+ }
1439
+ catch {
1440
+ // DB may not have queue_state table yet — ignore
1441
+ }
1442
+ // Kick off any restored queued jobs that are ready to run
1443
+ this._drainQueue();
1444
+ }
1445
+ _isDependencyMet(job) {
1446
+ if (!job.dependsOnJobId)
1447
+ return true;
1448
+ const parent = this._jobs.get(job.dependsOnJobId);
1449
+ if (parent)
1450
+ return parent.status === 'completed';
1451
+ if (this._db) {
1452
+ const row = this._db.prepare('SELECT status FROM jobs WHERE id = ?').get(job.dependsOnJobId);
1453
+ if (row)
1454
+ return row.status === 'completed';
1455
+ }
1456
+ return true;
1457
+ }
1458
+ _skipDependents(parentJobId, reason) {
1459
+ const toSkip = [];
1460
+ for (const [id, job] of this._jobs) {
1461
+ if (job.dependsOnJobId === parentJobId && job.status === 'queued') {
1462
+ toSkip.push(id);
1463
+ }
1464
+ }
1465
+ for (const id of toSkip) {
1466
+ const job = this._jobs.get(id);
1467
+ if (!job)
1468
+ continue;
1469
+ const idx = this._queue.indexOf(id);
1470
+ if (idx !== -1)
1471
+ this._queue.splice(idx, 1);
1472
+ job.status = 'skipped';
1473
+ job.finishedAt = new Date().toISOString();
1474
+ job.skipReason = reason;
1475
+ if (this._db) {
1476
+ // Ensure the job row exists before updating (queued jobs may not have been persisted via createJob yet)
1477
+ const exists = this._db.prepare('SELECT 1 FROM jobs WHERE id = ?').get(id);
1478
+ if (!exists) {
1479
+ this._db.prepare(`INSERT INTO jobs (id, command, started_at, status, skip_reason, finished_at, depends_on_job_id, pipeline_id) VALUES (?, ?, ?, 'skipped', ?, ?, ?, ?)`).run(id, job.command, job.finishedAt, reason, job.finishedAt, job.dependsOnJobId, job.pipelineId);
1480
+ }
1481
+ else {
1482
+ (0, db_1.skipJob)(this._db, id, reason);
1483
+ }
1484
+ }
1485
+ this._skipDependents(id, `Parent job ${id} was skipped`);
1486
+ }
1487
+ }
1488
+ _checkPipelineStatus(pipelineId) {
1489
+ const pipelineJobs = Array.from(this._jobs.values()).filter(j => j.pipelineId === pipelineId);
1490
+ if (pipelineJobs.length === 0)
1491
+ return;
1492
+ const allDone = pipelineJobs.every(j => j.status === 'completed');
1493
+ const anyFailed = pipelineJobs.some(j => j.status === 'failed' || j.status === 'skipped' || j.status === 'canceled' || j.status === 'zombie_terminated');
1494
+ const anyPending = pipelineJobs.some(j => j.status === 'queued' || j.status === 'running');
1495
+ if (allDone) {
1496
+ this._broadcast({ type: 'pipeline_status', pipelineId, status: 'completed' });
1497
+ }
1498
+ else if (anyFailed && !anyPending) {
1499
+ this._broadcast({ type: 'pipeline_status', pipelineId, status: 'failed' });
1500
+ }
1501
+ }
1502
+ _recomputePositions() {
1503
+ this._queue.forEach((id, index) => {
1504
+ const job = this._jobs.get(id);
1505
+ if (job) {
1506
+ job.queuePosition = index + 1;
1507
+ }
1508
+ });
1509
+ }
1510
+ }
1511
+ exports.QueueManager = QueueManager;