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.
- package/.claude/commands/specrails/batch-implement.md +287 -0
- package/.claude/commands/specrails/compat-check.md +271 -0
- package/.claude/commands/specrails/doctor.md +62 -0
- package/.claude/commands/specrails/enrich.md +1635 -0
- package/.claude/commands/specrails/explore-spec.md +173 -0
- package/.claude/commands/specrails/health-check.md +527 -0
- package/.claude/commands/specrails/implement.md +1457 -0
- package/.claude/commands/specrails/memory-inspect.md +259 -0
- package/.claude/commands/specrails/opsx-diff.md +419 -0
- package/.claude/commands/specrails/propose-spec.md +102 -0
- package/.claude/commands/specrails/reconfig.md +89 -0
- package/.claude/commands/specrails/refactor-recommender.md +212 -0
- package/.claude/commands/specrails/retry.md +363 -0
- package/.claude/commands/specrails/telemetry.md +552 -0
- package/.claude/commands/specrails/why.md +96 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/cli/dist/specrails-desktop.js +1098 -0
- package/client/dist/assets/ActivityFeedPage-Gy4x8dBt.js +1 -0
- package/client/dist/assets/AgentsPage-CPgu--Fb.js +86 -0
- package/client/dist/assets/AnalyticsPage-B5sJEee2.js +1 -0
- package/client/dist/assets/BarChart-7IMQ8HY1.js +33 -0
- package/client/dist/assets/CodePage-CBdFvbwe.js +2 -0
- package/client/dist/assets/DesktopAnalyticsPage-w0rdTq4w.js +1 -0
- package/client/dist/assets/DocsDialog-BZUYM7wm.js +11 -0
- package/client/dist/assets/DocsPage-9QglWl46.js +11 -0
- package/client/dist/assets/ExportDropdown-BLZFXtNi.js +1 -0
- package/client/dist/assets/IntegrationsPage-BxBE4y99.js +3 -0
- package/client/dist/assets/JobDetailPage-DydWx_5S.js +16 -0
- package/client/dist/assets/JobsPage-20ibw0IO.js +1 -0
- package/client/dist/assets/abap-Bw6f2wDG.js +1 -0
- package/client/dist/assets/activity-BEIp_Y1A.js +1 -0
- package/client/dist/assets/activity-BdrPln96.js +1 -0
- package/client/dist/assets/activity-CpkRS8Sx.js +1 -0
- package/client/dist/assets/activity-DKCpESPt.js +1 -0
- package/client/dist/assets/activity-DOUVEjJi.js +1 -0
- package/client/dist/assets/activity-DRwkql_y.js +1 -0
- package/client/dist/assets/activity-DcDQ7tjw.js +1 -0
- package/client/dist/assets/activity-Dv6H7wEr.js +1 -0
- package/client/dist/assets/addon-image-3WCl5Vhd.js +1 -0
- package/client/dist/assets/addon-ligatures-C5OdliKs.js +2 -0
- package/client/dist/assets/addon-webgl-BbX6pSjl.js +44 -0
- package/client/dist/assets/addspec-B5yl4Loj.js +1 -0
- package/client/dist/assets/addspec-BEeF5-zc.js +1 -0
- package/client/dist/assets/addspec-D33ocMxf.js +1 -0
- package/client/dist/assets/addspec-DFswZ0jK.js +1 -0
- package/client/dist/assets/addspec-DRE-jZv7.js +1 -0
- package/client/dist/assets/addspec-DVZ15Jp8.js +1 -0
- package/client/dist/assets/addspec-Fkv91Opc.js +1 -0
- package/client/dist/assets/addspec-GWm4ffKl.js +1 -0
- package/client/dist/assets/agents-1nCDWRmP.js +1 -0
- package/client/dist/assets/agents-Bm9rPqnt.js +1 -0
- package/client/dist/assets/agents-CMxtJMLD.js +1 -0
- package/client/dist/assets/agents-DK-Dlc0i.js +1 -0
- package/client/dist/assets/agents-Q6Ldfpxx.js +1 -0
- package/client/dist/assets/agents-TeOSy-ax.js +1 -0
- package/client/dist/assets/agents-iTqjRajS.js +1 -0
- package/client/dist/assets/agents-s87sMGzL.js +1 -0
- package/client/dist/assets/agentstudio-B6Wb59E7.js +1 -0
- package/client/dist/assets/agentstudio-BADhZ41e.js +1 -0
- package/client/dist/assets/agentstudio-BSnWLR63.js +1 -0
- package/client/dist/assets/agentstudio-BdidyBzZ.js +1 -0
- package/client/dist/assets/agentstudio-CxlUllqI.js +1 -0
- package/client/dist/assets/agentstudio-D3I62TLJ.js +1 -0
- package/client/dist/assets/agentstudio-DuH9TogZ.js +1 -0
- package/client/dist/assets/agentstudio-Kw88_dUF.js +1 -0
- package/client/dist/assets/aiedit-BWxHGsYA.js +1 -0
- package/client/dist/assets/aiedit-D2ji6Qy0.js +1 -0
- package/client/dist/assets/aiedit-DAhZTvtk.js +1 -0
- package/client/dist/assets/aiedit-DJMny-D5.js +1 -0
- package/client/dist/assets/aiedit-DOcxERkU.js +1 -0
- package/client/dist/assets/aiedit-DvrcbwGv.js +1 -0
- package/client/dist/assets/aiedit-TTwzL1TS.js +1 -0
- package/client/dist/assets/aiedit-WBSjT_C1.js +1 -0
- package/client/dist/assets/analytics-BIdr0YfL.js +1 -0
- package/client/dist/assets/analytics-C6EzgtdE.js +1 -0
- package/client/dist/assets/analytics-C9Zc-rkM.js +1 -0
- package/client/dist/assets/analytics-CVx3YOc0.js +1 -0
- package/client/dist/assets/analytics-CYj0tfj7.js +1 -0
- package/client/dist/assets/analytics-CnY4kNG3.js +1 -0
- package/client/dist/assets/analytics-CrPCZRJ-.js +1 -0
- package/client/dist/assets/analytics-DMCto-TF.js +1 -0
- package/client/dist/assets/apex-Cw8_REBo.js +1 -0
- package/client/dist/assets/atom-one-dark-B-oHczHB.css +1 -0
- package/client/dist/assets/attachments-BIsSSnHJ.js +1 -0
- package/client/dist/assets/attachments-BW4L3l2L.js +1 -0
- package/client/dist/assets/attachments-Bcf6BG6V.js +1 -0
- package/client/dist/assets/attachments-Bke8sCU4.js +1 -0
- package/client/dist/assets/attachments-COcrGRFz.js +1 -0
- package/client/dist/assets/attachments-DYHGA2Dj.js +1 -0
- package/client/dist/assets/attachments-Dd92KpUH.js +1 -0
- package/client/dist/assets/attachments-DzdU6DV6.js +1 -0
- package/client/dist/assets/azcli-Cz6HAoOw.js +1 -0
- package/client/dist/assets/bat-CcJ-xyqL.js +1 -0
- package/client/dist/assets/bicep-z1WDCKYz.js +2 -0
- package/client/dist/assets/browser-5ErDlJoR.js +1 -0
- package/client/dist/assets/browser-Bc-YdlVg.js +1 -0
- package/client/dist/assets/browser-BlYF4OOq.js +1 -0
- package/client/dist/assets/browser-CT-ReZGt.js +1 -0
- package/client/dist/assets/browser-DGITz3fC.js +1 -0
- package/client/dist/assets/browser-JsAIGCEW.js +1 -0
- package/client/dist/assets/browser-M5-rbPlw.js +1 -0
- package/client/dist/assets/browser-Qya9cARy.js +1 -0
- package/client/dist/assets/cameligo-BRewOpfa.js +1 -0
- package/client/dist/assets/chat-BEGuC03z.js +1 -0
- package/client/dist/assets/chat-BEW60P_u.js +1 -0
- package/client/dist/assets/chat-BQNMD0PL.js +1 -0
- package/client/dist/assets/chat-BsbNGPW9.js +1 -0
- package/client/dist/assets/chat-CboQguCi.js +1 -0
- package/client/dist/assets/chat-DRCa9pOt.js +1 -0
- package/client/dist/assets/chat-DwUm6W9z.js +1 -0
- package/client/dist/assets/chat-yoXwguQu.js +1 -0
- package/client/dist/assets/chunk-CilyBKbf.js +1 -0
- package/client/dist/assets/clojure-DBjRWN6g.js +1 -0
- package/client/dist/assets/clsx-DnqN-uhr.js +1 -0
- package/client/dist/assets/code-AL1rVIMb.js +1 -0
- package/client/dist/assets/code-C0BKpkht.js +1 -0
- package/client/dist/assets/code-C0FTS3ew.js +1 -0
- package/client/dist/assets/code-CPcHxzxw.js +1 -0
- package/client/dist/assets/code-D3ryDniw.js +1 -0
- package/client/dist/assets/code-D3zVVQTj.js +1 -0
- package/client/dist/assets/code-PCmfS3dn.js +1 -0
- package/client/dist/assets/code-exI0G5Wd.js +1 -0
- package/client/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/client/dist/assets/coffee-Cfk_XHGR.js +1 -0
- package/client/dist/assets/commands-B772IyDa.js +1 -0
- package/client/dist/assets/commands-BDDp6xFG.js +1 -0
- package/client/dist/assets/commands-CJxCry-o.js +1 -0
- package/client/dist/assets/commands-CfgY-_of.js +1 -0
- package/client/dist/assets/commands-DLrvnPNg.js +1 -0
- package/client/dist/assets/commands-IXMOKBYt.js +1 -0
- package/client/dist/assets/commands-UD1NzmwX.js +1 -0
- package/client/dist/assets/commands-sqrqsxyE.js +1 -0
- package/client/dist/assets/common-DCr6VzJ7.js +1 -0
- package/client/dist/assets/common-Dard9UNH.js +1 -0
- package/client/dist/assets/common-DeDELLZJ.js +1 -0
- package/client/dist/assets/common-DltqHaAe.js +1 -0
- package/client/dist/assets/common-Dmm1GhdD.js +1 -0
- package/client/dist/assets/common-DnjcgkPH.js +1 -0
- package/client/dist/assets/common-GbpxfPG8.js +1 -0
- package/client/dist/assets/common-wA36jmj1.js +1 -0
- package/client/dist/assets/cpp-BVob6BaP.js +1 -0
- package/client/dist/assets/csharp-C4fbRuOu.js +1 -0
- package/client/dist/assets/csp-DthFP_vT.js +1 -0
- package/client/dist/assets/css-CGMH0hcW.js +3 -0
- package/client/dist/assets/css.worker-Wv5dxAWO.js +89 -0
- package/client/dist/assets/cssMode-Cc6ozl-J.js +1 -0
- package/client/dist/assets/cypher-Pnf68BRV.js +1 -0
- package/client/dist/assets/dart-PMMOtxZX.js +1 -0
- package/client/dist/assets/dashboard-B4ixDVk8.js +1 -0
- package/client/dist/assets/dashboard-BZBADHSj.js +1 -0
- package/client/dist/assets/dashboard-C1MfeUHs.js +1 -0
- package/client/dist/assets/dashboard-C7SK6xu5.js +1 -0
- package/client/dist/assets/dashboard-CB6Le1yN.js +1 -0
- package/client/dist/assets/dashboard-CoTpMOBM.js +1 -0
- package/client/dist/assets/dashboard-Duo4DDCW.js +1 -0
- package/client/dist/assets/dashboard-I19DXBxw.js +1 -0
- package/client/dist/assets/dist-js-BY-Fv_fg.js +1 -0
- package/client/dist/assets/dist-js-Bakc4uxT.js +1 -0
- package/client/dist/assets/dockerfile-di1nsJCc.js +1 -0
- package/client/dist/assets/ecl-D_WVcB5M.js +1 -0
- package/client/dist/assets/editor-Br_kD0ds.css +1 -0
- package/client/dist/assets/editor.api2-XLGzZfbc.js +872 -0
- package/client/dist/assets/editor.main-CfXxHimg.js +6 -0
- package/client/dist/assets/editor.worker-Bd9IXS8d.js +26 -0
- package/client/dist/assets/elixir-OAdJEMOn.js +1 -0
- package/client/dist/assets/explore-4mFpnrKU.js +1 -0
- package/client/dist/assets/explore-A8Ltoblq.js +1 -0
- package/client/dist/assets/explore-B9A3iN2W.js +1 -0
- package/client/dist/assets/explore-BV5Xxlsn.js +1 -0
- package/client/dist/assets/explore-BrBJvfjP.js +1 -0
- package/client/dist/assets/explore-C3FSE42C.js +1 -0
- package/client/dist/assets/explore-D2EFgt8J.js +1 -0
- package/client/dist/assets/explore-hFc3HFcp.js +1 -0
- package/client/dist/assets/flow9-D3QEZjgn.js +1 -0
- package/client/dist/assets/format-command-CwGuwzGA.js +1 -0
- package/client/dist/assets/freemarker2-DP7J1gG3.js +3 -0
- package/client/dist/assets/fsharp-BF0k_8N8.js +1 -0
- package/client/dist/assets/go-BAQO5Jsz.js +1 -0
- package/client/dist/assets/graphql-hdFVFkiV.js +1 -0
- package/client/dist/assets/handlebars-BjRlucw6.js +1 -0
- package/client/dist/assets/hcl-DWnl1o-X.js +1 -0
- package/client/dist/assets/html-OumBQJ-U.js +1 -0
- package/client/dist/assets/html.worker-CQP8QQsS.js +502 -0
- package/client/dist/assets/htmlMode-CStc3zXM.js +1 -0
- package/client/dist/assets/index-CimDRRi7.css +2 -0
- package/client/dist/assets/index-XGZaKl_u.js +142 -0
- package/client/dist/assets/ini-CB-6OVu3.js +1 -0
- package/client/dist/assets/integrations-C3p12Ms6.js +1 -0
- package/client/dist/assets/integrations-Cr6hH7XR.js +1 -0
- package/client/dist/assets/integrations-Cublz3m6.js +1 -0
- package/client/dist/assets/integrations-D28q1kF6.js +1 -0
- package/client/dist/assets/integrations-DRdbki5W.js +1 -0
- package/client/dist/assets/integrations-DaC4SzzL.js +1 -0
- package/client/dist/assets/integrations-DmQYCUvN.js +1 -0
- package/client/dist/assets/integrations-HIlUxXVs.js +1 -0
- package/client/dist/assets/java-d1CmfiHX.js +1 -0
- package/client/dist/assets/javascript-CMk--e7g.js +1 -0
- package/client/dist/assets/jobs-BE1siB0M.js +1 -0
- package/client/dist/assets/jobs-BHcQ_Faf.js +1 -0
- package/client/dist/assets/jobs-CFfc2dNX.js +1 -0
- package/client/dist/assets/jobs-CSi5n8X_.js +1 -0
- package/client/dist/assets/jobs-Dc3X86PY.js +1 -0
- package/client/dist/assets/jobs-De5tASex.js +1 -0
- package/client/dist/assets/jobs-DsoXEdo7.js +1 -0
- package/client/dist/assets/jobs-Wl-ApPMb.js +1 -0
- package/client/dist/assets/json.worker-DzV-CpCQ.js +58 -0
- package/client/dist/assets/jsonMode-C2h3ZcjZ.js +7 -0
- package/client/dist/assets/julia-Bgv08lKa.js +1 -0
- package/client/dist/assets/kotlin-u98kaVTf.js +1 -0
- package/client/dist/assets/less-CjYwpgg5.js +2 -0
- package/client/dist/assets/lexon-YTjaAFBB.js +1 -0
- package/client/dist/assets/lib-CPxTMOAq.js +7 -0
- package/client/dist/assets/liquid-mI3KJrBE.js +1 -0
- package/client/dist/assets/lspLanguageFeatures-DU09ggWi.js +4 -0
- package/client/dist/assets/lua-BzmkWv27.js +1 -0
- package/client/dist/assets/m3-CFwk9fw0.js +1 -0
- package/client/dist/assets/markdown-CR5iMpSZ.js +1 -0
- package/client/dist/assets/mdx-C41VDTR_.js +1 -0
- package/client/dist/assets/mips-CcEalc17.js +1 -0
- package/client/dist/assets/monaco.contribution-CPObAXMC.js +2 -0
- package/client/dist/assets/msdax-BQbkawnr.js +1 -0
- package/client/dist/assets/mysql-GTlaaW_P.js +1 -0
- package/client/dist/assets/nav-0fwkrgHt.js +1 -0
- package/client/dist/assets/nav-BEL3MTwK.js +1 -0
- package/client/dist/assets/nav-B_G-TJDW.js +1 -0
- package/client/dist/assets/nav-C2YXcbZS.js +1 -0
- package/client/dist/assets/nav-ClzOE4mA.js +1 -0
- package/client/dist/assets/nav-CtYwmMgu.js +1 -0
- package/client/dist/assets/nav-D2bOGSEg.js +1 -0
- package/client/dist/assets/nav-iH1V5j6o.js +1 -0
- package/client/dist/assets/objective-c-Byu1T5if.js +1 -0
- package/client/dist/assets/pascal-BrfzBfRm.js +1 -0
- package/client/dist/assets/pascaligo-BXXKFUeo.js +1 -0
- package/client/dist/assets/perl-B3OikKq-.js +1 -0
- package/client/dist/assets/pgsql-CTsa0Acc.js +1 -0
- package/client/dist/assets/php-DiQh3FUW.js +1 -0
- package/client/dist/assets/pla-92uH8Fzm.js +1 -0
- package/client/dist/assets/postiats-BbeWkKUr.js +1 -0
- package/client/dist/assets/powerquery-DgDMzpsm.js +1 -0
- package/client/dist/assets/powershell-BfdUUzaG.js +1 -0
- package/client/dist/assets/preload-helper-DSXbuxSR.js +1 -0
- package/client/dist/assets/protobuf-BojW2ftW.js +2 -0
- package/client/dist/assets/pug-BxqTg3IU.js +1 -0
- package/client/dist/assets/python-Y27rKQtk.js +1 -0
- package/client/dist/assets/qsharp-BX_A-MW9.js +1 -0
- package/client/dist/assets/r-D9BMnxvJ.js +1 -0
- package/client/dist/assets/razor-Cd5-q9Bp.js +1 -0
- package/client/dist/assets/redis-5cJqEQJJ.js +1 -0
- package/client/dist/assets/redshift-d8BBqiwb.js +1 -0
- package/client/dist/assets/restructuredtext-C8a6yIcZ.js +1 -0
- package/client/dist/assets/ruby-egeh-6KX.js +1 -0
- package/client/dist/assets/rust-a3r9IInB.js +1 -0
- package/client/dist/assets/sb-y8iRIDei.js +1 -0
- package/client/dist/assets/scala-BPDK2AmK.js +1 -0
- package/client/dist/assets/scheme-BIWUEoOs.js +1 -0
- package/client/dist/assets/scss-CA-PSzwg.js +3 -0
- package/client/dist/assets/settings-55oDcbSh.js +1 -0
- package/client/dist/assets/settings-Bd4Tq1RB.js +1 -0
- package/client/dist/assets/settings-CCSM-Fhn.js +1 -0
- package/client/dist/assets/settings-D3e_bDoW.js +1 -0
- package/client/dist/assets/settings-DKbTkbn7.js +1 -0
- package/client/dist/assets/settings-Dxpo6_w7.js +1 -0
- package/client/dist/assets/settings-bt84e3Aa.js +1 -0
- package/client/dist/assets/settings-nu68QukM.js +1 -0
- package/client/dist/assets/setup-BMqwfbW9.js +1 -0
- package/client/dist/assets/setup-Bb5LcG28.js +1 -0
- package/client/dist/assets/setup-BeEx2_da.js +1 -0
- package/client/dist/assets/setup-CCCrB53Q.js +1 -0
- package/client/dist/assets/setup-CJA0ATmd.js +1 -0
- package/client/dist/assets/setup-CeiDbZcb.js +1 -0
- package/client/dist/assets/setup-Cus7TApA.js +1 -0
- package/client/dist/assets/setup-D9qOs2Xo.js +1 -0
- package/client/dist/assets/shell--LiT1Bja.js +1 -0
- package/client/dist/assets/solidity-DdqZccZg.js +1 -0
- package/client/dist/assets/sophia-S6-YxNG_.js +1 -0
- package/client/dist/assets/sparql-BSf5kMp2.js +1 -0
- package/client/dist/assets/specs-BFfu3u-a.js +1 -0
- package/client/dist/assets/specs-B__C8-8a.js +1 -0
- package/client/dist/assets/specs-CZ1PsXsC.js +1 -0
- package/client/dist/assets/specs-D2FzlLn9.js +1 -0
- package/client/dist/assets/specs-DaUTrNF9.js +1 -0
- package/client/dist/assets/specs-Dyc5hYeE.js +1 -0
- package/client/dist/assets/specs-cKEh2LXt.js +1 -0
- package/client/dist/assets/specs-k0PyLDVt.js +1 -0
- package/client/dist/assets/sql-D7KgjR8G.js +1 -0
- package/client/dist/assets/st-BnoDa-Ml.js +1 -0
- package/client/dist/assets/swift-DEUHTkUX.js +1 -0
- package/client/dist/assets/systemverilog-Tqb_KPnW.js +1 -0
- package/client/dist/assets/tcl-BmBFS2qq.js +1 -0
- package/client/dist/assets/terminal-80yDMgMF.js +1 -0
- package/client/dist/assets/terminal-Bje4ziIa.js +1 -0
- package/client/dist/assets/terminal-C2WYcFHF.js +1 -0
- package/client/dist/assets/terminal-CSONJOex.js +1 -0
- package/client/dist/assets/terminal-DEqzGtcr.js +1 -0
- package/client/dist/assets/terminal-DeWzh6ys.js +1 -0
- package/client/dist/assets/terminal-YOlsJCQj.js +1 -0
- package/client/dist/assets/terminal-lkZYR4wJ.js +1 -0
- package/client/dist/assets/tickets-CB7N30gm.js +1 -0
- package/client/dist/assets/tickets-CF2PYelu.js +1 -0
- package/client/dist/assets/tickets-DNOANUXr.js +1 -0
- package/client/dist/assets/tickets-DU1aqsbr.js +1 -0
- package/client/dist/assets/tickets-DYvafSaY.js +1 -0
- package/client/dist/assets/tickets-DlpC_iTg.js +1 -0
- package/client/dist/assets/tickets-DucYgtdl.js +1 -0
- package/client/dist/assets/tickets-clefmXLv.js +1 -0
- package/client/dist/assets/ts.worker-METxwbDZ.js +67719 -0
- package/client/dist/assets/tsMode-B0y_xEci.js +11 -0
- package/client/dist/assets/twig-BQV8igWC.js +1 -0
- package/client/dist/assets/typescript-BzK0OgwW.js +1 -0
- package/client/dist/assets/typespec-DlFroUGY.js +1 -0
- package/client/dist/assets/useProjectCache-DSaiGFjV.js +1 -0
- package/client/dist/assets/vb-BlrJpIMX.js +1 -0
- package/client/dist/assets/wgsl-BWgIc6FZ.js +298 -0
- package/client/dist/assets/workers-rt--R2Qy.js +1 -0
- package/client/dist/assets/xml-eX9QXAmI.js +1 -0
- package/client/dist/assets/yaml-fcsNkpOt.js +1 -0
- package/client/dist/index.html +246 -0
- package/docs/README.md +54 -0
- package/docs/cli.md +198 -0
- package/docs/codex.md +210 -0
- package/docs/creating-specs.md +197 -0
- package/docs/customizing.md +197 -0
- package/docs/getting-started.md +140 -0
- package/docs/internals/README.md +25 -0
- package/docs/internals/adding-a-provider.md +238 -0
- package/docs/internals/api-reference.md +634 -0
- package/docs/internals/architecture.md +332 -0
- package/docs/internals/configuration.md +172 -0
- package/docs/internals/openspec-workflow.md +282 -0
- package/docs/internals/operations-runbook.md +198 -0
- package/docs/internals/profiles.md +152 -0
- package/docs/platforms/macos.md +130 -0
- package/docs/platforms/windows.md +81 -0
- package/docs/running-pipelines.md +240 -0
- package/docs/terminal.md +138 -0
- package/docs/tracking-cost.md +155 -0
- package/package.json +82 -0
- package/server/dist/agent-generator.js +232 -0
- package/server/dist/agent-refine-db.js +124 -0
- package/server/dist/agent-refine-manager.js +526 -0
- package/server/dist/ai-invocations.js +111 -0
- package/server/dist/attachment-manager.js +299 -0
- package/server/dist/auth.js +207 -0
- package/server/dist/binary-probe.js +35 -0
- package/server/dist/browser-capture-manager.js +576 -0
- package/server/dist/browser-capture-types.js +28 -0
- package/server/dist/browser-network.js +149 -0
- package/server/dist/browser-playwright.js +888 -0
- package/server/dist/build-dirs.js +44 -0
- package/server/dist/changes-reader.js +120 -0
- package/server/dist/chat-manager.js +1060 -0
- package/server/dist/chromium-resolver.js +311 -0
- package/server/dist/code-explorer-router.js +788 -0
- package/server/dist/codex-otel-bridge.js +235 -0
- package/server/dist/command-resolver.js +102 -0
- package/server/dist/config.js +306 -0
- package/server/dist/context-budget.js +113 -0
- package/server/dist/context-scope.js +279 -0
- package/server/dist/contract-refine-runner.js +521 -0
- package/server/dist/core-compat.js +207 -0
- package/server/dist/core-package.js +14 -0
- package/server/dist/db.js +1034 -0
- package/server/dist/desktop-analytics.js +156 -0
- package/server/dist/desktop-db.js +456 -0
- package/server/dist/desktop-router.js +735 -0
- package/server/dist/docs-router.js +207 -0
- package/server/dist/explore-contract-refine.js +421 -0
- package/server/dist/explore-cwd-manager.js +242 -0
- package/server/dist/explore-draft-title.js +47 -0
- package/server/dist/explore-smash.js +450 -0
- package/server/dist/feature-flags.js +17 -0
- package/server/dist/file-provenance.js +382 -0
- package/server/dist/file-summary-generator.js +221 -0
- package/server/dist/file-summary-manager.js +689 -0
- package/server/dist/hooks.js +102 -0
- package/server/dist/ids.js +7 -0
- package/server/dist/index.js +586 -0
- package/server/dist/metrics.js +136 -0
- package/server/dist/mobile/index.js +16 -0
- package/server/dist/mobile/mobile-admin-router.js +84 -0
- package/server/dist/mobile/mobile-auth.js +67 -0
- package/server/dist/mobile/mobile-devices.js +80 -0
- package/server/dist/mobile/mobile-event-bus.js +39 -0
- package/server/dist/mobile/mobile-gateway.js +285 -0
- package/server/dist/mobile/mobile-mdns.js +81 -0
- package/server/dist/mobile/mobile-pairing.js +179 -0
- package/server/dist/mobile/mobile-redact.js +53 -0
- package/server/dist/mobile/mobile-router.js +411 -0
- package/server/dist/mobile/mobile-tls.js +86 -0
- package/server/dist/mobile/mobile-types.js +9 -0
- package/server/dist/mobile/mobile-ws.js +275 -0
- package/server/dist/path-resolver.js +298 -0
- package/server/dist/plugin-manager.js +617 -0
- package/server/dist/plugins/claude-approval.js +179 -0
- package/server/dist/plugins/claude-md-mutation.js +146 -0
- package/server/dist/plugins/codex-mcp.js +108 -0
- package/server/dist/plugins/contributors.js +72 -0
- package/server/dist/plugins/drift.js +58 -0
- package/server/dist/plugins/index.js +14 -0
- package/server/dist/plugins/json-mutation.js +120 -0
- package/server/dist/plugins/manager.js +32 -0
- package/server/dist/plugins/ownership.js +86 -0
- package/server/dist/plugins/paths.js +37 -0
- package/server/dist/plugins/prereq-installer.js +104 -0
- package/server/dist/plugins/rail-integration.js +79 -0
- package/server/dist/plugins/serena/index.js +13 -0
- package/server/dist/plugins/serena/install.js +91 -0
- package/server/dist/plugins/serena/instructions-content.js +21 -0
- package/server/dist/plugins/serena/manifest.js +111 -0
- package/server/dist/plugins/serena/verify.js +78 -0
- package/server/dist/plugins-router.js +215 -0
- package/server/dist/pricing.js +89 -0
- package/server/dist/profile-manager.js +310 -0
- package/server/dist/profiles-router.js +759 -0
- package/server/dist/project-registry.js +443 -0
- package/server/dist/project-router.js +4016 -0
- package/server/dist/proposal-manager.js +291 -0
- package/server/dist/provider-selection.js +69 -0
- package/server/dist/providers/claude-adapter.js +281 -0
- package/server/dist/providers/codex-adapter.js +264 -0
- package/server/dist/providers/index.js +23 -0
- package/server/dist/providers/registry.js +37 -0
- package/server/dist/providers/types.js +22 -0
- package/server/dist/queue-manager.js +1511 -0
- package/server/dist/rails-router.js +362 -0
- package/server/dist/rails-store.js +116 -0
- package/server/dist/result-event.js +106 -0
- package/server/dist/schemas/profile.v1.json +151 -0
- package/server/dist/setup-manager.js +1165 -0
- package/server/dist/setup-prerequisites.js +372 -0
- package/server/dist/smash-runner.js +663 -0
- package/server/dist/spec-draft-parser.js +133 -0
- package/server/dist/spec-launcher-manager.js +174 -0
- package/server/dist/spec-models.js +32 -0
- package/server/dist/specrails-tech-client.js +82 -0
- package/server/dist/spending.js +448 -0
- package/server/dist/telemetry-compactor.js +180 -0
- package/server/dist/telemetry-export.js +317 -0
- package/server/dist/telemetry-receiver.js +224 -0
- package/server/dist/terminal-manager.js +633 -0
- package/server/dist/terminal-marks-store.js +117 -0
- package/server/dist/terminal-osc-parser.js +159 -0
- package/server/dist/terminal-settings.js +282 -0
- package/server/dist/terminal-shell-integration.js +196 -0
- package/server/dist/ticket-broadcast.js +47 -0
- package/server/dist/ticket-store.js +397 -0
- package/server/dist/ticket-watcher.js +117 -0
- package/server/dist/types.js +10 -0
- package/server/dist/user-mcp-config.js +117 -0
- package/server/dist/util/cli-prompt.js +181 -0
- package/server/dist/util/secure-fs.js +50 -0
- package/server/dist/util/win-spawn.js +43 -0
- package/server/dist/webhook-manager.js +89 -0
- 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;
|