@band-app/server 0.27.0 → 0.27.2
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/dist/client/assets/{DockviewBrowserContainer-CGJxcrlC.js → DockviewBrowserContainer-COwf3WHv.js} +1 -1
- package/dist/client/assets/{DockviewTerminalContainer-Ch1hGjB3.js → DockviewTerminalContainer-CHShiUtc.js} +2 -2
- package/dist/client/assets/{TerminalPanel-D6316IRS.js → TerminalPanel-Do5FQ6ub.js} +1 -1
- package/dist/client/assets/{_basePickBy-CmBV18rc.js → _basePickBy-CsdxTHqK.js} +1 -1
- package/dist/client/assets/{_baseUniq-U0BTd0Rz.js → _baseUniq-B2EJ08Mj.js} +1 -1
- package/dist/client/assets/{arc-BsQxyIE1.js → arc-4o4sSvn3.js} +1 -1
- package/dist/client/assets/{architectureDiagram-VXUJARFQ-utzNWMoy.js → architectureDiagram-VXUJARFQ-BuLso7-7.js} +1 -1
- package/dist/client/assets/{blockDiagram-VD42YOAC-xxfCrQvu.js → blockDiagram-VD42YOAC-DLy4c1v0.js} +1 -1
- package/dist/client/assets/{c4Diagram-YG6GDRKO-CYbkaWJ4.js → c4Diagram-YG6GDRKO-CxGgKDvC.js} +1 -1
- package/dist/client/assets/channel-DK9j4T1y.js +1 -0
- package/dist/client/assets/{chunk-4BX2VUAB-DKCxjb37.js → chunk-4BX2VUAB-BjZOiC5V.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-B9W9ltjh.js → chunk-55IACEB6-wLT4Ekdw.js} +1 -1
- package/dist/client/assets/{chunk-B4BG7PRW-0MSaUsdv.js → chunk-B4BG7PRW-DNHRjTkG.js} +1 -1
- package/dist/client/assets/{chunk-DI55MBZ5-K1HZY-B4.js → chunk-DI55MBZ5-xiMesNd-.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-DL19-zkO.js → chunk-FMBD7UC4-wxbkh2Ei.js} +1 -1
- package/dist/client/assets/{chunk-QN33PNHL-Bjj5Xo40.js → chunk-QN33PNHL-C9S-hWrw.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-B7u9UlRD.js → chunk-QZHKN3VN-BbSdiGqJ.js} +1 -1
- package/dist/client/assets/{chunk-TZMSLE5B-1C4Ii4Su.js → chunk-TZMSLE5B-qzA2kORG.js} +1 -1
- package/dist/client/assets/classDiagram-2ON5EDUG-ao4KqSJW.js +1 -0
- package/dist/client/assets/classDiagram-v2-WZHVMYZB-ao4KqSJW.js +1 -0
- package/dist/client/assets/clone-DwKf57Xw.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-PGTI-4nv.js → cose-bilkent-S5V4N54A-Ce-91kdO.js} +1 -1
- package/dist/client/assets/{dagre-6UL2VRFP-SduuRX7O.js → dagre-6UL2VRFP-BJfEL8HP.js} +1 -1
- package/dist/client/assets/{diagram-PSM6KHXK-CBSJaBQx.js → diagram-PSM6KHXK-D9QZY1LM.js} +1 -1
- package/dist/client/assets/{diagram-QEK2KX5R-BtO_0TYR.js → diagram-QEK2KX5R-Dq_XSqOb.js} +1 -1
- package/dist/client/assets/{diagram-S2PKOQOG-CU-3HUn7.js → diagram-S2PKOQOG-NXHJcJ16.js} +1 -1
- package/dist/client/assets/{erDiagram-Q2GNP2WA-B7YcBWa7.js → erDiagram-Q2GNP2WA-BXnG9VYr.js} +1 -1
- package/dist/client/assets/{flowDiagram-NV44I4VS-B_exjXhH.js → flowDiagram-NV44I4VS-DtBdd0js.js} +1 -1
- package/dist/client/assets/{ganttDiagram-JELNMOA3-K_s2lhZo.js → ganttDiagram-JELNMOA3-BL9zC1XT.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-V2S2FVAM-Cy8dWU3T.js → gitGraphDiagram-V2S2FVAM-DixbOeLs.js} +1 -1
- package/dist/client/assets/{graph-F7Go3eb_.js → graph-BHFgIvQj.js} +1 -1
- package/dist/client/assets/{highlighted-body-B3W2YXNL-JIbcl_Gj.js → highlighted-body-B3W2YXNL-BREAoDZ4.js} +1 -1
- package/dist/client/assets/{index-C8kfQI4r.js → index-9wcsPWJR.js} +1 -1
- package/dist/client/assets/{index-CpaiC9qY.js → index-BWCi_Zs0.js} +1 -1
- package/dist/client/assets/{index-DKc3v7ID.js → index-C-bVTGUD.js} +1 -1
- package/dist/client/assets/{index-Cg4Lw3Yz.js → index-CF-5li7T.js} +1 -1
- package/dist/client/assets/{index-DR5Cdr17.js → index-COFoUJ6K.js} +1 -1
- package/dist/client/assets/{index-Cy5Bsovp.js → index-CRC_o9Ev.js} +1 -1
- package/dist/client/assets/{index-2I1ScAnS.js → index-Cl45brGw.js} +1 -1
- package/dist/client/assets/{index-DtwjNrea.js → index-CltbWfI8.js} +1 -1
- package/dist/client/assets/{index-VnMyG1bC.js → index-CqEMg5bi.js} +1 -1
- package/dist/client/assets/{index-BwLCRFTA.js → index-CvMgaEGN.js} +1 -1
- package/dist/client/assets/{index-QMtr_UY_.js → index-DDNdnPFA.js} +1 -1
- package/dist/client/assets/{index-Ckir_3bE.js → index-DEK-0WIp.js} +1 -1
- package/dist/client/assets/{index-CyKK9pLi.js → index-DJlZKXEl.js} +1 -1
- package/dist/client/assets/{index-qr9Za9SW.js → index-DK9JYQdj.js} +1 -1
- package/dist/client/assets/{index-D2dWAqXm.js → index-DTFreZSi.js} +1 -1
- package/dist/client/assets/{index-T2mcmOLE.js → index-Dw-2va3g.js} +1 -1
- package/dist/client/assets/{index-O8FbcZ5P.js → index-Z0JH8qku.js} +1 -1
- package/dist/client/assets/{index-D3_75AIk.js → index-sbEmQu7V.js} +1 -1
- package/dist/client/assets/{infoDiagram-HS3SLOUP-DwTbDgO5.js → infoDiagram-HS3SLOUP-Ci0THpdm.js} +1 -1
- package/dist/client/assets/{journeyDiagram-XKPGCS4Q-Di8V2Erc.js → journeyDiagram-XKPGCS4Q-BoRGQ0eN.js} +1 -1
- package/dist/client/assets/{kanban-definition-3W4ZIXB7-CsLOWnzV.js → kanban-definition-3W4ZIXB7-AQv1UOIJ.js} +1 -1
- package/dist/client/assets/{layout-C3qPiTeF.js → layout-D2p6hLD3.js} +1 -1
- package/dist/client/assets/{main-C0_EuI87.js → main-BOL9tKxz.js} +242 -242
- package/dist/client/assets/main-Cd3W1s-m.css +1 -0
- package/dist/client/assets/{mindmap-definition-VGOIOE7T-CTcR694h.js → mindmap-definition-VGOIOE7T-C9wrq9rZ.js} +1 -1
- package/dist/client/assets/{pieDiagram-ADFJNKIX-B-gdfE-T.js → pieDiagram-ADFJNKIX-TDDajIhh.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-AYHSOK5B-B0wmCtBi.js → quadrantDiagram-AYHSOK5B-c7rUV0Lx.js} +1 -1
- package/dist/client/assets/{requirementDiagram-UZGBJVZJ-B1DgDXmR.js → requirementDiagram-UZGBJVZJ-DTSqqMot.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-TZEHDZUN-B-onab8X.js → sankeyDiagram-TZEHDZUN-BB3ht3wT.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-WL72ISMW-B8sIiN1d.js → sequenceDiagram-WL72ISMW-DqBzLinO.js} +1 -1
- package/dist/client/assets/{square-terminal-Bi7EDEf7.js → square-terminal-Dp_ALPq8.js} +1 -1
- package/dist/client/assets/{stateDiagram-FKZM4ZOC-CTETJ7q1.js → stateDiagram-FKZM4ZOC-0RwNDoMO.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-4FDKWEC3-EK4CX-2u.js +1 -0
- package/dist/client/assets/{timeline-definition-IT6M3QCI-DYNUQPry.js → timeline-definition-IT6M3QCI-oQ14X47P.js} +1 -1
- package/dist/client/assets/{treemap-GDKQZRPO-extyB4z8.js → treemap-GDKQZRPO-BaGbKw1i.js} +1 -1
- package/dist/client/assets/workspace._workspaceId-7sbHEjW9.js +2 -0
- package/dist/client/assets/{workspace._workspaceId-D0Fj26bF.js → workspace._workspaceId-CmPT8dnn.js} +1 -1
- package/dist/client/assets/{xychartDiagram-PRI3JC2R-BtUY3rKA.js → xychartDiagram-PRI3JC2R-BWgqOpde.js} +1 -1
- package/dist/migrations/20260707132201_moaning_cerebro/migration.sql +2 -0
- package/dist/migrations/20260707132201_moaning_cerebro/snapshot.json +1187 -0
- package/dist/openapi.json +69 -15
- package/dist/server/assets/{DockviewBrowserContainer-BjdfNGy8.js → DockviewBrowserContainer-Cwa58lvF.js} +1 -1
- package/dist/server/assets/{DockviewTerminalContainer-CwXOWGN4.js → DockviewTerminalContainer-C1qdx1Wr.js} +3 -3
- package/dist/server/assets/{TerminalPanel-BkB-gDqg.js → TerminalPanel-D6sfYBb6.js} +1 -1
- package/dist/server/assets/{_basePickBy-8tzGwV15.js → _basePickBy-BRuNlqVs.js} +2 -2
- package/dist/server/assets/{_baseUniq-CTXsu8pj.js → _baseUniq-C9mURyHm.js} +1 -1
- package/dist/server/assets/_tanstack-start-manifest_v-CwczGC2T.js +4 -0
- package/dist/server/assets/{arc-DfLShGGX.js → arc-BtzL8k-u.js} +1 -1
- package/dist/server/assets/{architecture-7HQA4BMR-B3ThBUTU.js → architecture-7HQA4BMR-CKZ65Ll7.js} +6 -6
- package/dist/server/assets/{architectureDiagram-VXUJARFQ-CWzfFX__.js → architectureDiagram-VXUJARFQ-DTAYCIJW.js} +6 -6
- package/dist/server/assets/{blockDiagram-VD42YOAC-DCaPOrQR.js → blockDiagram-VD42YOAC-4VOCVg9L.js} +6 -6
- package/dist/server/assets/{c4Diagram-YG6GDRKO-BU-dO_db.js → c4Diagram-YG6GDRKO-BgCS3qmn.js} +2 -2
- package/dist/server/assets/{channel-Bs5JejOk.js → channel-DFQ08j-2.js} +1 -1
- package/dist/server/assets/{chunk-4BX2VUAB-BuiyFSiN.js → chunk-4BX2VUAB-DB8ZP4qQ.js} +1 -1
- package/dist/server/assets/{chunk-55IACEB6-CPYazKTg.js → chunk-55IACEB6-CfA2yw1X.js} +1 -1
- package/dist/server/assets/{chunk-B4BG7PRW-Kknt_fVn.js → chunk-B4BG7PRW-Cg_IGrn-.js} +4 -4
- package/dist/server/assets/{chunk-DI55MBZ5-9Ix3Oo-P.js → chunk-DI55MBZ5-C0P1Cguj.js} +3 -3
- package/dist/server/assets/{chunk-FMBD7UC4-OhxFQXHM.js → chunk-FMBD7UC4-BvqL2ldk.js} +1 -1
- package/dist/server/assets/{chunk-QN33PNHL-LsrLj9Ip.js → chunk-QN33PNHL-DG3BqN6c.js} +1 -1
- package/dist/server/assets/{chunk-QZHKN3VN-BwJQ_Q1g.js → chunk-QZHKN3VN-DbSVY1CL.js} +1 -1
- package/dist/server/assets/{chunk-TZMSLE5B-Bl5v6Ae6.js → chunk-TZMSLE5B-CQddTr4s.js} +1 -1
- package/dist/server/assets/{classDiagram-v2-WZHVMYZB-C6GK2Aoj.js → classDiagram-2ON5EDUG-lqwd66j1.js} +5 -5
- package/dist/server/assets/{classDiagram-2ON5EDUG-C6GK2Aoj.js → classDiagram-v2-WZHVMYZB-lqwd66j1.js} +5 -5
- package/dist/server/assets/{clone-DqNWgXxT.js → clone-Bm83-s49.js} +1 -1
- package/dist/server/assets/{cose-bilkent-S5V4N54A-qzfBNLDd.js → cose-bilkent-S5V4N54A-DJIkSye6.js} +1 -1
- package/dist/server/assets/{dagre-6UL2VRFP-DNZS86ly.js → dagre-6UL2VRFP-8RUa3xMj.js} +6 -6
- package/dist/server/assets/{diagram-PSM6KHXK-C6ImURyq.js → diagram-PSM6KHXK-flj7QMtm.js} +7 -7
- package/dist/server/assets/{diagram-QEK2KX5R-diOF3OBK.js → diagram-QEK2KX5R-CDO-gP_F.js} +6 -6
- package/dist/server/assets/{diagram-S2PKOQOG-CP1GoHeA.js → diagram-S2PKOQOG-WsMSDccc.js} +6 -6
- package/dist/server/assets/{erDiagram-Q2GNP2WA-DnkGfUio.js → erDiagram-Q2GNP2WA-C8BE13tX.js} +4 -4
- package/dist/server/assets/{flowDiagram-NV44I4VS-B1dpItw0.js → flowDiagram-NV44I4VS-CGQvsM8y.js} +5 -5
- package/dist/server/assets/{ganttDiagram-JELNMOA3-CfB_h9HP.js → ganttDiagram-JELNMOA3-CEYzmLyS.js} +1 -1
- package/dist/server/assets/{gitGraph-G5XIXVHT-uIxmtjOh.js → gitGraph-G5XIXVHT-CoBgFiav.js} +6 -6
- package/dist/server/assets/{gitGraphDiagram-V2S2FVAM-BWi1KybI.js → gitGraphDiagram-V2S2FVAM-CTvj2CUF.js} +7 -7
- package/dist/server/assets/{graph-BpINl381.js → graph-CcLRlZbU.js} +2 -2
- package/dist/server/assets/{highlighted-body-B3W2YXNL-X1-_bh6z.js → highlighted-body-B3W2YXNL-B-KIjXRR.js} +1 -1
- package/dist/server/assets/{index-CGqMcWZv.js → index-B4Nb7YRL.js} +5 -5
- package/dist/server/assets/{index-Bqv1Yqbp.js → index-BO8SzaHU.js} +2 -2
- package/dist/server/assets/{index-YfWUTx8U.js → index-BOujpirM.js} +2 -2
- package/dist/server/assets/{index-oLF27Nfu.js → index-BaRsZ-GG.js} +2 -2
- package/dist/server/assets/{index-BUCj1Yl_.js → index-BfY0gR9U.js} +4 -4
- package/dist/server/assets/{index-B_uZBCQp.js → index-BkzW7_Ky.js} +2 -2
- package/dist/server/assets/{index-CF9A2ZU-.js → index-Bn2w4FXC.js} +2 -2
- package/dist/server/assets/{index-CyY0UzHL.js → index-C9huWiJ-.js} +2 -2
- package/dist/server/assets/{index-CecHlx7n.js → index-CEtsZqNS.js} +1 -1
- package/dist/server/assets/{index-IM5Lznch.js → index-CWreRmYM.js} +3 -3
- package/dist/server/assets/{index-Cuy6v4sQ.js → index-Cf9HnAif.js} +5 -5
- package/dist/server/assets/{index-DXfbzq6y.js → index-CoXEmnej.js} +2 -2
- package/dist/server/assets/{index-D3oif9Da.js → index-Cwo9DINN.js} +2 -2
- package/dist/server/assets/{index-Bcu0C2js.js → index-DG7cMtrE.js} +3 -3
- package/dist/server/assets/{index-Ch5w2qx-.js → index-DGLaWKzg.js} +2 -2
- package/dist/server/assets/{index-DEYBFbnW.js → index-DL8Aq-MC.js} +2 -2
- package/dist/server/assets/{index-Bm4xacwx.js → index-DnkAwjsP.js} +1 -1
- package/dist/server/assets/{index-BIn7ueGd.js → index-DtBrHSR6.js} +2 -2
- package/dist/server/assets/{info-VBDWY6EO-CqVqNO_U.js → info-VBDWY6EO-dPnWGou0.js} +6 -6
- package/dist/server/assets/{infoDiagram-HS3SLOUP-LstCwT5M.js → infoDiagram-HS3SLOUP-CanJ2UdU.js} +5 -5
- package/dist/server/assets/{journeyDiagram-XKPGCS4Q-Bq4xdGvc.js → journeyDiagram-XKPGCS4Q-DHO36eI-.js} +4 -4
- package/dist/server/assets/{kanban-definition-3W4ZIXB7-Cz8QJrE-.js → kanban-definition-3W4ZIXB7-CVMwRab0.js} +2 -2
- package/dist/server/assets/{layout-BR6KziPh.js → layout-4KBouLlF.js} +4 -4
- package/dist/server/assets/{mermaid-3ZIDBTTL-CX1H3heG.js → mermaid-3ZIDBTTL-B7ZUN_Qp.js} +1 -1
- package/dist/server/assets/{mermaid-parser.core-DErnTWrV.js → mermaid-parser.core-X4fas7Ck.js} +11 -11
- package/dist/server/assets/{mindmap-definition-VGOIOE7T-OX1eekPS.js → mindmap-definition-VGOIOE7T-abL0gs0k.js} +3 -3
- package/dist/server/assets/{packet-DYOGHKS2-C8FvmUVf.js → packet-DYOGHKS2-CXiTEP4R.js} +6 -6
- package/dist/server/assets/{pie-VRWISCQL-Bb_jFdxC.js → pie-VRWISCQL-LzzDSiEA.js} +6 -6
- package/dist/server/assets/{pieDiagram-ADFJNKIX-CyhwaYTd.js → pieDiagram-ADFJNKIX-Dcjcisnd.js} +7 -7
- package/dist/server/assets/{quadrantDiagram-AYHSOK5B-BRzFRm7k.js → quadrantDiagram-AYHSOK5B-PVZmGqa8.js} +1 -1
- package/dist/server/assets/{radar-ZZBFDIW7-C2cWeYfK.js → radar-ZZBFDIW7-D-RH85sp.js} +6 -6
- package/dist/server/assets/{requirementDiagram-UZGBJVZJ-fJ6FCY2X.js → requirementDiagram-UZGBJVZJ-o3XZfWHL.js} +3 -3
- package/dist/server/assets/{router-BSilWMbB.js → router-CR-9ZnLb.js} +800 -517
- package/dist/server/assets/{sankeyDiagram-TZEHDZUN-DkGxRBc4.js → sankeyDiagram-TZEHDZUN-DJ9W5U5_.js} +1 -1
- package/dist/server/assets/{sequenceDiagram-WL72ISMW-Y9h8QzpA.js → sequenceDiagram-WL72ISMW-DLHwNIY3.js} +3 -3
- package/dist/server/assets/{square-terminal-lPOPYTqx.js → square-terminal-fwIFFrU0.js} +1 -1
- package/dist/server/assets/{stateDiagram-FKZM4ZOC-D9Xo5bg5.js → stateDiagram-FKZM4ZOC-Doy5tRH0.js} +8 -8
- package/dist/server/assets/{stateDiagram-v2-4FDKWEC3-BBj6Aiwx.js → stateDiagram-v2-4FDKWEC3-C9UezhSz.js} +4 -4
- package/dist/server/assets/{timeline-definition-IT6M3QCI-oAS-tbgM.js → timeline-definition-IT6M3QCI-DQgqc0il.js} +2 -2
- package/dist/server/assets/{treemap-GDKQZRPO-BA7EQwOb.js → treemap-GDKQZRPO-BYUsfiTc.js} +6 -6
- package/dist/server/assets/{workspace._workspaceId-BAO3HXDS.js → workspace._workspaceId-D89v3gxu.js} +1 -1
- package/dist/server/assets/{workspace._workspaceId-CREreypp.js → workspace._workspaceId-Du6uUhkr.js} +4 -4
- package/dist/server/assets/{xychartDiagram-PRI3JC2R-CYgqsSC_.js → xychartDiagram-PRI3JC2R-Bu3TCZ6_.js} +1 -1
- package/dist/server/server.js +2 -2
- package/dist/start-server.mjs +1225 -976
- package/package.json +4 -4
- package/dist/client/assets/channel-CpSzKNMk.js +0 -1
- package/dist/client/assets/classDiagram-2ON5EDUG-BgIuIwCk.js +0 -1
- package/dist/client/assets/classDiagram-v2-WZHVMYZB-BgIuIwCk.js +0 -1
- package/dist/client/assets/clone-BnxcJIG7.js +0 -1
- package/dist/client/assets/main-DFLn265W.css +0 -1
- package/dist/client/assets/stateDiagram-v2-4FDKWEC3-BvVewf6E.js +0 -1
- package/dist/client/assets/workspace._workspaceId-C8Z_0tr5.js +0 -2
- package/dist/server/assets/_tanstack-start-manifest_v-Bu_aDRUe.js +0 -4
package/dist/start-server.mjs
CHANGED
|
@@ -30394,6 +30394,20 @@ var init_claude_code = __esm({
|
|
|
30394
30394
|
args: [prompt]
|
|
30395
30395
|
};
|
|
30396
30396
|
}
|
|
30397
|
+
/**
|
|
30398
|
+
* Headless one-shot invocation for automated terminal dispatch (cronjobs,
|
|
30399
|
+
* issue #581). `claude -p "<prompt>"` runs the prompt in print
|
|
30400
|
+
* (non-interactive) mode and exits when the turn completes — unlike
|
|
30401
|
+
* `cliInvocation`'s bare `claude "<prompt>"`, which opens the REPL and
|
|
30402
|
+
* stays parked. The prompt is the value of `-p`, so a leading `-` in it is
|
|
30403
|
+
* consumed as the flag's argument rather than parsed as a flag.
|
|
30404
|
+
*/
|
|
30405
|
+
cliHeadlessInvocation(prompt) {
|
|
30406
|
+
return {
|
|
30407
|
+
command: this.executablePath ?? CLAUDE_CODE_DEFAULT_BINARY,
|
|
30408
|
+
args: ["-p", prompt]
|
|
30409
|
+
};
|
|
30410
|
+
}
|
|
30397
30411
|
/**
|
|
30398
30412
|
* Resume CLI invocation for the chat tab's "Continue in terminal" action.
|
|
30399
30413
|
* `claude --resume <session-id>` reopens the interactive REPL with that
|
|
@@ -31148,6 +31162,18 @@ var init_cursor_cli = __esm({
|
|
|
31148
31162
|
reason: "Cursor CLI has no interactive prompt-loading invocation; falling back to chat."
|
|
31149
31163
|
};
|
|
31150
31164
|
}
|
|
31165
|
+
/**
|
|
31166
|
+
* Cursor CLI is SDK-only (JSON over a managed subprocess) with no
|
|
31167
|
+
* non-interactive vendor CLI to run in a pane, so headless terminal
|
|
31168
|
+
* dispatch (cronjobs `via: "terminal"`, issue #581) also returns the
|
|
31169
|
+
* `unsupported` sentinel — the cronjob service then falls back to chat.
|
|
31170
|
+
*/
|
|
31171
|
+
cliHeadlessInvocation(_prompt) {
|
|
31172
|
+
return {
|
|
31173
|
+
unsupported: true,
|
|
31174
|
+
reason: "Cursor CLI has no non-interactive CLI invocation; falling back to chat."
|
|
31175
|
+
};
|
|
31176
|
+
}
|
|
31151
31177
|
/**
|
|
31152
31178
|
* The chat tab's "Continue in terminal" action has no Cursor equivalent:
|
|
31153
31179
|
* the agent runs over the SDK's managed JSON subprocess, with no
|
|
@@ -32346,6 +32372,18 @@ var init_codex = __esm({
|
|
|
32346
32372
|
args: [prompt]
|
|
32347
32373
|
};
|
|
32348
32374
|
}
|
|
32375
|
+
/**
|
|
32376
|
+
* Headless one-shot invocation for automated terminal dispatch (cronjobs,
|
|
32377
|
+
* issue #581). `codex exec "<prompt>"` runs the prompt non-interactively
|
|
32378
|
+
* and exits on completion — unlike `cliInvocation`'s bare `codex
|
|
32379
|
+
* "<prompt>"`, which opens the interactive TUI and stays parked.
|
|
32380
|
+
*/
|
|
32381
|
+
cliHeadlessInvocation(prompt) {
|
|
32382
|
+
return {
|
|
32383
|
+
command: this.executablePath ?? CODEX_DEFAULT_BINARY,
|
|
32384
|
+
args: ["exec", prompt]
|
|
32385
|
+
};
|
|
32386
|
+
}
|
|
32349
32387
|
/**
|
|
32350
32388
|
* Resume CLI invocation for the chat tab's "Continue in terminal" action.
|
|
32351
32389
|
* `codex resume <session-id>` reopens the interactive Codex TUI with that
|
|
@@ -32735,6 +32773,25 @@ var init_gemini_cli = __esm({
|
|
|
32735
32773
|
args: ["--", prompt]
|
|
32736
32774
|
};
|
|
32737
32775
|
}
|
|
32776
|
+
/**
|
|
32777
|
+
* Headless one-shot invocation for automated terminal dispatch (cronjobs,
|
|
32778
|
+
* issue #581). `gemini --prompt=<prompt>` runs non-interactively and exits
|
|
32779
|
+
* on completion — unlike `cliInvocation`'s `gemini -- "<prompt>"`, which
|
|
32780
|
+
* opens the interactive REPL and stays parked. The `--prompt=<value>`
|
|
32781
|
+
* joined form (rather than `--prompt <value>`) binds the prompt as the
|
|
32782
|
+
* flag's argument even when it starts with `-`, mirroring the leading-dash
|
|
32783
|
+
* safety the interactive `--` guard provides.
|
|
32784
|
+
*
|
|
32785
|
+
* `--output-format` is deliberately omitted (unlike `runSession`, which asks
|
|
32786
|
+
* for NDJSON): a cron terminal shows plain human-readable output in the pane,
|
|
32787
|
+
* not a machine-parsed stream.
|
|
32788
|
+
*/
|
|
32789
|
+
cliHeadlessInvocation(prompt) {
|
|
32790
|
+
return {
|
|
32791
|
+
command: this.executablePath,
|
|
32792
|
+
args: [`--prompt=${prompt}`]
|
|
32793
|
+
};
|
|
32794
|
+
}
|
|
32738
32795
|
/**
|
|
32739
32796
|
* The chat tab's "Continue in terminal" action has no Gemini equivalent:
|
|
32740
32797
|
* the Gemini CLI has no session model, so there's no session ID to resume
|
|
@@ -33230,6 +33287,21 @@ var init_opencode = __esm({
|
|
|
33230
33287
|
args: ["--prompt", prompt]
|
|
33231
33288
|
};
|
|
33232
33289
|
}
|
|
33290
|
+
/**
|
|
33291
|
+
* Headless one-shot invocation for automated terminal dispatch (cronjobs,
|
|
33292
|
+
* issue #581). `opencode run "<prompt>"` executes the prompt
|
|
33293
|
+
* non-interactively and exits on completion — unlike `cliInvocation`'s
|
|
33294
|
+
* `opencode --prompt "<prompt>"`, which opens the interactive TUI and stays
|
|
33295
|
+
* parked. `run` takes the prompt as its trailing positional (same shape as
|
|
33296
|
+
* the SDK `runSession` path above, minus the JSON formatting we only need
|
|
33297
|
+
* when parsing programmatically).
|
|
33298
|
+
*/
|
|
33299
|
+
cliHeadlessInvocation(prompt) {
|
|
33300
|
+
return {
|
|
33301
|
+
command: this.executablePath,
|
|
33302
|
+
args: ["run", prompt]
|
|
33303
|
+
};
|
|
33304
|
+
}
|
|
33233
33305
|
/**
|
|
33234
33306
|
* Resume CLI invocation for the chat tab's "Continue in terminal" action.
|
|
33235
33307
|
* `opencode --session <id>` launches the interactive TUI on that exact
|
|
@@ -101849,10 +101921,19 @@ var cronjobs = sqliteTable("cronjobs", {
|
|
|
101849
101921
|
cronExpression: text2("cron_expression").notNull(),
|
|
101850
101922
|
scope: text2("scope", { enum: ["project", "workspace"] }).notNull(),
|
|
101851
101923
|
workspaceId: text2("workspace_id"),
|
|
101924
|
+
// Where a fire dispatches the prompt (issue #581): "chat" submits a task to
|
|
101925
|
+
// the workspace's cron chat pane (default, backward-compatible); "terminal"
|
|
101926
|
+
// spawns the agent's vendor CLI in a fresh self-closing PTY pane. Mirrors the
|
|
101927
|
+
// `via` discriminator on `workspaces.create` (#551).
|
|
101928
|
+
via: text2("via", { enum: ["chat", "terminal"] }).notNull().default("chat"),
|
|
101852
101929
|
enabled: integer2("enabled", { mode: "boolean" }).notNull().default(true),
|
|
101853
101930
|
createdAt: text2("created_at").notNull(),
|
|
101854
101931
|
lastRunAt: text2("last_run_at"),
|
|
101855
|
-
lastRunStatus: text2("last_run_status", { enum: ["completed", "failed", "skipped"] })
|
|
101932
|
+
lastRunStatus: text2("last_run_status", { enum: ["completed", "failed", "skipped"] }),
|
|
101933
|
+
// For via="terminal" jobs, the id of the terminal spawned by the most recent
|
|
101934
|
+
// fire. Used as the overlap-check handle: if this PTY is still alive when the
|
|
101935
|
+
// next tick fires, the run is skipped rather than launching a second agent.
|
|
101936
|
+
lastTerminalId: text2("last_terminal_id")
|
|
101856
101937
|
});
|
|
101857
101938
|
var usageEvents = sqliteTable(
|
|
101858
101939
|
"usage_events",
|
|
@@ -111498,7 +111579,7 @@ var badgeVariants = cva(
|
|
|
111498
111579
|
// ../../packages/ui/src/components/button.tsx
|
|
111499
111580
|
var import_jsx_runtime20 = __toESM(require_jsx_runtime(), 1);
|
|
111500
111581
|
var buttonVariants = cva(
|
|
111501
|
-
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-
|
|
111582
|
+
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity,transform] outline-none active:scale-[0.97] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
111502
111583
|
{
|
|
111503
111584
|
variants: {
|
|
111504
111585
|
variant: {
|
|
@@ -111597,10 +111678,10 @@ function DialogOverlay2({
|
|
|
111597
111678
|
);
|
|
111598
111679
|
}
|
|
111599
111680
|
var DIALOG_CONTENT_VARIANTS = {
|
|
111600
|
-
default: "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
111681
|
+
default: "ease-out fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
111601
111682
|
"bottom-sheet": [
|
|
111602
111683
|
// Shared
|
|
111603
|
-
"fixed z-50 flex flex-col bg-background shadow-lg duration-200 outline-none",
|
|
111684
|
+
"ease-out fixed z-50 flex flex-col bg-background shadow-lg duration-200 outline-none",
|
|
111604
111685
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
|
111605
111686
|
// Mobile (< lg): bottom drawer
|
|
111606
111687
|
"inset-x-0 bottom-0 w-full max-w-none rounded-t-2xl border border-b-0 p-6",
|
|
@@ -111612,8 +111693,11 @@ var DIALOG_CONTENT_VARIANTS = {
|
|
|
111612
111693
|
].join(" "),
|
|
111613
111694
|
"command-palette": [
|
|
111614
111695
|
// Shared
|
|
111615
|
-
"fixed z-50 flex flex-col bg-background shadow-lg
|
|
111616
|
-
|
|
111696
|
+
"fixed z-50 flex flex-col bg-background shadow-lg outline-none",
|
|
111697
|
+
// Mobile-only open/close animation: the drawer slide is touch-initiated.
|
|
111698
|
+
// Desktop (lg+) gets NO open/close animation — this is a keyboard-summoned
|
|
111699
|
+
// surface hit dozens of times a day; it must appear instantly.
|
|
111700
|
+
"max-lg:duration-200 max-lg:data-[state=closed]:animate-out max-lg:data-[state=closed]:fade-out-0 max-lg:data-[state=open]:animate-in max-lg:data-[state=open]:fade-in-0",
|
|
111617
111701
|
// Mobile (< lg): bottom drawer with the input pinned to the bottom. Flip
|
|
111618
111702
|
// the command's flex direction so the input sits below the results list,
|
|
111619
111703
|
// and flip the input wrapper's divider to a top border to match. The sheet
|
|
@@ -111626,8 +111710,7 @@ var DIALOG_CONTENT_VARIANTS = {
|
|
|
111626
111710
|
"max-lg:data-[state=open]:slide-in-from-bottom max-lg:data-[state=closed]:slide-out-to-bottom",
|
|
111627
111711
|
// Desktop (lg+): anchored in the upper third by its TOP edge — no
|
|
111628
111712
|
// `translate-y`, so the input never moves as the list grows downward.
|
|
111629
|
-
"lg:inset-x-auto lg:bottom-auto lg:top-[12vh] lg:left-1/2 lg:w-full lg:max-w-lg lg:max-h-[70vh] lg:-translate-x-1/2 lg:rounded-lg lg:border"
|
|
111630
|
-
"lg:data-[state=open]:zoom-in-95 lg:data-[state=closed]:zoom-out-95"
|
|
111713
|
+
"lg:inset-x-auto lg:bottom-auto lg:top-[12vh] lg:left-1/2 lg:w-full lg:max-w-lg lg:max-h-[70vh] lg:-translate-x-1/2 lg:rounded-lg lg:border"
|
|
111631
111714
|
].join(" ")
|
|
111632
111715
|
};
|
|
111633
111716
|
var keyboardInsetHolders = /* @__PURE__ */ new Set();
|
|
@@ -111663,7 +111746,15 @@ function DialogContent2({
|
|
|
111663
111746
|
}) {
|
|
111664
111747
|
useKeyboardInset(variant === "command-palette");
|
|
111665
111748
|
return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(DialogPortal2, { "data-slot": "dialog-portal", children: [
|
|
111666
|
-
/* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
|
|
111749
|
+
/* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
|
|
111750
|
+
DialogOverlay2,
|
|
111751
|
+
{
|
|
111752
|
+
className: cn(
|
|
111753
|
+
variant === "command-palette" && "lg:data-[state=open]:animate-none lg:data-[state=closed]:animate-none",
|
|
111754
|
+
overlayClassName
|
|
111755
|
+
)
|
|
111756
|
+
}
|
|
111757
|
+
),
|
|
111667
111758
|
/* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
|
|
111668
111759
|
dist_exports2.Content,
|
|
111669
111760
|
{
|
|
@@ -111774,7 +111865,7 @@ function ContextMenuContent2({
|
|
|
111774
111865
|
if (event.button === 2) event.stopPropagation();
|
|
111775
111866
|
},
|
|
111776
111867
|
className: cn(
|
|
111777
|
-
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
111868
|
+
"ease-out duration-150 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
111778
111869
|
className
|
|
111779
111870
|
),
|
|
111780
111871
|
...props
|
|
@@ -111868,7 +111959,7 @@ function TooltipContent2({
|
|
|
111868
111959
|
"data-slot": "tooltip-content",
|
|
111869
111960
|
sideOffset,
|
|
111870
111961
|
className: cn(
|
|
111871
|
-
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background
|
|
111962
|
+
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in fade-in-0 duration-100 ease-out rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-75",
|
|
111872
111963
|
className
|
|
111873
111964
|
),
|
|
111874
111965
|
...props,
|
|
@@ -120638,7 +120729,7 @@ function mimeTypeFromFilename(filename) {
|
|
|
120638
120729
|
init_src();
|
|
120639
120730
|
init_zod();
|
|
120640
120731
|
import { execFile as execFile6 } from "node:child_process";
|
|
120641
|
-
import { randomUUID as
|
|
120732
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
120642
120733
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3 } from "node:fs";
|
|
120643
120734
|
import { rm } from "node:fs/promises";
|
|
120644
120735
|
import { join as join20 } from "node:path";
|
|
@@ -121554,6 +121645,7 @@ setBrowserLookup((browserId) => browserService.get(browserId));
|
|
|
121554
121645
|
|
|
121555
121646
|
// src/server/services/cronjob-service.ts
|
|
121556
121647
|
init_src();
|
|
121648
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
121557
121649
|
|
|
121558
121650
|
// ../../node_modules/.pnpm/croner@10.0.1/node_modules/croner/dist/croner.js
|
|
121559
121651
|
function T(s6) {
|
|
@@ -122247,10 +122339,12 @@ var CronjobQueries = class _CronjobQueries {
|
|
|
122247
122339
|
cronExpression: job.cronExpression,
|
|
122248
122340
|
scope: job.scope,
|
|
122249
122341
|
workspaceId: job.workspaceId ?? null,
|
|
122342
|
+
via: job.via ?? "chat",
|
|
122250
122343
|
enabled: job.enabled,
|
|
122251
122344
|
createdAt: job.createdAt,
|
|
122252
122345
|
lastRunAt: job.lastRunAt ?? null,
|
|
122253
|
-
lastRunStatus: job.lastRunStatus ?? null
|
|
122346
|
+
lastRunStatus: job.lastRunStatus ?? null,
|
|
122347
|
+
lastTerminalId: job.lastTerminalId ?? null
|
|
122254
122348
|
}).run();
|
|
122255
122349
|
}
|
|
122256
122350
|
});
|
|
@@ -122282,6 +122376,19 @@ var CronjobQueries = class _CronjobQueries {
|
|
|
122282
122376
|
const db2 = getDb();
|
|
122283
122377
|
db2.update(cronjobs).set({ lastRunAt: (/* @__PURE__ */ new Date()).toISOString(), lastRunStatus: status }).where(eq2(cronjobs.id, jobId)).run();
|
|
122284
122378
|
}
|
|
122379
|
+
/**
|
|
122380
|
+
* Record (or clear) the terminal a via="terminal" fire spawned.
|
|
122381
|
+
*
|
|
122382
|
+
* Dedicated targeted UPDATE — same rationale as `updateLastRun`: the fire
|
|
122383
|
+
* path shouldn't load + diff + rewrite the whole file just to stamp one
|
|
122384
|
+
* column. The stored id is the handle the scheduler uses on the next tick to
|
|
122385
|
+
* decide whether the previous run is still in flight (skip) or finished
|
|
122386
|
+
* (spawn a fresh pane). Pass `null` to clear it.
|
|
122387
|
+
*/
|
|
122388
|
+
updateLastTerminal(jobId, terminalId) {
|
|
122389
|
+
const db2 = getDb();
|
|
122390
|
+
db2.update(cronjobs).set({ lastTerminalId: terminalId }).where(eq2(cronjobs.id, jobId)).run();
|
|
122391
|
+
}
|
|
122285
122392
|
/**
|
|
122286
122393
|
* Convert a Drizzle row into the optional-T-shaped `CronjobDefinition`.
|
|
122287
122394
|
*
|
|
@@ -122296,1097 +122403,1242 @@ var CronjobQueries = class _CronjobQueries {
|
|
|
122296
122403
|
cronExpression: row.cronExpression,
|
|
122297
122404
|
scope: row.scope,
|
|
122298
122405
|
workspaceId: row.workspaceId ?? void 0,
|
|
122406
|
+
via: row.via ?? "chat",
|
|
122299
122407
|
enabled: row.enabled,
|
|
122300
122408
|
createdAt: row.createdAt,
|
|
122301
122409
|
lastRunAt: row.lastRunAt ?? void 0,
|
|
122302
|
-
lastRunStatus: row.lastRunStatus
|
|
122410
|
+
lastRunStatus: row.lastRunStatus,
|
|
122411
|
+
lastTerminalId: row.lastTerminalId ?? void 0
|
|
122303
122412
|
};
|
|
122304
122413
|
}
|
|
122305
122414
|
};
|
|
122306
122415
|
|
|
122307
|
-
// src/server/services/
|
|
122308
|
-
|
|
122309
|
-
|
|
122310
|
-
|
|
122311
|
-
|
|
122312
|
-
|
|
122313
|
-
|
|
122314
|
-
|
|
122315
|
-
|
|
122316
|
-
}
|
|
122317
|
-
|
|
122318
|
-
|
|
122319
|
-
|
|
122320
|
-
|
|
122321
|
-
|
|
122322
|
-
|
|
122323
|
-
|
|
122324
|
-
|
|
122325
|
-
|
|
122326
|
-
workspaceId: external_exports.string().optional(),
|
|
122327
|
-
enabled: external_exports.boolean().default(true)
|
|
122328
|
-
});
|
|
122329
|
-
var cronjobUpdateInput = external_exports.object({
|
|
122330
|
-
// `.min(1)` rejects the obviously invalid empty-string `key`/`id` at the
|
|
122331
|
-
// validator instead of letting it reach the service, which would
|
|
122332
|
-
// `loadFile("")` an empty result and 404 on `findIndex`. The legacy
|
|
122333
|
-
// router accepted bare `z.string()` here; the migration is the natural
|
|
122334
|
-
// moment to tighten the boundary so the create + update shapes stay
|
|
122335
|
-
// consistent (`cronjobCreateInput.key` already has `.min(1)`).
|
|
122336
|
-
key: external_exports.string().min(1),
|
|
122337
|
-
id: external_exports.string().min(1),
|
|
122338
|
-
name: external_exports.string().min(1).optional(),
|
|
122339
|
-
prompt: external_exports.string().min(1).optional(),
|
|
122340
|
-
cronExpression: external_exports.string().min(1).optional(),
|
|
122341
|
-
enabled: external_exports.boolean().optional()
|
|
122342
|
-
});
|
|
122343
|
-
var cronjobByIdInput = external_exports.object({
|
|
122344
|
-
key: external_exports.string().min(1),
|
|
122345
|
-
id: external_exports.string().min(1)
|
|
122346
|
-
});
|
|
122347
|
-
var CronjobNotFoundError = class extends Error {
|
|
122348
|
-
constructor(message = "Cronjob not found") {
|
|
122349
|
-
super(message);
|
|
122350
|
-
this.name = "CronjobNotFoundError";
|
|
122351
|
-
}
|
|
122352
|
-
};
|
|
122353
|
-
var InvalidCronExpressionError = class extends Error {
|
|
122354
|
-
constructor(message = "Invalid cron expression") {
|
|
122355
|
-
super(message);
|
|
122356
|
-
this.name = "InvalidCronExpressionError";
|
|
122357
|
-
}
|
|
122358
|
-
};
|
|
122359
|
-
var CronjobWorkspaceMissingError = class extends Error {
|
|
122360
|
-
constructor(message = "workspaceId is required for workspace-scoped cronjobs") {
|
|
122361
|
-
super(message);
|
|
122362
|
-
this.name = "CronjobWorkspaceMissingError";
|
|
122363
|
-
}
|
|
122364
|
-
};
|
|
122365
|
-
var CronjobProjectNotFoundError = class extends Error {
|
|
122366
|
-
constructor(message = "Project not found") {
|
|
122367
|
-
super(message);
|
|
122368
|
-
this.name = "CronjobProjectNotFoundError";
|
|
122369
|
-
}
|
|
122370
|
-
};
|
|
122371
|
-
var CronjobService = class {
|
|
122372
|
-
constructor(queries = new CronjobQueries()) {
|
|
122373
|
-
this.queries = queries;
|
|
122374
|
-
}
|
|
122375
|
-
// -------------------------------------------------------------------------
|
|
122376
|
-
// Read operations
|
|
122377
|
-
// -------------------------------------------------------------------------
|
|
122416
|
+
// src/server/services/terminal-service.ts
|
|
122417
|
+
init_src();
|
|
122418
|
+
init_zod();
|
|
122419
|
+
|
|
122420
|
+
// src/server/infra/terminals/terminal-pool.ts
|
|
122421
|
+
init_src();
|
|
122422
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
122423
|
+
import { existsSync as existsSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
122424
|
+
import { tmpdir } from "node:os";
|
|
122425
|
+
import { join as join19, sep as sep3 } from "node:path";
|
|
122426
|
+
var log20 = createLogger("terminal-pool");
|
|
122427
|
+
var MAX_SCROLLBACK_SIZE = 1e5;
|
|
122428
|
+
var TerminalPool = class {
|
|
122429
|
+
/** terminalId -> session */
|
|
122430
|
+
terminals = /* @__PURE__ */ new Map();
|
|
122431
|
+
/** workspaceId -> Set<terminalId> (reverse index for workspace-level cleanup) */
|
|
122432
|
+
workspaceTerminals = /* @__PURE__ */ new Map();
|
|
122433
|
+
/** terminalId -> Set<listener> for live output streaming */
|
|
122434
|
+
outputListeners = /* @__PURE__ */ new Map();
|
|
122378
122435
|
/**
|
|
122379
|
-
*
|
|
122380
|
-
*
|
|
122436
|
+
* terminalId -> in-flight spawn promise. A single terminal is created via TWO
|
|
122437
|
+
* concurrent paths — the WebSocket handler (spawn-on-`getSession`-miss) and
|
|
122438
|
+
* the tRPC `terminal.create` mutation. Without deduplication both spawn a PTY
|
|
122439
|
+
* and the second `terminals.set` overwrites the first, so the client stays
|
|
122440
|
+
* wired to one PTY while the server's session map (and thus scrollback /
|
|
122441
|
+
* `terminals output` / reconnect-replay) points at the other. That's the
|
|
122442
|
+
* "output on screen but empty server scrollback until reload" bug
|
|
122443
|
+
* (band-app/band#617). This map makes concurrent spawns share ONE PTY.
|
|
122444
|
+
*/
|
|
122445
|
+
spawning = /* @__PURE__ */ new Map();
|
|
122446
|
+
/**
|
|
122447
|
+
* Spawn (or return the already-spawning/spawned) PTY for a terminalId.
|
|
122381
122448
|
*
|
|
122382
|
-
*
|
|
122383
|
-
*
|
|
122384
|
-
*
|
|
122385
|
-
*
|
|
122449
|
+
* Idempotent per terminalId: if a session already exists it is returned
|
|
122450
|
+
* as-is, and concurrent spawn calls for the same id share a single in-flight
|
|
122451
|
+
* promise — so the WebSocket and tRPC create paths never create competing
|
|
122452
|
+
* PTYs. `workspaceRoot` is the absolute worktree path; resolving the
|
|
122453
|
+
* workspaceId to a path is the service tier's job so the pool stays oblivious
|
|
122454
|
+
* to the workspace registry.
|
|
122386
122455
|
*/
|
|
122387
|
-
|
|
122388
|
-
|
|
122389
|
-
|
|
122390
|
-
|
|
122391
|
-
|
|
122392
|
-
|
|
122393
|
-
|
|
122394
|
-
|
|
122395
|
-
|
|
122396
|
-
|
|
122397
|
-
|
|
122398
|
-
};
|
|
122456
|
+
async spawn(workspaceId, terminalId, workspaceRoot, options2, onExit) {
|
|
122457
|
+
const existing = this.terminals.get(terminalId);
|
|
122458
|
+
if (existing) return existing;
|
|
122459
|
+
const inflight = this.spawning.get(terminalId);
|
|
122460
|
+
if (inflight) return inflight;
|
|
122461
|
+
const promise = this.spawnNew(workspaceId, terminalId, workspaceRoot, options2, onExit);
|
|
122462
|
+
this.spawning.set(terminalId, promise);
|
|
122463
|
+
try {
|
|
122464
|
+
return await promise;
|
|
122465
|
+
} finally {
|
|
122466
|
+
this.spawning.delete(terminalId);
|
|
122399
122467
|
}
|
|
122400
|
-
return { jobs: this.queries.listAll() };
|
|
122401
122468
|
}
|
|
122402
|
-
|
|
122403
|
-
|
|
122404
|
-
const
|
|
122405
|
-
|
|
122406
|
-
|
|
122469
|
+
async spawnNew(workspaceId, terminalId, workspaceRoot, options2, onExit) {
|
|
122470
|
+
const shell = defaultShell();
|
|
122471
|
+
const resolvedPath = await shellPath();
|
|
122472
|
+
const env = {};
|
|
122473
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
122474
|
+
if (value != null) {
|
|
122475
|
+
env[key] = value;
|
|
122476
|
+
}
|
|
122477
|
+
}
|
|
122478
|
+
env.PATH = resolvedPath;
|
|
122479
|
+
env.TERM = "xterm-256color";
|
|
122480
|
+
env.COLORTERM = "truecolor";
|
|
122481
|
+
env.COLORFGBG = "15;0";
|
|
122482
|
+
delete env.PORT;
|
|
122483
|
+
if (options2?.env) {
|
|
122484
|
+
Object.assign(env, options2.env);
|
|
122485
|
+
}
|
|
122486
|
+
env.BAND_DISPATCH = "terminal";
|
|
122487
|
+
let cwd = workspaceRoot;
|
|
122488
|
+
if (options2?.cwd) {
|
|
122489
|
+
const resolved = join19(workspaceRoot, options2.cwd);
|
|
122490
|
+
if (resolved !== workspaceRoot && !resolved.startsWith(workspaceRoot + sep3)) {
|
|
122491
|
+
log20.warn(
|
|
122492
|
+
"Ignoring cwd %s \u2014 resolves outside workspace root %s",
|
|
122493
|
+
options2.cwd,
|
|
122494
|
+
workspaceRoot
|
|
122495
|
+
);
|
|
122496
|
+
} else if (existsSync8(resolved)) {
|
|
122497
|
+
cwd = resolved;
|
|
122498
|
+
} else {
|
|
122499
|
+
log20.warn("Ignoring cwd %s \u2014 directory does not exist", options2.cwd);
|
|
122500
|
+
}
|
|
122501
|
+
}
|
|
122502
|
+
if (!existsSync8(cwd)) {
|
|
122503
|
+
throw new Error(`Workspace directory does not exist: ${cwd}`);
|
|
122504
|
+
}
|
|
122505
|
+
if (!existsSync8(shell)) {
|
|
122506
|
+
throw new Error(`Shell not found: ${shell}`);
|
|
122507
|
+
}
|
|
122508
|
+
log20.debug(
|
|
122509
|
+
"Spawning shell %s in %s for terminal %s (PATH=%s)",
|
|
122510
|
+
shell,
|
|
122511
|
+
cwd,
|
|
122512
|
+
terminalId,
|
|
122513
|
+
resolvedPath.slice(0, 200)
|
|
122514
|
+
);
|
|
122515
|
+
const nodePty = await import("node-pty");
|
|
122516
|
+
let ptyProcess;
|
|
122517
|
+
try {
|
|
122518
|
+
ptyProcess = nodePty.spawn(shell, [], {
|
|
122519
|
+
name: "xterm-256color",
|
|
122520
|
+
cols: 80,
|
|
122521
|
+
rows: 24,
|
|
122522
|
+
cwd,
|
|
122523
|
+
env
|
|
122524
|
+
});
|
|
122525
|
+
} catch (err) {
|
|
122526
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
122527
|
+
log20.error("pty.spawn failed: %s (shell=%s, cwd=%s)", msg, shell, cwd);
|
|
122528
|
+
throw err;
|
|
122529
|
+
}
|
|
122530
|
+
const session = { pty: ptyProcess, scrollback: "", workspaceId };
|
|
122531
|
+
this.terminals.set(terminalId, session);
|
|
122532
|
+
if (options2?.command) {
|
|
122533
|
+
this.autoRunCommand(session, options2.command);
|
|
122534
|
+
}
|
|
122535
|
+
let ids = this.workspaceTerminals.get(workspaceId);
|
|
122536
|
+
if (!ids) {
|
|
122537
|
+
ids = /* @__PURE__ */ new Set();
|
|
122538
|
+
this.workspaceTerminals.set(workspaceId, ids);
|
|
122539
|
+
}
|
|
122540
|
+
ids.add(terminalId);
|
|
122541
|
+
ptyProcess.onData((data) => {
|
|
122542
|
+
session.scrollback += data;
|
|
122543
|
+
if (session.scrollback.length > MAX_SCROLLBACK_SIZE) {
|
|
122544
|
+
session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_SIZE);
|
|
122545
|
+
}
|
|
122546
|
+
const listeners3 = this.outputListeners.get(terminalId);
|
|
122547
|
+
if (listeners3) {
|
|
122548
|
+
for (const cb2 of listeners3) {
|
|
122549
|
+
try {
|
|
122550
|
+
cb2(data);
|
|
122551
|
+
} catch {
|
|
122552
|
+
}
|
|
122553
|
+
}
|
|
122554
|
+
}
|
|
122555
|
+
});
|
|
122556
|
+
ptyProcess.onExit(() => {
|
|
122557
|
+
log20.debug("Terminal exited: %s (workspace %s)", terminalId, workspaceId);
|
|
122558
|
+
const explicitlyKilled = !this.terminals.has(terminalId);
|
|
122559
|
+
this.terminals.delete(terminalId);
|
|
122560
|
+
this.outputListeners.delete(terminalId);
|
|
122561
|
+
const set = this.workspaceTerminals.get(workspaceId);
|
|
122562
|
+
if (set) {
|
|
122563
|
+
set.delete(terminalId);
|
|
122564
|
+
if (set.size === 0) {
|
|
122565
|
+
this.workspaceTerminals.delete(workspaceId);
|
|
122566
|
+
}
|
|
122567
|
+
}
|
|
122568
|
+
if (session.autoRunFile) {
|
|
122569
|
+
try {
|
|
122570
|
+
unlinkSync2(session.autoRunFile);
|
|
122571
|
+
} catch {
|
|
122572
|
+
}
|
|
122573
|
+
}
|
|
122574
|
+
if (onExit && !explicitlyKilled) {
|
|
122575
|
+
try {
|
|
122576
|
+
onExit();
|
|
122577
|
+
} catch (err) {
|
|
122578
|
+
log20.warn("onExit callback threw for terminal %s: %s", terminalId, err);
|
|
122579
|
+
}
|
|
122580
|
+
}
|
|
122581
|
+
});
|
|
122582
|
+
return session;
|
|
122407
122583
|
}
|
|
122408
|
-
// -------------------------------------------------------------------------
|
|
122409
|
-
// Mutations
|
|
122410
|
-
// -------------------------------------------------------------------------
|
|
122411
122584
|
/**
|
|
122412
|
-
*
|
|
122585
|
+
* Auto-run the workspace's initial command inside a freshly spawned PTY,
|
|
122586
|
+
* robustly.
|
|
122413
122587
|
*
|
|
122414
|
-
*
|
|
122415
|
-
*
|
|
122416
|
-
*
|
|
122417
|
-
*
|
|
122418
|
-
* its
|
|
122588
|
+
* Writing a long command line straight into a cold PTY is racy on three
|
|
122589
|
+
* fronts, all of which surfaced in production with `via=terminal`
|
|
122590
|
+
* prompt-as-argv launches (`'<agent-binary>' '<entire-prompt>'`):
|
|
122591
|
+
*
|
|
122592
|
+
* 1. **Dropped newline.** The shell may not have finished sourcing its
|
|
122593
|
+
* rc files, and the tty can still be in its startup canonical-mode
|
|
122594
|
+
* window. A trailing `\n` written then is swallowed — the command
|
|
122595
|
+
* is typed at the prompt but never executed.
|
|
122596
|
+
* 2. **Truncation.** A single input line longer than the tty's
|
|
122597
|
+
* canonical buffer (`MAX_CANON`, ~4 KB) is clipped. Long prompts
|
|
122598
|
+
* blow past that easily.
|
|
122599
|
+
* 3. **UTF-8 corruption.** A multi-byte sequence split across a tty
|
|
122600
|
+
* buffer boundary is mangled (the classic em-dash → mojibake).
|
|
122601
|
+
*
|
|
122602
|
+
* Fix both layers: (1) stage the command's bytes in a temp file so they
|
|
122603
|
+
* never traverse the tty's canonical input buffer — sidestepping the
|
|
122604
|
+
* truncation and UTF-8-split failures entirely — and (2) wait for the
|
|
122605
|
+
* shell to emit its first output (its prompt) before writing the
|
|
122606
|
+
* *short* `source <file>` line. The short line is well under the
|
|
122607
|
+
* canonical limit and lands after the cold-start window, so its newline
|
|
122608
|
+
* survives. `source` runs the command in the current interactive shell,
|
|
122609
|
+
* so the user keeps their session afterwards.
|
|
122419
122610
|
*/
|
|
122420
|
-
|
|
122421
|
-
|
|
122422
|
-
|
|
122423
|
-
|
|
122611
|
+
autoRunCommand(session, command) {
|
|
122612
|
+
const pty = session.pty;
|
|
122613
|
+
let cmdFile;
|
|
122614
|
+
try {
|
|
122615
|
+
cmdFile = join19(tmpdir(), `band-autorun-${randomUUID2()}.sh`);
|
|
122616
|
+
writeFileSync2(cmdFile, `${command}
|
|
122617
|
+
`, { encoding: "utf-8", mode: 384, flag: "wx" });
|
|
122618
|
+
session.autoRunFile = cmdFile;
|
|
122619
|
+
} catch (err) {
|
|
122620
|
+
log20.warn(
|
|
122621
|
+
"autoRunCommand: temp-file staging failed (%s); writing command directly",
|
|
122622
|
+
err instanceof Error ? err.message : String(err)
|
|
122623
|
+
);
|
|
122624
|
+
pty.write(`${command}
|
|
122625
|
+
`);
|
|
122626
|
+
return;
|
|
122424
122627
|
}
|
|
122425
|
-
const
|
|
122426
|
-
const
|
|
122427
|
-
|
|
122428
|
-
|
|
122429
|
-
|
|
122430
|
-
|
|
122431
|
-
|
|
122432
|
-
|
|
122433
|
-
|
|
122434
|
-
|
|
122628
|
+
const quotedPath = cmdFile.replace(/'/g, `'\\''`);
|
|
122629
|
+
const sourceLine = `. '${quotedPath}'
|
|
122630
|
+
`;
|
|
122631
|
+
let done = false;
|
|
122632
|
+
const inject = () => {
|
|
122633
|
+
if (done) return;
|
|
122634
|
+
done = true;
|
|
122635
|
+
try {
|
|
122636
|
+
pty.write(sourceLine);
|
|
122637
|
+
} catch (err) {
|
|
122638
|
+
log20.warn("autoRunCommand: failed to inject command line (%s)", err);
|
|
122639
|
+
}
|
|
122435
122640
|
};
|
|
122436
|
-
|
|
122437
|
-
|
|
122438
|
-
|
|
122439
|
-
|
|
122641
|
+
let dataDisposed = false;
|
|
122642
|
+
const disposeData = () => {
|
|
122643
|
+
if (dataDisposed) return;
|
|
122644
|
+
dataDisposed = true;
|
|
122645
|
+
dataDisposable.dispose();
|
|
122646
|
+
};
|
|
122647
|
+
const dataDisposable = pty.onData(() => {
|
|
122648
|
+
disposeData();
|
|
122649
|
+
inject();
|
|
122650
|
+
});
|
|
122651
|
+
const timer = setTimeout(inject, 750);
|
|
122652
|
+
timer.unref();
|
|
122653
|
+
let exitDisposed = false;
|
|
122654
|
+
const exitDisposable = pty.onExit(() => {
|
|
122655
|
+
done = true;
|
|
122656
|
+
clearTimeout(timer);
|
|
122657
|
+
disposeData();
|
|
122658
|
+
if (exitDisposed) return;
|
|
122659
|
+
exitDisposed = true;
|
|
122660
|
+
exitDisposable.dispose();
|
|
122661
|
+
});
|
|
122440
122662
|
}
|
|
122441
122663
|
/**
|
|
122442
|
-
*
|
|
122443
|
-
*
|
|
122444
|
-
* Each optional field is applied only when present, matching the legacy
|
|
122445
|
-
* router behavior. If `cronExpression` is supplied it's validated before
|
|
122446
|
-
* the write. Throws `CronjobNotFoundError` if no job matches `key`+`id`.
|
|
122664
|
+
* Returns an existing PTY session by terminalId, or undefined.
|
|
122447
122665
|
*/
|
|
122448
|
-
|
|
122449
|
-
|
|
122450
|
-
this.assertValidCron(input.cronExpression);
|
|
122451
|
-
}
|
|
122452
|
-
const file = this.queries.loadFile(input.key);
|
|
122453
|
-
const job = file.jobs.find((j) => j.id === input.id);
|
|
122454
|
-
if (!job) throw new CronjobNotFoundError();
|
|
122455
|
-
if (input.name !== void 0) job.name = input.name;
|
|
122456
|
-
if (input.prompt !== void 0) job.prompt = input.prompt;
|
|
122457
|
-
if (input.cronExpression !== void 0) job.cronExpression = input.cronExpression;
|
|
122458
|
-
if (input.enabled !== void 0) job.enabled = input.enabled;
|
|
122459
|
-
this.queries.saveFile(input.key, file);
|
|
122460
|
-
this.reloadSchedules();
|
|
122461
|
-
return { job };
|
|
122666
|
+
get(terminalId) {
|
|
122667
|
+
return this.terminals.get(terminalId);
|
|
122462
122668
|
}
|
|
122463
122669
|
/**
|
|
122464
|
-
*
|
|
122465
|
-
*
|
|
122466
|
-
* Throws `CronjobNotFoundError` if the job is already gone.
|
|
122670
|
+
* Resize the underlying PTY. No-op if the terminal isn't known to the pool.
|
|
122467
122671
|
*/
|
|
122468
|
-
|
|
122469
|
-
const
|
|
122470
|
-
|
|
122471
|
-
|
|
122472
|
-
|
|
122473
|
-
this.queries.saveFile(key, file);
|
|
122474
|
-
this.reloadSchedules();
|
|
122475
|
-
return { ok: true };
|
|
122672
|
+
resize(terminalId, cols, rows) {
|
|
122673
|
+
const session = this.terminals.get(terminalId);
|
|
122674
|
+
if (session) {
|
|
122675
|
+
session.pty.resize(cols, rows);
|
|
122676
|
+
}
|
|
122476
122677
|
}
|
|
122477
122678
|
/**
|
|
122478
|
-
*
|
|
122479
|
-
*
|
|
122480
|
-
* Uses the same `band:cronId`-labeled chat as the scheduled fire so the
|
|
122481
|
-
* user sees both paths converge on a single dedicated conversation
|
|
122482
|
-
* (issue #520). `submitTask` may throw `TaskConflictError`, which the API
|
|
122483
|
-
* tier translates into a 409 Conflict response.
|
|
122679
|
+
* List metadata for every PTY session bound to the given workspace.
|
|
122484
122680
|
*
|
|
122485
|
-
*
|
|
122486
|
-
*
|
|
122487
|
-
*
|
|
122488
|
-
* freshly-created chat remains in the workspace as an orphan with no
|
|
122489
|
-
* task ever attached. The next manual `trigger` (or scheduled fire)
|
|
122490
|
-
* reuses that chat via its `band:cronId` label, so the orphan
|
|
122491
|
-
* self-heals — but the user may briefly see an empty chat pane. The
|
|
122492
|
-
* legacy implementation had the same gap; preserved here to match
|
|
122493
|
-
* existing behavior. If this ever needs to be tightened, the fix is
|
|
122494
|
-
* either (a) wrap both calls in a try/catch that deletes the chat on
|
|
122495
|
-
* unexpected failure, or (b) defer `createChat` until `submitTask`
|
|
122496
|
-
* succeeds (which would require buffering the chat name/labels on the
|
|
122497
|
-
* task row).
|
|
122681
|
+
* The returned `title` is the foreground process name reported by the
|
|
122682
|
+
* PTY; reading `pty.process` can throw if the process has already exited
|
|
122683
|
+
* between the reverse-index lookup and the read, so the read is wrapped.
|
|
122498
122684
|
*/
|
|
122499
|
-
|
|
122500
|
-
const
|
|
122501
|
-
if (!
|
|
122502
|
-
const
|
|
122503
|
-
|
|
122504
|
-
|
|
122505
|
-
|
|
122506
|
-
|
|
122685
|
+
list(workspaceId) {
|
|
122686
|
+
const ids = this.workspaceTerminals.get(workspaceId);
|
|
122687
|
+
if (!ids) return [];
|
|
122688
|
+
const result = [];
|
|
122689
|
+
for (const terminalId of ids) {
|
|
122690
|
+
const session = this.terminals.get(terminalId);
|
|
122691
|
+
if (session) {
|
|
122692
|
+
let title = "";
|
|
122693
|
+
try {
|
|
122694
|
+
title = session.pty.process;
|
|
122695
|
+
} catch {
|
|
122696
|
+
}
|
|
122697
|
+
result.push({
|
|
122698
|
+
terminalId,
|
|
122699
|
+
workspaceId,
|
|
122700
|
+
pid: session.pty.pid,
|
|
122701
|
+
scrollbackLength: session.scrollback.length,
|
|
122702
|
+
title
|
|
122703
|
+
});
|
|
122704
|
+
}
|
|
122705
|
+
}
|
|
122706
|
+
return result;
|
|
122507
122707
|
}
|
|
122508
|
-
// -------------------------------------------------------------------------
|
|
122509
|
-
// Lifecycle hooks for workspace/project removal
|
|
122510
|
-
// -------------------------------------------------------------------------
|
|
122511
122708
|
/**
|
|
122512
|
-
*
|
|
122513
|
-
*
|
|
122514
|
-
* Called when a workspace or project is removed. Order matters: stop
|
|
122515
|
-
* timers first so no further fires queue up between the timer-stop and
|
|
122516
|
-
* the row-delete. An in-flight fire is already safe in either order
|
|
122517
|
-
* because `executeCronjob` operates on closed-over `job`/`fileKey` (it
|
|
122518
|
-
* never re-reads the row) and `safeUpdateLastRun` swallows the
|
|
122519
|
-
* row-missing case. The two steps were previously two separate calls
|
|
122520
|
-
* (`stopJobsForKey` + `deleteCronjobFile`) at every call site — the
|
|
122521
|
-
* legacy callers always invoked them in pairs, so they're collapsed
|
|
122522
|
-
* here to make the contract harder to misuse.
|
|
122523
|
-
*
|
|
122524
|
-
* Partial-failure recovery: if `deleteFile` throws (e.g. a transient DB
|
|
122525
|
-
* lock), the timers are already stopped but the rows remain in the DB.
|
|
122526
|
-
* The next `reloadSchedules()` call — triggered by any subsequent
|
|
122527
|
-
* cronjob mutation, or by a server restart — re-reads the rows from the
|
|
122528
|
-
* DB and re-registers the timers, so the orphaned rows self-heal
|
|
122529
|
-
* without manual intervention. We do NOT roll back the `stop()` calls
|
|
122530
|
-
* because re-binding timers for a workspace that's mid-deletion is
|
|
122531
|
-
* worse than leaving them stopped: the workspace path is about to be
|
|
122532
|
-
* removed and any fire that races the removal would target a
|
|
122533
|
-
* non-existent worktree. Re-registration on the next reload is the
|
|
122534
|
-
* intentional recovery strategy; operators encountering a partial
|
|
122535
|
-
* failure on this path should expect the next reload to be a no-op
|
|
122536
|
-
* once the workspace removal completes and the rows are re-attempted.
|
|
122709
|
+
* Returns the scrollback buffer for a terminal, optionally limited to the
|
|
122710
|
+
* last N lines. Returns `null` if the terminal is not found.
|
|
122537
122711
|
*/
|
|
122538
|
-
|
|
122539
|
-
this.
|
|
122540
|
-
|
|
122541
|
-
|
|
122542
|
-
|
|
122543
|
-
|
|
122544
|
-
// -------------------------------------------------------------------------
|
|
122545
|
-
/** Start the cronjob scheduler. Called once on server boot. */
|
|
122546
|
-
start() {
|
|
122547
|
-
const state = schedulerState();
|
|
122548
|
-
if (state.started) return;
|
|
122549
|
-
state.started = true;
|
|
122550
|
-
this.loadAndScheduleAll();
|
|
122551
|
-
log20.info("cronjob scheduler started");
|
|
122712
|
+
getScrollback(terminalId, lines) {
|
|
122713
|
+
const session = this.terminals.get(terminalId);
|
|
122714
|
+
if (!session) return null;
|
|
122715
|
+
if (lines == null) return session.scrollback;
|
|
122716
|
+
const allLines = session.scrollback.split("\n");
|
|
122717
|
+
return allLines.slice(-lines).join("\n");
|
|
122552
122718
|
}
|
|
122553
|
-
/**
|
|
122554
|
-
|
|
122555
|
-
|
|
122556
|
-
|
|
122557
|
-
|
|
122719
|
+
/**
|
|
122720
|
+
* Writes data to a terminal's PTY stdin.
|
|
122721
|
+
* Returns false if the terminal is not found.
|
|
122722
|
+
*/
|
|
122723
|
+
write(terminalId, data) {
|
|
122724
|
+
const session = this.terminals.get(terminalId);
|
|
122725
|
+
if (!session) return false;
|
|
122726
|
+
session.pty.write(data);
|
|
122727
|
+
return true;
|
|
122728
|
+
}
|
|
122729
|
+
/**
|
|
122730
|
+
* Subscribe to live output from a terminal's PTY.
|
|
122731
|
+
* Returns an unsubscribe function.
|
|
122732
|
+
*/
|
|
122733
|
+
subscribeOutput(terminalId, callback) {
|
|
122734
|
+
let listeners3 = this.outputListeners.get(terminalId);
|
|
122735
|
+
if (!listeners3) {
|
|
122736
|
+
listeners3 = /* @__PURE__ */ new Set();
|
|
122737
|
+
this.outputListeners.set(terminalId, listeners3);
|
|
122558
122738
|
}
|
|
122559
|
-
|
|
122560
|
-
|
|
122561
|
-
|
|
122739
|
+
listeners3.add(callback);
|
|
122740
|
+
return () => {
|
|
122741
|
+
listeners3.delete(callback);
|
|
122742
|
+
if (listeners3.size === 0) {
|
|
122743
|
+
this.outputListeners.delete(terminalId);
|
|
122744
|
+
}
|
|
122745
|
+
};
|
|
122562
122746
|
}
|
|
122563
|
-
/**
|
|
122564
|
-
|
|
122565
|
-
|
|
122566
|
-
|
|
122747
|
+
/**
|
|
122748
|
+
* Kill a single terminal by terminalId.
|
|
122749
|
+
*/
|
|
122750
|
+
kill(terminalId) {
|
|
122751
|
+
const session = this.terminals.get(terminalId);
|
|
122752
|
+
if (session) {
|
|
122753
|
+
session.pty.kill();
|
|
122754
|
+
this.terminals.delete(terminalId);
|
|
122755
|
+
const set = this.workspaceTerminals.get(session.workspaceId);
|
|
122756
|
+
if (set) {
|
|
122757
|
+
set.delete(terminalId);
|
|
122758
|
+
if (set.size === 0) {
|
|
122759
|
+
this.workspaceTerminals.delete(session.workspaceId);
|
|
122760
|
+
}
|
|
122761
|
+
}
|
|
122762
|
+
}
|
|
122567
122763
|
}
|
|
122568
122764
|
/**
|
|
122569
|
-
*
|
|
122570
|
-
*
|
|
122571
|
-
* Private because the only correct call site is `removeForKey` above:
|
|
122572
|
-
* stopping the timers without also dropping the rows would re-schedule
|
|
122573
|
-
* the same jobs on the next `reloadSchedules()` tick. Keeping it
|
|
122574
|
-
* accessible only through `removeForKey` prevents future callers from
|
|
122575
|
-
* accidentally invoking just one half of the pair.
|
|
122765
|
+
* Kill all terminals for a workspace.
|
|
122576
122766
|
*/
|
|
122577
|
-
|
|
122578
|
-
const
|
|
122579
|
-
|
|
122580
|
-
for (const
|
|
122581
|
-
const
|
|
122582
|
-
if (
|
|
122583
|
-
|
|
122584
|
-
|
|
122585
|
-
log20.info({ jobId: job.id, key }, "stopped cronjob");
|
|
122767
|
+
killWorkspace(workspaceId) {
|
|
122768
|
+
const ids = this.workspaceTerminals.get(workspaceId);
|
|
122769
|
+
if (!ids) return;
|
|
122770
|
+
for (const terminalId of ids) {
|
|
122771
|
+
const session = this.terminals.get(terminalId);
|
|
122772
|
+
if (session) {
|
|
122773
|
+
session.pty.kill();
|
|
122774
|
+
this.terminals.delete(terminalId);
|
|
122586
122775
|
}
|
|
122587
122776
|
}
|
|
122777
|
+
this.workspaceTerminals.delete(workspaceId);
|
|
122778
|
+
}
|
|
122779
|
+
/**
|
|
122780
|
+
* Kill every tracked PTY (server shutdown path).
|
|
122781
|
+
*/
|
|
122782
|
+
killAll() {
|
|
122783
|
+
for (const [, session] of this.terminals) {
|
|
122784
|
+
session.pty.kill();
|
|
122785
|
+
}
|
|
122786
|
+
this.terminals.clear();
|
|
122787
|
+
this.workspaceTerminals.clear();
|
|
122788
|
+
}
|
|
122789
|
+
};
|
|
122790
|
+
var terminalPool = new TerminalPool();
|
|
122791
|
+
|
|
122792
|
+
// src/server/services/_utils/terminal-layout-manager.ts
|
|
122793
|
+
var manager = new DockviewLayoutManager("terminal_layout");
|
|
122794
|
+
var getTerminalLayout = (workspaceId) => manager.get(workspaceId);
|
|
122795
|
+
var saveTerminalLayout = (workspaceId, tree) => manager.save(workspaceId, tree);
|
|
122796
|
+
var deleteTerminalLayout = (workspaceId) => manager.delete(workspaceId);
|
|
122797
|
+
function addTerminalToLayout(workspaceId, terminalId, opts) {
|
|
122798
|
+
manager.addPanel(workspaceId, {
|
|
122799
|
+
id: terminalId,
|
|
122800
|
+
contentComponent: "terminalTab",
|
|
122801
|
+
tabComponent: "terminalTab",
|
|
122802
|
+
title: opts?.title ?? "Terminal",
|
|
122803
|
+
params: {
|
|
122804
|
+
workspaceId,
|
|
122805
|
+
terminalId,
|
|
122806
|
+
...opts?.command ? { command: opts.command } : {},
|
|
122807
|
+
...opts?.cwd ? { cwd: opts.cwd } : {},
|
|
122808
|
+
...opts?.env ? { env: opts.env } : {}
|
|
122809
|
+
}
|
|
122810
|
+
});
|
|
122811
|
+
}
|
|
122812
|
+
function removeTerminalFromLayout(workspaceId, terminalId) {
|
|
122813
|
+
manager.removePanel(workspaceId, terminalId);
|
|
122814
|
+
}
|
|
122815
|
+
|
|
122816
|
+
// src/server/services/terminal-service.ts
|
|
122817
|
+
var log21 = createLogger("terminal-service");
|
|
122818
|
+
var TerminalPaneConfigSchema = external_exports.object({
|
|
122819
|
+
name: external_exports.string().optional(),
|
|
122820
|
+
command: external_exports.string().optional(),
|
|
122821
|
+
cwd: external_exports.string().optional(),
|
|
122822
|
+
env: external_exports.record(external_exports.string()).optional(),
|
|
122823
|
+
focus: external_exports.boolean().optional()
|
|
122824
|
+
});
|
|
122825
|
+
var PaneNodeSchema = external_exports.object({
|
|
122826
|
+
pane: TerminalPaneConfigSchema
|
|
122827
|
+
});
|
|
122828
|
+
var TerminalLayoutNodeSchema = external_exports.lazy(
|
|
122829
|
+
() => external_exports.union([
|
|
122830
|
+
PaneNodeSchema,
|
|
122831
|
+
external_exports.object({
|
|
122832
|
+
direction: external_exports.enum(["horizontal", "vertical"]),
|
|
122833
|
+
split: external_exports.number().min(0.1).max(0.9).optional().default(0.5),
|
|
122834
|
+
children: external_exports.tuple([TerminalLayoutNodeSchema, TerminalLayoutNodeSchema])
|
|
122835
|
+
})
|
|
122836
|
+
])
|
|
122837
|
+
);
|
|
122838
|
+
var WorkspaceTerminalConfigSchema = external_exports.object({
|
|
122839
|
+
layout: TerminalLayoutNodeSchema
|
|
122840
|
+
});
|
|
122841
|
+
var TerminalService = class {
|
|
122842
|
+
constructor(pool2 = terminalPool) {
|
|
122843
|
+
this.pool = pool2;
|
|
122588
122844
|
}
|
|
122589
122845
|
// -------------------------------------------------------------------------
|
|
122590
|
-
//
|
|
122846
|
+
// PTY lifecycle
|
|
122591
122847
|
// -------------------------------------------------------------------------
|
|
122592
122848
|
/**
|
|
122593
|
-
*
|
|
122849
|
+
* Spawn a new PTY for the given workspace + terminalId.
|
|
122594
122850
|
*
|
|
122595
|
-
*
|
|
122596
|
-
*
|
|
122597
|
-
*
|
|
122598
|
-
*
|
|
122599
|
-
*
|
|
122600
|
-
*
|
|
122601
|
-
* fire path (`executeCronjob` below) and the manual `trigger` route so
|
|
122602
|
-
* both stay aligned.
|
|
122851
|
+
* Resolves `workspaceId` to a worktree path before delegating to the
|
|
122852
|
+
* pool, and registers the new terminal in the saved dockview layout so it
|
|
122853
|
+
* survives a server restart (mirrors `chatService.create` /
|
|
122854
|
+
* `browserService.create`). Does NOT emit a `terminal-created` event —
|
|
122855
|
+
* the API entry points decide whether to broadcast (the WebSocket handler
|
|
122856
|
+
* stays silent; the tRPC `create` mutation emits explicitly).
|
|
122603
122857
|
*/
|
|
122604
|
-
|
|
122605
|
-
const
|
|
122606
|
-
|
|
122607
|
-
|
|
122608
|
-
|
|
122609
|
-
|
|
122610
|
-
|
|
122611
|
-
|
|
122612
|
-
|
|
122613
|
-
|
|
122614
|
-
|
|
122858
|
+
async spawn(workspaceId, terminalId, options2, opts) {
|
|
122859
|
+
const workspace = workspaceService.resolve(workspaceId);
|
|
122860
|
+
if (!workspace) {
|
|
122861
|
+
throw new Error(`Workspace not found: ${workspaceId}`);
|
|
122862
|
+
}
|
|
122863
|
+
const session = await this.pool.spawn(
|
|
122864
|
+
workspaceId,
|
|
122865
|
+
terminalId,
|
|
122866
|
+
workspace.worktree.path,
|
|
122867
|
+
options2,
|
|
122868
|
+
opts?.cleanupOnExit ? () => this.emitRemoved(workspaceId, terminalId) : void 0
|
|
122869
|
+
);
|
|
122870
|
+
addTerminalToLayout(workspaceId, terminalId, {
|
|
122871
|
+
command: options2?.command,
|
|
122872
|
+
cwd: options2?.cwd,
|
|
122873
|
+
env: options2?.env
|
|
122615
122874
|
});
|
|
122875
|
+
return session;
|
|
122616
122876
|
}
|
|
122617
122877
|
/**
|
|
122618
|
-
*
|
|
122619
|
-
*
|
|
122620
|
-
*
|
|
122621
|
-
* (which the scheduled fire path treats as a soft failure — see
|
|
122622
|
-
* `executeCronjob`). The manual trigger route maps the same condition to
|
|
122623
|
-
* a `CronjobProjectNotFoundError`.
|
|
122624
|
-
*
|
|
122625
|
-
* Workspace-scoped jobs return `null` rather than falling through to the
|
|
122626
|
-
* project-resolution branch when `workspaceId` is somehow missing. The
|
|
122627
|
-
* `create` path already rejects this via `CronjobWorkspaceMissingError`,
|
|
122628
|
-
* but a workspace-scoped row written by an older server version could
|
|
122629
|
-
* theoretically reach here with a null `workspaceId`. Falling through
|
|
122630
|
-
* would silently misfire into the project's default branch; returning
|
|
122631
|
-
* `null` makes the failure explicit (logged + `lastRunStatus = "failed"`
|
|
122632
|
-
* for the scheduler; `CronjobProjectNotFoundError` for the manual
|
|
122633
|
-
* trigger path).
|
|
122878
|
+
* Kill a single terminal. Removes it from the saved dockview layout and
|
|
122879
|
+
* emits `terminal-killed` so the dashboard's status stream prunes the
|
|
122880
|
+
* panel. Safe to call with an unknown terminalId — no-op.
|
|
122634
122881
|
*/
|
|
122635
|
-
|
|
122636
|
-
|
|
122637
|
-
|
|
122882
|
+
kill(terminalId) {
|
|
122883
|
+
const session = this.pool.get(terminalId);
|
|
122884
|
+
const workspaceId = session?.workspaceId;
|
|
122885
|
+
this.pool.kill(terminalId);
|
|
122886
|
+
if (workspaceId) {
|
|
122887
|
+
this.emitRemoved(workspaceId, terminalId);
|
|
122638
122888
|
}
|
|
122639
|
-
|
|
122640
|
-
|
|
122641
|
-
|
|
122642
|
-
|
|
122889
|
+
}
|
|
122890
|
+
/**
|
|
122891
|
+
* Drop a terminal from the saved dockview layout and broadcast
|
|
122892
|
+
* `terminal-killed` so an open dashboard prunes the panel. Shared by the
|
|
122893
|
+
* explicit {@link kill} path and the `cleanupOnExit` natural-exit hook wired
|
|
122894
|
+
* up in {@link spawn}. Idempotent: `removeTerminalFromLayout` is a no-op when
|
|
122895
|
+
* the panel is already gone, and a duplicate `terminal-killed` is harmless.
|
|
122896
|
+
*/
|
|
122897
|
+
emitRemoved(workspaceId, terminalId) {
|
|
122898
|
+
removeTerminalFromLayout(workspaceId, terminalId);
|
|
122899
|
+
emit({ kind: "terminal-killed", workspaceId, terminalId });
|
|
122900
|
+
}
|
|
122901
|
+
/**
|
|
122902
|
+
* Kill every PTY associated with a workspace. Used by the workspace
|
|
122903
|
+
* deletion path — the caller is responsible for tearing down the layout
|
|
122904
|
+
* tree via {@link deleteLayout} as well.
|
|
122905
|
+
*/
|
|
122906
|
+
killWorkspace(workspaceId) {
|
|
122907
|
+
this.pool.killWorkspace(workspaceId);
|
|
122908
|
+
}
|
|
122909
|
+
/**
|
|
122910
|
+
* Kill every tracked PTY across all workspaces. Used by the server
|
|
122911
|
+
* shutdown path in `start-server.ts`.
|
|
122912
|
+
*/
|
|
122913
|
+
killAll() {
|
|
122914
|
+
this.pool.killAll();
|
|
122643
122915
|
}
|
|
122644
122916
|
// -------------------------------------------------------------------------
|
|
122645
|
-
//
|
|
122917
|
+
// Per-terminal accessors
|
|
122646
122918
|
// -------------------------------------------------------------------------
|
|
122647
|
-
|
|
122648
|
-
|
|
122649
|
-
return file.jobs.find((j) => j.id === id);
|
|
122919
|
+
list(workspaceId) {
|
|
122920
|
+
return this.pool.list(workspaceId);
|
|
122650
122921
|
}
|
|
122651
|
-
|
|
122652
|
-
|
|
122653
|
-
const options2 = { maxRuns: 0 };
|
|
122654
|
-
void new E2(expression, options2);
|
|
122655
|
-
} catch {
|
|
122656
|
-
throw new InvalidCronExpressionError();
|
|
122657
|
-
}
|
|
122922
|
+
getSession(terminalId) {
|
|
122923
|
+
return this.pool.get(terminalId);
|
|
122658
122924
|
}
|
|
122659
|
-
|
|
122660
|
-
|
|
122661
|
-
const existing = state.jobs.get(job.id);
|
|
122662
|
-
if (existing) {
|
|
122663
|
-
existing.stop();
|
|
122664
|
-
state.jobs.delete(job.id);
|
|
122665
|
-
}
|
|
122666
|
-
if (!job.enabled) return;
|
|
122667
|
-
try {
|
|
122668
|
-
const cronInstance = new E2(job.cronExpression, () => {
|
|
122669
|
-
this.executeCronjob(job, fileKey).catch((err) => {
|
|
122670
|
-
log20.error({ jobId: job.id, err }, "unhandled error in cronjob execution");
|
|
122671
|
-
});
|
|
122672
|
-
});
|
|
122673
|
-
state.jobs.set(job.id, cronInstance);
|
|
122674
|
-
log20.info(
|
|
122675
|
-
{ jobId: job.id, name: job.name, cron: job.cronExpression, scope: job.scope },
|
|
122676
|
-
"scheduled cronjob"
|
|
122677
|
-
);
|
|
122678
|
-
} catch (err) {
|
|
122679
|
-
log20.error(
|
|
122680
|
-
{ jobId: job.id, cronExpression: job.cronExpression, err },
|
|
122681
|
-
"invalid cron expression, skipping job"
|
|
122682
|
-
);
|
|
122683
|
-
}
|
|
122925
|
+
getScrollback(terminalId, lines) {
|
|
122926
|
+
return this.pool.getScrollback(terminalId, lines);
|
|
122684
122927
|
}
|
|
122685
|
-
|
|
122686
|
-
|
|
122687
|
-
|
|
122688
|
-
|
|
122689
|
-
|
|
122690
|
-
|
|
122691
|
-
|
|
122692
|
-
|
|
122693
|
-
|
|
122694
|
-
|
|
122695
|
-
|
|
122696
|
-
|
|
122697
|
-
|
|
122698
|
-
|
|
122699
|
-
|
|
122700
|
-
|
|
122701
|
-
|
|
122702
|
-
|
|
122703
|
-
|
|
122704
|
-
|
|
122705
|
-
|
|
122706
|
-
);
|
|
122707
|
-
this.safeUpdateLastRun(job.id, "skipped");
|
|
122708
|
-
return;
|
|
122709
|
-
}
|
|
122710
|
-
log20.error({ jobId: job.id, err }, "cronjob execution failed");
|
|
122711
|
-
this.safeUpdateLastRun(job.id, "failed");
|
|
122712
|
-
}
|
|
122928
|
+
write(terminalId, data) {
|
|
122929
|
+
return this.pool.write(terminalId, data);
|
|
122930
|
+
}
|
|
122931
|
+
resize(terminalId, cols, rows) {
|
|
122932
|
+
this.pool.resize(terminalId, cols, rows);
|
|
122933
|
+
}
|
|
122934
|
+
subscribeOutput(terminalId, callback) {
|
|
122935
|
+
return this.pool.subscribeOutput(terminalId, callback);
|
|
122936
|
+
}
|
|
122937
|
+
// -------------------------------------------------------------------------
|
|
122938
|
+
// Layout persistence (dockview tree)
|
|
122939
|
+
//
|
|
122940
|
+
// Thin pass-throughs to `lib/terminal-layout-manager` so the API tier
|
|
122941
|
+
// doesn't reach across into `lib/` directly. The layout helpers will be
|
|
122942
|
+
// folded into a dedicated infra adapter in a follow-up phase (when the
|
|
122943
|
+
// chats/browsers domains get their own infra adapters); leaving them in
|
|
122944
|
+
// `lib/` for now is the minimum-disruption shape during the phased
|
|
122945
|
+
// migration.
|
|
122946
|
+
// -------------------------------------------------------------------------
|
|
122947
|
+
getLayout(workspaceId) {
|
|
122948
|
+
return getTerminalLayout(workspaceId);
|
|
122713
122949
|
}
|
|
122950
|
+
saveLayout(workspaceId, tree) {
|
|
122951
|
+
saveTerminalLayout(workspaceId, tree);
|
|
122952
|
+
}
|
|
122953
|
+
deleteLayout(workspaceId) {
|
|
122954
|
+
deleteTerminalLayout(workspaceId);
|
|
122955
|
+
}
|
|
122956
|
+
// -------------------------------------------------------------------------
|
|
122957
|
+
// Workspace `.band/config.json` `workspace.terminal` block
|
|
122958
|
+
// -------------------------------------------------------------------------
|
|
122714
122959
|
/**
|
|
122715
|
-
*
|
|
122960
|
+
* Load and validate the `workspace.terminal` block from
|
|
122961
|
+
* `.band/config.json`. Returns `null` when the workspace can't be
|
|
122962
|
+
* resolved, the config file is absent, the block is missing, or the
|
|
122963
|
+
* payload fails validation.
|
|
122716
122964
|
*
|
|
122717
|
-
*
|
|
122718
|
-
*
|
|
122719
|
-
*
|
|
122720
|
-
*
|
|
122965
|
+
* Absorbed from the old `lib/terminal-config.ts:loadWorkspaceTerminalConfig`
|
|
122966
|
+
* — same parsing semantics, but the lookup now goes through the service
|
|
122967
|
+
* tier so callers (currently the workspace router's `getTerminalConfig`
|
|
122968
|
+
* query) reach it via `terminalService` instead of a stand-alone helper.
|
|
122721
122969
|
*/
|
|
122722
|
-
|
|
122723
|
-
|
|
122724
|
-
|
|
122725
|
-
|
|
122726
|
-
log20.warn({ jobId, err }, "failed to update lastRun on cronjob");
|
|
122727
|
-
}
|
|
122970
|
+
getWorkspaceConfig(workspaceId) {
|
|
122971
|
+
const workspace = workspaceService.resolve(workspaceId);
|
|
122972
|
+
if (!workspace) return null;
|
|
122973
|
+
return this.loadWorkspaceConfigFromPaths(workspace.worktree.path, workspace.project.path);
|
|
122728
122974
|
}
|
|
122729
|
-
|
|
122730
|
-
|
|
122731
|
-
|
|
122732
|
-
|
|
122733
|
-
|
|
122734
|
-
|
|
122735
|
-
|
|
122736
|
-
|
|
122975
|
+
/**
|
|
122976
|
+
* Internal: the path-driven parse so tests / future non-tRPC entry
|
|
122977
|
+
* points can plug raw paths in without going through `resolveWorkspace`.
|
|
122978
|
+
*/
|
|
122979
|
+
loadWorkspaceConfigFromPaths(worktreePath, projectPath) {
|
|
122980
|
+
const raw = loadProjectConfig(worktreePath, projectPath);
|
|
122981
|
+
if (!raw) return null;
|
|
122982
|
+
const terminalBlock = raw.workspace && typeof raw.workspace === "object" ? raw.workspace.terminal : void 0;
|
|
122983
|
+
if (!terminalBlock) return null;
|
|
122984
|
+
const result = WorkspaceTerminalConfigSchema.safeParse(terminalBlock);
|
|
122985
|
+
if (!result.success) {
|
|
122986
|
+
log21.warn(
|
|
122987
|
+
"Invalid workspace.terminal config: %s",
|
|
122988
|
+
result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`).join("; ")
|
|
122989
|
+
);
|
|
122990
|
+
return null;
|
|
122737
122991
|
}
|
|
122738
|
-
|
|
122992
|
+
return result.data;
|
|
122739
122993
|
}
|
|
122740
122994
|
};
|
|
122741
|
-
var
|
|
122995
|
+
var terminalService = new TerminalService();
|
|
122742
122996
|
|
|
122743
|
-
// src/server/services/
|
|
122744
|
-
|
|
122745
|
-
var
|
|
122746
|
-
var
|
|
122747
|
-
|
|
122748
|
-
|
|
122997
|
+
// src/server/services/cronjob-service.ts
|
|
122998
|
+
var log22 = createLogger("cronjob-service");
|
|
122999
|
+
var SCHEDULER_KEY = Symbol.for("band.cronjob-scheduler");
|
|
123000
|
+
var g12 = globalThis;
|
|
123001
|
+
if (!g12[SCHEDULER_KEY]) {
|
|
123002
|
+
g12[SCHEDULER_KEY] = {
|
|
123003
|
+
jobs: /* @__PURE__ */ new Map(),
|
|
123004
|
+
started: false,
|
|
123005
|
+
spawningTerminals: /* @__PURE__ */ new Set()
|
|
123006
|
+
};
|
|
122749
123007
|
}
|
|
122750
|
-
|
|
122751
|
-
|
|
122752
|
-
|
|
122753
|
-
|
|
122754
|
-
|
|
122755
|
-
|
|
122756
|
-
|
|
122757
|
-
|
|
122758
|
-
|
|
122759
|
-
|
|
122760
|
-
|
|
122761
|
-
|
|
123008
|
+
function schedulerState() {
|
|
123009
|
+
return g12[SCHEDULER_KEY];
|
|
123010
|
+
}
|
|
123011
|
+
var cronjobVia = external_exports.enum(["chat", "terminal"]);
|
|
123012
|
+
var cronjobCreateInput = external_exports.object({
|
|
123013
|
+
key: external_exports.string().min(1),
|
|
123014
|
+
name: external_exports.string().min(1),
|
|
123015
|
+
prompt: external_exports.string().min(1),
|
|
123016
|
+
cronExpression: external_exports.string().min(1),
|
|
123017
|
+
scope: external_exports.enum(["project", "workspace"]),
|
|
123018
|
+
workspaceId: external_exports.string().optional(),
|
|
123019
|
+
// Dispatch target for scheduled + manual fires. Optional — absent defaults to
|
|
123020
|
+
// "chat" in `create()` so existing callers (the web UI) keep their behavior.
|
|
123021
|
+
via: cronjobVia.optional(),
|
|
123022
|
+
enabled: external_exports.boolean().default(true)
|
|
123023
|
+
});
|
|
123024
|
+
var cronjobUpdateInput = external_exports.object({
|
|
123025
|
+
// `.min(1)` rejects the obviously invalid empty-string `key`/`id` at the
|
|
123026
|
+
// validator instead of letting it reach the service, which would
|
|
123027
|
+
// `loadFile("")` an empty result and 404 on `findIndex`. The legacy
|
|
123028
|
+
// router accepted bare `z.string()` here; the migration is the natural
|
|
123029
|
+
// moment to tighten the boundary so the create + update shapes stay
|
|
123030
|
+
// consistent (`cronjobCreateInput.key` already has `.min(1)`).
|
|
123031
|
+
key: external_exports.string().min(1),
|
|
123032
|
+
id: external_exports.string().min(1),
|
|
123033
|
+
name: external_exports.string().min(1).optional(),
|
|
123034
|
+
prompt: external_exports.string().min(1).optional(),
|
|
123035
|
+
cronExpression: external_exports.string().min(1).optional(),
|
|
123036
|
+
enabled: external_exports.boolean().optional()
|
|
123037
|
+
});
|
|
123038
|
+
var cronjobByIdInput = external_exports.object({
|
|
123039
|
+
key: external_exports.string().min(1),
|
|
123040
|
+
id: external_exports.string().min(1)
|
|
123041
|
+
});
|
|
123042
|
+
var CronjobNotFoundError = class extends Error {
|
|
123043
|
+
constructor(message = "Cronjob not found") {
|
|
123044
|
+
super(message);
|
|
123045
|
+
this.name = "CronjobNotFoundError";
|
|
123046
|
+
}
|
|
123047
|
+
};
|
|
123048
|
+
var InvalidCronExpressionError = class extends Error {
|
|
123049
|
+
constructor(message = "Invalid cron expression") {
|
|
123050
|
+
super(message);
|
|
123051
|
+
this.name = "InvalidCronExpressionError";
|
|
123052
|
+
}
|
|
123053
|
+
};
|
|
123054
|
+
var CronjobWorkspaceMissingError = class extends Error {
|
|
123055
|
+
constructor(message = "workspaceId is required for workspace-scoped cronjobs") {
|
|
123056
|
+
super(message);
|
|
123057
|
+
this.name = "CronjobWorkspaceMissingError";
|
|
123058
|
+
}
|
|
123059
|
+
};
|
|
123060
|
+
var CronjobProjectNotFoundError = class extends Error {
|
|
123061
|
+
constructor(message = "Project not found") {
|
|
123062
|
+
super(message);
|
|
123063
|
+
this.name = "CronjobProjectNotFoundError";
|
|
123064
|
+
}
|
|
123065
|
+
};
|
|
123066
|
+
var CronjobService = class {
|
|
123067
|
+
constructor(queries = new CronjobQueries()) {
|
|
123068
|
+
this.queries = queries;
|
|
123069
|
+
}
|
|
123070
|
+
// -------------------------------------------------------------------------
|
|
123071
|
+
// Read operations
|
|
123072
|
+
// -------------------------------------------------------------------------
|
|
122762
123073
|
/**
|
|
122763
|
-
*
|
|
122764
|
-
*
|
|
123074
|
+
* List jobs for a key, or — if neither `project` nor `workspaceId` is
|
|
123075
|
+
* provided — every cronjob across every key.
|
|
123076
|
+
*
|
|
123077
|
+
* Mirrors the legacy tRPC `cronjobs.list` shape so the router stays a
|
|
123078
|
+
* straight delegate. The "all" branch returns `fileKey` on each row
|
|
123079
|
+
* because callers (the dashboard's cronjob settings page) need both
|
|
123080
|
+
* pieces to render the project/workspace breadcrumbs.
|
|
122765
123081
|
*/
|
|
122766
|
-
|
|
122767
|
-
if (
|
|
122768
|
-
|
|
122769
|
-
|
|
122770
|
-
|
|
122771
|
-
if (!row) return;
|
|
122772
|
-
this.persisted.add(workspaceId);
|
|
122773
|
-
try {
|
|
122774
|
-
const parsed = JSON.parse(row.state);
|
|
122775
|
-
this.focus.set(workspaceId, {
|
|
122776
|
-
chat: parsed.chat,
|
|
122777
|
-
terminal: parsed.terminal,
|
|
122778
|
-
browser: parsed.browser
|
|
122779
|
-
});
|
|
122780
|
-
} catch (err) {
|
|
122781
|
-
log21.warn({ workspaceId, err }, "failed to parse persisted panel focus; ignoring");
|
|
123082
|
+
list(filter) {
|
|
123083
|
+
if (filter?.project) {
|
|
123084
|
+
const { project } = filter;
|
|
123085
|
+
const file = this.queries.loadFile(project);
|
|
123086
|
+
return { jobs: file.jobs.map((j) => ({ ...j, fileKey: project })) };
|
|
122782
123087
|
}
|
|
123088
|
+
if (filter?.workspaceId) {
|
|
123089
|
+
const { workspaceId } = filter;
|
|
123090
|
+
const file = this.queries.loadFile(workspaceId);
|
|
123091
|
+
return {
|
|
123092
|
+
jobs: file.jobs.map((j) => ({ ...j, fileKey: workspaceId }))
|
|
123093
|
+
};
|
|
123094
|
+
}
|
|
123095
|
+
return { jobs: this.queries.listAll() };
|
|
122783
123096
|
}
|
|
122784
|
-
/** Get
|
|
122785
|
-
get(
|
|
122786
|
-
this.
|
|
122787
|
-
|
|
123097
|
+
/** Get a single job by `key` + `id`. Throws `CronjobNotFoundError` if absent. */
|
|
123098
|
+
get(key, id) {
|
|
123099
|
+
const job = this.findJob(key, id);
|
|
123100
|
+
if (!job) throw new CronjobNotFoundError();
|
|
123101
|
+
return { job };
|
|
122788
123102
|
}
|
|
123103
|
+
// -------------------------------------------------------------------------
|
|
123104
|
+
// Mutations
|
|
123105
|
+
// -------------------------------------------------------------------------
|
|
122789
123106
|
/**
|
|
122790
|
-
*
|
|
122791
|
-
*
|
|
122792
|
-
*
|
|
123107
|
+
* Create a new cronjob and re-bind the scheduler.
|
|
123108
|
+
*
|
|
123109
|
+
* Validates the cron expression by attempting to construct a `Cron` with
|
|
123110
|
+
* `maxRuns: 0` (parses without scheduling). Throws
|
|
123111
|
+
* `InvalidCronExpressionError` for a bad expression and
|
|
123112
|
+
* `CronjobWorkspaceMissingError` when a workspace-scoped job is missing
|
|
123113
|
+
* its `workspaceId`.
|
|
122793
123114
|
*/
|
|
122794
|
-
|
|
122795
|
-
this.
|
|
122796
|
-
|
|
122797
|
-
|
|
122798
|
-
const next = { ...current2, [panelType]: panelId };
|
|
122799
|
-
this.focus.set(workspaceId, next);
|
|
122800
|
-
const now = Date.now();
|
|
122801
|
-
const state = JSON.stringify(next);
|
|
122802
|
-
if (this.persisted.has(workspaceId)) {
|
|
122803
|
-
updatePanelState(focusRowId(workspaceId), { state, updatedAt: now });
|
|
122804
|
-
} else {
|
|
122805
|
-
insertPanelState({
|
|
122806
|
-
id: focusRowId(workspaceId),
|
|
122807
|
-
workspaceId,
|
|
122808
|
-
panelType: PANEL_FOCUS_TYPE,
|
|
122809
|
-
state,
|
|
122810
|
-
createdAt: now,
|
|
122811
|
-
updatedAt: now
|
|
122812
|
-
});
|
|
122813
|
-
this.persisted.add(workspaceId);
|
|
123115
|
+
create(input) {
|
|
123116
|
+
this.assertValidCron(input.cronExpression);
|
|
123117
|
+
if (input.scope === "workspace" && !input.workspaceId) {
|
|
123118
|
+
throw new CronjobWorkspaceMissingError();
|
|
122814
123119
|
}
|
|
123120
|
+
const file = this.queries.loadFile(input.key);
|
|
123121
|
+
const job = {
|
|
123122
|
+
id: generateCronjobId(),
|
|
123123
|
+
name: input.name,
|
|
123124
|
+
prompt: input.prompt,
|
|
123125
|
+
cronExpression: input.cronExpression,
|
|
123126
|
+
scope: input.scope,
|
|
123127
|
+
workspaceId: input.workspaceId,
|
|
123128
|
+
// Absent `via` means the web UI (or a pre-#581 caller) — default to chat
|
|
123129
|
+
// so existing cronjobs keep dispatching exactly as before.
|
|
123130
|
+
via: input.via ?? "chat",
|
|
123131
|
+
enabled: input.enabled,
|
|
123132
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
123133
|
+
};
|
|
123134
|
+
file.jobs.push(job);
|
|
123135
|
+
this.queries.saveFile(input.key, file);
|
|
123136
|
+
this.reloadSchedules();
|
|
123137
|
+
return { job };
|
|
122815
123138
|
}
|
|
122816
123139
|
/**
|
|
122817
|
-
*
|
|
122818
|
-
*
|
|
123140
|
+
* Partially update an existing cronjob.
|
|
123141
|
+
*
|
|
123142
|
+
* Each optional field is applied only when present, matching the legacy
|
|
123143
|
+
* router behavior. If `cronExpression` is supplied it's validated before
|
|
123144
|
+
* the write. Throws `CronjobNotFoundError` if no job matches `key`+`id`.
|
|
122819
123145
|
*/
|
|
122820
|
-
|
|
122821
|
-
|
|
122822
|
-
|
|
122823
|
-
|
|
122824
|
-
|
|
123146
|
+
update(input) {
|
|
123147
|
+
if (input.cronExpression !== void 0) {
|
|
123148
|
+
this.assertValidCron(input.cronExpression);
|
|
123149
|
+
}
|
|
123150
|
+
const file = this.queries.loadFile(input.key);
|
|
123151
|
+
const job = file.jobs.find((j) => j.id === input.id);
|
|
123152
|
+
if (!job) throw new CronjobNotFoundError();
|
|
123153
|
+
if (input.name !== void 0) job.name = input.name;
|
|
123154
|
+
if (input.prompt !== void 0) job.prompt = input.prompt;
|
|
123155
|
+
if (input.cronExpression !== void 0) job.cronExpression = input.cronExpression;
|
|
123156
|
+
if (input.enabled !== void 0) job.enabled = input.enabled;
|
|
123157
|
+
this.queries.saveFile(input.key, file);
|
|
123158
|
+
this.reloadSchedules();
|
|
123159
|
+
return { job };
|
|
122825
123160
|
}
|
|
122826
|
-
};
|
|
122827
|
-
var panelFocusService = new PanelFocusService();
|
|
122828
|
-
|
|
122829
|
-
// src/server/services/terminal-service.ts
|
|
122830
|
-
init_src();
|
|
122831
|
-
init_zod();
|
|
122832
|
-
|
|
122833
|
-
// src/server/infra/terminals/terminal-pool.ts
|
|
122834
|
-
init_src();
|
|
122835
|
-
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
122836
|
-
import { existsSync as existsSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
122837
|
-
import { tmpdir } from "node:os";
|
|
122838
|
-
import { join as join19, sep as sep3 } from "node:path";
|
|
122839
|
-
var log22 = createLogger("terminal-pool");
|
|
122840
|
-
var MAX_SCROLLBACK_SIZE = 1e5;
|
|
122841
|
-
var TerminalPool = class {
|
|
122842
|
-
/** terminalId -> session */
|
|
122843
|
-
terminals = /* @__PURE__ */ new Map();
|
|
122844
|
-
/** workspaceId -> Set<terminalId> (reverse index for workspace-level cleanup) */
|
|
122845
|
-
workspaceTerminals = /* @__PURE__ */ new Map();
|
|
122846
|
-
/** terminalId -> Set<listener> for live output streaming */
|
|
122847
|
-
outputListeners = /* @__PURE__ */ new Map();
|
|
122848
|
-
/**
|
|
122849
|
-
* terminalId -> in-flight spawn promise. A single terminal is created via TWO
|
|
122850
|
-
* concurrent paths — the WebSocket handler (spawn-on-`getSession`-miss) and
|
|
122851
|
-
* the tRPC `terminal.create` mutation. Without deduplication both spawn a PTY
|
|
122852
|
-
* and the second `terminals.set` overwrites the first, so the client stays
|
|
122853
|
-
* wired to one PTY while the server's session map (and thus scrollback /
|
|
122854
|
-
* `terminals output` / reconnect-replay) points at the other. That's the
|
|
122855
|
-
* "output on screen but empty server scrollback until reload" bug
|
|
122856
|
-
* (band-app/band#617). This map makes concurrent spawns share ONE PTY.
|
|
122857
|
-
*/
|
|
122858
|
-
spawning = /* @__PURE__ */ new Map();
|
|
122859
123161
|
/**
|
|
122860
|
-
*
|
|
123162
|
+
* Delete a cronjob and re-bind the scheduler.
|
|
122861
123163
|
*
|
|
122862
|
-
*
|
|
122863
|
-
* as-is, and concurrent spawn calls for the same id share a single in-flight
|
|
122864
|
-
* promise — so the WebSocket and tRPC create paths never create competing
|
|
122865
|
-
* PTYs. `workspaceRoot` is the absolute worktree path; resolving the
|
|
122866
|
-
* workspaceId to a path is the service tier's job so the pool stays oblivious
|
|
122867
|
-
* to the workspace registry.
|
|
123164
|
+
* Throws `CronjobNotFoundError` if the job is already gone.
|
|
122868
123165
|
*/
|
|
122869
|
-
|
|
122870
|
-
const
|
|
122871
|
-
|
|
122872
|
-
|
|
122873
|
-
|
|
122874
|
-
|
|
122875
|
-
|
|
122876
|
-
|
|
122877
|
-
return await promise;
|
|
122878
|
-
} finally {
|
|
122879
|
-
this.spawning.delete(terminalId);
|
|
122880
|
-
}
|
|
122881
|
-
}
|
|
122882
|
-
async spawnNew(workspaceId, terminalId, workspaceRoot, options2) {
|
|
122883
|
-
const shell = defaultShell();
|
|
122884
|
-
const resolvedPath = await shellPath();
|
|
122885
|
-
const env = {};
|
|
122886
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
122887
|
-
if (value != null) {
|
|
122888
|
-
env[key] = value;
|
|
122889
|
-
}
|
|
122890
|
-
}
|
|
122891
|
-
env.PATH = resolvedPath;
|
|
122892
|
-
env.TERM = "xterm-256color";
|
|
122893
|
-
env.COLORTERM = "truecolor";
|
|
122894
|
-
env.COLORFGBG = "15;0";
|
|
122895
|
-
delete env.PORT;
|
|
122896
|
-
if (options2?.env) {
|
|
122897
|
-
Object.assign(env, options2.env);
|
|
122898
|
-
}
|
|
122899
|
-
env.BAND_DISPATCH = "terminal";
|
|
122900
|
-
let cwd = workspaceRoot;
|
|
122901
|
-
if (options2?.cwd) {
|
|
122902
|
-
const resolved = join19(workspaceRoot, options2.cwd);
|
|
122903
|
-
if (resolved !== workspaceRoot && !resolved.startsWith(workspaceRoot + sep3)) {
|
|
122904
|
-
log22.warn(
|
|
122905
|
-
"Ignoring cwd %s \u2014 resolves outside workspace root %s",
|
|
122906
|
-
options2.cwd,
|
|
122907
|
-
workspaceRoot
|
|
122908
|
-
);
|
|
122909
|
-
} else if (existsSync8(resolved)) {
|
|
122910
|
-
cwd = resolved;
|
|
122911
|
-
} else {
|
|
122912
|
-
log22.warn("Ignoring cwd %s \u2014 directory does not exist", options2.cwd);
|
|
122913
|
-
}
|
|
122914
|
-
}
|
|
122915
|
-
if (!existsSync8(cwd)) {
|
|
122916
|
-
throw new Error(`Workspace directory does not exist: ${cwd}`);
|
|
122917
|
-
}
|
|
122918
|
-
if (!existsSync8(shell)) {
|
|
122919
|
-
throw new Error(`Shell not found: ${shell}`);
|
|
122920
|
-
}
|
|
122921
|
-
log22.debug(
|
|
122922
|
-
"Spawning shell %s in %s for terminal %s (PATH=%s)",
|
|
122923
|
-
shell,
|
|
122924
|
-
cwd,
|
|
122925
|
-
terminalId,
|
|
122926
|
-
resolvedPath.slice(0, 200)
|
|
122927
|
-
);
|
|
122928
|
-
const nodePty = await import("node-pty");
|
|
122929
|
-
let ptyProcess;
|
|
122930
|
-
try {
|
|
122931
|
-
ptyProcess = nodePty.spawn(shell, [], {
|
|
122932
|
-
name: "xterm-256color",
|
|
122933
|
-
cols: 80,
|
|
122934
|
-
rows: 24,
|
|
122935
|
-
cwd,
|
|
122936
|
-
env
|
|
122937
|
-
});
|
|
122938
|
-
} catch (err) {
|
|
122939
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
122940
|
-
log22.error("pty.spawn failed: %s (shell=%s, cwd=%s)", msg, shell, cwd);
|
|
122941
|
-
throw err;
|
|
122942
|
-
}
|
|
122943
|
-
const session = { pty: ptyProcess, scrollback: "", workspaceId };
|
|
122944
|
-
this.terminals.set(terminalId, session);
|
|
122945
|
-
if (options2?.command) {
|
|
122946
|
-
this.autoRunCommand(session, options2.command);
|
|
122947
|
-
}
|
|
122948
|
-
let ids = this.workspaceTerminals.get(workspaceId);
|
|
122949
|
-
if (!ids) {
|
|
122950
|
-
ids = /* @__PURE__ */ new Set();
|
|
122951
|
-
this.workspaceTerminals.set(workspaceId, ids);
|
|
123166
|
+
delete(key, id) {
|
|
123167
|
+
const file = this.queries.loadFile(key);
|
|
123168
|
+
const index3 = file.jobs.findIndex((j) => j.id === id);
|
|
123169
|
+
if (index3 === -1) throw new CronjobNotFoundError();
|
|
123170
|
+
const [removed] = file.jobs.splice(index3, 1);
|
|
123171
|
+
this.queries.saveFile(key, file);
|
|
123172
|
+
if (removed?.lastTerminalId) {
|
|
123173
|
+
terminalService.kill(removed.lastTerminalId);
|
|
122952
123174
|
}
|
|
122953
|
-
|
|
122954
|
-
|
|
122955
|
-
session.scrollback += data;
|
|
122956
|
-
if (session.scrollback.length > MAX_SCROLLBACK_SIZE) {
|
|
122957
|
-
session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_SIZE);
|
|
122958
|
-
}
|
|
122959
|
-
const listeners3 = this.outputListeners.get(terminalId);
|
|
122960
|
-
if (listeners3) {
|
|
122961
|
-
for (const cb2 of listeners3) {
|
|
122962
|
-
try {
|
|
122963
|
-
cb2(data);
|
|
122964
|
-
} catch {
|
|
122965
|
-
}
|
|
122966
|
-
}
|
|
122967
|
-
}
|
|
122968
|
-
});
|
|
122969
|
-
ptyProcess.onExit(() => {
|
|
122970
|
-
log22.debug("Terminal exited: %s (workspace %s)", terminalId, workspaceId);
|
|
122971
|
-
this.terminals.delete(terminalId);
|
|
122972
|
-
this.outputListeners.delete(terminalId);
|
|
122973
|
-
const set = this.workspaceTerminals.get(workspaceId);
|
|
122974
|
-
if (set) {
|
|
122975
|
-
set.delete(terminalId);
|
|
122976
|
-
if (set.size === 0) {
|
|
122977
|
-
this.workspaceTerminals.delete(workspaceId);
|
|
122978
|
-
}
|
|
122979
|
-
}
|
|
122980
|
-
if (session.autoRunFile) {
|
|
122981
|
-
try {
|
|
122982
|
-
unlinkSync2(session.autoRunFile);
|
|
122983
|
-
} catch {
|
|
122984
|
-
}
|
|
122985
|
-
}
|
|
122986
|
-
});
|
|
122987
|
-
return session;
|
|
123175
|
+
this.reloadSchedules();
|
|
123176
|
+
return { ok: true };
|
|
122988
123177
|
}
|
|
122989
123178
|
/**
|
|
122990
|
-
*
|
|
122991
|
-
* robustly.
|
|
122992
|
-
*
|
|
122993
|
-
* Writing a long command line straight into a cold PTY is racy on three
|
|
122994
|
-
* fronts, all of which surfaced in production with `via=terminal`
|
|
122995
|
-
* prompt-as-argv launches (`'<agent-binary>' '<entire-prompt>'`):
|
|
123179
|
+
* Fire a cronjob synchronously (manual "run now" path).
|
|
122996
123180
|
*
|
|
122997
|
-
*
|
|
122998
|
-
*
|
|
122999
|
-
*
|
|
123000
|
-
*
|
|
123001
|
-
* 2. **Truncation.** A single input line longer than the tty's
|
|
123002
|
-
* canonical buffer (`MAX_CANON`, ~4 KB) is clipped. Long prompts
|
|
123003
|
-
* blow past that easily.
|
|
123004
|
-
* 3. **UTF-8 corruption.** A multi-byte sequence split across a tty
|
|
123005
|
-
* buffer boundary is mangled (the classic em-dash → mojibake).
|
|
123181
|
+
* Uses the same `band:cronId`-labeled chat as the scheduled fire so the
|
|
123182
|
+
* user sees both paths converge on a single dedicated conversation
|
|
123183
|
+
* (issue #520). `submitTask` may throw `TaskConflictError`, which the API
|
|
123184
|
+
* tier translates into a 409 Conflict response.
|
|
123006
123185
|
*
|
|
123007
|
-
*
|
|
123008
|
-
*
|
|
123009
|
-
*
|
|
123010
|
-
*
|
|
123011
|
-
*
|
|
123012
|
-
*
|
|
123013
|
-
*
|
|
123014
|
-
*
|
|
123186
|
+
* Atomicity caveat: `getOrCreateCronjobChat` and `submitTask` are not run
|
|
123187
|
+
* inside a single transaction. If `createChat` succeeds but `submitTask`
|
|
123188
|
+
* later throws an unexpected (non-`TaskConflictError`) error, the
|
|
123189
|
+
* freshly-created chat remains in the workspace as an orphan with no
|
|
123190
|
+
* task ever attached. The next manual `trigger` (or scheduled fire)
|
|
123191
|
+
* reuses that chat via its `band:cronId` label, so the orphan
|
|
123192
|
+
* self-heals — but the user may briefly see an empty chat pane. The
|
|
123193
|
+
* legacy implementation had the same gap; preserved here to match
|
|
123194
|
+
* existing behavior. If this ever needs to be tightened, the fix is
|
|
123195
|
+
* either (a) wrap both calls in a try/catch that deletes the chat on
|
|
123196
|
+
* unexpected failure, or (b) defer `createChat` until `submitTask`
|
|
123197
|
+
* succeeds (which would require buffering the chat name/labels on the
|
|
123198
|
+
* task row).
|
|
123015
123199
|
*/
|
|
123016
|
-
|
|
123017
|
-
const
|
|
123018
|
-
|
|
123019
|
-
|
|
123020
|
-
|
|
123021
|
-
|
|
123022
|
-
|
|
123023
|
-
|
|
123024
|
-
|
|
123025
|
-
log22.warn(
|
|
123026
|
-
"autoRunCommand: temp-file staging failed (%s); writing command directly",
|
|
123027
|
-
err instanceof Error ? err.message : String(err)
|
|
123028
|
-
);
|
|
123029
|
-
pty.write(`${command}
|
|
123030
|
-
`);
|
|
123031
|
-
return;
|
|
123032
|
-
}
|
|
123033
|
-
const quotedPath = cmdFile.replace(/'/g, `'\\''`);
|
|
123034
|
-
const sourceLine = `. '${quotedPath}'
|
|
123035
|
-
`;
|
|
123036
|
-
let done = false;
|
|
123037
|
-
const inject = () => {
|
|
123038
|
-
if (done) return;
|
|
123039
|
-
done = true;
|
|
123040
|
-
try {
|
|
123041
|
-
pty.write(sourceLine);
|
|
123042
|
-
} catch (err) {
|
|
123043
|
-
log22.warn("autoRunCommand: failed to inject command line (%s)", err);
|
|
123200
|
+
async trigger(key, id) {
|
|
123201
|
+
const job = this.findJob(key, id);
|
|
123202
|
+
if (!job) throw new CronjobNotFoundError();
|
|
123203
|
+
const workspaceId = this.resolveWorkspaceId(job, key);
|
|
123204
|
+
if (!workspaceId) throw new CronjobProjectNotFoundError();
|
|
123205
|
+
if (job.via === "terminal") {
|
|
123206
|
+
const { terminalId } = await this.spawnCronTerminal(job, workspaceId, key);
|
|
123207
|
+
if (terminalId) {
|
|
123208
|
+
return { via: "terminal", terminalId, workspaceId };
|
|
123044
123209
|
}
|
|
123045
|
-
}
|
|
123046
|
-
|
|
123047
|
-
const
|
|
123048
|
-
|
|
123049
|
-
dataDisposed = true;
|
|
123050
|
-
dataDisposable.dispose();
|
|
123051
|
-
};
|
|
123052
|
-
const dataDisposable = pty.onData(() => {
|
|
123053
|
-
disposeData();
|
|
123054
|
-
inject();
|
|
123055
|
-
});
|
|
123056
|
-
const timer = setTimeout(inject, 750);
|
|
123057
|
-
timer.unref();
|
|
123058
|
-
let exitDisposed = false;
|
|
123059
|
-
const exitDisposable = pty.onExit(() => {
|
|
123060
|
-
done = true;
|
|
123061
|
-
clearTimeout(timer);
|
|
123062
|
-
disposeData();
|
|
123063
|
-
if (exitDisposed) return;
|
|
123064
|
-
exitDisposed = true;
|
|
123065
|
-
exitDisposable.dispose();
|
|
123066
|
-
});
|
|
123210
|
+
}
|
|
123211
|
+
const cronChat = this.getOrCreateCronjobChat(workspaceId, job);
|
|
123212
|
+
const task = taskService.submitTask({ workspaceId, chatId: cronChat.id, prompt: job.prompt });
|
|
123213
|
+
return { via: "chat", taskId: task.id, workspaceId, chatId: cronChat.id };
|
|
123067
123214
|
}
|
|
123215
|
+
// -------------------------------------------------------------------------
|
|
123216
|
+
// Lifecycle hooks for workspace/project removal
|
|
123217
|
+
// -------------------------------------------------------------------------
|
|
123068
123218
|
/**
|
|
123069
|
-
*
|
|
123219
|
+
* Stop all timers for jobs in `key` and remove the file rows.
|
|
123220
|
+
*
|
|
123221
|
+
* Called when a workspace or project is removed. Order matters: stop
|
|
123222
|
+
* timers first so no further fires queue up between the timer-stop and
|
|
123223
|
+
* the row-delete. An in-flight fire is already safe in either order
|
|
123224
|
+
* because `executeCronjob` operates on closed-over `job`/`fileKey` (it
|
|
123225
|
+
* never re-reads the row) and `safeUpdateLastRun` swallows the
|
|
123226
|
+
* row-missing case. The two steps were previously two separate calls
|
|
123227
|
+
* (`stopJobsForKey` + `deleteCronjobFile`) at every call site — the
|
|
123228
|
+
* legacy callers always invoked them in pairs, so they're collapsed
|
|
123229
|
+
* here to make the contract harder to misuse.
|
|
123230
|
+
*
|
|
123231
|
+
* Partial-failure recovery: if `deleteFile` throws (e.g. a transient DB
|
|
123232
|
+
* lock), the timers are already stopped but the rows remain in the DB.
|
|
123233
|
+
* The next `reloadSchedules()` call — triggered by any subsequent
|
|
123234
|
+
* cronjob mutation, or by a server restart — re-reads the rows from the
|
|
123235
|
+
* DB and re-registers the timers, so the orphaned rows self-heal
|
|
123236
|
+
* without manual intervention. We do NOT roll back the `stop()` calls
|
|
123237
|
+
* because re-binding timers for a workspace that's mid-deletion is
|
|
123238
|
+
* worse than leaving them stopped: the workspace path is about to be
|
|
123239
|
+
* removed and any fire that races the removal would target a
|
|
123240
|
+
* non-existent worktree. Re-registration on the next reload is the
|
|
123241
|
+
* intentional recovery strategy; operators encountering a partial
|
|
123242
|
+
* failure on this path should expect the next reload to be a no-op
|
|
123243
|
+
* once the workspace removal completes and the rows are re-attempted.
|
|
123070
123244
|
*/
|
|
123071
|
-
|
|
123072
|
-
|
|
123245
|
+
removeForKey(key) {
|
|
123246
|
+
this.stopJobsForKey(key);
|
|
123247
|
+
this.queries.deleteFile(key);
|
|
123073
123248
|
}
|
|
123074
|
-
|
|
123075
|
-
|
|
123076
|
-
|
|
123077
|
-
|
|
123078
|
-
|
|
123079
|
-
|
|
123080
|
-
|
|
123249
|
+
// -------------------------------------------------------------------------
|
|
123250
|
+
// Scheduler lifecycle
|
|
123251
|
+
// -------------------------------------------------------------------------
|
|
123252
|
+
/** Start the cronjob scheduler. Called once on server boot. */
|
|
123253
|
+
start() {
|
|
123254
|
+
const state = schedulerState();
|
|
123255
|
+
if (state.started) return;
|
|
123256
|
+
state.started = true;
|
|
123257
|
+
this.loadAndScheduleAll();
|
|
123258
|
+
log22.info("cronjob scheduler started");
|
|
123259
|
+
}
|
|
123260
|
+
/** Stop the cronjob scheduler. Called on graceful shutdown. */
|
|
123261
|
+
stop() {
|
|
123262
|
+
const state = schedulerState();
|
|
123263
|
+
for (const [, cron] of state.jobs) {
|
|
123264
|
+
cron.stop();
|
|
123081
123265
|
}
|
|
123266
|
+
state.jobs.clear();
|
|
123267
|
+
state.started = false;
|
|
123268
|
+
log22.info("cronjob scheduler stopped");
|
|
123269
|
+
}
|
|
123270
|
+
/** Reload all schedules from the database. Call after cronjob mutations. */
|
|
123271
|
+
reloadSchedules() {
|
|
123272
|
+
if (!schedulerState().started) return;
|
|
123273
|
+
this.loadAndScheduleAll();
|
|
123082
123274
|
}
|
|
123083
123275
|
/**
|
|
123084
|
-
*
|
|
123276
|
+
* Stop all scheduled jobs for a key (e.g. when a workspace is removed).
|
|
123085
123277
|
*
|
|
123086
|
-
*
|
|
123087
|
-
*
|
|
123088
|
-
*
|
|
123278
|
+
* Private because the only correct call site is `removeForKey` above:
|
|
123279
|
+
* stopping the timers without also dropping the rows would re-schedule
|
|
123280
|
+
* the same jobs on the next `reloadSchedules()` tick. Keeping it
|
|
123281
|
+
* accessible only through `removeForKey` prevents future callers from
|
|
123282
|
+
* accidentally invoking just one half of the pair.
|
|
123089
123283
|
*/
|
|
123090
|
-
|
|
123091
|
-
const
|
|
123092
|
-
|
|
123093
|
-
const
|
|
123094
|
-
|
|
123095
|
-
|
|
123096
|
-
|
|
123097
|
-
|
|
123098
|
-
|
|
123099
|
-
title = session.pty.process;
|
|
123100
|
-
} catch {
|
|
123101
|
-
}
|
|
123102
|
-
result.push({
|
|
123103
|
-
terminalId,
|
|
123104
|
-
workspaceId,
|
|
123105
|
-
pid: session.pty.pid,
|
|
123106
|
-
scrollbackLength: session.scrollback.length,
|
|
123107
|
-
title
|
|
123108
|
-
});
|
|
123284
|
+
stopJobsForKey(key) {
|
|
123285
|
+
const state = schedulerState();
|
|
123286
|
+
const file = this.queries.loadFile(key);
|
|
123287
|
+
for (const job of file.jobs) {
|
|
123288
|
+
const cron = state.jobs.get(job.id);
|
|
123289
|
+
if (cron) {
|
|
123290
|
+
cron.stop();
|
|
123291
|
+
state.jobs.delete(job.id);
|
|
123292
|
+
log22.info({ jobId: job.id, key }, "stopped cronjob");
|
|
123109
123293
|
}
|
|
123110
123294
|
}
|
|
123111
|
-
return result;
|
|
123112
|
-
}
|
|
123113
|
-
/**
|
|
123114
|
-
* Returns the scrollback buffer for a terminal, optionally limited to the
|
|
123115
|
-
* last N lines. Returns `null` if the terminal is not found.
|
|
123116
|
-
*/
|
|
123117
|
-
getScrollback(terminalId, lines) {
|
|
123118
|
-
const session = this.terminals.get(terminalId);
|
|
123119
|
-
if (!session) return null;
|
|
123120
|
-
if (lines == null) return session.scrollback;
|
|
123121
|
-
const allLines = session.scrollback.split("\n");
|
|
123122
|
-
return allLines.slice(-lines).join("\n");
|
|
123123
123295
|
}
|
|
123296
|
+
// -------------------------------------------------------------------------
|
|
123297
|
+
// Shared resolution helpers (used by both the API + the scheduled fire path)
|
|
123298
|
+
// -------------------------------------------------------------------------
|
|
123124
123299
|
/**
|
|
123125
|
-
*
|
|
123126
|
-
*
|
|
123300
|
+
* Find — or create — the dedicated chat this cronjob writes into.
|
|
123301
|
+
*
|
|
123302
|
+
* Issue #520: each cronjob owns its own chat in the workspace, looked up
|
|
123303
|
+
* by the canonical `band:cronId` label. First call creates the chat;
|
|
123304
|
+
* subsequent calls reuse it. If the user deletes the chat, the next call
|
|
123305
|
+
* recreates it (intentional soft reset). This replaces the old
|
|
123306
|
+
* `getOrCreateDefaultChat` dispatch, which silently latched onto whatever
|
|
123307
|
+
* chat the user happened to be focused on. Shared between the scheduled
|
|
123308
|
+
* fire path (`executeCronjob` below) and the manual `trigger` route so
|
|
123309
|
+
* both stay aligned.
|
|
123127
123310
|
*/
|
|
123128
|
-
|
|
123129
|
-
const
|
|
123130
|
-
|
|
123131
|
-
|
|
123132
|
-
return
|
|
123311
|
+
getOrCreateCronjobChat(workspaceId, job) {
|
|
123312
|
+
const labelMatch = { [BAND_CRON_ID_LABEL]: job.id };
|
|
123313
|
+
const existing = chatService.findByLabels(workspaceId, labelMatch);
|
|
123314
|
+
if (existing) return existing;
|
|
123315
|
+
return chatService.create(workspaceId, {
|
|
123316
|
+
name: job.name,
|
|
123317
|
+
labels: labelMatch,
|
|
123318
|
+
// Cronjob scheduler is a trusted server-side caller — it's allowed
|
|
123319
|
+
// to write the reserved `band:` prefix that user-facing surfaces
|
|
123320
|
+
// are blocked from touching.
|
|
123321
|
+
allowReservedLabels: true
|
|
123322
|
+
});
|
|
123133
123323
|
}
|
|
123134
123324
|
/**
|
|
123135
|
-
*
|
|
123136
|
-
*
|
|
123325
|
+
* Dispatch a `via="terminal"` fire: spawn the agent's *headless* vendor CLI
|
|
123326
|
+
* in a fresh, self-closing PTY pane. Mirrors the `via=terminal` branch of
|
|
123327
|
+
* `WorkspaceService.create` (#551), but adapted for a recurring cron in two
|
|
123328
|
+
* important ways:
|
|
123329
|
+
*
|
|
123330
|
+
* - **Headless, not interactive.** We use `adapter.cliHeadlessInvocation`
|
|
123331
|
+
* (`claude -p`, `codex exec`, …), NOT the interactive `cliInvocation`
|
|
123332
|
+
* that workspace-create uses. The interactive REPL never exits on its
|
|
123333
|
+
* own, so the pane would stay parked after the turn — the shell's `exit`
|
|
123334
|
+
* would never run and the overlap guard below would treat "idle at the
|
|
123335
|
+
* REPL" as "still running", blocking every subsequent tick (issue #581
|
|
123336
|
+
* follow-up). The headless form runs the prompt and exits, which is what
|
|
123337
|
+
* makes both the self-close and the overlap guard actually work.
|
|
123338
|
+
* - **Overlap guard.** If the terminal spawned by this job's previous fire
|
|
123339
|
+
* is still alive, throw `TaskConflictError` instead of launching a second
|
|
123340
|
+
* agent — the caller records "skipped" (scheduled) or surfaces 409
|
|
123341
|
+
* (manual trigger), exactly like the chat running-task conflict. Because
|
|
123342
|
+
* the headless process exits when the turn completes, a live PTY reliably
|
|
123343
|
+
* means the previous run is genuinely still working.
|
|
123344
|
+
* - **Self-closing pane.** The command is `<headless-cli>\nexit`, staged
|
|
123345
|
+
* and sourced by the pool via `.`, so the shell exits the moment the
|
|
123346
|
+
* headless CLI returns. Combined with `cleanupOnExit`, the finished pane
|
|
123347
|
+
* removes itself from the layout and emits `terminal-killed` — bounding a
|
|
123348
|
+
* frequent cron to ~1 pane and keeping the next tick's liveness check
|
|
123349
|
+
* accurate.
|
|
123350
|
+
*
|
|
123351
|
+
* Returns `{ terminalId: null }` when the agent exposes no headless CLI (or
|
|
123352
|
+
* the workspace can't be resolved), signalling the caller to fall back to
|
|
123353
|
+
* chat. Spawn failures propagate (recorded as "failed").
|
|
123354
|
+
*
|
|
123355
|
+
* Edge case: if `terminalService.spawn` throws *after* the PTY is forked but
|
|
123356
|
+
* before `updateLastTerminal` runs, `lastTerminalId` stays unset for this
|
|
123357
|
+
* fire, so the next tick's overlap guard won't see that (now likely dead)
|
|
123358
|
+
* PTY. This is acceptable — a spawn that rejects has almost certainly failed
|
|
123359
|
+
* to produce a usable pane, and the synchronous in-process lock above still
|
|
123360
|
+
* prevents a *concurrent* double-spawn; only a subsequent, well-separated
|
|
123361
|
+
* tick could add a second pane, which is the same at-most-transient
|
|
123362
|
+
* duplication the self-close bound already tolerates.
|
|
123137
123363
|
*/
|
|
123138
|
-
|
|
123139
|
-
|
|
123140
|
-
if (
|
|
123141
|
-
|
|
123142
|
-
this.outputListeners.set(terminalId, listeners3);
|
|
123364
|
+
async spawnCronTerminal(job, workspaceId, fileKey) {
|
|
123365
|
+
const state = schedulerState();
|
|
123366
|
+
if (state.spawningTerminals.has(job.id)) {
|
|
123367
|
+
throw new TaskConflictError(`Cronjob ${job.id} terminal run is already being spawned`);
|
|
123143
123368
|
}
|
|
123144
|
-
|
|
123145
|
-
|
|
123146
|
-
|
|
123147
|
-
if (
|
|
123148
|
-
|
|
123369
|
+
state.spawningTerminals.add(job.id);
|
|
123370
|
+
try {
|
|
123371
|
+
const workspace = workspaceService.resolve(workspaceId);
|
|
123372
|
+
if (!workspace) {
|
|
123373
|
+
log22.warn(
|
|
123374
|
+
{ jobId: job.id, workspaceId },
|
|
123375
|
+
"workspace not resolvable for via=terminal cronjob; falling back to chat"
|
|
123376
|
+
);
|
|
123377
|
+
return { terminalId: null };
|
|
123149
123378
|
}
|
|
123150
|
-
|
|
123151
|
-
|
|
123152
|
-
|
|
123153
|
-
* Kill a single terminal by terminalId.
|
|
123154
|
-
*/
|
|
123155
|
-
kill(terminalId) {
|
|
123156
|
-
const session = this.terminals.get(terminalId);
|
|
123157
|
-
if (session) {
|
|
123158
|
-
session.pty.kill();
|
|
123159
|
-
this.terminals.delete(terminalId);
|
|
123160
|
-
const set = this.workspaceTerminals.get(session.workspaceId);
|
|
123161
|
-
if (set) {
|
|
123162
|
-
set.delete(terminalId);
|
|
123163
|
-
if (set.size === 0) {
|
|
123164
|
-
this.workspaceTerminals.delete(session.workspaceId);
|
|
123165
|
-
}
|
|
123379
|
+
const prevTerminalId = this.findJob(fileKey, job.id)?.lastTerminalId;
|
|
123380
|
+
if (prevTerminalId && terminalService.getSession(prevTerminalId)) {
|
|
123381
|
+
throw new TaskConflictError(`Cronjob ${job.id} terminal run is still in progress`);
|
|
123166
123382
|
}
|
|
123383
|
+
const adapter = await agentService.createWorkspaceAgent(workspace.worktree.path);
|
|
123384
|
+
const invocation = adapter.cliHeadlessInvocation?.(job.prompt);
|
|
123385
|
+
adapter.abort?.();
|
|
123386
|
+
if (!invocation || invocation.unsupported) {
|
|
123387
|
+
const reason = invocation?.reason ?? "adapter does not expose cliHeadlessInvocation";
|
|
123388
|
+
log22.warn(
|
|
123389
|
+
{ jobId: job.id, workspaceId, reason },
|
|
123390
|
+
"via=terminal requested but agent has no headless CLI; falling back to chat"
|
|
123391
|
+
);
|
|
123392
|
+
return { terminalId: null };
|
|
123393
|
+
}
|
|
123394
|
+
const command = `${formatShellCommand(invocation.command, invocation.args)}
|
|
123395
|
+
exit`;
|
|
123396
|
+
const terminalId = randomUUID3();
|
|
123397
|
+
await terminalService.spawn(workspaceId, terminalId, { command }, { cleanupOnExit: true });
|
|
123398
|
+
this.queries.updateLastTerminal(job.id, terminalId);
|
|
123399
|
+
emit({ kind: "terminal-created", workspaceId, terminalId });
|
|
123400
|
+
return { terminalId };
|
|
123401
|
+
} finally {
|
|
123402
|
+
state.spawningTerminals.delete(job.id);
|
|
123167
123403
|
}
|
|
123168
123404
|
}
|
|
123169
123405
|
/**
|
|
123170
|
-
*
|
|
123406
|
+
* Resolve the workspace the cronjob fires into.
|
|
123407
|
+
*
|
|
123408
|
+
* Returns `null` for project-scoped jobs whose project no longer exists
|
|
123409
|
+
* (which the scheduled fire path treats as a soft failure — see
|
|
123410
|
+
* `executeCronjob`). The manual trigger route maps the same condition to
|
|
123411
|
+
* a `CronjobProjectNotFoundError`.
|
|
123412
|
+
*
|
|
123413
|
+
* Workspace-scoped jobs return `null` rather than falling through to the
|
|
123414
|
+
* project-resolution branch when `workspaceId` is somehow missing. The
|
|
123415
|
+
* `create` path already rejects this via `CronjobWorkspaceMissingError`,
|
|
123416
|
+
* but a workspace-scoped row written by an older server version could
|
|
123417
|
+
* theoretically reach here with a null `workspaceId`. Falling through
|
|
123418
|
+
* would silently misfire into the project's default branch; returning
|
|
123419
|
+
* `null` makes the failure explicit (logged + `lastRunStatus = "failed"`
|
|
123420
|
+
* for the scheduler; `CronjobProjectNotFoundError` for the manual
|
|
123421
|
+
* trigger path).
|
|
123171
123422
|
*/
|
|
123172
|
-
|
|
123173
|
-
|
|
123174
|
-
|
|
123175
|
-
for (const terminalId of ids) {
|
|
123176
|
-
const session = this.terminals.get(terminalId);
|
|
123177
|
-
if (session) {
|
|
123178
|
-
session.pty.kill();
|
|
123179
|
-
this.terminals.delete(terminalId);
|
|
123180
|
-
}
|
|
123423
|
+
resolveWorkspaceId(job, fileKey) {
|
|
123424
|
+
if (job.scope === "workspace") {
|
|
123425
|
+
return job.workspaceId ?? null;
|
|
123181
123426
|
}
|
|
123182
|
-
|
|
123427
|
+
const appState = loadState();
|
|
123428
|
+
const project = appState.projects.find((p6) => p6.name === fileKey);
|
|
123429
|
+
if (!project) return null;
|
|
123430
|
+
return toWorkspaceId(project.name, project.defaultBranch);
|
|
123183
123431
|
}
|
|
123184
|
-
|
|
123185
|
-
|
|
123186
|
-
|
|
123187
|
-
|
|
123188
|
-
|
|
123189
|
-
|
|
123432
|
+
// -------------------------------------------------------------------------
|
|
123433
|
+
// Internals
|
|
123434
|
+
// -------------------------------------------------------------------------
|
|
123435
|
+
findJob(key, id) {
|
|
123436
|
+
const file = this.queries.loadFile(key);
|
|
123437
|
+
return file.jobs.find((j) => j.id === id);
|
|
123438
|
+
}
|
|
123439
|
+
assertValidCron(expression) {
|
|
123440
|
+
try {
|
|
123441
|
+
const options2 = { maxRuns: 0 };
|
|
123442
|
+
void new E2(expression, options2);
|
|
123443
|
+
} catch {
|
|
123444
|
+
throw new InvalidCronExpressionError();
|
|
123190
123445
|
}
|
|
123191
|
-
this.terminals.clear();
|
|
123192
|
-
this.workspaceTerminals.clear();
|
|
123193
123446
|
}
|
|
123194
|
-
|
|
123195
|
-
|
|
123196
|
-
|
|
123197
|
-
|
|
123198
|
-
|
|
123199
|
-
|
|
123200
|
-
|
|
123201
|
-
|
|
123202
|
-
|
|
123203
|
-
|
|
123204
|
-
|
|
123205
|
-
|
|
123206
|
-
|
|
123207
|
-
|
|
123208
|
-
|
|
123209
|
-
|
|
123210
|
-
|
|
123211
|
-
|
|
123212
|
-
|
|
123213
|
-
|
|
123447
|
+
scheduleJob(job, fileKey) {
|
|
123448
|
+
const state = schedulerState();
|
|
123449
|
+
const existing = state.jobs.get(job.id);
|
|
123450
|
+
if (existing) {
|
|
123451
|
+
existing.stop();
|
|
123452
|
+
state.jobs.delete(job.id);
|
|
123453
|
+
}
|
|
123454
|
+
if (!job.enabled) return;
|
|
123455
|
+
try {
|
|
123456
|
+
const cronInstance = new E2(job.cronExpression, () => {
|
|
123457
|
+
this.executeCronjob(job, fileKey).catch((err) => {
|
|
123458
|
+
log22.error({ jobId: job.id, err }, "unhandled error in cronjob execution");
|
|
123459
|
+
});
|
|
123460
|
+
});
|
|
123461
|
+
state.jobs.set(job.id, cronInstance);
|
|
123462
|
+
log22.info(
|
|
123463
|
+
{ jobId: job.id, name: job.name, cron: job.cronExpression, scope: job.scope },
|
|
123464
|
+
"scheduled cronjob"
|
|
123465
|
+
);
|
|
123466
|
+
} catch (err) {
|
|
123467
|
+
log22.error(
|
|
123468
|
+
{ jobId: job.id, cronExpression: job.cronExpression, err },
|
|
123469
|
+
"invalid cron expression, skipping job"
|
|
123470
|
+
);
|
|
123471
|
+
}
|
|
123472
|
+
}
|
|
123473
|
+
async executeCronjob(job, fileKey) {
|
|
123474
|
+
const workspaceId = this.resolveWorkspaceId(job, fileKey);
|
|
123475
|
+
if (!workspaceId) {
|
|
123476
|
+
log22.warn({ jobId: job.id, fileKey }, "project not found for cronjob, skipping");
|
|
123477
|
+
this.safeUpdateLastRun(job.id, "failed");
|
|
123478
|
+
return;
|
|
123479
|
+
}
|
|
123480
|
+
log22.info({ jobId: job.id, name: job.name, workspaceId }, "executing cronjob");
|
|
123481
|
+
if (job.via === "terminal") {
|
|
123482
|
+
try {
|
|
123483
|
+
const { terminalId } = await this.spawnCronTerminal(job, workspaceId, fileKey);
|
|
123484
|
+
if (terminalId) {
|
|
123485
|
+
log22.info({ jobId: job.id, terminalId, workspaceId }, "cronjob terminal spawned");
|
|
123486
|
+
this.safeUpdateLastRun(job.id, "completed");
|
|
123487
|
+
return;
|
|
123488
|
+
}
|
|
123489
|
+
log22.info(
|
|
123490
|
+
{ jobId: job.id, workspaceId },
|
|
123491
|
+
"via=terminal not supported by agent; falling back to chat dispatch"
|
|
123492
|
+
);
|
|
123493
|
+
} catch (err) {
|
|
123494
|
+
if (err instanceof TaskConflictError) {
|
|
123495
|
+
log22.info(
|
|
123496
|
+
{ jobId: job.id, workspaceId },
|
|
123497
|
+
"previous terminal run still active, skipping cronjob execution"
|
|
123498
|
+
);
|
|
123499
|
+
this.safeUpdateLastRun(job.id, "skipped");
|
|
123500
|
+
return;
|
|
123501
|
+
}
|
|
123502
|
+
log22.error({ jobId: job.id, err }, "cronjob terminal dispatch failed");
|
|
123503
|
+
this.safeUpdateLastRun(job.id, "failed");
|
|
123504
|
+
return;
|
|
123505
|
+
}
|
|
123506
|
+
}
|
|
123507
|
+
try {
|
|
123508
|
+
const chat = this.getOrCreateCronjobChat(workspaceId, job);
|
|
123509
|
+
const task = taskService.submitTask({ workspaceId, chatId: chat.id, prompt: job.prompt });
|
|
123510
|
+
log22.info(
|
|
123511
|
+
{ jobId: job.id, taskId: task.id, workspaceId, chatId: chat.id },
|
|
123512
|
+
"cronjob task submitted"
|
|
123513
|
+
);
|
|
123514
|
+
this.safeUpdateLastRun(job.id, "completed");
|
|
123515
|
+
} catch (err) {
|
|
123516
|
+
if (err instanceof TaskConflictError) {
|
|
123517
|
+
log22.info(
|
|
123518
|
+
{ jobId: job.id, workspaceId },
|
|
123519
|
+
"task already running, skipping cronjob execution"
|
|
123520
|
+
);
|
|
123521
|
+
this.safeUpdateLastRun(job.id, "skipped");
|
|
123522
|
+
return;
|
|
123523
|
+
}
|
|
123524
|
+
log22.error({ jobId: job.id, err }, "cronjob execution failed");
|
|
123525
|
+
this.safeUpdateLastRun(job.id, "failed");
|
|
123214
123526
|
}
|
|
123215
|
-
});
|
|
123216
|
-
}
|
|
123217
|
-
function removeTerminalFromLayout(workspaceId, terminalId) {
|
|
123218
|
-
manager.removePanel(workspaceId, terminalId);
|
|
123219
|
-
}
|
|
123220
|
-
|
|
123221
|
-
// src/server/services/terminal-service.ts
|
|
123222
|
-
var log23 = createLogger("terminal-service");
|
|
123223
|
-
var TerminalPaneConfigSchema = external_exports.object({
|
|
123224
|
-
name: external_exports.string().optional(),
|
|
123225
|
-
command: external_exports.string().optional(),
|
|
123226
|
-
cwd: external_exports.string().optional(),
|
|
123227
|
-
env: external_exports.record(external_exports.string()).optional(),
|
|
123228
|
-
focus: external_exports.boolean().optional()
|
|
123229
|
-
});
|
|
123230
|
-
var PaneNodeSchema = external_exports.object({
|
|
123231
|
-
pane: TerminalPaneConfigSchema
|
|
123232
|
-
});
|
|
123233
|
-
var TerminalLayoutNodeSchema = external_exports.lazy(
|
|
123234
|
-
() => external_exports.union([
|
|
123235
|
-
PaneNodeSchema,
|
|
123236
|
-
external_exports.object({
|
|
123237
|
-
direction: external_exports.enum(["horizontal", "vertical"]),
|
|
123238
|
-
split: external_exports.number().min(0.1).max(0.9).optional().default(0.5),
|
|
123239
|
-
children: external_exports.tuple([TerminalLayoutNodeSchema, TerminalLayoutNodeSchema])
|
|
123240
|
-
})
|
|
123241
|
-
])
|
|
123242
|
-
);
|
|
123243
|
-
var WorkspaceTerminalConfigSchema = external_exports.object({
|
|
123244
|
-
layout: TerminalLayoutNodeSchema
|
|
123245
|
-
});
|
|
123246
|
-
var TerminalService = class {
|
|
123247
|
-
constructor(pool2 = terminalPool) {
|
|
123248
|
-
this.pool = pool2;
|
|
123249
123527
|
}
|
|
123250
|
-
// -------------------------------------------------------------------------
|
|
123251
|
-
// PTY lifecycle
|
|
123252
|
-
// -------------------------------------------------------------------------
|
|
123253
123528
|
/**
|
|
123254
|
-
*
|
|
123529
|
+
* Update `lastRunAt`/`lastRunStatus`, swallowing DB errors.
|
|
123255
123530
|
*
|
|
123256
|
-
*
|
|
123257
|
-
*
|
|
123258
|
-
*
|
|
123259
|
-
* `
|
|
123260
|
-
* the API entry points decide whether to broadcast (the WebSocket handler
|
|
123261
|
-
* stays silent; the tRPC `create` mutation emits explicitly).
|
|
123531
|
+
* Errors here are diagnostic noise during the fire path — the cronjob
|
|
123532
|
+
* already executed (or failed/skipped), and we don't want a transient
|
|
123533
|
+
* DB lock to mask the real outcome by re-raising. The legacy
|
|
123534
|
+
* implementation logged at `warn` and moved on; preserved here.
|
|
123262
123535
|
*/
|
|
123263
|
-
|
|
123264
|
-
|
|
123265
|
-
|
|
123266
|
-
|
|
123536
|
+
safeUpdateLastRun(jobId, status) {
|
|
123537
|
+
try {
|
|
123538
|
+
this.queries.updateLastRun(jobId, status);
|
|
123539
|
+
} catch (err) {
|
|
123540
|
+
log22.warn({ jobId, err }, "failed to update lastRun on cronjob");
|
|
123267
123541
|
}
|
|
123268
|
-
const session = await this.pool.spawn(
|
|
123269
|
-
workspaceId,
|
|
123270
|
-
terminalId,
|
|
123271
|
-
workspace.worktree.path,
|
|
123272
|
-
options2
|
|
123273
|
-
);
|
|
123274
|
-
addTerminalToLayout(workspaceId, terminalId, {
|
|
123275
|
-
command: options2?.command,
|
|
123276
|
-
cwd: options2?.cwd,
|
|
123277
|
-
env: options2?.env
|
|
123278
|
-
});
|
|
123279
|
-
return session;
|
|
123280
123542
|
}
|
|
123281
|
-
|
|
123282
|
-
|
|
123283
|
-
|
|
123284
|
-
|
|
123285
|
-
|
|
123286
|
-
|
|
123287
|
-
const
|
|
123288
|
-
|
|
123289
|
-
this.pool.kill(terminalId);
|
|
123290
|
-
if (workspaceId) {
|
|
123291
|
-
removeTerminalFromLayout(workspaceId, terminalId);
|
|
123292
|
-
emit({ kind: "terminal-killed", workspaceId, terminalId });
|
|
123543
|
+
loadAndScheduleAll() {
|
|
123544
|
+
const state = schedulerState();
|
|
123545
|
+
for (const [, cron] of state.jobs) {
|
|
123546
|
+
cron.stop();
|
|
123547
|
+
}
|
|
123548
|
+
state.jobs.clear();
|
|
123549
|
+
for (const job of this.queries.listAll()) {
|
|
123550
|
+
this.scheduleJob(job, job.fileKey);
|
|
123293
123551
|
}
|
|
123552
|
+
log22.info({ count: state.jobs.size }, "loaded cronjob schedules");
|
|
123294
123553
|
}
|
|
123554
|
+
};
|
|
123555
|
+
var cronjobService = new CronjobService();
|
|
123556
|
+
|
|
123557
|
+
// src/server/services/panel-focus-service.ts
|
|
123558
|
+
init_src();
|
|
123559
|
+
var log23 = createLogger("panel-focus-service");
|
|
123560
|
+
var PANEL_FOCUS_TYPE = "panel_focus";
|
|
123561
|
+
function focusRowId(workspaceId) {
|
|
123562
|
+
return `${PANEL_FOCUS_TYPE}:${workspaceId}`;
|
|
123563
|
+
}
|
|
123564
|
+
var PanelFocusService = class {
|
|
123565
|
+
/** workspaceId → last-focused panel ids. */
|
|
123566
|
+
focus = /* @__PURE__ */ new Map();
|
|
123295
123567
|
/**
|
|
123296
|
-
*
|
|
123297
|
-
*
|
|
123298
|
-
*
|
|
123568
|
+
* Workspaces whose focus row has been loaded from the DB into `focus`.
|
|
123569
|
+
* Separate from the map's own key presence because a workspace can have a
|
|
123570
|
+
* loaded-but-empty record (row absent) — we still don't want to re-hit the
|
|
123571
|
+
* DB on every read.
|
|
123299
123572
|
*/
|
|
123300
|
-
|
|
123301
|
-
|
|
123302
|
-
|
|
123573
|
+
hydrated = /* @__PURE__ */ new Set();
|
|
123574
|
+
/** Workspaces that already have a `panel_states` row (drives insert vs update). */
|
|
123575
|
+
persisted = /* @__PURE__ */ new Set();
|
|
123303
123576
|
/**
|
|
123304
|
-
*
|
|
123305
|
-
*
|
|
123577
|
+
* Load a workspace's focus row from the DB on first access. Cheap and
|
|
123578
|
+
* idempotent — subsequent calls short-circuit on the `hydrated` set.
|
|
123306
123579
|
*/
|
|
123307
|
-
|
|
123308
|
-
this.
|
|
123309
|
-
|
|
123310
|
-
|
|
123311
|
-
|
|
123312
|
-
|
|
123313
|
-
|
|
123314
|
-
|
|
123315
|
-
|
|
123316
|
-
|
|
123317
|
-
|
|
123318
|
-
|
|
123319
|
-
|
|
123320
|
-
|
|
123321
|
-
|
|
123322
|
-
|
|
123323
|
-
|
|
123324
|
-
}
|
|
123325
|
-
resize(terminalId, cols, rows) {
|
|
123326
|
-
this.pool.resize(terminalId, cols, rows);
|
|
123327
|
-
}
|
|
123328
|
-
subscribeOutput(terminalId, callback) {
|
|
123329
|
-
return this.pool.subscribeOutput(terminalId, callback);
|
|
123330
|
-
}
|
|
123331
|
-
// -------------------------------------------------------------------------
|
|
123332
|
-
// Layout persistence (dockview tree)
|
|
123333
|
-
//
|
|
123334
|
-
// Thin pass-throughs to `lib/terminal-layout-manager` so the API tier
|
|
123335
|
-
// doesn't reach across into `lib/` directly. The layout helpers will be
|
|
123336
|
-
// folded into a dedicated infra adapter in a follow-up phase (when the
|
|
123337
|
-
// chats/browsers domains get their own infra adapters); leaving them in
|
|
123338
|
-
// `lib/` for now is the minimum-disruption shape during the phased
|
|
123339
|
-
// migration.
|
|
123340
|
-
// -------------------------------------------------------------------------
|
|
123341
|
-
getLayout(workspaceId) {
|
|
123342
|
-
return getTerminalLayout(workspaceId);
|
|
123343
|
-
}
|
|
123344
|
-
saveLayout(workspaceId, tree) {
|
|
123345
|
-
saveTerminalLayout(workspaceId, tree);
|
|
123580
|
+
ensureHydrated(workspaceId) {
|
|
123581
|
+
if (this.hydrated.has(workspaceId)) return;
|
|
123582
|
+
this.hydrated.add(workspaceId);
|
|
123583
|
+
const rows = listPanelStatesForWorkspace(workspaceId, PANEL_FOCUS_TYPE);
|
|
123584
|
+
const row = rows[0];
|
|
123585
|
+
if (!row) return;
|
|
123586
|
+
this.persisted.add(workspaceId);
|
|
123587
|
+
try {
|
|
123588
|
+
const parsed = JSON.parse(row.state);
|
|
123589
|
+
this.focus.set(workspaceId, {
|
|
123590
|
+
chat: parsed.chat,
|
|
123591
|
+
terminal: parsed.terminal,
|
|
123592
|
+
browser: parsed.browser
|
|
123593
|
+
});
|
|
123594
|
+
} catch (err) {
|
|
123595
|
+
log23.warn({ workspaceId, err }, "failed to parse persisted panel focus; ignoring");
|
|
123596
|
+
}
|
|
123346
123597
|
}
|
|
123347
|
-
|
|
123348
|
-
|
|
123598
|
+
/** Get the last-focused panel ids for a workspace. */
|
|
123599
|
+
get(workspaceId) {
|
|
123600
|
+
this.ensureHydrated(workspaceId);
|
|
123601
|
+
return { ...this.focus.get(workspaceId) ?? {} };
|
|
123349
123602
|
}
|
|
123350
|
-
// -------------------------------------------------------------------------
|
|
123351
|
-
// Workspace `.band/config.json` `workspace.terminal` block
|
|
123352
|
-
// -------------------------------------------------------------------------
|
|
123353
123603
|
/**
|
|
123354
|
-
*
|
|
123355
|
-
* `.
|
|
123356
|
-
*
|
|
123357
|
-
* payload fails validation.
|
|
123358
|
-
*
|
|
123359
|
-
* Absorbed from the old `lib/terminal-config.ts:loadWorkspaceTerminalConfig`
|
|
123360
|
-
* — same parsing semantics, but the lookup now goes through the service
|
|
123361
|
-
* tier so callers (currently the workspace router's `getTerminalConfig`
|
|
123362
|
-
* query) reach it via `terminalService` instead of a stand-alone helper.
|
|
123604
|
+
* Record that `panelId` is the last-focused panel of `panelType` in
|
|
123605
|
+
* `workspaceId`. Write-through: updates the in-memory map and upserts the
|
|
123606
|
+
* backing `panel_states` row.
|
|
123363
123607
|
*/
|
|
123364
|
-
|
|
123365
|
-
|
|
123366
|
-
|
|
123367
|
-
|
|
123608
|
+
set(workspaceId, panelType, panelId) {
|
|
123609
|
+
this.ensureHydrated(workspaceId);
|
|
123610
|
+
const current2 = this.focus.get(workspaceId) ?? {};
|
|
123611
|
+
if (current2[panelType] === panelId) return;
|
|
123612
|
+
const next = { ...current2, [panelType]: panelId };
|
|
123613
|
+
this.focus.set(workspaceId, next);
|
|
123614
|
+
const now = Date.now();
|
|
123615
|
+
const state = JSON.stringify(next);
|
|
123616
|
+
if (this.persisted.has(workspaceId)) {
|
|
123617
|
+
updatePanelState(focusRowId(workspaceId), { state, updatedAt: now });
|
|
123618
|
+
} else {
|
|
123619
|
+
insertPanelState({
|
|
123620
|
+
id: focusRowId(workspaceId),
|
|
123621
|
+
workspaceId,
|
|
123622
|
+
panelType: PANEL_FOCUS_TYPE,
|
|
123623
|
+
state,
|
|
123624
|
+
createdAt: now,
|
|
123625
|
+
updatedAt: now
|
|
123626
|
+
});
|
|
123627
|
+
this.persisted.add(workspaceId);
|
|
123628
|
+
}
|
|
123368
123629
|
}
|
|
123369
123630
|
/**
|
|
123370
|
-
*
|
|
123371
|
-
*
|
|
123631
|
+
* Drop all focus tracking for a workspace. Called from the workspace delete
|
|
123632
|
+
* path so the row doesn't outlive the workspace.
|
|
123372
123633
|
*/
|
|
123373
|
-
|
|
123374
|
-
|
|
123375
|
-
|
|
123376
|
-
|
|
123377
|
-
|
|
123378
|
-
const result = WorkspaceTerminalConfigSchema.safeParse(terminalBlock);
|
|
123379
|
-
if (!result.success) {
|
|
123380
|
-
log23.warn(
|
|
123381
|
-
"Invalid workspace.terminal config: %s",
|
|
123382
|
-
result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`).join("; ")
|
|
123383
|
-
);
|
|
123384
|
-
return null;
|
|
123385
|
-
}
|
|
123386
|
-
return result.data;
|
|
123634
|
+
remove(workspaceId) {
|
|
123635
|
+
this.focus.delete(workspaceId);
|
|
123636
|
+
this.hydrated.delete(workspaceId);
|
|
123637
|
+
this.persisted.delete(workspaceId);
|
|
123638
|
+
deletePanelState(focusRowId(workspaceId));
|
|
123387
123639
|
}
|
|
123388
123640
|
};
|
|
123389
|
-
var
|
|
123641
|
+
var panelFocusService = new PanelFocusService();
|
|
123390
123642
|
|
|
123391
123643
|
// src/server/services/workspace-service.ts
|
|
123392
123644
|
var execFileAsync2 = promisify2(execFile6);
|
|
@@ -123608,7 +123860,7 @@ var WorkspaceService = class {
|
|
|
123608
123860
|
via = "chat";
|
|
123609
123861
|
} else {
|
|
123610
123862
|
terminalCommand = formatShellCommand(invocation.command, invocation.args);
|
|
123611
|
-
terminalId =
|
|
123863
|
+
terminalId = randomUUID4();
|
|
123612
123864
|
}
|
|
123613
123865
|
} catch (err) {
|
|
123614
123866
|
log24.warn(
|
|
@@ -123697,7 +123949,7 @@ var WorkspaceService = class {
|
|
|
123697
123949
|
};
|
|
123698
123950
|
}
|
|
123699
123951
|
const command = formatShellCommand(invocation.command, invocation.args);
|
|
123700
|
-
const terminalId =
|
|
123952
|
+
const terminalId = randomUUID4();
|
|
123701
123953
|
await terminalService.spawn(chat.workspaceId, terminalId, { command });
|
|
123702
123954
|
emit({ kind: "terminal-created", workspaceId: chat.workspaceId, terminalId });
|
|
123703
123955
|
log24.info(
|
|
@@ -137690,9 +137942,9 @@ var cronjobsRouter = t2.router({
|
|
|
137690
137942
|
throwAsTRPCError(err);
|
|
137691
137943
|
}
|
|
137692
137944
|
}),
|
|
137693
|
-
trigger: publicProcedure.input(cronjobByIdInput).mutation(({ input }) => {
|
|
137945
|
+
trigger: publicProcedure.input(cronjobByIdInput).mutation(async ({ input }) => {
|
|
137694
137946
|
try {
|
|
137695
|
-
return cronjobService.trigger(input.key, input.id);
|
|
137947
|
+
return await cronjobService.trigger(input.key, input.id);
|
|
137696
137948
|
} catch (err) {
|
|
137697
137949
|
throwAsTRPCError(err);
|
|
137698
137950
|
}
|
|
@@ -137706,10 +137958,7 @@ function throwAsTRPCError(err) {
|
|
|
137706
137958
|
throw new TRPCError({ code: "BAD_REQUEST", message: err.message });
|
|
137707
137959
|
}
|
|
137708
137960
|
if (err instanceof TaskConflictError) {
|
|
137709
|
-
throw new TRPCError({
|
|
137710
|
-
code: "CONFLICT",
|
|
137711
|
-
message: "Task already running for this chat pane"
|
|
137712
|
-
});
|
|
137961
|
+
throw new TRPCError({ code: "CONFLICT", message: err.message });
|
|
137713
137962
|
}
|
|
137714
137963
|
throw err;
|
|
137715
137964
|
}
|
|
@@ -140479,7 +140728,7 @@ function throwAsTrpcError2(err) {
|
|
|
140479
140728
|
}
|
|
140480
140729
|
|
|
140481
140730
|
// src/server/api/terminals/router.ts
|
|
140482
|
-
import { randomUUID as
|
|
140731
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
140483
140732
|
init_zod();
|
|
140484
140733
|
|
|
140485
140734
|
// src/server/api/terminals/strip-queries.ts
|
|
@@ -140505,7 +140754,7 @@ var terminalRouter = t2.router({
|
|
|
140505
140754
|
env: external_exports.record(external_exports.string()).optional()
|
|
140506
140755
|
})
|
|
140507
140756
|
).mutation(async ({ input }) => {
|
|
140508
|
-
const terminalId = input.id ??
|
|
140757
|
+
const terminalId = input.id ?? randomUUID5();
|
|
140509
140758
|
const session = await terminalService.spawn(input.workspaceId, terminalId, {
|
|
140510
140759
|
command: input.command,
|
|
140511
140760
|
cwd: input.cwd,
|