specrails-desktop 2.2.1 → 2.4.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/client/dist/assets/ActivityFeedPage-DJJlZ3mF.js +1 -0
- package/client/dist/assets/AgentsPage-49JaEDjR.js +86 -0
- package/client/dist/assets/{AnalyticsPage-BD0paa75.js → AnalyticsPage-BUd3gWYC.js} +1 -1
- package/client/dist/assets/{BarChart-D8ZZRab3.js → BarChart-HDe_YoUD.js} +1 -1
- package/client/dist/assets/CodePage-CqPPND47.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-mwd8460_.js → DesktopAnalyticsPage-CgvmSvF0.js} +1 -1
- package/client/dist/assets/DocsDialog-hHFd3Ejs.js +11 -0
- package/client/dist/assets/DocsPage-B4R1aksg.js +11 -0
- package/client/dist/assets/{ExportDropdown-CLYmQhic.js → ExportDropdown-f4dwQjlT.js} +1 -1
- package/client/dist/assets/IntegrationsPage-CX2Ybxx0.js +3 -0
- package/client/dist/assets/JobDetailPage-DN2Jc8Ti.js +16 -0
- package/client/dist/assets/JobsPage-DmdpqijT.js +1 -0
- package/client/dist/assets/code-BwIz8agY.js +1 -0
- package/client/dist/assets/code-CD7yNSK0.js +1 -0
- package/client/dist/assets/code-CDFlxUFC.js +1 -0
- package/client/dist/assets/code-CY85RXZU.js +1 -0
- package/client/dist/assets/code-Cp3Fdng-.js +1 -0
- package/client/dist/assets/code-D24e1Crx.js +1 -0
- package/client/dist/assets/code-DtZBQTi9.js +1 -0
- package/client/dist/assets/code-nKa0fkm_.js +1 -0
- package/client/dist/assets/{cssMode-Cc6ozl-J.js → cssMode-DzNPAYFh.js} +1 -1
- package/client/dist/assets/{dist-js-D3MxtOYa.js → dist-js-COfIfLRE.js} +1 -1
- package/client/dist/assets/{dist-js-BOu_cXw3.js → dist-js-CvScGQU_.js} +1 -1
- package/client/dist/assets/{editor.main-CfXxHimg.js → editor.main-C7Rmw-hR.js} +2 -2
- package/client/dist/assets/{freemarker2-DP7J1gG3.js → freemarker2-Cszs4SVo.js} +1 -1
- package/client/dist/assets/{handlebars-BjRlucw6.js → handlebars-Dp7Lsuym.js} +1 -1
- package/client/dist/assets/{html-OumBQJ-U.js → html-BURidrEm.js} +1 -1
- package/client/dist/assets/{htmlMode-CStc3zXM.js → htmlMode--k5M7GjZ.js} +1 -1
- package/client/dist/assets/index-DBpvYrDK.css +2 -0
- package/client/dist/assets/index-DGIXKRHE.js +142 -0
- package/client/dist/assets/{integrations-Cublz3m6.js → integrations-2C7MkGT0.js} +1 -1
- package/client/dist/assets/{integrations-HIlUxXVs.js → integrations-BDC670cg.js} +1 -1
- package/client/dist/assets/integrations-BqUmRUef.js +1 -0
- package/client/dist/assets/{integrations-DmQYCUvN.js → integrations-C2jQtv-s.js} +1 -1
- package/client/dist/assets/{integrations-DRdbki5W.js → integrations-CB98NeH5.js} +1 -1
- package/client/dist/assets/{integrations-C3p12Ms6.js → integrations-CX4p_bij.js} +1 -1
- package/client/dist/assets/{integrations-DaC4SzzL.js → integrations-_SuVeQIG.js} +1 -1
- package/client/dist/assets/{integrations-Cr6hH7XR.js → integrations-eQPHAYsE.js} +1 -1
- package/client/dist/assets/{javascript-CMk--e7g.js → javascript-kJQz__44.js} +1 -1
- package/client/dist/assets/jira-C-ATCti0.js +1 -0
- package/client/dist/assets/jira-CmVfRM-b.js +1 -0
- package/client/dist/assets/jira-D7bkKAX8.js +1 -0
- package/client/dist/assets/jira-DKImM1YH.js +1 -0
- package/client/dist/assets/jira-DOw8bkIR.js +1 -0
- package/client/dist/assets/jira-DlA-wGp-.js +1 -0
- package/client/dist/assets/jira-Fob8EGxN.js +1 -0
- package/client/dist/assets/jira-xZA2lixb.js +1 -0
- package/client/dist/assets/{jsonMode-C2h3ZcjZ.js → jsonMode-v5JYPpnz.js} +1 -1
- package/client/dist/assets/{lib-DQ2hrj8m.js → lib-Bro9Z0gp.js} +1 -1
- package/client/dist/assets/{liquid-mI3KJrBE.js → liquid-Dl9I6gWt.js} +1 -1
- package/client/dist/assets/{lspLanguageFeatures-DU09ggWi.js → lspLanguageFeatures-CPlEe0NK.js} +1 -1
- package/client/dist/assets/{mdx-C41VDTR_.js → mdx-Byl7TtzQ.js} +1 -1
- package/client/dist/assets/{monaco.contribution-CPObAXMC.js → monaco.contribution-YMAkHQcQ.js} +2 -2
- package/client/dist/assets/{python-Y27rKQtk.js → python-jWQwT6j2.js} +1 -1
- package/client/dist/assets/{razor-Cd5-q9Bp.js → razor-BWS3sP-E.js} +1 -1
- package/client/dist/assets/setup-C0dzw8j4.js +1 -0
- package/client/dist/assets/setup-C1IA-9YS.js +1 -0
- package/client/dist/assets/setup-CpfjaNut.js +1 -0
- package/client/dist/assets/setup-D3rNZA9A.js +1 -0
- package/client/dist/assets/setup-UD2aanGs.js +1 -0
- package/client/dist/assets/setup-WP6WOYQh.js +1 -0
- package/client/dist/assets/setup-gzLG8T6F.js +1 -0
- package/client/dist/assets/setup-pjgmYHx6.js +1 -0
- package/client/dist/assets/specs-4lA_u79w.js +1 -0
- package/client/dist/assets/{specs-D2FzlLn9.js → specs-BHjxcjOf.js} +1 -1
- package/client/dist/assets/{specs-CZ1PsXsC.js → specs-CXNQzPk9.js} +1 -1
- package/client/dist/assets/{specs-Dyc5hYeE.js → specs-DFnc2Huj.js} +1 -1
- package/client/dist/assets/{specs-BFfu3u-a.js → specs-DZCLH2-l.js} +1 -1
- package/client/dist/assets/{specs-B__C8-8a.js → specs-DgmyAE3N.js} +1 -1
- package/client/dist/assets/{specs-DaUTrNF9.js → specs-DicWhvwi.js} +1 -1
- package/client/dist/assets/{specs-k0PyLDVt.js → specs-dkro6lSM.js} +1 -1
- package/client/dist/assets/{tsMode-B0y_xEci.js → tsMode-BbOGOuSV.js} +1 -1
- package/client/dist/assets/{typescript-BzK0OgwW.js → typescript-eBtFQJLs.js} +1 -1
- package/client/dist/assets/{useProjectCache-BxY4aTjd.js → useProjectCache-D9juBhsO.js} +1 -1
- package/client/dist/assets/{workers-rt--R2Qy.js → workers-BvicOoDf.js} +1 -1
- package/client/dist/assets/{xml-eX9QXAmI.js → xml-BJepAPyM.js} +1 -1
- package/client/dist/assets/{yaml-fcsNkpOt.js → yaml-DabgV-eA.js} +1 -1
- package/client/dist/index.html +13 -12
- package/docs/jira-integration-plan.md +321 -0
- package/package.json +1 -1
- package/server/dist/agent-refine-manager.js +128 -153
- package/server/dist/chat-manager.js +242 -0
- package/server/dist/code-explorer-router.js +78 -0
- package/server/dist/command-resolver.js +17 -0
- package/server/dist/contract-refine-runner.js +42 -10
- package/server/dist/db.js +86 -0
- package/server/dist/desktop-db.js +3 -0
- package/server/dist/explore-stdin-session.js +129 -0
- package/server/dist/feature-flags.js +11 -0
- package/server/dist/jira/jira-adf.js +113 -0
- package/server/dist/jira/jira-backlog-config.js +58 -0
- package/server/dist/jira/jira-client.js +279 -0
- package/server/dist/jira/jira-credential-store.js +103 -0
- package/server/dist/jira/jira-db.js +341 -0
- package/server/dist/jira/jira-issue-fields.js +428 -0
- package/server/dist/jira/jira-materializer.js +250 -0
- package/server/dist/jira/jira-status-resolver.js +211 -0
- package/server/dist/jira/jira-sync-manager.js +1014 -0
- package/server/dist/jira/types.js +9 -0
- package/server/dist/jira-router.js +304 -0
- package/server/dist/mobile/mobile-auth.js +16 -0
- package/server/dist/project-registry.js +43 -1
- package/server/dist/project-router-chat.js +218 -0
- package/server/dist/project-router-helpers.js +275 -0
- package/server/dist/project-router-jobs.js +389 -0
- package/server/dist/project-router-settings.js +312 -0
- package/server/dist/project-router-setup.js +456 -0
- package/server/dist/project-router-spending.js +320 -0
- package/server/dist/project-router-terminals.js +312 -0
- package/server/dist/project-router-tickets.js +1815 -0
- package/server/dist/project-router.js +31 -3950
- package/server/dist/providers/claude-adapter.js +23 -0
- package/server/dist/providers/codex-adapter.js +6 -0
- package/server/dist/rails-router.js +12 -0
- package/server/dist/spawn-lifecycle.js +117 -0
- package/client/dist/assets/ActivityFeedPage-BpjXuX2H.js +0 -1
- package/client/dist/assets/AgentsPage-D-7fDbTc.js +0 -86
- package/client/dist/assets/CodePage-B6q6CiYJ.js +0 -2
- package/client/dist/assets/DocsDialog-D_dyF2h9.js +0 -11
- package/client/dist/assets/DocsPage-C9-Ru8wE.js +0 -11
- package/client/dist/assets/IntegrationsPage-3WWtx9hi.js +0 -3
- package/client/dist/assets/JobDetailPage-DgN-79s-.js +0 -16
- package/client/dist/assets/JobsPage-Du8_w1ob.js +0 -1
- package/client/dist/assets/code-AL1rVIMb.js +0 -1
- package/client/dist/assets/code-C0BKpkht.js +0 -1
- package/client/dist/assets/code-C0FTS3ew.js +0 -1
- package/client/dist/assets/code-CPcHxzxw.js +0 -1
- package/client/dist/assets/code-D3ryDniw.js +0 -1
- package/client/dist/assets/code-D3zVVQTj.js +0 -1
- package/client/dist/assets/code-PCmfS3dn.js +0 -1
- package/client/dist/assets/code-exI0G5Wd.js +0 -1
- package/client/dist/assets/index-D17R4Cjc.css +0 -2
- package/client/dist/assets/index-D9G_K4L-.js +0 -142
- package/client/dist/assets/integrations-D28q1kF6.js +0 -1
- package/client/dist/assets/setup--FMCsnQS.js +0 -1
- package/client/dist/assets/setup-B19ZpBNi.js +0 -1
- package/client/dist/assets/setup-BZPmkjSN.js +0 -1
- package/client/dist/assets/setup-BqYA02rS.js +0 -1
- package/client/dist/assets/setup-ChKQDHN9.js +0 -1
- package/client/dist/assets/setup-D2n9jMfM.js +0 -1
- package/client/dist/assets/setup-P3r6YP1D.js +0 -1
- package/client/dist/assets/setup-fnfEbwlv.js +0 -1
- package/client/dist/assets/specs-cKEh2LXt.js +0 -1
- /package/client/dist/assets/{abap-Bw6f2wDG.js → abap-s65oMlhi.js} +0 -0
- /package/client/dist/assets/{activity-BdrPln96.js → activity-BqqwnH_h.js} +0 -0
- /package/client/dist/assets/{activity-BEIp_Y1A.js → activity-C8qqEIoP.js} +0 -0
- /package/client/dist/assets/{activity-CpkRS8Sx.js → activity-CZVM4nlJ.js} +0 -0
- /package/client/dist/assets/{activity-DOUVEjJi.js → activity-Cyy07Tgo.js} +0 -0
- /package/client/dist/assets/{activity-DRwkql_y.js → activity-DlbWCa4y.js} +0 -0
- /package/client/dist/assets/{activity-DKCpESPt.js → activity-Dwq0heud.js} +0 -0
- /package/client/dist/assets/{activity-DcDQ7tjw.js → activity-qFTcMyW9.js} +0 -0
- /package/client/dist/assets/{addon-image-3WCl5Vhd.js → addon-image-CpF0L0jM.js} +0 -0
- /package/client/dist/assets/{addon-ligatures-C5OdliKs.js → addon-ligatures-hXysGZrA.js} +0 -0
- /package/client/dist/assets/{addon-webgl-BbX6pSjl.js → addon-webgl-Cn1slavz.js} +0 -0
- /package/client/dist/assets/{addspec-D33ocMxf.js → addspec-B1FTtI2a.js} +0 -0
- /package/client/dist/assets/{addspec-DFswZ0jK.js → addspec-BCT9vm_c.js} +0 -0
- /package/client/dist/assets/{addspec-DVZ15Jp8.js → addspec-DeDOztDr.js} +0 -0
- /package/client/dist/assets/{addspec-Fkv91Opc.js → addspec-DpRgmfmx.js} +0 -0
- /package/client/dist/assets/{addspec-BEeF5-zc.js → addspec-Dw-0Dg-4.js} +0 -0
- /package/client/dist/assets/{addspec-B5yl4Loj.js → addspec-rp496P_F.js} +0 -0
- /package/client/dist/assets/{addspec-DRE-jZv7.js → addspec-v8j6A7CD.js} +0 -0
- /package/client/dist/assets/{agents-DK-Dlc0i.js → agents-23iPejcA.js} +0 -0
- /package/client/dist/assets/{agents-Q6Ldfpxx.js → agents-BDx1RXcl.js} +0 -0
- /package/client/dist/assets/{agents-TeOSy-ax.js → agents-BFr3kUhK.js} +0 -0
- /package/client/dist/assets/{agents-Bm9rPqnt.js → agents-B_1L9xRg.js} +0 -0
- /package/client/dist/assets/{agents-1nCDWRmP.js → agents-BlPnx-mz.js} +0 -0
- /package/client/dist/assets/{agents-iTqjRajS.js → agents-DcxZHzNr.js} +0 -0
- /package/client/dist/assets/{agents-s87sMGzL.js → agents-G3shOewU.js} +0 -0
- /package/client/dist/assets/{agentstudio-B6Wb59E7.js → agentstudio-B-CMAQqy.js} +0 -0
- /package/client/dist/assets/{agentstudio-D3I62TLJ.js → agentstudio-Bk1eZcv4.js} +0 -0
- /package/client/dist/assets/{agentstudio-DuH9TogZ.js → agentstudio-ChxNuGAu.js} +0 -0
- /package/client/dist/assets/{agentstudio-Kw88_dUF.js → agentstudio-DNlme601.js} +0 -0
- /package/client/dist/assets/{agentstudio-BdidyBzZ.js → agentstudio-DpP9caEE.js} +0 -0
- /package/client/dist/assets/{agentstudio-BSnWLR63.js → agentstudio-Y3G0ddJ2.js} +0 -0
- /package/client/dist/assets/{agentstudio-BADhZ41e.js → agentstudio-kk9RB7Se.js} +0 -0
- /package/client/dist/assets/{aiedit-DJMny-D5.js → aiedit-5ETerMK1.js} +0 -0
- /package/client/dist/assets/{aiedit-D2ji6Qy0.js → aiedit-BBCrOpHq.js} +0 -0
- /package/client/dist/assets/{aiedit-DAhZTvtk.js → aiedit-BMtcGYNE.js} +0 -0
- /package/client/dist/assets/{aiedit-DvrcbwGv.js → aiedit-D9ddlgkM.js} +0 -0
- /package/client/dist/assets/{aiedit-WBSjT_C1.js → aiedit-De0SOH6S.js} +0 -0
- /package/client/dist/assets/{aiedit-BWxHGsYA.js → aiedit-DrfzQroF.js} +0 -0
- /package/client/dist/assets/{aiedit-DOcxERkU.js → aiedit-fMltW101.js} +0 -0
- /package/client/dist/assets/{analytics-C9Zc-rkM.js → analytics-BeTyviO8.js} +0 -0
- /package/client/dist/assets/{analytics-CrPCZRJ-.js → analytics-C4eEO260.js} +0 -0
- /package/client/dist/assets/{analytics-CYj0tfj7.js → analytics-C67cIA1b.js} +0 -0
- /package/client/dist/assets/{analytics-C6EzgtdE.js → analytics-CAguvW28.js} +0 -0
- /package/client/dist/assets/{analytics-CVx3YOc0.js → analytics-DBtt8Mgk.js} +0 -0
- /package/client/dist/assets/{analytics-CnY4kNG3.js → analytics-DUPtODxX.js} +0 -0
- /package/client/dist/assets/{analytics-BIdr0YfL.js → analytics-YIpQvPAc.js} +0 -0
- /package/client/dist/assets/{apex-Cw8_REBo.js → apex-BLUBIldB.js} +0 -0
- /package/client/dist/assets/{attachments-DYHGA2Dj.js → attachments-CCWasu-P.js} +0 -0
- /package/client/dist/assets/{attachments-Dd92KpUH.js → attachments-CHaDUfjB.js} +0 -0
- /package/client/dist/assets/{attachments-DzdU6DV6.js → attachments-CVSAbGNl.js} +0 -0
- /package/client/dist/assets/{attachments-Bcf6BG6V.js → attachments-Chg5poG1.js} +0 -0
- /package/client/dist/assets/{attachments-BW4L3l2L.js → attachments-DazTVJbH.js} +0 -0
- /package/client/dist/assets/{attachments-COcrGRFz.js → attachments-Dn-JImAK.js} +0 -0
- /package/client/dist/assets/{attachments-Bke8sCU4.js → attachments-LDA9kp2X.js} +0 -0
- /package/client/dist/assets/{azcli-Cz6HAoOw.js → azcli-DuWxh9mO.js} +0 -0
- /package/client/dist/assets/{bat-CcJ-xyqL.js → bat-UKoTejQm.js} +0 -0
- /package/client/dist/assets/{bicep-z1WDCKYz.js → bicep-4sTT4B3D.js} +0 -0
- /package/client/dist/assets/{browser-DGITz3fC.js → browser-BDd1dbFa.js} +0 -0
- /package/client/dist/assets/{browser-JsAIGCEW.js → browser-BWSgbfdX.js} +0 -0
- /package/client/dist/assets/{browser-M5-rbPlw.js → browser-D2Y_UAKA.js} +0 -0
- /package/client/dist/assets/{browser-BlYF4OOq.js → browser-DH9SGVfM.js} +0 -0
- /package/client/dist/assets/{browser-Bc-YdlVg.js → browser-DWOVYMlg.js} +0 -0
- /package/client/dist/assets/{browser-CT-ReZGt.js → browser-Dxc_VIRK.js} +0 -0
- /package/client/dist/assets/{browser-5ErDlJoR.js → browser-lTQwcDCI.js} +0 -0
- /package/client/dist/assets/{cameligo-BRewOpfa.js → cameligo-CAAryRYO.js} +0 -0
- /package/client/dist/assets/{chat-DwUm6W9z.js → chat-BO9MvVID.js} +0 -0
- /package/client/dist/assets/{chat-BEGuC03z.js → chat-CPgmgZOj.js} +0 -0
- /package/client/dist/assets/{chat-CboQguCi.js → chat-CUrG1eVg.js} +0 -0
- /package/client/dist/assets/{chat-DRCa9pOt.js → chat-CvOOKB2s.js} +0 -0
- /package/client/dist/assets/{chat-BEW60P_u.js → chat-DIh3hr6y.js} +0 -0
- /package/client/dist/assets/{chat-yoXwguQu.js → chat-UVVZqA57.js} +0 -0
- /package/client/dist/assets/{chat-BQNMD0PL.js → chat-mPn3UlMl.js} +0 -0
- /package/client/dist/assets/{clojure-DBjRWN6g.js → clojure-BlMERO1w.js} +0 -0
- /package/client/dist/assets/{clsx-DnqN-uhr.js → clsx-CnH-HMk3.js} +0 -0
- /package/client/dist/assets/{coffee-Cfk_XHGR.js → coffee-Cj8D-Wl1.js} +0 -0
- /package/client/dist/assets/{commands-sqrqsxyE.js → commands-B-MVT-2F.js} +0 -0
- /package/client/dist/assets/{commands-UD1NzmwX.js → commands-B0yFTp7e.js} +0 -0
- /package/client/dist/assets/{commands-DLrvnPNg.js → commands-BR1kDkHQ.js} +0 -0
- /package/client/dist/assets/{commands-CJxCry-o.js → commands-Cb21pDlG.js} +0 -0
- /package/client/dist/assets/{commands-CfgY-_of.js → commands-DWgp-8W1.js} +0 -0
- /package/client/dist/assets/{commands-B772IyDa.js → commands-ddsl1V91.js} +0 -0
- /package/client/dist/assets/{commands-BDDp6xFG.js → commands-t4frzhB0.js} +0 -0
- /package/client/dist/assets/{common-Dmm1GhdD.js → common-5ilvMOcH.js} +0 -0
- /package/client/dist/assets/{common-DltqHaAe.js → common-B4sqsKp7.js} +0 -0
- /package/client/dist/assets/{common-GbpxfPG8.js → common-BKpVwUIf.js} +0 -0
- /package/client/dist/assets/{common-DeDELLZJ.js → common-BzEC3kJU.js} +0 -0
- /package/client/dist/assets/{common-DnjcgkPH.js → common-CALKUpYm.js} +0 -0
- /package/client/dist/assets/{common-Dard9UNH.js → common-CTEbWVZS.js} +0 -0
- /package/client/dist/assets/{common-DCr6VzJ7.js → common-DQiza2Xp.js} +0 -0
- /package/client/dist/assets/{cpp-BVob6BaP.js → cpp-BPfKnaj_.js} +0 -0
- /package/client/dist/assets/{csharp-C4fbRuOu.js → csharp-gX-x5uD6.js} +0 -0
- /package/client/dist/assets/{csp-DthFP_vT.js → csp-DKGVt8SM.js} +0 -0
- /package/client/dist/assets/{css-CGMH0hcW.js → css-CPMdnAVq.js} +0 -0
- /package/client/dist/assets/{cypher-Pnf68BRV.js → cypher-ClMDrj9S.js} +0 -0
- /package/client/dist/assets/{dart-PMMOtxZX.js → dart-C4zbzpVv.js} +0 -0
- /package/client/dist/assets/{dashboard-BZBADHSj.js → dashboard--Y6yzMlf.js} +0 -0
- /package/client/dist/assets/{dashboard-I19DXBxw.js → dashboard--a4-6oYE.js} +0 -0
- /package/client/dist/assets/{dashboard-CB6Le1yN.js → dashboard-BiJ3CDTG.js} +0 -0
- /package/client/dist/assets/{dashboard-B4ixDVk8.js → dashboard-CiXjk63Z.js} +0 -0
- /package/client/dist/assets/{dashboard-C1MfeUHs.js → dashboard-Cx5VjCea.js} +0 -0
- /package/client/dist/assets/{dashboard-C7SK6xu5.js → dashboard-D7jg25XR.js} +0 -0
- /package/client/dist/assets/{dashboard-CoTpMOBM.js → dashboard-DpGYK2s1.js} +0 -0
- /package/client/dist/assets/{dockerfile-di1nsJCc.js → dockerfile-D9xw73D1.js} +0 -0
- /package/client/dist/assets/{ecl-D_WVcB5M.js → ecl-gqO8tIR9.js} +0 -0
- /package/client/dist/assets/{editor.api2-XLGzZfbc.js → editor.api2-BPnIxMjz.js} +0 -0
- /package/client/dist/assets/{elixir-OAdJEMOn.js → elixir-DSAhVF3_.js} +0 -0
- /package/client/dist/assets/{explore-D2EFgt8J.js → explore-BE5UmlbD.js} +0 -0
- /package/client/dist/assets/{explore-BV5Xxlsn.js → explore-BmTaI8dX.js} +0 -0
- /package/client/dist/assets/{explore-A8Ltoblq.js → explore-CCwkqoWq.js} +0 -0
- /package/client/dist/assets/{explore-4mFpnrKU.js → explore-CMdEoPDx.js} +0 -0
- /package/client/dist/assets/{explore-C3FSE42C.js → explore-CtdCL4QU.js} +0 -0
- /package/client/dist/assets/{explore-B9A3iN2W.js → explore-DHjxSkqQ.js} +0 -0
- /package/client/dist/assets/{explore-BrBJvfjP.js → explore-__BeALjE.js} +0 -0
- /package/client/dist/assets/{flow9-D3QEZjgn.js → flow9-DeQCSPOd.js} +0 -0
- /package/client/dist/assets/{format-command-CwGuwzGA.js → format-command-2VNoNnMv.js} +0 -0
- /package/client/dist/assets/{fsharp-BF0k_8N8.js → fsharp-CEfaXL-S.js} +0 -0
- /package/client/dist/assets/{go-BAQO5Jsz.js → go-Xp1OkZCh.js} +0 -0
- /package/client/dist/assets/{graphql-hdFVFkiV.js → graphql-BwRXrUwe.js} +0 -0
- /package/client/dist/assets/{hcl-DWnl1o-X.js → hcl-u06DtVFk.js} +0 -0
- /package/client/dist/assets/{ini-CB-6OVu3.js → ini-AmeIpFND.js} +0 -0
- /package/client/dist/assets/{java-d1CmfiHX.js → java-CyDbRQjX.js} +0 -0
- /package/client/dist/assets/{jobs-DPPT6bV6.js → jobs-8viuHLDV.js} +0 -0
- /package/client/dist/assets/{jobs-3j3_npyo.js → jobs-AW2eB5D-.js} +0 -0
- /package/client/dist/assets/{jobs-2N3RXDAM.js → jobs-BSm89DL5.js} +0 -0
- /package/client/dist/assets/{jobs-BqEbCCxD.js → jobs-BZ3sQHjZ.js} +0 -0
- /package/client/dist/assets/{jobs-cHYInoau.js → jobs-Bd8AdOTb.js} +0 -0
- /package/client/dist/assets/{jobs-DRzjWI9u.js → jobs-CRtsq_u0.js} +0 -0
- /package/client/dist/assets/{jobs-2f6Hdc72.js → jobs-CSRwFQ6K.js} +0 -0
- /package/client/dist/assets/{jobs-vGfzIDQa.js → jobs-CbEl7WMI.js} +0 -0
- /package/client/dist/assets/{julia-Bgv08lKa.js → julia-BqialFRG.js} +0 -0
- /package/client/dist/assets/{kotlin-u98kaVTf.js → kotlin-Dzz8TWAt.js} +0 -0
- /package/client/dist/assets/{less-CjYwpgg5.js → less-DHRJD3TR.js} +0 -0
- /package/client/dist/assets/{lexon-YTjaAFBB.js → lexon-5Y3QgTmT.js} +0 -0
- /package/client/dist/assets/{lua-BzmkWv27.js → lua-sKvhfPn5.js} +0 -0
- /package/client/dist/assets/{m3-CFwk9fw0.js → m3-DWDVwkFG.js} +0 -0
- /package/client/dist/assets/{markdown-CR5iMpSZ.js → markdown-CD_aSBxW.js} +0 -0
- /package/client/dist/assets/{mips-CcEalc17.js → mips-687T03hg.js} +0 -0
- /package/client/dist/assets/{msdax-BQbkawnr.js → msdax-C1St-dIV.js} +0 -0
- /package/client/dist/assets/{mysql-GTlaaW_P.js → mysql-BG7r8oBS.js} +0 -0
- /package/client/dist/assets/{nav-C2YXcbZS.js → nav-B05EYB0b.js} +0 -0
- /package/client/dist/assets/{nav-D2bOGSEg.js → nav-BNGCq-0y.js} +0 -0
- /package/client/dist/assets/{nav-BEL3MTwK.js → nav-BRInPX8a.js} +0 -0
- /package/client/dist/assets/{nav-CtYwmMgu.js → nav-Bf87DRHD.js} +0 -0
- /package/client/dist/assets/{nav-iH1V5j6o.js → nav-BkVzzFpc.js} +0 -0
- /package/client/dist/assets/{nav-0fwkrgHt.js → nav-BzFLtS1W.js} +0 -0
- /package/client/dist/assets/{nav-ClzOE4mA.js → nav-DBDbQOYn.js} +0 -0
- /package/client/dist/assets/{nav-B_G-TJDW.js → nav-X9sVtUWC.js} +0 -0
- /package/client/dist/assets/{objective-c-Byu1T5if.js → objective-c-Ds1-m05L.js} +0 -0
- /package/client/dist/assets/{pascal-BrfzBfRm.js → pascal-BKK9FpIi.js} +0 -0
- /package/client/dist/assets/{pascaligo-BXXKFUeo.js → pascaligo-SRS3nwtO.js} +0 -0
- /package/client/dist/assets/{perl-B3OikKq-.js → perl-B2hTOlrF.js} +0 -0
- /package/client/dist/assets/{pgsql-CTsa0Acc.js → pgsql-DIQJYNpL.js} +0 -0
- /package/client/dist/assets/{php-DiQh3FUW.js → php-BEaVe8X2.js} +0 -0
- /package/client/dist/assets/{pla-92uH8Fzm.js → pla-oPLHpZ-Q.js} +0 -0
- /package/client/dist/assets/{postiats-BbeWkKUr.js → postiats-D_vzrAzD.js} +0 -0
- /package/client/dist/assets/{powerquery-DgDMzpsm.js → powerquery-BKG6w-FH.js} +0 -0
- /package/client/dist/assets/{powershell-BfdUUzaG.js → powershell-B3dLhDt4.js} +0 -0
- /package/client/dist/assets/{protobuf-BojW2ftW.js → protobuf-DC8SGjcl.js} +0 -0
- /package/client/dist/assets/{pug-BxqTg3IU.js → pug-D5E-4fI0.js} +0 -0
- /package/client/dist/assets/{qsharp-BX_A-MW9.js → qsharp-6vJAWv0x.js} +0 -0
- /package/client/dist/assets/{r-D9BMnxvJ.js → r-CDwsEcbM.js} +0 -0
- /package/client/dist/assets/{redis-5cJqEQJJ.js → redis-CuQbbESS.js} +0 -0
- /package/client/dist/assets/{redshift-d8BBqiwb.js → redshift-B9e1k-qI.js} +0 -0
- /package/client/dist/assets/{restructuredtext-C8a6yIcZ.js → restructuredtext-BiJ5IwaU.js} +0 -0
- /package/client/dist/assets/{ruby-egeh-6KX.js → ruby-B0UAHY9b.js} +0 -0
- /package/client/dist/assets/{rust-a3r9IInB.js → rust-Dg_spmFr.js} +0 -0
- /package/client/dist/assets/{sb-y8iRIDei.js → sb-DjU66I8Q.js} +0 -0
- /package/client/dist/assets/{scala-BPDK2AmK.js → scala-qvStIdfG.js} +0 -0
- /package/client/dist/assets/{scheme-BIWUEoOs.js → scheme-FstEk5Rh.js} +0 -0
- /package/client/dist/assets/{scss-CA-PSzwg.js → scss-w0U3rQLK.js} +0 -0
- /package/client/dist/assets/{settings-CTcwN9RE.js → settings-5tzo0Rn3.js} +0 -0
- /package/client/dist/assets/{settings-D_dujJZI.js → settings-BDAW3trC.js} +0 -0
- /package/client/dist/assets/{settings-Bg0A3zoS.js → settings-BEWv3VEu.js} +0 -0
- /package/client/dist/assets/{settings-BgPqg2nv.js → settings-BORg56um.js} +0 -0
- /package/client/dist/assets/{settings-BSze3_9q.js → settings-D3LurcR5.js} +0 -0
- /package/client/dist/assets/{settings-CSJ0ahZ8.js → settings-DcqWIEM6.js} +0 -0
- /package/client/dist/assets/{settings-DYIV89nV.js → settings-Dfz8QbZS.js} +0 -0
- /package/client/dist/assets/{settings-DDcfx_ca.js → settings-yMubjqYw.js} +0 -0
- /package/client/dist/assets/{shell--LiT1Bja.js → shell-DJ78wREd.js} +0 -0
- /package/client/dist/assets/{solidity-DdqZccZg.js → solidity-1aGIVsdX.js} +0 -0
- /package/client/dist/assets/{sophia-S6-YxNG_.js → sophia-40LqcGjB.js} +0 -0
- /package/client/dist/assets/{sparql-BSf5kMp2.js → sparql-Cz5dqG_g.js} +0 -0
- /package/client/dist/assets/{sql-D7KgjR8G.js → sql-64f62Ni4.js} +0 -0
- /package/client/dist/assets/{st-BnoDa-Ml.js → st-gJe2yG8J.js} +0 -0
- /package/client/dist/assets/{swift-DEUHTkUX.js → swift-C6ME22mv.js} +0 -0
- /package/client/dist/assets/{systemverilog-Tqb_KPnW.js → systemverilog-CEWz259w.js} +0 -0
- /package/client/dist/assets/{tcl-BmBFS2qq.js → tcl-CcLVIi3m.js} +0 -0
- /package/client/dist/assets/{terminal-Bje4ziIa.js → terminal-BYtreaaF.js} +0 -0
- /package/client/dist/assets/{terminal-CSONJOex.js → terminal-C0xx0SjA.js} +0 -0
- /package/client/dist/assets/{terminal-DeWzh6ys.js → terminal-CPpK58RC.js} +0 -0
- /package/client/dist/assets/{terminal-C2WYcFHF.js → terminal-CdxkpafL.js} +0 -0
- /package/client/dist/assets/{terminal-DEqzGtcr.js → terminal-Ciia0wh2.js} +0 -0
- /package/client/dist/assets/{terminal-80yDMgMF.js → terminal-DHIkiWcs.js} +0 -0
- /package/client/dist/assets/{terminal-lkZYR4wJ.js → terminal-DY42QANg.js} +0 -0
- /package/client/dist/assets/{terminal-YOlsJCQj.js → terminal-DoxtVdma.js} +0 -0
- /package/client/dist/assets/{tickets-DYvafSaY.js → tickets-0rM0lIXd.js} +0 -0
- /package/client/dist/assets/{tickets-DNOANUXr.js → tickets-1UIGf_oA.js} +0 -0
- /package/client/dist/assets/{tickets-DlpC_iTg.js → tickets-9kdPXInd.js} +0 -0
- /package/client/dist/assets/{tickets-CF2PYelu.js → tickets-C6pwZwt4.js} +0 -0
- /package/client/dist/assets/{tickets-CB7N30gm.js → tickets-DAjtxAVb.js} +0 -0
- /package/client/dist/assets/{tickets-DU1aqsbr.js → tickets-DNmXcAwu.js} +0 -0
- /package/client/dist/assets/{tickets-clefmXLv.js → tickets-n23kDqJT.js} +0 -0
- /package/client/dist/assets/{tickets-DucYgtdl.js → tickets-tGx5AR5b.js} +0 -0
- /package/client/dist/assets/{twig-BQV8igWC.js → twig-DvsO-WjW.js} +0 -0
- /package/client/dist/assets/{typespec-DlFroUGY.js → typespec-Brkt3IAA.js} +0 -0
- /package/client/dist/assets/{vb-BlrJpIMX.js → vb-r121Uzxt.js} +0 -0
- /package/client/dist/assets/{wgsl-BWgIc6FZ.js → wgsl-BRX8uYh4.js} +0 -0
|
@@ -0,0 +1,1815 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.registerTicketsRoutes = registerTicketsRoutes;
|
|
40
|
+
// Domain routes extracted from project-router.ts (tickets).
|
|
41
|
+
// Registered on the shared router by createProjectRouter — behaviour-preserving.
|
|
42
|
+
const fs_1 = __importDefault(require("fs"));
|
|
43
|
+
const ids_1 = require("./ids");
|
|
44
|
+
const db_1 = require("./db");
|
|
45
|
+
const providers_1 = require("./providers");
|
|
46
|
+
const contract_refine_runner_1 = require("./contract-refine-runner");
|
|
47
|
+
const explore_contract_refine_1 = require("./explore-contract-refine");
|
|
48
|
+
const smash_runner_1 = require("./smash-runner");
|
|
49
|
+
const explore_smash_1 = require("./explore-smash");
|
|
50
|
+
const ai_invocations_1 = require("./ai-invocations");
|
|
51
|
+
const context_scope_1 = require("./context-scope");
|
|
52
|
+
const result_event_1 = require("./result-event");
|
|
53
|
+
const crypto_1 = require("crypto");
|
|
54
|
+
const spec_models_1 = require("./spec-models");
|
|
55
|
+
const provider_selection_1 = require("./provider-selection");
|
|
56
|
+
const ticket_store_1 = require("./ticket-store");
|
|
57
|
+
const explore_draft_title_1 = require("./explore-draft-title");
|
|
58
|
+
const cli_prompt_1 = require("./util/cli-prompt");
|
|
59
|
+
const readline_1 = require("readline");
|
|
60
|
+
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
61
|
+
const multer_1 = __importDefault(require("multer"));
|
|
62
|
+
const attachment_manager_1 = require("./attachment-manager");
|
|
63
|
+
const project_router_helpers_1 = require("./project-router-helpers");
|
|
64
|
+
/**
|
|
65
|
+
* Add Spec on a Jira-backed project: promote the freshly-created LOCAL ticket to
|
|
66
|
+
* a Jira issue (best-effort, server-side). The per-spec `createLocal` escape
|
|
67
|
+
* hatch keeps it local. On any failure the ticket simply stays local and a
|
|
68
|
+
* non-fatal warning is broadcast — a spec is never lost to a Jira error.
|
|
69
|
+
*/
|
|
70
|
+
async function maybePromoteSpecToJira(c, ticketId, createLocal, broadcast) {
|
|
71
|
+
// jiraSyncManager is always present in production (constructed per project);
|
|
72
|
+
// guard defensively so partial test contexts and a disabled feature are no-ops.
|
|
73
|
+
if (createLocal || !c.jiraSyncManager?.isActive())
|
|
74
|
+
return;
|
|
75
|
+
try {
|
|
76
|
+
const r = await c.jiraSyncManager.promoteTicketToJira(ticketId);
|
|
77
|
+
if (!r.ok) {
|
|
78
|
+
broadcast({ type: 'jira.sync_error', projectId: c.project.id, reason: `Kept as a local spec — couldn't create it in Jira: ${r.error}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.error('[project-router] jira promote failed:', err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function registerTicketsRoutes(deps) {
|
|
86
|
+
const { router, registry, ctx, ticketPath } = deps;
|
|
87
|
+
// ─── Tickets ──────────────────────────────────────────────────────────────────
|
|
88
|
+
/** Resolve the ticket storage file path for a project */
|
|
89
|
+
// GET /:projectId/tickets — List all tickets with optional filters
|
|
90
|
+
router.get('/:projectId/tickets', (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
const filePath = ticketPath(req);
|
|
93
|
+
const store = (0, ticket_store_1.readStore)(filePath);
|
|
94
|
+
const allTickets = Object.values(store.tickets);
|
|
95
|
+
const filtered = (0, ticket_store_1.filterTickets)(allTickets, {
|
|
96
|
+
status: req.query.status,
|
|
97
|
+
label: req.query.label,
|
|
98
|
+
q: req.query.q,
|
|
99
|
+
});
|
|
100
|
+
// Sort by updated_at descending
|
|
101
|
+
filtered.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
|
102
|
+
res.json({ tickets: filtered, revision: store.revision, total: allTickets.length });
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error('[project-router] ticket list error:', err);
|
|
106
|
+
res.status(500).json({ error: 'Failed to read tickets' });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// GET /:projectId/tickets/:id — Get single ticket
|
|
110
|
+
router.get('/:projectId/tickets/:id', (req, res) => {
|
|
111
|
+
const ticketId = req.params.id;
|
|
112
|
+
if (!/^\d+$/.test(ticketId)) {
|
|
113
|
+
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const store = (0, ticket_store_1.readStore)(ticketPath(req));
|
|
118
|
+
const ticket = store.tickets[ticketId];
|
|
119
|
+
if (!ticket) {
|
|
120
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.json({ ticket, revision: store.revision });
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error('[project-router] ticket get error:', err);
|
|
127
|
+
res.status(500).json({ error: 'Failed to read ticket' });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// POST /:projectId/tickets/generate-spec — Fast AI spec generation (no codebase exploration)
|
|
131
|
+
router.post('/:projectId/tickets/generate-spec', async (req, res) => {
|
|
132
|
+
const idea = req.body?.idea;
|
|
133
|
+
if (!idea?.trim()) {
|
|
134
|
+
res.status(400).json({ error: 'idea is required' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const attachmentIds = Array.isArray(req.body?.attachmentIds)
|
|
138
|
+
? req.body.attachmentIds.filter((x) => typeof x === 'string')
|
|
139
|
+
: [];
|
|
140
|
+
const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId : null;
|
|
141
|
+
if (attachmentIds.length > 0 && !pendingSpecId) {
|
|
142
|
+
res.status(400).json({ error: 'pendingSpecId is required when attachmentIds are provided' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const { project, broadcast, ticketWatcher } = ctx(req);
|
|
146
|
+
// Multi-provider: optional aiEngine (alias provider) picks the engine for
|
|
147
|
+
// this Quick spec; must be installed on the project. Omitting it uses the
|
|
148
|
+
// primary provider.
|
|
149
|
+
const requestedEngine = req.body?.aiEngine ?? req.body?.provider;
|
|
150
|
+
const engineCheck = (0, provider_selection_1.validateRequestedProvider)(project, requestedEngine);
|
|
151
|
+
if (!engineCheck.ok) {
|
|
152
|
+
res.status(400).json({ error: engineCheck.error });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const provider = engineCheck.provider;
|
|
156
|
+
// Resolve and validate the model. Order:
|
|
157
|
+
// - Body had a `model` and it's valid → use it.
|
|
158
|
+
// - Body had a `model` and it's invalid → 400 with the allow-list.
|
|
159
|
+
// - Body had no `model` → fall back to project default.
|
|
160
|
+
const rawModel = req.body?.model;
|
|
161
|
+
let resolvedModel;
|
|
162
|
+
if (rawModel === undefined || rawModel === null || rawModel === '') {
|
|
163
|
+
resolvedModel = (0, project_router_helpers_1.resolveDefaultSpecModel)({ projectPath: project.path, provider });
|
|
164
|
+
}
|
|
165
|
+
else if ((0, spec_models_1.isValidModelForProvider)(rawModel, provider)) {
|
|
166
|
+
resolvedModel = rawModel;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
res.status(400).json({
|
|
170
|
+
error: `Invalid model "${String(rawModel)}" for provider "${provider}"`,
|
|
171
|
+
allowed: (0, spec_models_1.getModelsForProvider)(provider).map((m) => m.value),
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const requestId = (0, ids_1.newId)();
|
|
176
|
+
const projectId = project.id;
|
|
177
|
+
const filePath = ticketPath(req);
|
|
178
|
+
let hasAttachments = false;
|
|
179
|
+
let baseUserPrompt = `Generate a spec for the following idea:\n\n${idea.trim()}`;
|
|
180
|
+
let imageFlags = [];
|
|
181
|
+
if (attachmentIds.length > 0 && pendingSpecId) {
|
|
182
|
+
try {
|
|
183
|
+
const extracted = await attachment_manager_1.attachmentManager.getClaudeArgs(project.slug, pendingSpecId, attachmentIds);
|
|
184
|
+
imageFlags = extracted.imageFlags;
|
|
185
|
+
if (extracted.textBlocks.length > 0) {
|
|
186
|
+
hasAttachments = true;
|
|
187
|
+
baseUserPrompt = `${baseUserPrompt}\n\n## Attached Resources\n\n${extracted.textBlocks.join('\n\n')}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
console.error('[project-router] generate-spec attachment extraction error:', err);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Parse contextScope from body. Quick and Explore share the same Context
|
|
195
|
+
// Awareness controls; Quick still keeps Contract Refine as a top-level
|
|
196
|
+
// field for the refine scheduler.
|
|
197
|
+
const rawScope = req.body?.contextScope;
|
|
198
|
+
// Contract Layer is Claude-only — force it off for any non-claude engine
|
|
199
|
+
// (defence-in-depth; the Quick UI hides the toggle for those).
|
|
200
|
+
const quickContractRefine = provider !== 'claude'
|
|
201
|
+
? false
|
|
202
|
+
: typeof req.body?.contractRefine === 'boolean'
|
|
203
|
+
? req.body.contractRefine
|
|
204
|
+
: typeof rawScope?.contractRefine === 'boolean'
|
|
205
|
+
? rawScope.contractRefine
|
|
206
|
+
: false;
|
|
207
|
+
const quickScope = {
|
|
208
|
+
specrails: typeof rawScope?.specrails === 'boolean' ? rawScope.specrails : false,
|
|
209
|
+
openspec: typeof rawScope?.openspec === 'boolean' ? rawScope.openspec : false,
|
|
210
|
+
full: typeof rawScope?.full === 'boolean' ? rawScope.full : false,
|
|
211
|
+
// Quick spawns from project.path, so project `.mcp.json` (the `mcp`
|
|
212
|
+
// toggle) is discovered natively. `userMcp` additionally loads the
|
|
213
|
+
// developer's user-scope/plugin/connector MCP servers via the claude
|
|
214
|
+
// adapter's `loadUserEnv` (see below).
|
|
215
|
+
mcp: typeof rawScope?.mcp === 'boolean' ? rawScope.mcp : false,
|
|
216
|
+
contractRefine: quickContractRefine,
|
|
217
|
+
userMcp: typeof rawScope?.userMcp === 'boolean' ? rawScope.userMcp : false,
|
|
218
|
+
};
|
|
219
|
+
// Persist Quick mode Contract Refine choice (per-project last value).
|
|
220
|
+
(0, db_1.setQuickContractRefineLast)(ctx(req).db, quickContractRefine);
|
|
221
|
+
const specsPrefix = (0, context_scope_1.buildScopedSystemPromptPrefix)(quickScope, project.path);
|
|
222
|
+
const codebaseRule = quickScope.full
|
|
223
|
+
? `- You MAY use Read, Grep, and Glob to inspect the project codebase. Bash is not available.`
|
|
224
|
+
: hasAttachments
|
|
225
|
+
? `- Do NOT explore the project codebase. The resources inside <user-attachment> blocks below are pre-loaded context the user intentionally provided — read and use them freely.`
|
|
226
|
+
: `- Do NOT read any files or explore the codebase. Work purely from the user's description.`;
|
|
227
|
+
// The specrails-tickets prefix (when scope.specrails is toggled on)
|
|
228
|
+
// dumps every ticket into the prompt as informational context. Without
|
|
229
|
+
// an explicit dedup instruction the model treats it as background and
|
|
230
|
+
// still proposes a near-duplicate of something already in the backlog.
|
|
231
|
+
// Adding the rule here, gated on `quickScope.specrails`, keeps the
|
|
232
|
+
// "toggle is the only gate" contract the user asked for.
|
|
233
|
+
const dedupRule = quickScope.specrails
|
|
234
|
+
? `- The "Specrails Tickets" section above lists every ticket already in the backlog. Do NOT propose a duplicate or a near-duplicate of any of them. If the user's idea is already covered by an existing ticket, say so in "Problem Statement" and pick a *different* angle / sub-feature / next step that builds on the existing one — do not repeat it.\n`
|
|
235
|
+
: '';
|
|
236
|
+
const backlogRecommendationRule = quickScope.specrails
|
|
237
|
+
? `- If the user's idea asks for the "next best spec" or a backlog recommendation, use the existing tickets and OpenSpec context to choose one concrete next spec. Do not respond with generic product directions.\n`
|
|
238
|
+
: '';
|
|
239
|
+
let baseSystemPrompt = `You are a senior product engineer generating a structured spec proposal.\n\n` +
|
|
240
|
+
(specsPrefix ? `${specsPrefix}\n\n` : '') +
|
|
241
|
+
`RULES:\n` +
|
|
242
|
+
`${codebaseRule}\n` +
|
|
243
|
+
dedupRule +
|
|
244
|
+
backlogRecommendationRule +
|
|
245
|
+
`- Do NOT create files, tickets, or issues.\n` +
|
|
246
|
+
`- Output ONLY the structured markdown below. No preamble, no explanation.\n\n` +
|
|
247
|
+
`REQUIRED FORMAT:\n` +
|
|
248
|
+
`## Spec Title\n[Concise, action-oriented title]\n\n` +
|
|
249
|
+
`## Labels\n[2-4 short kebab-case tags categorising the spec — comma-separated on one line, e.g. "ui, settings, dark-mode". Lowercase, no spaces inside a tag.]\n\n` +
|
|
250
|
+
`## Problem Statement\n[2-3 sentences]\n\n` +
|
|
251
|
+
`## Proposed Solution\n[3-5 sentences]\n\n` +
|
|
252
|
+
`## Out of Scope\n[Bullet list]\n\n` +
|
|
253
|
+
`## Acceptance Criteria\n[Numbered list of testable outcomes]\n\n` +
|
|
254
|
+
`## Technical Considerations\n[Bullet list]\n\n` +
|
|
255
|
+
`## Estimated Complexity\n[Low/Medium/High/Very High + one sentence justification]\n\n` +
|
|
256
|
+
`## Short Summary\n[One or two plain-language sentences, max 120 characters total, that capture the essence of this spec for a dashboard postit. No markdown, no bullets, no headings.]`;
|
|
257
|
+
if (hasAttachments)
|
|
258
|
+
baseSystemPrompt = `${baseSystemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
|
|
259
|
+
const systemPrompt = baseSystemPrompt;
|
|
260
|
+
const userPrompt = baseUserPrompt;
|
|
261
|
+
// Generate-spec spawn args are adapter-driven. For Claude the `--tools`
|
|
262
|
+
// flag set comes from `toolFlagsForScope(quickScope)` which the adapter
|
|
263
|
+
// doesn't model — pass them through `extraArgs` so they slot in after
|
|
264
|
+
// the standard COMMON_FLAGS. `imageFlags` (also Claude-only) goes the
|
|
265
|
+
// same way. For codex the system prompt folds into the user prompt
|
|
266
|
+
// (no --system-prompt flag) and the extra Claude-only flags are ignored
|
|
267
|
+
// by the codex adapter (it doesn't read extraArgs that don't apply).
|
|
268
|
+
const adapter = (0, providers_1.getAdapter)(provider);
|
|
269
|
+
const toolFlags = provider === 'claude' ? (0, context_scope_1.toolFlagsForScope)(quickScope) : { args: [] };
|
|
270
|
+
// Full scope grants Read/Grep/Glob. The model spends turns exploring the
|
|
271
|
+
// repo before it writes the spec; 6 was too tight (a few tool calls on a
|
|
272
|
+
// sparse/empty repo hit error_max_turns → exit 1 → opaque failure). 15
|
|
273
|
+
// leaves comfortable headroom while --max-turns still bounds runaway loops.
|
|
274
|
+
const claudeMaxTurns = quickScope.full ? 15 : (hasAttachments ? 3 : 1);
|
|
275
|
+
const args = adapter.buildArgs('spec-gen', {
|
|
276
|
+
prompt: userPrompt,
|
|
277
|
+
systemPrompt,
|
|
278
|
+
model: resolvedModel,
|
|
279
|
+
maxTurns: provider === 'claude' ? claudeMaxTurns : undefined,
|
|
280
|
+
extraArgs: provider === 'claude' ? [...toolFlags.args, ...imageFlags] : undefined,
|
|
281
|
+
// "My approved MCPs" (scope.userMcp) loads the developer's user-scope,
|
|
282
|
+
// plugin, and connector MCP servers (claude-only). Quick already spawns
|
|
283
|
+
// from project.path so project `.mcp.json` is discovered without a flag.
|
|
284
|
+
loadUserEnv: provider === 'claude' && quickScope.userMcp,
|
|
285
|
+
});
|
|
286
|
+
const binary = adapter.binary;
|
|
287
|
+
// spawnAiCli reroutes multi-line argv values through stdin on Windows;
|
|
288
|
+
// POSIX argv path unchanged.
|
|
289
|
+
console.log(`[project-router] spec-gen spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
|
|
290
|
+
const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
|
|
291
|
+
env: process.env,
|
|
292
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
293
|
+
cwd: project.path,
|
|
294
|
+
});
|
|
295
|
+
// Watchdog: unlike ai-edit, generate-spec keeps no cancellable handle, so a
|
|
296
|
+
// hung CLI (network stall, model never emitting a terminating event) would
|
|
297
|
+
// otherwise leak this child + its readline for the app's lifetime. Cap is
|
|
298
|
+
// generous — the 'full' scope can legitimately run minutes and --max-turns
|
|
299
|
+
// bounds turns, not wall-clock. Cleared on close/error.
|
|
300
|
+
const GENERATE_SPEC_TIMEOUT_MS = 8 * 60 * 1000;
|
|
301
|
+
let specGenWatchdog = setTimeout(() => {
|
|
302
|
+
specGenWatchdog = null;
|
|
303
|
+
if (child.pid) {
|
|
304
|
+
try {
|
|
305
|
+
(0, tree_kill_1.default)(child.pid, 'SIGTERM');
|
|
306
|
+
}
|
|
307
|
+
catch { /* best-effort */ }
|
|
308
|
+
}
|
|
309
|
+
broadcast({
|
|
310
|
+
type: 'spec_gen_error', projectId, requestId,
|
|
311
|
+
error: `Spec generation timed out after ${Math.round(GENERATE_SPEC_TIMEOUT_MS / 1000)}s`,
|
|
312
|
+
timestamp: new Date().toISOString(),
|
|
313
|
+
});
|
|
314
|
+
}, GENERATE_SPEC_TIMEOUT_MS);
|
|
315
|
+
if (typeof specGenWatchdog.unref === 'function')
|
|
316
|
+
specGenWatchdog.unref();
|
|
317
|
+
const clearSpecGenWatchdog = () => {
|
|
318
|
+
if (specGenWatchdog) {
|
|
319
|
+
clearTimeout(specGenWatchdog);
|
|
320
|
+
specGenWatchdog = null;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
// Capture stderr so failures (auth missing, model errors, etc.) surface
|
|
324
|
+
// in the server log instead of being swallowed.
|
|
325
|
+
let stderrBuf = '';
|
|
326
|
+
/* c8 ignore start -- diagnostic-only; fires only when claude writes stderr */
|
|
327
|
+
child.stderr?.on('data', (chunk) => {
|
|
328
|
+
const s = chunk.toString();
|
|
329
|
+
stderrBuf += s;
|
|
330
|
+
console.error(`[project-router] spec-gen stderr (${requestId}): ${s.trimEnd()}`);
|
|
331
|
+
});
|
|
332
|
+
/* c8 ignore stop */
|
|
333
|
+
// Without this listener, ENOENT (binary missing on PATH) propagates as
|
|
334
|
+
// an unhandled 'error' event and crashes the entire app process.
|
|
335
|
+
/* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
|
|
336
|
+
child.on('error', (err) => {
|
|
337
|
+
clearSpecGenWatchdog();
|
|
338
|
+
console.error(`[project-router] spec-gen spawn failed (${binary}): ${err.message}`);
|
|
339
|
+
const errMsg = {
|
|
340
|
+
type: 'spec_gen_error', projectId, requestId,
|
|
341
|
+
error: `Failed to launch ${binary}: ${err.message}`,
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
};
|
|
344
|
+
broadcast(errMsg);
|
|
345
|
+
});
|
|
346
|
+
/* c8 ignore stop */
|
|
347
|
+
res.status(202).json({ requestId });
|
|
348
|
+
let buffer = '';
|
|
349
|
+
let lastResultEvent = null;
|
|
350
|
+
// Canonical adapter events feed finaliseInvocationResult on close, giving
|
|
351
|
+
// codex a real pricing-table cost estimate (+ estimated flag) and tokens,
|
|
352
|
+
// instead of the legacy hardcoded $0. Accumulated ALONGSIDE the existing
|
|
353
|
+
// buffer/delta plumbing below — never in place of it.
|
|
354
|
+
const adapterEvents = [];
|
|
355
|
+
const turnStartedAt = new Date().toISOString();
|
|
356
|
+
const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
357
|
+
stdoutReader.on('line', (line) => {
|
|
358
|
+
const adapterEv = adapter.parseStreamLine(line);
|
|
359
|
+
if (adapterEv)
|
|
360
|
+
adapterEvents.push(adapterEv);
|
|
361
|
+
let parsed = null;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(line);
|
|
364
|
+
}
|
|
365
|
+
catch { /* skip */ }
|
|
366
|
+
if (!parsed)
|
|
367
|
+
return;
|
|
368
|
+
if (provider === 'codex') {
|
|
369
|
+
// Codex `exec --json` emits one event per line. Capture the final
|
|
370
|
+
// `turn.completed` for usage extraction, and accumulate ONLY the
|
|
371
|
+
// assistant_message text — never the command_execution items or
|
|
372
|
+
// wrapper events, otherwise the raw JSONL ends up in the ticket
|
|
373
|
+
// description.
|
|
374
|
+
if (parsed.type === 'turn.completed') {
|
|
375
|
+
lastResultEvent = parsed;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (parsed.type !== 'item.completed')
|
|
379
|
+
return;
|
|
380
|
+
const item = parsed.item;
|
|
381
|
+
if (!item || item.type !== 'agent_message')
|
|
382
|
+
return;
|
|
383
|
+
const newText = (item.text ?? '').trim();
|
|
384
|
+
if (!newText)
|
|
385
|
+
return;
|
|
386
|
+
// Each agent_message is a complete chunk — separate with a blank
|
|
387
|
+
// line so the parser regexes match cleanly across chunks.
|
|
388
|
+
buffer += (buffer.endsWith('\n') || buffer.length === 0 ? '' : '\n') + newText + '\n';
|
|
389
|
+
const msg = {
|
|
390
|
+
type: 'spec_gen_stream', projectId, requestId,
|
|
391
|
+
delta: newText + '\n', timestamp: new Date().toISOString(),
|
|
392
|
+
};
|
|
393
|
+
broadcast(msg);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Claude path.
|
|
397
|
+
if (parsed.type === 'result') {
|
|
398
|
+
lastResultEvent = parsed;
|
|
399
|
+
}
|
|
400
|
+
if (parsed.type === 'assistant') {
|
|
401
|
+
const msg = parsed.message;
|
|
402
|
+
const texts = (msg?.content ?? [])
|
|
403
|
+
.filter((c) => c.type === 'text')
|
|
404
|
+
.map((c) => c.text ?? '');
|
|
405
|
+
const newText = texts.join('');
|
|
406
|
+
if (newText) {
|
|
407
|
+
buffer += newText;
|
|
408
|
+
const wsMsg = {
|
|
409
|
+
type: 'spec_gen_stream', projectId, requestId,
|
|
410
|
+
delta: newText, timestamp: new Date().toISOString(),
|
|
411
|
+
};
|
|
412
|
+
broadcast(wsMsg);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
child.on('close', async (code) => {
|
|
417
|
+
clearSpecGenWatchdog();
|
|
418
|
+
let createdTicketId = null;
|
|
419
|
+
// When claude burns its whole --max-turns budget it exits non-zero with
|
|
420
|
+
// a result event of subtype:error_max_turns — but it may already have
|
|
421
|
+
// emitted a complete spec. Salvage that usable output instead of failing
|
|
422
|
+
// the whole request on an exit code.
|
|
423
|
+
const resultSubtype = lastResultEvent?.subtype ?? null;
|
|
424
|
+
const hasUsableSpec = buffer.trim().length > 0 && /##\s*Spec Title/i.test(buffer);
|
|
425
|
+
const salvageMaxTurns = code !== 0 && resultSubtype === 'error_max_turns' && hasUsableSpec;
|
|
426
|
+
if ((code === 0 && buffer.trim()) || salvageMaxTurns) {
|
|
427
|
+
if (salvageMaxTurns) {
|
|
428
|
+
console.warn(`[project-router] spec-gen salvaged partial output after error_max_turns (${requestId}); ` +
|
|
429
|
+
`consider raising --max-turns if this recurs`);
|
|
430
|
+
}
|
|
431
|
+
// Extract title from generated spec
|
|
432
|
+
const titleMatch = buffer.match(/##\s*Spec Title\s*\n+(.+)/);
|
|
433
|
+
const specTitle = titleMatch ? titleMatch[1].trim() : idea.trim().slice(0, 80);
|
|
434
|
+
// Extract complexity for priority mapping
|
|
435
|
+
const complexityMatch = buffer.match(/##\s*Estimated Complexity\s*\n+(\w+)/);
|
|
436
|
+
const complexity = complexityMatch ? complexityMatch[1].toLowerCase() : 'medium';
|
|
437
|
+
const priority = complexity === 'low' ? 'low' : complexity === 'high' || complexity === 'very' ? 'high' : 'medium';
|
|
438
|
+
// Extract labels from the `## Labels` section. Comma- or
|
|
439
|
+
// newline-separated tags, normalised to lowercase kebab-case.
|
|
440
|
+
// `spec-proposal` is always retained as the marker label.
|
|
441
|
+
const labelsMatch = buffer.match(/##\s*Labels\s*\n+([^\n]+(?:\n(?!##)[^\n]+)*)/);
|
|
442
|
+
const claudeLabels = labelsMatch
|
|
443
|
+
? labelsMatch[1]
|
|
444
|
+
.replace(/[\[\]]/g, '')
|
|
445
|
+
.split(/[,\n]/)
|
|
446
|
+
.map((s) => s.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''))
|
|
447
|
+
.filter((s) => s.length > 0 && s.length <= 32)
|
|
448
|
+
.slice(0, 6)
|
|
449
|
+
: [];
|
|
450
|
+
const finalLabels = Array.from(new Set(['spec-proposal', ...claudeLabels]));
|
|
451
|
+
const shortSummary = (0, ticket_store_1.clampShortSummary)((0, project_router_helpers_1.extractShortSummary)(buffer));
|
|
452
|
+
const description = (0, project_router_helpers_1.stripSpecMetadataSections)(buffer);
|
|
453
|
+
// Create ticket directly
|
|
454
|
+
try {
|
|
455
|
+
const now = new Date().toISOString();
|
|
456
|
+
let created;
|
|
457
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
458
|
+
const id = s.next_id++;
|
|
459
|
+
const ticket = {
|
|
460
|
+
id,
|
|
461
|
+
title: specTitle,
|
|
462
|
+
description,
|
|
463
|
+
status: 'todo',
|
|
464
|
+
priority: priority,
|
|
465
|
+
labels: finalLabels,
|
|
466
|
+
assignee: null,
|
|
467
|
+
prerequisites: [],
|
|
468
|
+
metadata: {},
|
|
469
|
+
comments: [],
|
|
470
|
+
origin_conversation_id: null,
|
|
471
|
+
is_epic: false,
|
|
472
|
+
parent_epic_id: null,
|
|
473
|
+
execution_order: null,
|
|
474
|
+
short_summary: shortSummary,
|
|
475
|
+
created_at: now,
|
|
476
|
+
updated_at: now,
|
|
477
|
+
created_by: 'sr-product-engineer',
|
|
478
|
+
source: 'propose-spec',
|
|
479
|
+
};
|
|
480
|
+
s.tickets[String(id)] = ticket;
|
|
481
|
+
created = ticket;
|
|
482
|
+
});
|
|
483
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
484
|
+
if (created)
|
|
485
|
+
createdTicketId = created.id;
|
|
486
|
+
// Migrate attachments from pendingSpecId → real ticket id (if any were uploaded).
|
|
487
|
+
// Must complete BEFORE broadcasting ticket_created so WS listeners see the populated attachments[].
|
|
488
|
+
if (pendingSpecId && created) {
|
|
489
|
+
try {
|
|
490
|
+
const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
|
|
491
|
+
slug: project.slug,
|
|
492
|
+
pendingId: pendingSpecId,
|
|
493
|
+
realTicketId: created.id,
|
|
494
|
+
projectPath: project.path,
|
|
495
|
+
});
|
|
496
|
+
if (migrated.length > 0) {
|
|
497
|
+
created.attachments = migrated;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
console.error('[project-router] generate-spec attachment migration error:', err);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const ticketMsg = {
|
|
505
|
+
type: 'ticket_created', ticket: created,
|
|
506
|
+
projectId, timestamp: new Date().toISOString(),
|
|
507
|
+
};
|
|
508
|
+
broadcast(ticketMsg);
|
|
509
|
+
const doneMsg = {
|
|
510
|
+
type: 'spec_gen_done', projectId, requestId,
|
|
511
|
+
ticket: created, timestamp: new Date().toISOString(),
|
|
512
|
+
};
|
|
513
|
+
broadcast(doneMsg);
|
|
514
|
+
// Add Spec → Jira: promote the new ticket to a Jira issue when the
|
|
515
|
+
// project is Jira-backed (unless the user opted to keep it local).
|
|
516
|
+
void maybePromoteSpecToJira(ctx(req), created.id, req.body?.createLocal === true, broadcast);
|
|
517
|
+
// Quick mode Contract Refine: when toggle is on in the request body
|
|
518
|
+
// AND the project setting + kill switch permit it, fire the no-resume
|
|
519
|
+
// Quick refine path asynchronously. Claude-only today — codex
|
|
520
|
+
// contract refine isn't wired (the spawn hardcodes the `claude`
|
|
521
|
+
// binary). Skip silently on codex projects so the ticket lands
|
|
522
|
+
// without the misleading "Contract layer skipped — model_error"
|
|
523
|
+
// toast that the refine kill-switch would otherwise emit.
|
|
524
|
+
if (quickContractRefine && created && provider === 'claude') {
|
|
525
|
+
const refineTicketId = created.id;
|
|
526
|
+
const refineTitle = created.title;
|
|
527
|
+
const refineDescription = created.description;
|
|
528
|
+
const refineModel = req.body?.model ?? null;
|
|
529
|
+
process.nextTick(() => {
|
|
530
|
+
void (0, contract_refine_runner_1.runContractRefineForQuick)({
|
|
531
|
+
db: ctx(req).db,
|
|
532
|
+
projectId: project.id,
|
|
533
|
+
projectSlug: project.slug,
|
|
534
|
+
projectPath: project.path,
|
|
535
|
+
projectName: project.name,
|
|
536
|
+
broadcast: broadcast,
|
|
537
|
+
}, refineTicketId, refineTitle, refineDescription, refineModel).catch((err) => {
|
|
538
|
+
console.error('[project-router] runContractRefineForQuick error:', err);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
else if (quickContractRefine && created && provider === 'codex') {
|
|
543
|
+
console.log(`[project-router] quick contract refine skipped for codex project (ticket #${created.id}); ` +
|
|
544
|
+
`feature is claude-only today`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
console.error('[project-router] generate-spec ticket creation error:', err);
|
|
549
|
+
const errMsg = {
|
|
550
|
+
type: 'spec_gen_error', projectId, requestId,
|
|
551
|
+
error: 'Failed to create ticket', timestamp: new Date().toISOString(),
|
|
552
|
+
};
|
|
553
|
+
broadcast(errMsg);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
const reason = code === 0
|
|
558
|
+
? 'Empty response from AI'
|
|
559
|
+
: resultSubtype === 'error_max_turns'
|
|
560
|
+
? 'AI hit its turn limit before finishing the spec. Try again, or narrow the idea / turn off full-codebase context.'
|
|
561
|
+
: `Process exited with code ${code}`;
|
|
562
|
+
console.error(`[project-router] spec-gen failed (${requestId}): ${reason}` +
|
|
563
|
+
(stderrBuf.trim() ? `\n stderr: ${stderrBuf.trim()}` : '') +
|
|
564
|
+
(buffer.trim() ? `\n stdout-buffer: ${buffer.trim().slice(0, 500)}` : ''));
|
|
565
|
+
const msg = {
|
|
566
|
+
type: 'spec_gen_error', projectId, requestId,
|
|
567
|
+
error: reason,
|
|
568
|
+
timestamp: new Date().toISOString(),
|
|
569
|
+
};
|
|
570
|
+
broadcast(msg);
|
|
571
|
+
}
|
|
572
|
+
// ai_invocations capture (surface='quick-spec'). Always emit a row, success or fail.
|
|
573
|
+
try {
|
|
574
|
+
// Adapter-driven finalisation: claude passes its native total_cost_usd
|
|
575
|
+
// through untouched; codex (nativeCostUsd:false) gets a pricing-table
|
|
576
|
+
// estimate from its captured token usage + estimated=true. This
|
|
577
|
+
// replaces the legacy normaliseResultEvent path that hardcoded codex
|
|
578
|
+
// cost to $0 and never set the estimated flag.
|
|
579
|
+
const { result: normalised, estimated } = (0, result_event_1.finaliseInvocationResult)(adapter, adapterEvents, { fallbackModel: resolvedModel });
|
|
580
|
+
// extractCodexResult does not surface duration (codex stream carries
|
|
581
|
+
// none); stamp wall-clock so the row's duration isn't lost. Claude's
|
|
582
|
+
// result event already provides duration_ms, so prefer it.
|
|
583
|
+
const wallMs = Date.now() - new Date(turnStartedAt).getTime();
|
|
584
|
+
(0, ai_invocations_1.recordInvocation)(ctx(req).db, {
|
|
585
|
+
id: (0, crypto_1.randomUUID)(),
|
|
586
|
+
project_id: projectId,
|
|
587
|
+
provider: adapter.id,
|
|
588
|
+
surface: 'quick-spec',
|
|
589
|
+
surface_ref_id: requestId,
|
|
590
|
+
ticket_id: createdTicketId,
|
|
591
|
+
status: code === 0 && buffer.trim() ? 'success' : 'failed',
|
|
592
|
+
started_at: turnStartedAt,
|
|
593
|
+
finished_at: new Date().toISOString(),
|
|
594
|
+
total_cost_usd_estimated: estimated,
|
|
595
|
+
...normalised,
|
|
596
|
+
duration_ms: normalised.duration_ms ?? wallMs,
|
|
597
|
+
});
|
|
598
|
+
broadcast({ type: 'spending.invalidated', projectId });
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
console.error('[project-router] generate-spec recordInvocation failed:', err);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
// POST /:projectId/tickets/save-as-draft — Persist an in-progress Explore session as a draft ticket
|
|
606
|
+
router.post('/:projectId/tickets/save-as-draft', (req, res) => {
|
|
607
|
+
const body = req.body ?? {};
|
|
608
|
+
const conversationId = typeof body.conversationId === 'string' ? body.conversationId.trim() : '';
|
|
609
|
+
if (!conversationId) {
|
|
610
|
+
res.status(400).json({ error: 'conversationId is required' });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const providedTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
|
614
|
+
const labels = Array.isArray(body.labels)
|
|
615
|
+
? body.labels.filter((l) => typeof l === 'string')
|
|
616
|
+
: [];
|
|
617
|
+
const description = typeof body.description === 'string' ? body.description : '';
|
|
618
|
+
// Optional editTicketId — when present, demote that specific ticket in
|
|
619
|
+
// place instead of looking up by conversationId. Drives the
|
|
620
|
+
// Continue-Editing-on-non-draft flow.
|
|
621
|
+
let editTicketId;
|
|
622
|
+
if (body.editTicketId !== undefined && body.editTicketId !== null) {
|
|
623
|
+
if (typeof body.editTicketId !== 'number' || !Number.isFinite(body.editTicketId)) {
|
|
624
|
+
res.status(400).json({ error: 'editTicketId must be a number' });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
editTicketId = body.editTicketId;
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const { db, project, broadcast, ticketWatcher } = ctx(req);
|
|
631
|
+
// Require at least one user-submitted turn before accepting a save
|
|
632
|
+
const messages = (0, db_1.getMessages)(db, conversationId);
|
|
633
|
+
const hasUserTurn = messages.some((m) => m.role === 'user' && (m.content ?? '').trim().length > 0);
|
|
634
|
+
if (!hasUserTurn) {
|
|
635
|
+
res.status(400).json({ error: 'conversation has no user-submitted turn yet' });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const filePath = ticketPath(req);
|
|
639
|
+
const now = new Date().toISOString();
|
|
640
|
+
let saved;
|
|
641
|
+
let flippedInPlace = false;
|
|
642
|
+
let notFound = false;
|
|
643
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
644
|
+
if (editTicketId !== undefined) {
|
|
645
|
+
const target = s.tickets[String(editTicketId)];
|
|
646
|
+
if (!target) {
|
|
647
|
+
notFound = true;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const title = providedTitle || target.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
|
|
651
|
+
target.title = title;
|
|
652
|
+
if (description)
|
|
653
|
+
target.description = description;
|
|
654
|
+
if (labels.length > 0)
|
|
655
|
+
target.labels = labels;
|
|
656
|
+
target.status = 'draft';
|
|
657
|
+
target.priority = null;
|
|
658
|
+
target.origin_conversation_id = conversationId;
|
|
659
|
+
target.updated_at = now;
|
|
660
|
+
saved = target;
|
|
661
|
+
flippedInPlace = true;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Idempotent on conversationId: if a draft ticket already references this
|
|
665
|
+
// conversation, update in place rather than create a second one.
|
|
666
|
+
const existing = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
|
|
667
|
+
const title = providedTitle || existing?.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
|
|
668
|
+
if (existing) {
|
|
669
|
+
existing.title = title;
|
|
670
|
+
if (description)
|
|
671
|
+
existing.description = description;
|
|
672
|
+
if (labels.length > 0)
|
|
673
|
+
existing.labels = labels;
|
|
674
|
+
existing.updated_at = now;
|
|
675
|
+
saved = existing;
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const id = s.next_id++;
|
|
679
|
+
const ticket = {
|
|
680
|
+
id,
|
|
681
|
+
title,
|
|
682
|
+
description,
|
|
683
|
+
status: 'draft',
|
|
684
|
+
priority: null,
|
|
685
|
+
labels,
|
|
686
|
+
assignee: null,
|
|
687
|
+
prerequisites: [],
|
|
688
|
+
metadata: {},
|
|
689
|
+
comments: [],
|
|
690
|
+
origin_conversation_id: conversationId,
|
|
691
|
+
is_epic: false,
|
|
692
|
+
parent_epic_id: null,
|
|
693
|
+
execution_order: null,
|
|
694
|
+
short_summary: null,
|
|
695
|
+
created_at: now,
|
|
696
|
+
updated_at: now,
|
|
697
|
+
created_by: 'sr-explore-spec',
|
|
698
|
+
source: 'explore-draft',
|
|
699
|
+
};
|
|
700
|
+
s.tickets[String(id)] = ticket;
|
|
701
|
+
saved = ticket;
|
|
702
|
+
});
|
|
703
|
+
if (notFound) {
|
|
704
|
+
res.status(404).json({ error: 'ticket not found' });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
708
|
+
if (flippedInPlace) {
|
|
709
|
+
const msg = {
|
|
710
|
+
type: 'ticket_updated',
|
|
711
|
+
ticket: saved,
|
|
712
|
+
projectId: project.id,
|
|
713
|
+
timestamp: now,
|
|
714
|
+
};
|
|
715
|
+
broadcast(msg);
|
|
716
|
+
res.status(200).json({ ticket: saved, revision: store.revision });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const msg = saved.created_at === saved.updated_at
|
|
720
|
+
? { type: 'ticket_created', ticket: saved, projectId: project.id, timestamp: now }
|
|
721
|
+
: { type: 'ticket_updated', ticket: saved, projectId: project.id, timestamp: now };
|
|
722
|
+
broadcast(msg);
|
|
723
|
+
res.status(201).json({ ticket: saved, revision: store.revision });
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
console.error('[project-router] save-as-draft error:', err);
|
|
727
|
+
res.status(500).json({ error: 'Failed to save draft' });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// POST /:projectId/tickets/from-draft — Commit an Explore Spec draft as a real ticket
|
|
731
|
+
// Two paths:
|
|
732
|
+
// (1) Legacy: payload has no `draftTicketId` → create a brand-new ticket (status='todo').
|
|
733
|
+
// (2) Flip in place: payload has `draftTicketId` referencing an existing
|
|
734
|
+
// status='draft' ticket → update that ticket in place to status='todo',
|
|
735
|
+
// set priority, replace title/description, preserve origin_conversation_id.
|
|
736
|
+
router.post('/:projectId/tickets/from-draft', async (req, res) => {
|
|
737
|
+
const body = req.body ?? {};
|
|
738
|
+
const rawTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
|
739
|
+
if (!rawTitle) {
|
|
740
|
+
res.status(400).json({ error: 'title is required' });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const draftTicketId = typeof body.draftTicketId === 'number' ? body.draftTicketId : null;
|
|
744
|
+
const pendingSpecId = typeof body.pendingSpecId === 'string' ? body.pendingSpecId : null;
|
|
745
|
+
const conversationId = typeof body.conversationId === 'string' ? body.conversationId : null;
|
|
746
|
+
const baseDescription = typeof body.description === 'string' ? body.description.trim() : '';
|
|
747
|
+
const labels = Array.isArray(body.labels)
|
|
748
|
+
? body.labels.filter((l) => typeof l === 'string')
|
|
749
|
+
: [];
|
|
750
|
+
const acceptanceCriteria = Array.isArray(body.acceptanceCriteria)
|
|
751
|
+
? body.acceptanceCriteria
|
|
752
|
+
.filter((c) => typeof c === 'string')
|
|
753
|
+
.map((c) => c.trim())
|
|
754
|
+
.filter((c) => c.length > 0)
|
|
755
|
+
: [];
|
|
756
|
+
const priority = (0, ticket_store_1.isValidPriority)(body.priority) ? body.priority : 'medium';
|
|
757
|
+
// Compose the final ticket body. The title is already its own ticket
|
|
758
|
+
// field, so we deliberately do NOT echo it as a `## Spec Title` heading
|
|
759
|
+
// inside the description. The body is just the structured sections from
|
|
760
|
+
// Claude (Problem Statement / Proposed Solution / Out of Scope /
|
|
761
|
+
// Technical Considerations / Estimated Complexity) followed by the
|
|
762
|
+
// Acceptance Criteria bullets.
|
|
763
|
+
const description = (0, project_router_helpers_1.formatDescriptionWithCriteria)(baseDescription, acceptanceCriteria);
|
|
764
|
+
// Short summary: explicit body field wins; otherwise try extracting a
|
|
765
|
+
// `## Short Summary` section from the description and stripping it.
|
|
766
|
+
let bodyShortSummary = null;
|
|
767
|
+
let descriptionForStore = description;
|
|
768
|
+
if (typeof body.shortSummary === 'string') {
|
|
769
|
+
bodyShortSummary = (0, ticket_store_1.clampShortSummary)(body.shortSummary);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
const extracted = (0, project_router_helpers_1.extractShortSummary)(description);
|
|
773
|
+
if (extracted !== null) {
|
|
774
|
+
bodyShortSummary = (0, ticket_store_1.clampShortSummary)(extracted);
|
|
775
|
+
descriptionForStore = description
|
|
776
|
+
.replace(/##\s*Short Summary\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
|
|
777
|
+
.trim();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (bodyShortSummary === null) {
|
|
781
|
+
bodyShortSummary = (0, project_router_helpers_1.deriveFallbackShortSummary)(rawTitle, descriptionForStore);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const filePath = ticketPath(req);
|
|
785
|
+
const now = new Date().toISOString();
|
|
786
|
+
let created;
|
|
787
|
+
let wasFlip = false;
|
|
788
|
+
let explicitDraftMissing = false;
|
|
789
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
790
|
+
// Resolve flip target: explicit `draftTicketId` wins; otherwise look up
|
|
791
|
+
// an existing draft ticket whose origin_conversation_id matches the
|
|
792
|
+
// current conversation so a resumed session commits in place even when
|
|
793
|
+
// the client doesn't track the draft id explicitly.
|
|
794
|
+
let flipTarget;
|
|
795
|
+
if (draftTicketId !== null) {
|
|
796
|
+
flipTarget = s.tickets[String(draftTicketId)];
|
|
797
|
+
if (!flipTarget || flipTarget.status !== 'draft') {
|
|
798
|
+
explicitDraftMissing = true;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
else if (conversationId) {
|
|
803
|
+
flipTarget = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
|
|
804
|
+
}
|
|
805
|
+
if (flipTarget) {
|
|
806
|
+
flipTarget.status = 'todo';
|
|
807
|
+
flipTarget.priority = priority;
|
|
808
|
+
flipTarget.title = rawTitle;
|
|
809
|
+
flipTarget.description = descriptionForStore;
|
|
810
|
+
if (labels.length > 0)
|
|
811
|
+
flipTarget.labels = labels;
|
|
812
|
+
flipTarget.updated_at = now;
|
|
813
|
+
// Preserve prior short_summary on flip when the model/body omits one;
|
|
814
|
+
// overwrite only when a non-null value is provided.
|
|
815
|
+
if (bodyShortSummary !== null) {
|
|
816
|
+
flipTarget.short_summary = bodyShortSummary;
|
|
817
|
+
}
|
|
818
|
+
// origin_conversation_id is intentionally preserved
|
|
819
|
+
created = flipTarget;
|
|
820
|
+
wasFlip = true;
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// B62: from-draft is idempotent only while the ticket is still a draft.
|
|
824
|
+
// After a successful commit the draft is 'todo', so the draft lookup above
|
|
825
|
+
// no longer matches and a second from-draft for the same conversation
|
|
826
|
+
// would insert a DUPLICATE ticket. If a (now non-draft) ticket already
|
|
827
|
+
// originates from this conversation, return it instead of re-inserting.
|
|
828
|
+
if (conversationId) {
|
|
829
|
+
const alreadyCommitted = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId);
|
|
830
|
+
if (alreadyCommitted) {
|
|
831
|
+
created = alreadyCommitted;
|
|
832
|
+
wasFlip = true; // treat as in-place: broadcast ticket_updated, not created
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Legacy: insert new ticket
|
|
837
|
+
const id = s.next_id++;
|
|
838
|
+
const ticket = {
|
|
839
|
+
id,
|
|
840
|
+
title: rawTitle,
|
|
841
|
+
description: descriptionForStore,
|
|
842
|
+
status: 'todo',
|
|
843
|
+
priority,
|
|
844
|
+
labels,
|
|
845
|
+
assignee: null,
|
|
846
|
+
prerequisites: [],
|
|
847
|
+
metadata: {},
|
|
848
|
+
comments: [],
|
|
849
|
+
origin_conversation_id: conversationId,
|
|
850
|
+
is_epic: false,
|
|
851
|
+
parent_epic_id: null,
|
|
852
|
+
execution_order: null,
|
|
853
|
+
short_summary: bodyShortSummary,
|
|
854
|
+
created_at: now,
|
|
855
|
+
updated_at: now,
|
|
856
|
+
created_by: 'sr-explore-spec',
|
|
857
|
+
source: 'propose-spec',
|
|
858
|
+
};
|
|
859
|
+
s.tickets[String(id)] = ticket;
|
|
860
|
+
created = ticket;
|
|
861
|
+
});
|
|
862
|
+
if (explicitDraftMissing) {
|
|
863
|
+
res.status(404).json({ error: 'Draft ticket not found or not in draft status' });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const { broadcast, ticketWatcher, project } = ctx(req);
|
|
867
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
868
|
+
// Migrate attachments from pendingSpecId → real ticket id (mirrors the
|
|
869
|
+
// generate-spec flow). Must complete before broadcasting ticket_created
|
|
870
|
+
// so listeners see the populated attachments[].
|
|
871
|
+
if (pendingSpecId && created) {
|
|
872
|
+
try {
|
|
873
|
+
const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
|
|
874
|
+
slug: project.slug,
|
|
875
|
+
pendingId: pendingSpecId,
|
|
876
|
+
realTicketId: created.id,
|
|
877
|
+
projectPath: project.path,
|
|
878
|
+
});
|
|
879
|
+
if (migrated.length > 0) {
|
|
880
|
+
created.attachments = migrated;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
console.error('[project-router] from-draft attachment migration error:', err);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const msg = wasFlip
|
|
888
|
+
? {
|
|
889
|
+
type: 'ticket_updated',
|
|
890
|
+
ticket: created,
|
|
891
|
+
projectId: project.id,
|
|
892
|
+
timestamp: new Date().toISOString(),
|
|
893
|
+
}
|
|
894
|
+
: {
|
|
895
|
+
type: 'ticket_created',
|
|
896
|
+
ticket: created,
|
|
897
|
+
projectId: project.id,
|
|
898
|
+
timestamp: new Date().toISOString(),
|
|
899
|
+
};
|
|
900
|
+
broadcast(msg);
|
|
901
|
+
// Back-fill ticket_id on the conversation's prior ai_invocations rows.
|
|
902
|
+
if (conversationId && created) {
|
|
903
|
+
try {
|
|
904
|
+
const changes = (0, ai_invocations_1.updateTicketIdForConversation)(ctx(req).db, conversationId, created.id);
|
|
905
|
+
if (changes > 0) {
|
|
906
|
+
broadcast({ type: 'spending.invalidated', projectId: project.id });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
console.error('[project-router] from-draft ai_invocations back-fill failed:', err);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
res.status(201).json({ ticket: created, revision: store.revision });
|
|
914
|
+
// Add Spec (Explore) → Jira: promote the committed ticket to a Jira issue
|
|
915
|
+
// when the project is Jira-backed (unless the user opted to keep it local).
|
|
916
|
+
void maybePromoteSpecToJira(ctx(req), created.id, body.createLocal === true, broadcast);
|
|
917
|
+
// Fire Contract Refine post-commit (fire-and-forget). Toggle + kill-switch
|
|
918
|
+
// are checked inside runContractRefine. Claude-only today — codex
|
|
919
|
+
// contract refine isn't wired (the spawn hardcodes the `claude`
|
|
920
|
+
// binary). Skip silently on codex projects.
|
|
921
|
+
if (conversationId && created && project.provider === 'claude') {
|
|
922
|
+
const createdTicketId = created.id;
|
|
923
|
+
const convoId = conversationId;
|
|
924
|
+
console.log(`[project-router] from-draft hook: scheduling refine ticket=${createdTicketId} conv=${convoId}`);
|
|
925
|
+
process.nextTick(() => {
|
|
926
|
+
void (0, contract_refine_runner_1.runContractRefine)({
|
|
927
|
+
db: ctx(req).db,
|
|
928
|
+
projectId: project.id,
|
|
929
|
+
projectSlug: project.slug,
|
|
930
|
+
projectPath: project.path,
|
|
931
|
+
projectName: project.name,
|
|
932
|
+
broadcast: broadcast,
|
|
933
|
+
}, convoId, createdTicketId).catch((err) => {
|
|
934
|
+
console.error('[project-router] runContractRefine error:', err);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
else if (conversationId && created && project.provider === 'codex') {
|
|
939
|
+
console.log(`[project-router] from-draft contract refine skipped for codex project (ticket #${created.id})`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
console.error('[project-router] from-draft create error:', err);
|
|
944
|
+
res.status(500).json({ error: 'Failed to create ticket' });
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
// POST /:projectId/tickets/from-prompt — Create a spec directly from a
|
|
948
|
+
// free-form prompt (the "Raw" Add-Spec mode). NO AI is invoked: the user's
|
|
949
|
+
// text becomes the ticket description verbatim. The ticket lands as
|
|
950
|
+
// status='todo' (ready for rails) with source='free-prompt'. There is no
|
|
951
|
+
// ai_invocations row (nothing was billed) and no contract-refine (no origin
|
|
952
|
+
// conversation, no description format to refine).
|
|
953
|
+
router.post('/:projectId/tickets/from-prompt', async (req, res) => {
|
|
954
|
+
const body = req.body ?? {};
|
|
955
|
+
const rawDescription = typeof body.description === 'string' ? body.description.trim() : '';
|
|
956
|
+
if (!rawDescription) {
|
|
957
|
+
res.status(400).json({ error: 'description is required' });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
// Optional light-structuring (v1: the client always sends `false`; the flag
|
|
961
|
+
// keeps the contract stable for a future non-generative structuring pass).
|
|
962
|
+
const structured = body.structured === true;
|
|
963
|
+
const description = structured ? (0, project_router_helpers_1.lightlyStructurePrompt)(rawDescription) : rawDescription;
|
|
964
|
+
// Title: explicit value wins; otherwise derive a single-line summary from
|
|
965
|
+
// the body (reusing the deterministic Explore-draft summarizer).
|
|
966
|
+
const providedTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
|
967
|
+
const title = providedTitle || (0, explore_draft_title_1.generateAutoTitle)([{ role: 'user', content: rawDescription }]);
|
|
968
|
+
const labels = Array.isArray(body.labels)
|
|
969
|
+
? body.labels.filter((l) => typeof l === 'string')
|
|
970
|
+
: [];
|
|
971
|
+
// Priority: validate against the allowed set; default 'medium'. A
|
|
972
|
+
// status='todo' ticket MUST carry a non-null priority (see
|
|
973
|
+
// validatePriorityForStatus), so we never accept null here.
|
|
974
|
+
const priority = (0, ticket_store_1.isValidPriority)(body.priority) ? body.priority : 'medium';
|
|
975
|
+
const validationError = (0, ticket_store_1.validatePriorityForStatus)('todo', priority);
|
|
976
|
+
if (validationError) {
|
|
977
|
+
res.status(400).json({ error: validationError });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const pendingSpecId = typeof body.pendingSpecId === 'string' ? body.pendingSpecId : null;
|
|
981
|
+
const shortSummary = (0, project_router_helpers_1.deriveFallbackShortSummary)(title, description);
|
|
982
|
+
try {
|
|
983
|
+
const filePath = ticketPath(req);
|
|
984
|
+
const now = new Date().toISOString();
|
|
985
|
+
let created;
|
|
986
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
987
|
+
const id = s.next_id++;
|
|
988
|
+
const ticket = {
|
|
989
|
+
id,
|
|
990
|
+
title,
|
|
991
|
+
description,
|
|
992
|
+
status: 'todo',
|
|
993
|
+
priority,
|
|
994
|
+
labels,
|
|
995
|
+
assignee: null,
|
|
996
|
+
prerequisites: [],
|
|
997
|
+
metadata: {},
|
|
998
|
+
comments: [],
|
|
999
|
+
origin_conversation_id: null,
|
|
1000
|
+
is_epic: false,
|
|
1001
|
+
parent_epic_id: null,
|
|
1002
|
+
execution_order: null,
|
|
1003
|
+
short_summary: shortSummary,
|
|
1004
|
+
created_at: now,
|
|
1005
|
+
updated_at: now,
|
|
1006
|
+
created_by: 'hub', // legacy on-disk wire value (tickets.json, shared with specrails-core) — do not rename
|
|
1007
|
+
source: 'free-prompt',
|
|
1008
|
+
};
|
|
1009
|
+
s.tickets[String(id)] = ticket;
|
|
1010
|
+
created = ticket;
|
|
1011
|
+
});
|
|
1012
|
+
const { broadcast, ticketWatcher, project } = ctx(req);
|
|
1013
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
1014
|
+
// Migrate attachments from pendingSpecId → real ticket id (mirrors the
|
|
1015
|
+
// generate-spec / from-draft flow). Must complete before broadcasting so
|
|
1016
|
+
// listeners see the populated attachments[].
|
|
1017
|
+
if (pendingSpecId && created) {
|
|
1018
|
+
try {
|
|
1019
|
+
const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
|
|
1020
|
+
slug: project.slug,
|
|
1021
|
+
pendingId: pendingSpecId,
|
|
1022
|
+
realTicketId: created.id,
|
|
1023
|
+
projectPath: project.path,
|
|
1024
|
+
});
|
|
1025
|
+
if (migrated.length > 0) {
|
|
1026
|
+
created.attachments = migrated;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
console.error('[project-router] from-prompt attachment migration error:', err);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const msg = {
|
|
1034
|
+
type: 'ticket_created',
|
|
1035
|
+
ticket: created,
|
|
1036
|
+
projectId: project.id,
|
|
1037
|
+
timestamp: new Date().toISOString(),
|
|
1038
|
+
};
|
|
1039
|
+
broadcast(msg);
|
|
1040
|
+
res.status(201).json({ ticket: created, revision: store.revision });
|
|
1041
|
+
}
|
|
1042
|
+
catch (err) {
|
|
1043
|
+
console.error('[project-router] from-prompt create error:', err);
|
|
1044
|
+
res.status(500).json({ error: 'Failed to create ticket' });
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
// POST /:projectId/tickets/:id/contract-refine — Manually re-fire refine
|
|
1048
|
+
router.post('/:projectId/tickets/:id/contract-refine', async (req, res) => {
|
|
1049
|
+
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
1050
|
+
if (!Number.isFinite(ticketId)) {
|
|
1051
|
+
res.status(400).json({ error: 'invalid ticket id' });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const { project, db, broadcast } = ctx(req);
|
|
1055
|
+
if ((0, explore_contract_refine_1.isExploreContractRefineKillSwitchActive)()) {
|
|
1056
|
+
res.status(409).json({ error: 'feature_disabled_by_env' });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (project.provider === 'codex') {
|
|
1060
|
+
res.status(409).json({ error: 'contract_refine_unsupported_for_codex' });
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
// Validate the ticket exists.
|
|
1064
|
+
try {
|
|
1065
|
+
const filePath = ticketPath(req);
|
|
1066
|
+
const { withLock } = await Promise.resolve().then(() => __importStar(require('./ticket-store')));
|
|
1067
|
+
const ticket = withLock(filePath, (s) => s.tickets[String(ticketId)]);
|
|
1068
|
+
if (!ticket) {
|
|
1069
|
+
res.status(404).json({ error: 'ticket not found' });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (!ticket.origin_conversation_id) {
|
|
1073
|
+
res.status(409).json({ error: 'ticket has no origin conversation' });
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const convoId = ticket.origin_conversation_id;
|
|
1077
|
+
res.status(202).json({ scheduled: true });
|
|
1078
|
+
process.nextTick(() => {
|
|
1079
|
+
void (0, contract_refine_runner_1.runContractRefine)({
|
|
1080
|
+
db,
|
|
1081
|
+
projectId: project.id,
|
|
1082
|
+
projectSlug: project.slug,
|
|
1083
|
+
projectPath: project.path,
|
|
1084
|
+
projectName: project.name,
|
|
1085
|
+
broadcast: broadcast,
|
|
1086
|
+
ignoreConversationScope: true,
|
|
1087
|
+
}, convoId, ticketId).catch((err) => {
|
|
1088
|
+
console.error('[project-router] retry runContractRefine error:', err);
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
console.error('[project-router] retry endpoint error:', err);
|
|
1094
|
+
res.status(500).json({ error: 'Failed to schedule retry' });
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
// POST /:projectId/tickets/:id/smash — Decompose ticket into N children
|
|
1098
|
+
router.post('/:projectId/tickets/:id/smash', async (req, res) => {
|
|
1099
|
+
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
1100
|
+
if (!Number.isFinite(ticketId)) {
|
|
1101
|
+
res.status(400).json({ error: 'invalid ticket id' });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
|
|
1105
|
+
res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const { project, db, broadcast } = ctx(req);
|
|
1109
|
+
try {
|
|
1110
|
+
const filePath = ticketPath(req);
|
|
1111
|
+
const { readStore } = await Promise.resolve().then(() => __importStar(require('./ticket-store')));
|
|
1112
|
+
const store = readStore(filePath);
|
|
1113
|
+
const gate = (0, smash_runner_1.checkSmashEligibility)(store, ticketId);
|
|
1114
|
+
if (!gate.ok) {
|
|
1115
|
+
const statusCode = gate.reason === 'ticket-not-found' ? 404 : 409;
|
|
1116
|
+
res.status(statusCode).json({ error: 'ineligible', reason: gate.reason });
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const rawMode = typeof req.body?.mode === 'string' ? req.body.mode : 'simple';
|
|
1120
|
+
const mode = rawMode === 'full' ? 'full' : 'simple';
|
|
1121
|
+
const model = typeof req.body?.model === 'string' && req.body.model.length > 0 ? req.body.model : null;
|
|
1122
|
+
res.status(202).json({ scheduled: true, mode });
|
|
1123
|
+
process.nextTick(() => {
|
|
1124
|
+
void (0, smash_runner_1.runSmash)({
|
|
1125
|
+
db,
|
|
1126
|
+
projectId: project.id,
|
|
1127
|
+
projectSlug: project.slug,
|
|
1128
|
+
projectPath: project.path,
|
|
1129
|
+
projectName: project.name,
|
|
1130
|
+
broadcast: broadcast,
|
|
1131
|
+
mode,
|
|
1132
|
+
model,
|
|
1133
|
+
}, ticketId).catch((err) => {
|
|
1134
|
+
console.error('[project-router] runSmash error:', err);
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
console.error('[project-router] smash endpoint error:', err);
|
|
1140
|
+
res.status(500).json({ error: 'Failed to schedule SMASH' });
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
// POST /:projectId/tickets/:id/smash/undo — Reverse a prior SMASH
|
|
1144
|
+
router.post('/:projectId/tickets/:id/smash/undo', async (req, res) => {
|
|
1145
|
+
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
1146
|
+
if (!Number.isFinite(ticketId)) {
|
|
1147
|
+
res.status(400).json({ error: 'invalid ticket id' });
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
|
|
1151
|
+
res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const smashedAt = typeof req.body?.smashedAt === 'string' ? req.body.smashedAt : null;
|
|
1155
|
+
if (!smashedAt) {
|
|
1156
|
+
res.status(400).json({ error: 'smashedAt timestamp required' });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const { project, db, broadcast } = ctx(req);
|
|
1160
|
+
try {
|
|
1161
|
+
const result = await (0, smash_runner_1.runSmashUndo)({
|
|
1162
|
+
db,
|
|
1163
|
+
projectId: project.id,
|
|
1164
|
+
projectSlug: project.slug,
|
|
1165
|
+
projectPath: project.path,
|
|
1166
|
+
projectName: project.name,
|
|
1167
|
+
broadcast: broadcast,
|
|
1168
|
+
}, ticketId, smashedAt);
|
|
1169
|
+
if (!result.ok) {
|
|
1170
|
+
const statusCode = result.reason === 'ticket-not-found' ? 404 : 409;
|
|
1171
|
+
res.status(statusCode).json({ error: 'undo_failed', reason: result.reason });
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
res.json({ ok: true, deletedChildren: result.deletedChildren });
|
|
1175
|
+
}
|
|
1176
|
+
catch (err) {
|
|
1177
|
+
console.error('[project-router] smash/undo endpoint error:', err);
|
|
1178
|
+
res.status(500).json({ error: 'Failed to undo SMASH' });
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
// DELETE /:projectId/tickets/:id/children — Delete all children of an épica
|
|
1182
|
+
router.delete('/:projectId/tickets/:id/children', (req, res) => {
|
|
1183
|
+
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
1184
|
+
if (!Number.isFinite(ticketId)) {
|
|
1185
|
+
res.status(400).json({ error: 'invalid ticket id' });
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
|
|
1189
|
+
res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
const { project, broadcast, ticketWatcher } = ctx(req);
|
|
1193
|
+
try {
|
|
1194
|
+
const filePath = ticketPath(req);
|
|
1195
|
+
const result = (0, smash_runner_1.applyDeleteEpicChildren)(filePath, ticketId);
|
|
1196
|
+
// Pass the real post-write revision (not 0) so the chokidar echo is
|
|
1197
|
+
// suppressed; a hardcoded 0 never matches the on-disk revision and
|
|
1198
|
+
// triggers a spurious full-refresh broadcast to every client.
|
|
1199
|
+
ticketWatcher.notifyDesktopWrite(result.revision);
|
|
1200
|
+
const now = new Date().toISOString();
|
|
1201
|
+
for (const id of result.deletedChildren) {
|
|
1202
|
+
broadcast({
|
|
1203
|
+
type: 'ticket_deleted',
|
|
1204
|
+
ticketId: id,
|
|
1205
|
+
projectId: project.id,
|
|
1206
|
+
timestamp: now,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
res.json({ ok: true, deletedChildren: result.deletedChildren });
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
console.error('[project-router] delete-children error:', err);
|
|
1213
|
+
res.status(500).json({ error: 'Failed to delete children' });
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
// POST /:projectId/tickets — Create new ticket
|
|
1217
|
+
router.post('/:projectId/tickets', (req, res) => {
|
|
1218
|
+
const { title, description, status, priority, labels, assignee, prerequisites, metadata, source } = req.body ?? {};
|
|
1219
|
+
if (!title || typeof title !== 'string' || !title.trim()) {
|
|
1220
|
+
res.status(400).json({ error: 'title is required' });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (status !== undefined && !(0, ticket_store_1.isValidStatus)(status)) {
|
|
1224
|
+
res.status(400).json({ error: 'status must be one of: draft, todo, in_progress, done, cancelled' });
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const finalStatus = (status ?? 'todo');
|
|
1228
|
+
const finalPriority = priority === undefined ? (finalStatus === 'draft' ? null : 'medium') : (priority === null ? null : priority);
|
|
1229
|
+
const priorityError = (0, ticket_store_1.validatePriorityForStatus)(finalStatus, finalPriority);
|
|
1230
|
+
if (priorityError) {
|
|
1231
|
+
res.status(400).json({ error: priorityError });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
const filePath = ticketPath(req);
|
|
1236
|
+
const now = new Date().toISOString();
|
|
1237
|
+
let created;
|
|
1238
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
1239
|
+
const id = s.next_id++;
|
|
1240
|
+
const ticket = {
|
|
1241
|
+
id,
|
|
1242
|
+
title: title.trim(),
|
|
1243
|
+
description: typeof description === 'string' ? description : '',
|
|
1244
|
+
status: finalStatus,
|
|
1245
|
+
priority: finalPriority,
|
|
1246
|
+
labels: Array.isArray(labels) ? labels.filter((l) => typeof l === 'string') : [],
|
|
1247
|
+
assignee: typeof assignee === 'string' ? assignee : null,
|
|
1248
|
+
prerequisites: Array.isArray(prerequisites) ? prerequisites.filter((p) => typeof p === 'number') : [],
|
|
1249
|
+
metadata: typeof metadata === 'object' && metadata !== null ? metadata : {},
|
|
1250
|
+
comments: [],
|
|
1251
|
+
origin_conversation_id: null,
|
|
1252
|
+
is_epic: false,
|
|
1253
|
+
parent_epic_id: null,
|
|
1254
|
+
execution_order: null,
|
|
1255
|
+
short_summary: null,
|
|
1256
|
+
created_at: now,
|
|
1257
|
+
updated_at: now,
|
|
1258
|
+
created_by: 'hub', // legacy on-disk wire value (tickets.json, shared with specrails-core) — do not rename
|
|
1259
|
+
source: source === 'product-backlog' || source === 'propose-spec' || source === 'manual' ? source : 'hub', // legacy on-disk wire value — do not rename
|
|
1260
|
+
};
|
|
1261
|
+
s.tickets[String(id)] = ticket;
|
|
1262
|
+
created = ticket;
|
|
1263
|
+
});
|
|
1264
|
+
const { broadcast, ticketWatcher } = ctx(req);
|
|
1265
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
1266
|
+
const msg = { type: 'ticket_created', ticket: created, projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
|
|
1267
|
+
broadcast(msg);
|
|
1268
|
+
res.status(201).json({ ticket: created, revision: store.revision });
|
|
1269
|
+
}
|
|
1270
|
+
catch (err) {
|
|
1271
|
+
console.error('[project-router] ticket create error:', err);
|
|
1272
|
+
res.status(500).json({ error: 'Failed to create ticket' });
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
// PATCH /:projectId/tickets/:id — Update ticket fields
|
|
1276
|
+
router.patch('/:projectId/tickets/:id', (req, res) => {
|
|
1277
|
+
const ticketId = req.params.id;
|
|
1278
|
+
if (!/^\d+$/.test(ticketId)) {
|
|
1279
|
+
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const { title, description, status, priority, labels, assignee, prerequisites, metadata, acceptanceCriteria, short_summary } = req.body ?? {};
|
|
1283
|
+
if (status !== undefined && !(0, ticket_store_1.isValidStatus)(status)) {
|
|
1284
|
+
res.status(400).json({ error: 'status must be one of: draft, todo, in_progress, done, cancelled' });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
if (priority !== undefined && priority !== null && !(0, ticket_store_1.isValidPriority)(priority)) {
|
|
1288
|
+
res.status(400).json({ error: 'priority must be one of: critical, high, medium, low' });
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (title !== undefined && (typeof title !== 'string' || !title.trim())) {
|
|
1292
|
+
res.status(400).json({ error: 'title cannot be empty' });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (acceptanceCriteria !== undefined) {
|
|
1296
|
+
if (!Array.isArray(acceptanceCriteria) || !acceptanceCriteria.every((c) => typeof c === 'string')) {
|
|
1297
|
+
res.status(400).json({ error: 'acceptanceCriteria must be an array of strings' });
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
const filePath = ticketPath(req);
|
|
1303
|
+
let updated;
|
|
1304
|
+
let validationError = null;
|
|
1305
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
1306
|
+
const ticket = s.tickets[ticketId];
|
|
1307
|
+
if (!ticket)
|
|
1308
|
+
return;
|
|
1309
|
+
const nextStatus = (status ?? ticket.status);
|
|
1310
|
+
const nextPriority = priority === undefined ? ticket.priority : (priority === null ? null : priority);
|
|
1311
|
+
const err = (0, ticket_store_1.validatePriorityForStatus)(nextStatus, nextPriority);
|
|
1312
|
+
if (err) {
|
|
1313
|
+
validationError = err;
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (title !== undefined)
|
|
1317
|
+
ticket.title = title.trim();
|
|
1318
|
+
if (description !== undefined)
|
|
1319
|
+
ticket.description = description;
|
|
1320
|
+
if (acceptanceCriteria !== undefined) {
|
|
1321
|
+
// Fold criteria into the description body under a `## Acceptance Criteria`
|
|
1322
|
+
// section, replacing any existing one. Use the just-set description if
|
|
1323
|
+
// present, otherwise the ticket's current description.
|
|
1324
|
+
ticket.description = (0, project_router_helpers_1.formatDescriptionWithCriteria)(ticket.description ?? '', acceptanceCriteria);
|
|
1325
|
+
}
|
|
1326
|
+
if (status !== undefined)
|
|
1327
|
+
ticket.status = status;
|
|
1328
|
+
if (priority !== undefined)
|
|
1329
|
+
ticket.priority = nextPriority;
|
|
1330
|
+
if (labels !== undefined && Array.isArray(labels))
|
|
1331
|
+
ticket.labels = labels.filter((l) => typeof l === 'string');
|
|
1332
|
+
if (assignee !== undefined)
|
|
1333
|
+
ticket.assignee = typeof assignee === 'string' ? assignee : null;
|
|
1334
|
+
if (prerequisites !== undefined && Array.isArray(prerequisites))
|
|
1335
|
+
ticket.prerequisites = prerequisites.filter((p) => typeof p === 'number');
|
|
1336
|
+
if (metadata !== undefined && typeof metadata === 'object' && metadata !== null) {
|
|
1337
|
+
ticket.metadata = { ...ticket.metadata, ...metadata };
|
|
1338
|
+
}
|
|
1339
|
+
// Short summary: explicit non-empty overwrites; explicit null clears;
|
|
1340
|
+
// omitted leaves the existing value untouched (preserves prior summary
|
|
1341
|
+
// when AI Refine omits it for a partial edit).
|
|
1342
|
+
if (short_summary === null) {
|
|
1343
|
+
ticket.short_summary = null;
|
|
1344
|
+
}
|
|
1345
|
+
else if (typeof short_summary === 'string') {
|
|
1346
|
+
ticket.short_summary = (0, ticket_store_1.clampShortSummary)(short_summary);
|
|
1347
|
+
}
|
|
1348
|
+
ticket.updated_at = new Date().toISOString();
|
|
1349
|
+
updated = ticket;
|
|
1350
|
+
});
|
|
1351
|
+
if (validationError) {
|
|
1352
|
+
res.status(400).json({ error: validationError });
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (!updated) {
|
|
1356
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const { broadcast, ticketWatcher, jiraSyncManager } = ctx(req);
|
|
1360
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
1361
|
+
const msg = { type: 'ticket_updated', ticket: updated, projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
|
|
1362
|
+
broadcast(msg);
|
|
1363
|
+
// Write the edited fields back to Jira for Jira-backed specs (no-op
|
|
1364
|
+
// otherwise). Uses the FINAL stored values (e.g. acceptance-criteria
|
|
1365
|
+
// folding) for the fields that were actually changed. Never breaks the
|
|
1366
|
+
// local save — a Jira hiccup only fails the enqueue, which is caught.
|
|
1367
|
+
try {
|
|
1368
|
+
const u = updated;
|
|
1369
|
+
const changes = {};
|
|
1370
|
+
if (title !== undefined)
|
|
1371
|
+
changes.title = u.title;
|
|
1372
|
+
if (description !== undefined || acceptanceCriteria !== undefined)
|
|
1373
|
+
changes.description = u.description;
|
|
1374
|
+
if (priority !== undefined)
|
|
1375
|
+
changes.priority = u.priority ?? null;
|
|
1376
|
+
if (labels !== undefined)
|
|
1377
|
+
changes.labels = u.labels;
|
|
1378
|
+
if (Object.keys(changes).length > 0)
|
|
1379
|
+
jiraSyncManager?.onSpecEdited(Number(ticketId), changes);
|
|
1380
|
+
}
|
|
1381
|
+
catch (e) {
|
|
1382
|
+
console.error('[project-router] jira write-back enqueue failed:', e);
|
|
1383
|
+
}
|
|
1384
|
+
res.json({ ticket: updated, revision: store.revision });
|
|
1385
|
+
}
|
|
1386
|
+
catch (err) {
|
|
1387
|
+
console.error('[project-router] ticket update error:', err);
|
|
1388
|
+
res.status(500).json({ error: 'Failed to update ticket' });
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
// POST /:projectId/tickets/:id/ai-edit — AI-powered description editing
|
|
1392
|
+
const _aiEditProcesses = new Map();
|
|
1393
|
+
router.post('/:projectId/tickets/:id/ai-edit', async (req, res) => {
|
|
1394
|
+
const ticketId = req.params.id;
|
|
1395
|
+
if (!/^\d+$/.test(ticketId)) {
|
|
1396
|
+
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const instructions = req.body?.instructions;
|
|
1400
|
+
const currentDescription = req.body?.description;
|
|
1401
|
+
const currentTitle = typeof req.body?.title === 'string' ? req.body.title : '';
|
|
1402
|
+
if (!instructions?.trim()) {
|
|
1403
|
+
res.status(400).json({ error: 'instructions is required' });
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (!currentDescription) {
|
|
1407
|
+
res.status(400).json({ error: 'description is required' });
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
const attachmentIds = Array.isArray(req.body?.attachmentIds)
|
|
1411
|
+
? req.body.attachmentIds.filter((x) => typeof x === 'string')
|
|
1412
|
+
: [];
|
|
1413
|
+
const priorInstructions = Array.isArray(req.body?.priorInstructions)
|
|
1414
|
+
? req.body.priorInstructions.filter((x) => typeof x === 'string')
|
|
1415
|
+
: [];
|
|
1416
|
+
const priorProposalRaw = req.body?.priorProposal;
|
|
1417
|
+
const priorProposal = typeof priorProposalRaw === 'string' && priorProposalRaw.length > 0 ? priorProposalRaw : null;
|
|
1418
|
+
const isRefinement = priorProposal !== null;
|
|
1419
|
+
const { project, broadcast } = ctx(req);
|
|
1420
|
+
const provider = project.provider ?? 'claude';
|
|
1421
|
+
const requestId = (0, ids_1.newId)();
|
|
1422
|
+
const projectId = project.id;
|
|
1423
|
+
// Build the focused pre-prompt
|
|
1424
|
+
const baseRules = `- Output format MUST be exactly:\n` +
|
|
1425
|
+
` TITLE: <one-line spec title>\n` +
|
|
1426
|
+
` SHORT-SUMMARY: <one or two plain-language sentences, max 120 chars, summarising the spec for a dashboard postit. No markdown, no bullets.>\n` +
|
|
1427
|
+
` \n` +
|
|
1428
|
+
` <markdown description body>\n` +
|
|
1429
|
+
` The first line MUST start with "TITLE: " followed by the refined title.\n` +
|
|
1430
|
+
` The second line MUST start with "SHORT-SUMMARY: " followed by the summary.\n` +
|
|
1431
|
+
` Then exactly one blank line. Then the markdown description.\n` +
|
|
1432
|
+
`- Keep the title concise (under 80 characters) and reflective of the latest description.\n` +
|
|
1433
|
+
` If the user's refinement does not affect the title's intent, you may keep it unchanged — but always emit the TITLE line.\n` +
|
|
1434
|
+
`- The SHORT-SUMMARY line MUST always be present. If the user's refinement does not change what the spec is about, keep the previous summary verbatim. Never omit the line.\n` +
|
|
1435
|
+
`- After the SHORT-SUMMARY line and blank line, output ONLY the modified description in markdown. No preamble, no explanation, no wrapping.\n` +
|
|
1436
|
+
`- Preserve the existing markdown structure and section headings in the description.\n` +
|
|
1437
|
+
`- If the user asks to add technical details, briefly check CLAUDE.md and the project directory structure (ls, not deep reads) to ground your edits.\n` +
|
|
1438
|
+
`- Keep it concise and actionable.\n` +
|
|
1439
|
+
`- Do NOT create files, tickets, or issues. Only output text.`;
|
|
1440
|
+
const refinementRule = isRefinement
|
|
1441
|
+
? `\n- You are editing an in-progress draft, not the saved description. Apply the new refinement to the Latest Draft below.`
|
|
1442
|
+
: '';
|
|
1443
|
+
let systemPrompt = `You are a spec editor. You will receive a ticket title and description plus user instructions for how to modify them. ` +
|
|
1444
|
+
`Your job is to produce an improved version of BOTH the title and the description.\n\n` +
|
|
1445
|
+
`RULES:\n` +
|
|
1446
|
+
`${baseRules}${refinementRule}`;
|
|
1447
|
+
let userPrompt = isRefinement
|
|
1448
|
+
? `## Current Title (saved baseline)\n\n${currentTitle}\n\n` +
|
|
1449
|
+
`## Current Description (saved baseline — do not rewrite)\n\n${currentDescription}\n\n` +
|
|
1450
|
+
`## Prior Refinement Turns\n\n${priorInstructions.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n` +
|
|
1451
|
+
`## Latest Draft (from previous turn — apply the new refinement to this; the draft already includes a TITLE: line)\n\n${priorProposal}\n\n` +
|
|
1452
|
+
`## New Refinement\n\n${instructions.trim()}\n\n` +
|
|
1453
|
+
`Output the updated TITLE line followed by the updated description now.`
|
|
1454
|
+
: `## Current Title\n\n${currentTitle}\n\n` +
|
|
1455
|
+
`## Current Description\n\n${currentDescription}\n\n` +
|
|
1456
|
+
`## User Instructions\n\n${instructions.trim()}\n\n` +
|
|
1457
|
+
`Output the modified TITLE line followed by the modified description now.`;
|
|
1458
|
+
let imageFlags = [];
|
|
1459
|
+
if (attachmentIds.length > 0) {
|
|
1460
|
+
try {
|
|
1461
|
+
const extracted = await attachment_manager_1.attachmentManager.getClaudeArgs(project.slug, ticketId, attachmentIds);
|
|
1462
|
+
imageFlags = extracted.imageFlags;
|
|
1463
|
+
if (extracted.textBlocks.length > 0) {
|
|
1464
|
+
systemPrompt = `${systemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
|
|
1465
|
+
userPrompt = `${userPrompt}\n\n## Attached Files\n\n${extracted.textBlocks.join('\n\n')}`;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
catch (err) {
|
|
1469
|
+
console.error('[project-router] ai-edit attachment extraction error:', err);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
let binary;
|
|
1473
|
+
let args;
|
|
1474
|
+
if (provider === 'codex') {
|
|
1475
|
+
binary = 'codex';
|
|
1476
|
+
// Use gpt-5.5 (default for Codex per CODEX_MODELS/PRESET_DEFAULTS in ModelSelector); never hardcode o4-mini
|
|
1477
|
+
args = ['exec', `${systemPrompt}\n\n${userPrompt}`, '--model', 'gpt-5.5'];
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
binary = 'claude';
|
|
1481
|
+
args = [
|
|
1482
|
+
'--dangerously-skip-permissions',
|
|
1483
|
+
'--tools', 'default',
|
|
1484
|
+
'--output-format', 'stream-json',
|
|
1485
|
+
'--verbose',
|
|
1486
|
+
'--max-turns', '4',
|
|
1487
|
+
...imageFlags,
|
|
1488
|
+
'--system-prompt', systemPrompt,
|
|
1489
|
+
'-p', userPrompt,
|
|
1490
|
+
];
|
|
1491
|
+
}
|
|
1492
|
+
// spawnAiCli reroutes multi-line argv values through stdin on Windows.
|
|
1493
|
+
console.log(`[project-router] ai-edit spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
|
|
1494
|
+
const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
|
|
1495
|
+
env: process.env,
|
|
1496
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1497
|
+
cwd: project.path,
|
|
1498
|
+
});
|
|
1499
|
+
_aiEditProcesses.set(requestId, child);
|
|
1500
|
+
// Pipe stderr to server log so failures surface for debugging.
|
|
1501
|
+
let aiEditStderrBuf = '';
|
|
1502
|
+
/* c8 ignore start -- diagnostic-only; fires only when claude writes stderr */
|
|
1503
|
+
child.stderr?.on('data', (chunk) => {
|
|
1504
|
+
const s = chunk.toString();
|
|
1505
|
+
aiEditStderrBuf += s;
|
|
1506
|
+
console.error(`[project-router] ai-edit stderr (${requestId}): ${s.trimEnd()}`);
|
|
1507
|
+
});
|
|
1508
|
+
/* c8 ignore stop */
|
|
1509
|
+
// Without this listener, ENOENT (binary missing on PATH) propagates as
|
|
1510
|
+
// an unhandled 'error' event and crashes the entire app process.
|
|
1511
|
+
/* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
|
|
1512
|
+
child.on('error', (err) => {
|
|
1513
|
+
console.error(`[project-router] ai-edit spawn failed (${binary}): ${err.message}`);
|
|
1514
|
+
_aiEditProcesses.delete(requestId);
|
|
1515
|
+
const errMsg = {
|
|
1516
|
+
type: 'ticket_ai_edit_error', projectId, ticketId: Number(ticketId),
|
|
1517
|
+
requestId, error: `Failed to launch ${binary}: ${err.message}`,
|
|
1518
|
+
timestamp: new Date().toISOString(),
|
|
1519
|
+
};
|
|
1520
|
+
broadcast(errMsg);
|
|
1521
|
+
});
|
|
1522
|
+
/* c8 ignore stop */
|
|
1523
|
+
res.status(202).json({ requestId });
|
|
1524
|
+
let buffer = '';
|
|
1525
|
+
const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
1526
|
+
stdoutReader.on('line', (line) => {
|
|
1527
|
+
if (provider === 'codex') {
|
|
1528
|
+
if (line) {
|
|
1529
|
+
buffer += line + '\n';
|
|
1530
|
+
const msg = {
|
|
1531
|
+
type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
|
|
1532
|
+
requestId, delta: line + '\n', timestamp: new Date().toISOString(),
|
|
1533
|
+
};
|
|
1534
|
+
broadcast(msg);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
let parsed = null;
|
|
1539
|
+
try {
|
|
1540
|
+
parsed = JSON.parse(line);
|
|
1541
|
+
}
|
|
1542
|
+
catch { /* skip */ }
|
|
1543
|
+
if (!parsed)
|
|
1544
|
+
return;
|
|
1545
|
+
if (parsed.type === 'assistant') {
|
|
1546
|
+
const msg = parsed.message;
|
|
1547
|
+
const texts = (msg?.content ?? [])
|
|
1548
|
+
.filter((c) => c.type === 'text')
|
|
1549
|
+
.map((c) => c.text ?? '');
|
|
1550
|
+
const newText = texts.join('');
|
|
1551
|
+
if (newText) {
|
|
1552
|
+
buffer += newText;
|
|
1553
|
+
const wsMsg = {
|
|
1554
|
+
type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
|
|
1555
|
+
requestId, delta: newText, timestamp: new Date().toISOString(),
|
|
1556
|
+
};
|
|
1557
|
+
broadcast(wsMsg);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
child.on('close', (code) => {
|
|
1563
|
+
_aiEditProcesses.delete(requestId);
|
|
1564
|
+
if (code === 0 && buffer.trim()) {
|
|
1565
|
+
const msg = {
|
|
1566
|
+
type: 'ticket_ai_edit_done', projectId, ticketId: Number(ticketId),
|
|
1567
|
+
requestId, fullText: buffer.trim(), timestamp: new Date().toISOString(),
|
|
1568
|
+
};
|
|
1569
|
+
broadcast(msg);
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1572
|
+
const reason = code === 0 ? 'Empty response from AI' : `Process exited with code ${code}`;
|
|
1573
|
+
console.error(`[project-router] ai-edit failed (${requestId}): ${reason}` +
|
|
1574
|
+
(aiEditStderrBuf.trim() ? `\n stderr: ${aiEditStderrBuf.trim()}` : '') +
|
|
1575
|
+
(buffer.trim() ? `\n stdout-buffer: ${buffer.trim().slice(0, 500)}` : ''));
|
|
1576
|
+
const msg = {
|
|
1577
|
+
type: 'ticket_ai_edit_error', projectId, ticketId: Number(ticketId),
|
|
1578
|
+
requestId, error: reason,
|
|
1579
|
+
timestamp: new Date().toISOString(),
|
|
1580
|
+
};
|
|
1581
|
+
broadcast(msg);
|
|
1582
|
+
}
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
router.delete('/:projectId/tickets/:id/ai-edit', (req, res) => {
|
|
1586
|
+
const requestId = req.query.requestId;
|
|
1587
|
+
if (!requestId) {
|
|
1588
|
+
res.status(400).json({ error: 'requestId query param required' });
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const child = _aiEditProcesses.get(requestId);
|
|
1592
|
+
if (!child?.pid) {
|
|
1593
|
+
res.status(404).json({ error: 'No active AI edit for this request' });
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
(0, tree_kill_1.default)(child.pid, 'SIGTERM');
|
|
1597
|
+
_aiEditProcesses.delete(requestId);
|
|
1598
|
+
res.json({ ok: true });
|
|
1599
|
+
});
|
|
1600
|
+
// DELETE /:projectId/tickets/:id — Delete ticket
|
|
1601
|
+
router.delete('/:projectId/tickets/:id', (req, res) => {
|
|
1602
|
+
const ticketId = req.params.id;
|
|
1603
|
+
if (!/^\d+$/.test(ticketId)) {
|
|
1604
|
+
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
try {
|
|
1608
|
+
const filePath = ticketPath(req);
|
|
1609
|
+
let found = false;
|
|
1610
|
+
let orphanedConversationId = null;
|
|
1611
|
+
const orphanedChildren = [];
|
|
1612
|
+
const numericId = Number(ticketId);
|
|
1613
|
+
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
1614
|
+
const t = s.tickets[ticketId];
|
|
1615
|
+
if (!t)
|
|
1616
|
+
return;
|
|
1617
|
+
// If this is a draft and no other ticket references the same
|
|
1618
|
+
// origin_conversation_id, mark it for cascade delete.
|
|
1619
|
+
if (t.status === 'draft' && t.origin_conversation_id) {
|
|
1620
|
+
const otherRefs = Object.values(s.tickets).some((other) => other.id !== t.id && other.origin_conversation_id === t.origin_conversation_id);
|
|
1621
|
+
if (!otherRefs)
|
|
1622
|
+
orphanedConversationId = t.origin_conversation_id;
|
|
1623
|
+
}
|
|
1624
|
+
// SMASH: when deleting an épica, orphan its children (set
|
|
1625
|
+
// parent_epic_id/execution_order to null) rather than cascade-delete.
|
|
1626
|
+
if (t.is_epic) {
|
|
1627
|
+
const now = new Date().toISOString();
|
|
1628
|
+
for (const childId of Object.keys(s.tickets)) {
|
|
1629
|
+
const child = s.tickets[childId];
|
|
1630
|
+
if (child.parent_epic_id === numericId) {
|
|
1631
|
+
child.parent_epic_id = null;
|
|
1632
|
+
child.execution_order = null;
|
|
1633
|
+
child.updated_at = now;
|
|
1634
|
+
orphanedChildren.push(child);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
delete s.tickets[ticketId];
|
|
1639
|
+
found = true;
|
|
1640
|
+
});
|
|
1641
|
+
if (!found) {
|
|
1642
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
const { broadcast, ticketWatcher, db, chatManager } = ctx(req);
|
|
1646
|
+
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
1647
|
+
// Cascade-delete attachments for this ticket
|
|
1648
|
+
attachment_manager_1.attachmentManager.deleteAll(ctx(req).project.slug, ticketId).catch((e) => {
|
|
1649
|
+
console.error('[project-router] attachment cascade delete failed:', e);
|
|
1650
|
+
});
|
|
1651
|
+
// Cascade-delete the orphaned Explore conversation, if any.
|
|
1652
|
+
if (orphanedConversationId) {
|
|
1653
|
+
try {
|
|
1654
|
+
const conv = (0, db_1.getConversation)(db, orphanedConversationId);
|
|
1655
|
+
if (conv && conv.kind === 'explore') {
|
|
1656
|
+
(0, db_1.deleteConversation)(db, orphanedConversationId);
|
|
1657
|
+
chatManager?.forgetSpecDraft(orphanedConversationId);
|
|
1658
|
+
chatManager?.forgetExploreLifecycle(orphanedConversationId);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
catch (err) {
|
|
1662
|
+
console.error('[project-router] orphan conversation cleanup failed:', err);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
// Broadcast ticket_updated for each orphaned child so observers see them
|
|
1666
|
+
// as regular tickets (no longer attached to the deleted épica).
|
|
1667
|
+
for (const child of orphanedChildren) {
|
|
1668
|
+
broadcast({
|
|
1669
|
+
type: 'ticket_updated',
|
|
1670
|
+
ticket: child,
|
|
1671
|
+
projectId: ctx(req).project.id,
|
|
1672
|
+
timestamp: new Date().toISOString(),
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
const msg = { type: 'ticket_deleted', ticketId: Number(ticketId), projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
|
|
1676
|
+
broadcast(msg);
|
|
1677
|
+
res.json({ ok: true, revision: store.revision });
|
|
1678
|
+
}
|
|
1679
|
+
catch (err) {
|
|
1680
|
+
console.error('[project-router] ticket delete error:', err);
|
|
1681
|
+
res.status(500).json({ error: 'Failed to delete ticket' });
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
// ─── Ticket attachments ─────────────────────────────────────────────────────
|
|
1685
|
+
const attachmentUpload = (0, multer_1.default)({
|
|
1686
|
+
storage: multer_1.default.memoryStorage(),
|
|
1687
|
+
limits: { fileSize: 25 * 1024 * 1024 }, // 25 MB per file
|
|
1688
|
+
fileFilter: (_req, file, cb) => {
|
|
1689
|
+
if ((0, attachment_manager_1.isSupportedUploadedFile)({ mimetype: file.mimetype, originalname: file.originalname }))
|
|
1690
|
+
cb(null, true);
|
|
1691
|
+
else
|
|
1692
|
+
cb(null, false);
|
|
1693
|
+
},
|
|
1694
|
+
});
|
|
1695
|
+
/** A ticket key is either a numeric real id or a UUID (pendingSpecId). */
|
|
1696
|
+
function parseTicketKey(raw) {
|
|
1697
|
+
if (/^\d+$/.test(raw))
|
|
1698
|
+
return { key: raw, isPending: false };
|
|
1699
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
|
1700
|
+
return { key: raw, isPending: true };
|
|
1701
|
+
}
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
router.post('/:projectId/tickets/:ticketId/attachments', attachmentUpload.single('file'), async (req, res) => {
|
|
1705
|
+
const parsed = parseTicketKey(req.params.ticketId);
|
|
1706
|
+
if (!parsed) {
|
|
1707
|
+
res.status(400).json({ error: 'Invalid ticketId (must be numeric id or UUID)' });
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
const file = req.file;
|
|
1711
|
+
if (!file) {
|
|
1712
|
+
res.status(400).json({ error: 'No file uploaded or file type unsupported' });
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (!parsed.isPending) {
|
|
1716
|
+
const store = (0, ticket_store_1.readStore)(ticketPath(req));
|
|
1717
|
+
if (!store.tickets[parsed.key]) {
|
|
1718
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
try {
|
|
1723
|
+
const attachment = await attachment_manager_1.attachmentManager.upload({
|
|
1724
|
+
slug: ctx(req).project.slug,
|
|
1725
|
+
ticketKey: parsed.key,
|
|
1726
|
+
projectPath: parsed.isPending ? null : ctx(req).project.path,
|
|
1727
|
+
file: {
|
|
1728
|
+
buffer: file.buffer,
|
|
1729
|
+
originalname: file.originalname,
|
|
1730
|
+
mimetype: file.mimetype,
|
|
1731
|
+
size: file.size,
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
res.status(201).json({ attachment });
|
|
1735
|
+
}
|
|
1736
|
+
catch (err) {
|
|
1737
|
+
const status = err.status ?? 500;
|
|
1738
|
+
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
1739
|
+
console.error('[project-router] attachment upload error:', err);
|
|
1740
|
+
res.status(status).json({ error: message });
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
router.get('/:projectId/tickets/:ticketId/attachments', (req, res) => {
|
|
1744
|
+
const parsed = parseTicketKey(req.params.ticketId);
|
|
1745
|
+
if (!parsed) {
|
|
1746
|
+
res.status(400).json({ error: 'Invalid ticketId' });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
const attachments = attachment_manager_1.attachmentManager.list(ctx(req).project.slug, parsed.key);
|
|
1750
|
+
res.json({ attachments });
|
|
1751
|
+
});
|
|
1752
|
+
router.get('/:projectId/tickets/:ticketId/attachments/:attachmentId', (req, res) => {
|
|
1753
|
+
const parsed = parseTicketKey(req.params.ticketId);
|
|
1754
|
+
if (!parsed) {
|
|
1755
|
+
res.status(400).json({ error: 'Invalid ticketId' });
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const attachmentId = req.params.attachmentId;
|
|
1759
|
+
const slug = ctx(req).project.slug;
|
|
1760
|
+
const meta = attachment_manager_1.attachmentManager.getMeta(slug, parsed.key, attachmentId);
|
|
1761
|
+
const abs = meta ? attachment_manager_1.attachmentManager.getFilePath(slug, parsed.key, attachmentId) : null;
|
|
1762
|
+
if (!meta || !abs) {
|
|
1763
|
+
res.status(404).json({ error: 'Attachment not found' });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
res.setHeader('Content-Type', meta.mimeType);
|
|
1767
|
+
// Strip quotes AND CR/LF: a newline in the stored (raw) original filename
|
|
1768
|
+
// makes Node's setHeader throw ERR_INVALID_CHAR after Content-Type is
|
|
1769
|
+
// already set, 500-ing the download. Also emit an RFC 5987 filename* so
|
|
1770
|
+
// non-ASCII names survive.
|
|
1771
|
+
const asciiName = meta.filename.replace(/[\r\n"]/g, '_');
|
|
1772
|
+
res.setHeader('Content-Disposition', `inline; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(meta.filename)}`);
|
|
1773
|
+
fs_1.default.createReadStream(abs).pipe(res);
|
|
1774
|
+
});
|
|
1775
|
+
router.delete('/:projectId/tickets/:ticketId/attachments/:attachmentId', async (req, res) => {
|
|
1776
|
+
const parsed = parseTicketKey(req.params.ticketId);
|
|
1777
|
+
if (!parsed) {
|
|
1778
|
+
res.status(400).json({ error: 'Invalid ticketId' });
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const attachmentId = req.params.attachmentId;
|
|
1782
|
+
try {
|
|
1783
|
+
const ok = await attachment_manager_1.attachmentManager.delete({
|
|
1784
|
+
slug: ctx(req).project.slug,
|
|
1785
|
+
ticketKey: parsed.key,
|
|
1786
|
+
attachmentId,
|
|
1787
|
+
projectPath: parsed.isPending ? null : ctx(req).project.path,
|
|
1788
|
+
});
|
|
1789
|
+
if (!ok) {
|
|
1790
|
+
res.status(404).json({ error: 'Attachment not found' });
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
res.status(204).end();
|
|
1794
|
+
}
|
|
1795
|
+
catch (err) {
|
|
1796
|
+
console.error('[project-router] attachment delete error:', err);
|
|
1797
|
+
res.status(500).json({ error: 'Delete failed' });
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
router.delete('/:projectId/tickets/:ticketId/attachments', async (req, res) => {
|
|
1801
|
+
const parsed = parseTicketKey(req.params.ticketId);
|
|
1802
|
+
if (!parsed) {
|
|
1803
|
+
res.status(400).json({ error: 'Invalid ticketId' });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
try {
|
|
1807
|
+
await attachment_manager_1.attachmentManager.deleteAll(ctx(req).project.slug, parsed.key);
|
|
1808
|
+
res.status(204).end();
|
|
1809
|
+
}
|
|
1810
|
+
catch (err) {
|
|
1811
|
+
console.error('[project-router] attachment bulk delete error:', err);
|
|
1812
|
+
res.status(500).json({ error: 'Bulk delete failed' });
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
}
|