@rubytech/create-maxy-code 0.1.478 → 0.1.480
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/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1829-platform-ui-typecheck.md +830 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1829-platform-ui-typecheck-design.md +163 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +22 -0
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
- package/payload/platform/lib/graph-style/src/index.ts +20 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +155 -12
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +3 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +68 -28
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +194 -16
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +74 -29
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
- package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
- package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js +41 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +249 -2
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js +127 -3
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts +17 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js +26 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts +37 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js +126 -5
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +18 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +123 -18
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
- package/payload/platform/plugins/outlook/references/auth.md +45 -2
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/server/{chunk-3XLLTG6R.js → chunk-REWXLAPC.js} +30 -10
- package/payload/server/package.json +1 -1
- package/payload/server/public/activity.html +7 -6
- package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DXYJWXaX.js} +1 -1
- package/payload/server/public/assets/AdminShell-UDgNN4CA.js +2 -0
- package/payload/server/public/assets/Checkbox-C08Ulc_a.js +1 -0
- package/payload/server/public/assets/activity-06gsRDVv.js +1 -0
- package/payload/server/public/assets/admin-BhiHyORH.js +1 -0
- package/payload/server/public/assets/{arc-BrHWN2Kr.js → arc-BHXuzTwg.js} +1 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-Ct5q6ks1.js +1 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Clt9NOr0.js → architectureDiagram-Q4EWVU46-DsN_vxau.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-BOe_jmXi.js → blockDiagram-DXYQGD6D-CvNat1uB.js} +1 -1
- package/payload/server/public/assets/browser-Cz4IKS47.js +1 -0
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-B64FaKMV.js → c4Diagram-AHTNJAMY-E9yqTPTW.js} +1 -1
- package/payload/server/public/assets/calendar-D0xtNqH8.js +1 -0
- package/payload/server/public/assets/channel-PE9vBKEQ.js +1 -0
- package/payload/server/public/assets/chat-DwLJ830n.js +1 -0
- package/payload/server/public/assets/chevron-left-Cv-TFuEf.js +1 -0
- package/payload/server/public/assets/{chunk-2KRD3SAO-BGAUfWvM.js → chunk-2KRD3SAO-DpzI_0EY.js} +1 -1
- package/payload/server/public/assets/{chunk-336JU56O-PFi6uQZ0.js → chunk-336JU56O-eR_I9Eu4.js} +2 -2
- package/payload/server/public/assets/chunk-426QAEUC-BDNZtJsz.js +1 -0
- package/payload/server/public/assets/{chunk-4BX2VUAB-CkxXXCiF.js → chunk-4BX2VUAB-D-6xk1t8.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-B-7tN4IA.js → chunk-4TB4RGXK-BkqUcFpL.js} +1 -1
- package/payload/server/public/assets/{chunk-55IACEB6-CBkNmqrt.js → chunk-55IACEB6-ChCl2pSP.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-wa9ujn7i.js → chunk-5FUZZQ4R-DasGsg54.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DIk-xBaE.js → chunk-5PVQY5BW-DXlHUX1h.js} +1 -1
- package/payload/server/public/assets/{chunk-67CJDMHE-BbMV1lgW.js → chunk-67CJDMHE-BZfVpTsE.js} +1 -1
- package/payload/server/public/assets/{chunk-7N4EOEYR-C49Mv-nQ.js → chunk-7N4EOEYR-C6U5TIgT.js} +1 -1
- package/payload/server/public/assets/{chunk-AA7GKIK3-Dy_pJOp_.js → chunk-AA7GKIK3-CA7cVDmw.js} +1 -1
- package/payload/server/public/assets/{chunk-BSJP7CBP-Ce-OmPqn.js → chunk-BSJP7CBP-DxN2GC3p.js} +1 -1
- package/payload/server/public/assets/{chunk-CIAEETIT-tnjq-APC.js → chunk-CIAEETIT-C0-4wA8y.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-Bvv_deZ2.js → chunk-EDXVE4YY--kyb1rLc.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-CmxCPKyU.js → chunk-ENJZ2VHE-FBN6hR7V.js} +1 -1
- package/payload/server/public/assets/{chunk-FMBD7UC4-Top6y08S.js → chunk-FMBD7UC4-Lpge4M5g.js} +1 -1
- package/payload/server/public/assets/{chunk-FOC6F5B3-B9LPNCUS.js → chunk-FOC6F5B3-CM1rwOou.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-Bi-_c351.js → chunk-ICPOFSXX-JdYLY3yu.js} +2 -2
- package/payload/server/public/assets/{chunk-K5T4RW27-DAnRM04h.js → chunk-K5T4RW27-DCUblGBr.js} +1 -1
- package/payload/server/public/assets/{chunk-KGLVRYIC-C0QYcbLe.js → chunk-KGLVRYIC-BP_cide2.js} +1 -1
- package/payload/server/public/assets/{chunk-LIHQZDEY-BjvA5ykt.js → chunk-LIHQZDEY-DjMa1lOl.js} +1 -1
- package/payload/server/public/assets/{chunk-ORNJ4GCN-Cs46Gj7z.js → chunk-ORNJ4GCN-D51eJAoV.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-BBpimab5.js → chunk-OYMX7WX6-4hWIO544.js} +1 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-B_Hris8x.js +1 -0
- package/payload/server/public/assets/{chunk-U2HBQHQK-CQUSBHoK.js → chunk-U2HBQHQK-BwdQf9cS.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-Dm8TevG0.js → chunk-X2U36JSP-DmDxwLOg.js} +1 -1
- package/payload/server/public/assets/{chunk-XPW4576I-B10QuxYb.js → chunk-XPW4576I-JQyAB7ND.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-vaGsu4Wk.js → chunk-YZCP3GAM-BonLdCzI.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-D1Qi22bL.js → chunk-ZZ45TVLE-BXKB2wSk.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-D8Q05iMI.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Dm3hAxfm.js +1 -0
- package/payload/server/public/assets/clone-C-wOwvpY.js +1 -0
- package/payload/server/public/assets/{cose-bilkent-S5V4N54A-CRDvLRXs.js → cose-bilkent-S5V4N54A-BDnd4Blw.js} +1 -1
- package/payload/server/public/assets/{dagre-CEmqJTt3.js → dagre-BmexzuER.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-LXMTo7Fp.js → dagre-KV5264BT-D4WVuwPj.js} +1 -1
- package/payload/server/public/assets/data-DUDJfnjI.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-zyis6nPt.js → diagram-5BDNPKRD-Bbf4mT8a.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-BiU0XAAD.js → diagram-G4DWMVQ6-BpqVTUmx.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-rGFiz1SX.js → diagram-MMDJMWI5-DY-k5k6r.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-B0wak1ZJ.js → diagram-TYMM5635-CXHxwacS.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8AUxLyX.js → erDiagram-SMLLAGMA-BNKEc3l8.js} +1 -1
- package/payload/server/public/assets/{flatten-naJ5PJ4f.js → flatten-jn8bZgcI.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-BtdUf68b.js → flowDiagram-DWJPFMVM-CbAeuJsi.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-C-lkzDmf.js → ganttDiagram-T4ZO3ILL-CeeNnL4l.js} +1 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-BBHsY16R.js +1 -0
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DRUKumq2.js → gitGraphDiagram-UUTBAWPF-DG8RCtfg.js} +1 -1
- package/payload/server/public/assets/{graph-DRloNic4.js → graph-BAoktBcx.js} +19 -19
- package/payload/server/public/assets/graph-labels-LeIhl7ZG.js +1 -0
- package/payload/server/public/assets/{graphlib-zRsaoFEC.js → graphlib-BkDExPQi.js} +1 -1
- package/payload/server/public/assets/info-OMHHGYJF-C1j5I_zx.js +1 -0
- package/payload/server/public/assets/infoDiagram-42DDH7IO-ZWkwHWEu.js +2 -0
- package/payload/server/public/assets/{isEmpty-Bnpg7b8f.js → isEmpty-Dkk6_L-B.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-BguMgoR8.js → ishikawaDiagram-UXIWVN3A-D_iMHV64.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CXhyK1n-.js → journeyDiagram-VCZTEJTY-BvPRpulx.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-C0UCWVx-.js → kanban-definition-6JOO6SKY-CkWaXWo2.js} +1 -1
- package/payload/server/public/assets/{line--uhS9YDE.js → line-BGQT9unx.js} +1 -1
- package/payload/server/public/assets/{linear-CAOWPodQ.js → linear-DzrYI8-8.js} +1 -1
- package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-2MgPSwlx.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-D6kPx7vc.js → mermaid-parser.core-DrMY7FMe.js} +2 -2
- package/payload/server/public/assets/{mermaid.core-CTd4DS6O.js → mermaid.core-BWdf6fZ7.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-_AyvyG8C.js → mindmap-definition-QFDTVHPH-CgqM9h97.js} +1 -1
- package/payload/server/public/assets/operator-CZifmNhp.js +1 -0
- package/payload/server/public/assets/{ordinal-Wx4m9zHk.js → ordinal-DuJYM7p1.js} +1 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-CGiTbe0N.js +1 -0
- package/payload/server/public/assets/{page-ef-6rRPL.js → page-B4kllGd_.js} +6 -6
- package/payload/server/public/assets/page-DbfE7QwJ.js +1 -0
- package/payload/server/public/assets/{pdf-render-CeX6d2v7.js → pdf-render-riHufgrn.js} +1 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-BrK023dE.js +1 -0
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-6TsUDkkQ.js → pieDiagram-DEJITSTG-BeUGoYS7.js} +1 -1
- package/payload/server/public/assets/public-VtmyYz8E.js +1 -0
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CWw9gOI4.js → quadrantDiagram-34T5L4WZ-CX82WgKy.js} +1 -1
- package/payload/server/public/assets/radar-PYXPWWZC-p7NRamXX.js +1 -0
- package/payload/server/public/assets/{reduce-BUmc8RDR.js → reduce-COyotL2V.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-DCOlxNvx.js → requirementDiagram-MS252O5E-CzqebQXj.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-DaZdB2cz.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CW4Xbir3.js → sankeyDiagram-XADWPNL6-CaHi1yzU.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-vhb_pBLG.js → sequenceDiagram-FGHM5R23-BF2AeiKW.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-Cb1WExEX.js → stateDiagram-FHFEXIEX-DtUCA8Ih.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a4FvB3L8.js +1 -0
- package/payload/server/public/assets/tasks-CvqVDbfO.js +1 -0
- package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-TD-PPEIu.js} +1 -1
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BAHTf_AF.js → timeline-definition-GMOUNBTQ-D3Q1YrKY.js} +1 -1
- package/payload/server/public/assets/treeView-SZITEDCU-CoWk8VoG.js +1 -0
- package/payload/server/public/assets/treemap-W4RFUUIX-BhWeGj8Y.js +1 -0
- package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-BaLhVNnn.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-BhjPzRa3.js +1 -0
- package/payload/server/public/assets/useSubAccountSwitcher-CWRpVt1-.css +1 -0
- package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-HEVLb6Z9.js} +2 -2
- package/payload/server/public/assets/useVoiceRecorder-CfGgj3vm.js +2 -0
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-DtEttvW4.js → vennDiagram-DHZGUBPP-DnjzQDGf.js} +1 -1
- package/payload/server/public/assets/wardley-RL74JXVD-C0q3Hqng.js +1 -0
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-K06y6CVJ.js → wardleyDiagram-NUSXRM2D-T3WHrUvi.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-D66c40lp.js → xychartDiagram-5P7HB3ND-2Efyv4h2.js} +1 -1
- package/payload/server/public/brand-defaults.css +1 -0
- package/payload/server/public/browser.html +4 -5
- package/payload/server/public/calendar.html +5 -6
- package/payload/server/public/chat.html +12 -13
- package/payload/server/public/data.html +11 -12
- package/payload/server/public/graph.html +10 -11
- package/payload/server/public/index.html +14 -15
- package/payload/server/public/operator.html +14 -15
- package/payload/server/public/privacy.html +1 -0
- package/payload/server/public/public.html +12 -13
- package/payload/server/public/tasks.html +7 -5
- package/payload/server/public/vnc-popout.html +1 -0
- package/payload/server/server.js +833 -389
- package/payload/server/{src-XQ63FPRE.js → src-JJ6PHO5I.js} +1 -1
- package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
- package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
- package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
- package/payload/server/public/assets/admin-D2KLZnQW.js +0 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-CZQzGRSM.js +0 -1
- package/payload/server/public/assets/browser-CDWqEiCD.js +0 -1
- package/payload/server/public/assets/calendar-BG7fOpbB.js +0 -1
- package/payload/server/public/assets/channel-zDUAN9Ks.js +0 -1
- package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
- package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
- package/payload/server/public/assets/chunk-426QAEUC-iJnqNRug.js +0 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-Qlc2PFAj.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CFsKL52p.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BWBUUCiO.js +0 -1
- package/payload/server/public/assets/clone-h5XhkDUL.js +0 -1
- package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-CBZPQJLj.js +0 -1
- package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
- package/payload/server/public/assets/info-OMHHGYJF-1vIoX-kP.js +0 -1
- package/payload/server/public/assets/infoDiagram-42DDH7IO-KHcXRaan.js +0 -2
- package/payload/server/public/assets/operator-BZeeyait.js +0 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-CTU0gCGb.js +0 -1
- package/payload/server/public/assets/page-B0obcg3b.js +0 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-D-EEHxhC.js +0 -1
- package/payload/server/public/assets/public-9D3MMOkC.js +0 -1
- package/payload/server/public/assets/radar-PYXPWWZC-DHXATZGB.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CxOIKvXh.js +0 -1
- package/payload/server/public/assets/tasks-BB0L7e5a.js +0 -1
- package/payload/server/public/assets/treeView-SZITEDCU-CC7ouMFd.js +0 -1
- package/payload/server/public/assets/treemap-W4RFUUIX-CaCg27im.js +0 -1
- package/payload/server/public/assets/useCopyFeedback-jTBQ2h0V.js +0 -1
- package/payload/server/public/assets/useSelectionMode-BooArySZ.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
- package/payload/server/public/assets/useVoiceRecorder-PUT8q_sm.js +0 -2
- package/payload/server/public/assets/wardley-RL74JXVD-bMPsqQZ7.js +0 -1
- /package/payload/server/public/assets/{_baseFor-k8hYPe-U.js → _baseFor-Bjj9It3Q.js} +0 -0
- /package/payload/server/public/assets/{array-BFDiaBgf.js → array-BrrxEo12.js} +0 -0
- /package/payload/server/public/assets/{cytoscape.esm-Ch0xptA4.js → cytoscape.esm-CTWJxUxo.js} +0 -0
- /package/payload/server/public/assets/{defaultLocale-ZeknFqNe.js → defaultLocale-CFDbRV4k.js} +0 -0
- /package/payload/server/public/assets/{dist-B2N0v8hZ.js → dist-BATfy7Sn.js} +0 -0
- /package/payload/server/public/assets/{init-0VJVrkRJ.js → init-dXCzGgLP.js} +0 -0
- /package/payload/server/public/assets/{katex-Br7bH10N.js → katex-CHA1JFYJ.js} +0 -0
- /package/payload/server/public/assets/{path-INs8XTPH.js → path-CCRpdXhK.js} +0 -0
- /package/payload/server/public/assets/{preload-helper-D_sPnjeL.js → preload-helper-CH6UZRzu.js} +0 -0
- /package/payload/server/public/assets/{rough.esm-DU9bZLvZ.js → rough.esm-Bud4g4fh.js} +0 -0
- /package/payload/server/public/assets/{src-DHSQDYx-.js → src-DNdBRXXx.js} +0 -0
package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
# Task 1789 — Channel-row re-seat Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Re-seating a WhatsApp or Telegram admin row forks it as a channel session and repoints the sender at the fork, instead of forking it to webchat and destroying the channel seat.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two changes, both required. The re-seat route reads the source row's `.meta.json` sidecar and builds a channel-shaped fork payload when the source is an admin channel row. A new per-sender forward-pointer file in the account directory, written at that fork, is consulted at inbound resolution so the sender's deterministic id yields the fork instead. The pure spawn-request builders gain an optional override input; the filesystem read stays in `server/index.ts`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Hono, Vitest, Node `fs`.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Working directory for every command: `maxy-code/platform/ui`.
|
|
14
|
+
- Test runner: `npx vitest run <path>`. Full suite: `npm test`.
|
|
15
|
+
- Scope is **admin** channel rows only. A `role:'public'` sidecar takes today's webchat path unchanged.
|
|
16
|
+
- Channels in scope: `whatsapp` and `telegram`. The standing audit (Task 8) is WhatsApp-only.
|
|
17
|
+
- The webchat re-seat payload must stay **byte-for-byte identical** to today. Every task that touches `resolveReseatPlan` re-runs the existing webchat assertions.
|
|
18
|
+
- Override reads must never throw. A malformed or absent file reads as `null` and routing falls back to `adminSessionIdFor`.
|
|
19
|
+
- Every commit ends with the two trailers:
|
|
20
|
+
```
|
|
21
|
+
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
22
|
+
Session: <id from `ls -t /Users/neo/.claude/projects/-Users-neo-getmaxy-maxy-code/*.jsonl | head -1`>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## File Structure
|
|
26
|
+
|
|
27
|
+
| File | Responsibility |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `server/channel-session-override.ts` (new) | Read/write the per-sender forward pointer. Pure filesystem unit, no callers' knowledge. |
|
|
30
|
+
| `server/routes/webchat.ts` | `locateSidecar` widened to full `SidecarMeta`; new exported `readSourceRowMeta`. |
|
|
31
|
+
| `server/routes/admin/session-reseat.ts` | Sidecar read, channel-shaped payload, override write, lifeline fields. |
|
|
32
|
+
| `app/lib/whatsapp/gateway/spawn-request.ts` | Optional `overrideSessionId`. |
|
|
33
|
+
| `app/lib/telegram/gateway/spawn-request.ts` | Optional `overrideSessionId`. |
|
|
34
|
+
| `server/index.ts` | Resolve account dir, read override at both spawn sites, inject gateway dep, wire unseated audit. |
|
|
35
|
+
| `app/lib/whatsapp/gateway/wa-gateway.ts` | Detach-time map; `resolveSessionOverride` dep for the `op=inbound` line. |
|
|
36
|
+
| `app/lib/whatsapp/gateway/routes.ts` | `onAttached`/`onDetached` hooks. |
|
|
37
|
+
| `app/lib/telegram/gateway/telegram-gateway.ts` | `resolveSessionOverride` dep for the `op=inbound` line. |
|
|
38
|
+
| `app/lib/whatsapp/gateway/duplicate-sender-audit.ts` | `op=sender-unseated` emitter. |
|
|
39
|
+
| `app/components/SessionReseatControl.tsx` | Comment correction. |
|
|
40
|
+
| `../../.docs/admin-webchat-native-channel.md` | Document the override and the unbound window. |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### Task 1: The override file
|
|
45
|
+
|
|
46
|
+
**Files:**
|
|
47
|
+
- Create: `server/channel-session-override.ts`
|
|
48
|
+
- Test: `server/__tests__/channel-session-override.test.ts`
|
|
49
|
+
|
|
50
|
+
**Interfaces:**
|
|
51
|
+
- Consumes: nothing.
|
|
52
|
+
- Produces:
|
|
53
|
+
- `channelOverridePath(accountDir: string): string`
|
|
54
|
+
- `readChannelOverrideId(accountDir: string, channel: string, senderId: string): string | null`
|
|
55
|
+
- `writeChannelOverrideId(accountDir: string, channel: string, senderId: string, sessionId: string): void`
|
|
56
|
+
|
|
57
|
+
- [ ] **Step 1: Write the failing test**
|
|
58
|
+
|
|
59
|
+
Create `server/__tests__/channel-session-override.test.ts`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
63
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
64
|
+
import { tmpdir } from 'node:os'
|
|
65
|
+
import { join } from 'node:path'
|
|
66
|
+
import { readChannelOverrideId, writeChannelOverrideId, channelOverridePath } from '../channel-session-override'
|
|
67
|
+
|
|
68
|
+
const FORK = 'fc397054-1111-4111-8111-111111111111'
|
|
69
|
+
const OTHER = 'aa11bb22-2222-4222-8222-222222222222'
|
|
70
|
+
|
|
71
|
+
let dir: string
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
dir = mkdtempSync(join(tmpdir(), 'chan-override-'))
|
|
74
|
+
})
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('channel-session-override', () => {
|
|
80
|
+
it('round-trips a fork id for one sender', () => {
|
|
81
|
+
writeChannelOverrideId(dir, 'whatsapp', '447504472444', FORK)
|
|
82
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '447504472444')).toBe(FORK)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns null for an unwritten sender', () => {
|
|
86
|
+
writeChannelOverrideId(dir, 'whatsapp', '447504472444', FORK)
|
|
87
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '447958391734')).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns null when no file exists at all', () => {
|
|
91
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '447504472444')).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('partitions by channel', () => {
|
|
95
|
+
writeChannelOverrideId(dir, 'whatsapp', '123', FORK)
|
|
96
|
+
expect(readChannelOverrideId(dir, 'telegram', '123')).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('preserves other senders on a second write', () => {
|
|
100
|
+
writeChannelOverrideId(dir, 'whatsapp', '111', FORK)
|
|
101
|
+
writeChannelOverrideId(dir, 'telegram', '222', OTHER)
|
|
102
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBe(FORK)
|
|
103
|
+
expect(readChannelOverrideId(dir, 'telegram', '222')).toBe(OTHER)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('overwrites the same sender on re-seat of a fork', () => {
|
|
107
|
+
writeChannelOverrideId(dir, 'whatsapp', '111', FORK)
|
|
108
|
+
writeChannelOverrideId(dir, 'whatsapp', '111', OTHER)
|
|
109
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBe(OTHER)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('reads null rather than throwing on a malformed file', () => {
|
|
113
|
+
writeFileSync(channelOverridePath(dir), '{ not json', 'utf8')
|
|
114
|
+
expect(() => readChannelOverrideId(dir, 'whatsapp', '111')).not.toThrow()
|
|
115
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('reads null when the stored value is not a UUID', () => {
|
|
119
|
+
writeFileSync(channelOverridePath(dir), JSON.stringify({ bySender: { 'whatsapp:111': 'nope' } }), 'utf8')
|
|
120
|
+
expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
126
|
+
|
|
127
|
+
Run: `npx vitest run server/__tests__/channel-session-override.test.ts`
|
|
128
|
+
Expected: FAIL — cannot resolve `../channel-session-override`.
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
131
|
+
|
|
132
|
+
Create `server/channel-session-override.ts`:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// Task 1789 — per-sender channel forward pointer.
|
|
136
|
+
//
|
|
137
|
+
// A channel session's id is adminSessionIdFor(accountId, senderId): sha256
|
|
138
|
+
// shaped into a v4 UUID, a pure function. When an operator re-seats a channel
|
|
139
|
+
// row, the fork gets a fresh id that hash will never return, so every future
|
|
140
|
+
// inbound from that sender would resolve back to the stranded original. This
|
|
141
|
+
// per-account file points the sender forward at the fork, keyed by
|
|
142
|
+
// `<channel>:<senderId>`.
|
|
143
|
+
//
|
|
144
|
+
// This is NOT Task 1526's per-firing seat, which Task 1746 removed. That seat
|
|
145
|
+
// was salted per scheduled dispatch and minted a NEW session every firing,
|
|
146
|
+
// which then attached under the sender's own hub key and swallowed their real
|
|
147
|
+
// messages. This pointer is stable: one session, repointed once by an operator
|
|
148
|
+
// action, resolving identically for every subsequent inbound. It can never
|
|
149
|
+
// yield a second id for a sender, so it cannot split a conversation.
|
|
150
|
+
|
|
151
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
152
|
+
import { join } from 'node:path'
|
|
153
|
+
|
|
154
|
+
const FILE = 'channel-session-override.json'
|
|
155
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
|
156
|
+
|
|
157
|
+
export function channelOverridePath(accountDir: string): string {
|
|
158
|
+
return join(accountDir, FILE)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function senderKey(channel: string, senderId: string): string {
|
|
162
|
+
return `${channel}:${senderId}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type OverrideFile = { bySender?: Record<string, unknown> }
|
|
166
|
+
|
|
167
|
+
function readRaw(accountDir: string): OverrideFile | null {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(readFileSync(channelOverridePath(accountDir), 'utf8')) as OverrideFile
|
|
170
|
+
} catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The re-seated session id for this sender, or null when unset, malformed, or
|
|
176
|
+
* not UUID-shaped. Never throws: a corrupt file must degrade to the
|
|
177
|
+
* deterministic id, never wedge inbound routing. */
|
|
178
|
+
export function readChannelOverrideId(accountDir: string, channel: string, senderId: string): string | null {
|
|
179
|
+
const raw = readRaw(accountDir)
|
|
180
|
+
if (!raw || !raw.bySender || typeof raw.bySender !== 'object') return null
|
|
181
|
+
const v = raw.bySender[senderKey(channel, senderId)]
|
|
182
|
+
return typeof v === 'string' && UUID.test(v) ? v : null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Point this sender forward at a re-seated session. Merges into `bySender`,
|
|
186
|
+
* preserving every other sender's entry. Atomic tmp+rename so a concurrent
|
|
187
|
+
* read never sees a half-written file. */
|
|
188
|
+
export function writeChannelOverrideId(accountDir: string, channel: string, senderId: string, sessionId: string): void {
|
|
189
|
+
const raw = readRaw(accountDir)
|
|
190
|
+
const bySender: Record<string, string> = {}
|
|
191
|
+
if (raw?.bySender && typeof raw.bySender === 'object') {
|
|
192
|
+
for (const [k, v] of Object.entries(raw.bySender)) {
|
|
193
|
+
if (typeof v === 'string') bySender[k] = v
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
bySender[senderKey(channel, senderId)] = sessionId
|
|
197
|
+
const path = channelOverridePath(accountDir)
|
|
198
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
199
|
+
writeFileSync(tmp, JSON.stringify({ bySender }, null, 2), 'utf8')
|
|
200
|
+
renameSync(tmp, path)
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
205
|
+
|
|
206
|
+
Run: `npx vitest run server/__tests__/channel-session-override.test.ts`
|
|
207
|
+
Expected: PASS, 8 tests.
|
|
208
|
+
|
|
209
|
+
- [ ] **Step 5: Commit**
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
git add platform/ui/server/channel-session-override.ts platform/ui/server/__tests__/channel-session-override.test.ts
|
|
213
|
+
git commit -m "feat(1789): per-sender channel session forward pointer"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
### Task 2: Expose the source row's full sidecar
|
|
219
|
+
|
|
220
|
+
`locateSidecar` in `server/routes/webchat.ts` scans project slugs for a session's `.meta.json` but returns only three fields. The re-seat route needs `role` and `senderId` too. Widen it to return the full `SidecarMeta` and add an exported resolver that mirrors `readTargetOwner`'s two-path lookup (sidecar scan, then the meta adjacent to a located JSONL).
|
|
221
|
+
|
|
222
|
+
**Files:**
|
|
223
|
+
- Modify: `server/routes/webchat.ts:78-115`
|
|
224
|
+
|
|
225
|
+
**Interfaces:**
|
|
226
|
+
- Consumes: `readSidecarMeta`, `SidecarMeta` from `server/routes/admin/sidebar-sessions.ts` (already imported by this file).
|
|
227
|
+
- Produces: `readSourceRowMeta(sessionId: string): SidecarMeta | null` — the row's sidecar, or null when the session is unknown.
|
|
228
|
+
|
|
229
|
+
- [ ] **Step 1: Widen `locateSidecar` to the full sidecar**
|
|
230
|
+
|
|
231
|
+
Replace the signature and return at `server/routes/webchat.ts:78` and its two return sites:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
function locateSidecar(sessionId: string): SidecarMeta | null {
|
|
235
|
+
const cfg = claudeConfigDir()
|
|
236
|
+
if (!cfg) return null
|
|
237
|
+
const projectsRoot = join(cfg, 'projects')
|
|
238
|
+
let slugs: string[]
|
|
239
|
+
try {
|
|
240
|
+
slugs = readdirSync(projectsRoot, { withFileTypes: true })
|
|
241
|
+
.filter((d) => d.isDirectory())
|
|
242
|
+
.map((d) => d.name)
|
|
243
|
+
} catch {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
for (const slug of slugs) {
|
|
247
|
+
const metaPath = join(projectsRoot, slug, `${sessionId}.meta.json`)
|
|
248
|
+
// Task 1789 — the whole sidecar, not a hand-picked triple: the re-seat
|
|
249
|
+
// route needs role/senderId to decide whether the fork is channel-shaped.
|
|
250
|
+
if (existsSync(metaPath)) return readSidecarMeta(metaPath)
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Ensure `SidecarMeta` is in the existing type import from `./admin/sidebar-sessions`. The two existing call sites (`:108` in `readTargetOwner`, `:657`) already read `.adminUserId` / `.channel` / `.accountId`, which the wider type still carries — no changes needed there.
|
|
257
|
+
|
|
258
|
+
- [ ] **Step 2: Add the exported resolver**
|
|
259
|
+
|
|
260
|
+
Insert immediately after `readTargetOwner` (around `server/routes/webchat.ts:115`):
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
/** Task 1789 — the source row's sidecar for the re-seat route, resolved by the
|
|
264
|
+
* same two paths as {@link readTargetOwner}: the slug-top-level scan for a
|
|
265
|
+
* JSONL-less fresh target, then the meta adjacent to a located JSONL (incl.
|
|
266
|
+
* archived). null when the session is unknown. */
|
|
267
|
+
export function readSourceRowMeta(sessionId: string): SidecarMeta | null {
|
|
268
|
+
const sidecar = locateSidecar(sessionId)
|
|
269
|
+
if (sidecar !== null) return sidecar
|
|
270
|
+
const { projectDir } = locateSession(sessionId)
|
|
271
|
+
if (projectDir !== null) return readSidecarMeta(join(projectDir, `${sessionId}.meta.json`))
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
- [ ] **Step 3: Run the existing webchat suite to prove no regression**
|
|
277
|
+
|
|
278
|
+
Run: `npx vitest run server/routes/__tests__/webchat.test.ts`
|
|
279
|
+
Expected: PASS, no new failures versus the pre-change baseline.
|
|
280
|
+
|
|
281
|
+
- [ ] **Step 4: Typecheck**
|
|
282
|
+
|
|
283
|
+
Run: `npx tsc --noEmit -p tsconfig.json`
|
|
284
|
+
Expected: no errors.
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 5: Commit**
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
git add platform/ui/server/routes/webchat.ts
|
|
290
|
+
git commit -m "refactor(1789): widen locateSidecar to full SidecarMeta, export readSourceRowMeta"
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
### Task 3: `resolveReseatPlan` builds a channel-shaped payload
|
|
296
|
+
|
|
297
|
+
**Files:**
|
|
298
|
+
- Modify: `server/routes/admin/session-reseat.ts:45-81`
|
|
299
|
+
- Test: `server/routes/admin/__tests__/session-reseat.test.ts`
|
|
300
|
+
|
|
301
|
+
**Interfaces:**
|
|
302
|
+
- Consumes: `readSourceRowMeta` (Task 2) — used by the route in Task 4, not here.
|
|
303
|
+
- Produces: `resolveReseatPlan(body, mintId?, adminUserId?, authenticatedName?, source?)` where the new fifth positional parameter is:
|
|
304
|
+
```ts
|
|
305
|
+
source?: { channel: string | null; senderId: string | null; role: string | null; accountId: string | null } | null
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
- [ ] **Step 1: Write the failing tests**
|
|
309
|
+
|
|
310
|
+
Append to `server/routes/admin/__tests__/session-reseat.test.ts`:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
const WA_SOURCE = { channel: 'whatsapp', senderId: '447504472444', role: 'admin', accountId: '5e99bba5-1111-4111-8111-111111111111' }
|
|
314
|
+
|
|
315
|
+
describe('resolveReseatPlan — channel rows (Task 1789)', () => {
|
|
316
|
+
it('inherits a whatsapp admin source channel and sender', () => {
|
|
317
|
+
const plan = resolveReseatPlan({ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined, WA_SOURCE)
|
|
318
|
+
expect(plan.payload).toMatchObject({
|
|
319
|
+
sessionId: 'NEW',
|
|
320
|
+
forkFromSessionId: SRC,
|
|
321
|
+
channel: 'whatsapp',
|
|
322
|
+
senderId: '447504472444',
|
|
323
|
+
role: 'admin',
|
|
324
|
+
targetAccountId: WA_SOURCE.accountId,
|
|
325
|
+
})
|
|
326
|
+
expect(plan.payload.webchatChannel).toBeUndefined()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('inherits a telegram admin source channel and sender', () => {
|
|
330
|
+
const plan = resolveReseatPlan(
|
|
331
|
+
{ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
|
|
332
|
+
{ ...WA_SOURCE, channel: 'telegram', senderId: '99887766' },
|
|
333
|
+
)
|
|
334
|
+
expect(plan.payload).toMatchObject({ channel: 'telegram', senderId: '99887766', role: 'admin' })
|
|
335
|
+
expect(plan.payload.webchatChannel).toBeUndefined()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('omits targetAccountId when the sidecar predates accountId', () => {
|
|
339
|
+
const plan = resolveReseatPlan(
|
|
340
|
+
{ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
|
|
341
|
+
{ ...WA_SOURCE, accountId: null },
|
|
342
|
+
)
|
|
343
|
+
expect(plan.payload.channel).toBe('whatsapp')
|
|
344
|
+
expect('targetAccountId' in plan.payload).toBe(false)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('takes the webchat path for a public whatsapp source', () => {
|
|
348
|
+
const plan = resolveReseatPlan(
|
|
349
|
+
{ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
|
|
350
|
+
{ ...WA_SOURCE, role: 'public' },
|
|
351
|
+
)
|
|
352
|
+
expect(plan.payload.channel).toBe('webchat')
|
|
353
|
+
expect(plan.payload.senderId).toBe('session:NEW')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('takes the webchat path when the source sidecar has no senderId', () => {
|
|
357
|
+
const plan = resolveReseatPlan(
|
|
358
|
+
{ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
|
|
359
|
+
{ ...WA_SOURCE, senderId: null },
|
|
360
|
+
)
|
|
361
|
+
expect(plan.payload.channel).toBe('webchat')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('takes the webchat path for a webchat source and for no source at all', () => {
|
|
365
|
+
const fromWebchat = resolveReseatPlan(
|
|
366
|
+
{ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
|
|
367
|
+
{ channel: 'webchat', senderId: 'session:old', role: 'admin', accountId: null },
|
|
368
|
+
)
|
|
369
|
+
const fromNothing = resolveReseatPlan({ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1')
|
|
370
|
+
expect(fromWebchat.payload).toEqual(fromNothing.payload)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
376
|
+
|
|
377
|
+
Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
|
|
378
|
+
Expected: FAIL — the channel assertions see `channel: 'webchat'`.
|
|
379
|
+
|
|
380
|
+
- [ ] **Step 3: Rewrite the header comment and the function**
|
|
381
|
+
|
|
382
|
+
Replace `server/routes/admin/session-reseat.ts:45-81` with:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
/** The channel/sender shape a fork inherits from its source row. */
|
|
386
|
+
export interface ReseatSource {
|
|
387
|
+
channel: string | null
|
|
388
|
+
senderId: string | null
|
|
389
|
+
role: string | null
|
|
390
|
+
accountId: string | null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Task 1789 — is this source an admin channel row? All three must hold: the
|
|
394
|
+
* sidecar says role admin, its channel is a native channel, and it names a
|
|
395
|
+
* sender. Public rows are excluded deliberately: their session id keys on
|
|
396
|
+
* personId as well as senderId, so the per-sender forward pointer would be
|
|
397
|
+
* ambiguous for them, and public re-seat is not evidenced as broken. */
|
|
398
|
+
function isAdminChannelSource(source: ReseatSource | null | undefined): source is ReseatSource & { channel: 'whatsapp' | 'telegram'; senderId: string } {
|
|
399
|
+
return (
|
|
400
|
+
!!source &&
|
|
401
|
+
source.role === 'admin' &&
|
|
402
|
+
(source.channel === 'whatsapp' || source.channel === 'telegram') &&
|
|
403
|
+
typeof source.senderId === 'string' &&
|
|
404
|
+
source.senderId.length > 0
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Pure: build the manager /rc-spawn fork payload. `mintId` is injected for
|
|
410
|
+
* deterministic tests; production passes randomUUID. `forkFromSessionId` tells
|
|
411
|
+
* the manager to seed history from the source; `model` pins the fork.
|
|
412
|
+
*
|
|
413
|
+
* Task 1789 — the fork inherits the SOURCE ROW's channel. An admin whatsapp or
|
|
414
|
+
* telegram row forks as that channel, carrying the source's own senderId, so
|
|
415
|
+
* the fork is a channel session rather than a webchat one. Before this, every
|
|
416
|
+
* fork was webchat-shaped with senderId `session:<newId>`, which replaced the
|
|
417
|
+
* per-sender channel server and detached the sender's seat. Every other source
|
|
418
|
+
* — webchat, public, or a row with no sidecar — keeps the webchat shape, keyed
|
|
419
|
+
* on `session:<newId>`, lining up with /send and the reply tool.
|
|
420
|
+
*
|
|
421
|
+
* The channel fork carries NO waChannel/telegramChannel descriptor: this route
|
|
422
|
+
* has no gateway url or server path. The fork therefore has no reply tool until
|
|
423
|
+
* the next inbound rebinds it through ensureChannelSession. That window is
|
|
424
|
+
* logged by the caller (`op=channel-fork-unbound`), because the manager's own
|
|
425
|
+
* `op=channel-binding-lost` invariant is gated on its channel store and cannot
|
|
426
|
+
* fire for a freshly minted fork id.
|
|
427
|
+
*/
|
|
428
|
+
export function resolveReseatPlan(
|
|
429
|
+
body: { fromSessionId: string; model: string; name?: string; permissionMode?: string; effort?: string },
|
|
430
|
+
mintId: () => string = randomUUID,
|
|
431
|
+
adminUserId?: string,
|
|
432
|
+
authenticatedName?: string,
|
|
433
|
+
source?: ReseatSource | null,
|
|
434
|
+
): ReseatPlan {
|
|
435
|
+
const sessionId = mintId()
|
|
436
|
+
const key = `session:${sessionId}`
|
|
437
|
+
const payload: Record<string, unknown> = {
|
|
438
|
+
sessionId,
|
|
439
|
+
forkFromSessionId: body.fromSessionId,
|
|
440
|
+
model: body.model,
|
|
441
|
+
role: 'admin',
|
|
442
|
+
}
|
|
443
|
+
if (isAdminChannelSource(source)) {
|
|
444
|
+
payload.channel = source.channel
|
|
445
|
+
payload.senderId = source.senderId
|
|
446
|
+
// Task 1789 — the fork must run under the SOURCE's account. Without this
|
|
447
|
+
// the manager defaults it to the boot account, so a channel row belonging
|
|
448
|
+
// to a client sub-account would fork into the house account and every
|
|
449
|
+
// Neo4j write the fork makes would land under the wrong scope. Pre-983
|
|
450
|
+
// sidecars carry no accountId; those keep the manager default.
|
|
451
|
+
if (source.accountId) payload.targetAccountId = source.accountId
|
|
452
|
+
} else {
|
|
453
|
+
payload.channel = 'webchat'
|
|
454
|
+
payload.senderId = key
|
|
455
|
+
payload.webchatChannel = webchatChannelDescriptor(key)
|
|
456
|
+
}
|
|
457
|
+
if (body.name) payload.name = body.name
|
|
458
|
+
// Task 928 — carry the picked mode/effort only when supplied; an absent lever
|
|
459
|
+
// leaves the fork on the settings-resolved mode / harness-default effort.
|
|
460
|
+
if (body.permissionMode) payload.permissionMode = body.permissionMode
|
|
461
|
+
if (body.effort) payload.effort = body.effort
|
|
462
|
+
if (adminUserId) payload.adminUserId = adminUserId
|
|
463
|
+
// Task 889 — carry the resolved authenticated-admin name so the manager
|
|
464
|
+
// injects the identity line on the fork's first turn.
|
|
465
|
+
if (authenticatedName) payload.authenticatedName = authenticatedName
|
|
466
|
+
return { sessionId, payload }
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Also update the file's top-of-file header (`session-reseat.ts:1-17`): replace the sentence describing the route as webchat-only with:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
// Operator-initiated model re-seat for an existing admin session — webchat rows
|
|
474
|
+
// in the Sessions panel and whatsapp/telegram channel rows alike. Claude reads
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
478
|
+
|
|
479
|
+
Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
|
|
480
|
+
Expected: PASS — the new channel tests and every pre-existing webchat test.
|
|
481
|
+
|
|
482
|
+
- [ ] **Step 5: Commit**
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
git add platform/ui/server/routes/admin/session-reseat.ts platform/ui/server/routes/admin/__tests__/session-reseat.test.ts
|
|
486
|
+
git commit -m "feat(1789): fork inherits the source row's channel"
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
### Task 4: Route wiring — sidecar read, override write, lifeline
|
|
492
|
+
|
|
493
|
+
**Files:**
|
|
494
|
+
- Modify: `server/routes/admin/session-reseat.ts` (route handler, ~`:91-212`)
|
|
495
|
+
|
|
496
|
+
**Interfaces:**
|
|
497
|
+
- Consumes: `readSourceRowMeta` (Task 2), `readChannelOverrideId` / `writeChannelOverrideId` (Task 1), `listValidAccounts` from `app/lib/claude-agent/account`.
|
|
498
|
+
- Produces: nothing new for later tasks.
|
|
499
|
+
|
|
500
|
+
- [ ] **Step 1: Add the imports**
|
|
501
|
+
|
|
502
|
+
At the top of `server/routes/admin/session-reseat.ts`:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { WEBCHAT_ADMIN_KEY, resolvePrimaryAdminUserId, readTargetOwner, ownedByRequester, readSourceRowMeta } from '../webchat'
|
|
506
|
+
import { readCanonicalOverrideId, writeCanonicalOverrideId } from '../../canonical-webchat-override'
|
|
507
|
+
import { writeChannelOverrideId } from '../../channel-session-override'
|
|
508
|
+
import { listValidAccounts } from '../../../app/lib/claude-agent/account'
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
- [ ] **Step 2: Read the source sidecar and pass it to the plan**
|
|
512
|
+
|
|
513
|
+
Replace the `const plan = resolveReseatPlan(...)` call (currently `:141-146`) with:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
// Task 1789 — the fork inherits the source row's channel, so read the source
|
|
517
|
+
// sidecar before building the payload. A null sidecar (unknown or husk row)
|
|
518
|
+
// yields today's webchat shape.
|
|
519
|
+
const sourceMeta = readSourceRowMeta(fromSessionId)
|
|
520
|
+
const source = sourceMeta
|
|
521
|
+
? { channel: sourceMeta.channel, senderId: sourceMeta.senderId, role: sourceMeta.role, accountId: sourceMeta.accountId }
|
|
522
|
+
: null
|
|
523
|
+
const plan = resolveReseatPlan(
|
|
524
|
+
{ fromSessionId, model, ...(permissionMode ? { permissionMode } : {}), ...(effort ? { effort } : {}) },
|
|
525
|
+
randomUUID,
|
|
526
|
+
adminUserId,
|
|
527
|
+
authenticatedName,
|
|
528
|
+
source,
|
|
529
|
+
)
|
|
530
|
+
const isChannelFork = plan.payload.channel === 'whatsapp' || plan.payload.channel === 'telegram'
|
|
531
|
+
const reseatReqId = randomUUID()
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
- [ ] **Step 3: Name the source row on the lifeline**
|
|
535
|
+
|
|
536
|
+
Replace the `op=request` line (currently `:152`) with:
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
// Task 1789 — the lifeline names WHAT KIND of row was re-seated. `outcome=bound`
|
|
540
|
+
// reported that the manager returned, not that the seat survived, so a re-seat
|
|
541
|
+
// that destroyed a channel seat logged as a success.
|
|
542
|
+
console.log(`[chat-reseat] op=request reseatReqId=${reseatReqId} from=${fromSessionId} from-channel=${source?.channel ?? 'none'} from-sender=${source?.senderId ?? 'none'} to-model=${model} to-mode=${permissionMode ?? 'none'} to-effort=${effort ?? 'none'} new=${plan.sessionId}`)
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- [ ] **Step 4: Write the forward pointer after a successful channel fork**
|
|
546
|
+
|
|
547
|
+
Insert immediately after the existing canonical-forward `try/catch` block (currently ends `:201`):
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
// Task 1789 — point this sender forward at the fork, so the next inbound
|
|
551
|
+
// resolves to it instead of recomputing the stranded deterministic id.
|
|
552
|
+
// Written under the SOURCE's account dir, the same account the gateway reads
|
|
553
|
+
// under (its effectiveAccountId), so an account-manager-routed sub-account
|
|
554
|
+
// sender resolves its own pointer and never the house's. Best-effort: a
|
|
555
|
+
// failure here must not fail the re-seat, but it does leave the sender on the
|
|
556
|
+
// original session, so it is logged loudly.
|
|
557
|
+
if (isChannelFork) {
|
|
558
|
+
const channel = plan.payload.channel as 'whatsapp' | 'telegram'
|
|
559
|
+
const senderId = plan.payload.senderId as string
|
|
560
|
+
try {
|
|
561
|
+
const dir = source?.accountId
|
|
562
|
+
? (listValidAccounts().find((a) => a.accountId === source.accountId)?.accountDir ?? null)
|
|
563
|
+
: (resolveAccount()?.accountDir ?? null)
|
|
564
|
+
if (dir) {
|
|
565
|
+
writeChannelOverrideId(dir, channel, senderId, plan.sessionId)
|
|
566
|
+
console.log(`[chat-reseat] op=channel-forward reseatReqId=${reseatReqId} senderId=${senderId} from=${fromSessionId} to=${plan.sessionId}`)
|
|
567
|
+
} else {
|
|
568
|
+
console.error(`[session-reseat] channel-forward-failed reason=no-account-dir accountId=${source?.accountId ?? 'none'} senderId=${senderId}`)
|
|
569
|
+
}
|
|
570
|
+
} catch (err) {
|
|
571
|
+
console.error(`[session-reseat] channel-forward-failed err=${err instanceof Error ? err.message : String(err)} senderId=${senderId}`)
|
|
572
|
+
}
|
|
573
|
+
// Task 1789 — the fork carries no channel descriptor (this route has no
|
|
574
|
+
// gateway url or server path), so it has no reply tool until the next
|
|
575
|
+
// inbound rebinds it via ensureChannelSession. The manager's own
|
|
576
|
+
// op=channel-binding-lost invariant is gated on its channel store, which is
|
|
577
|
+
// empty for a freshly minted fork id, so this window is otherwise silent.
|
|
578
|
+
console.log(`[chat-reseat] op=channel-fork-unbound reseatReqId=${reseatReqId} senderId=${senderId} session=${plan.sessionId} note=binds-on-next-inbound`)
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
**On the chained re-seat.** The write above is unconditional for a channel fork, and that alone handles the chained case: re-seating a fork writes the pointer again at the same `<channel>:<senderId>` key, moving it forward. No `fromSessionId` comparison is needed here, unlike the canonical pointer above it — the canonical pointer is keyed by *admin*, so it must check whether the re-seated row is the one that admin's bare `/chat` resolves to. The channel key is derived from the source row's own sender, so a channel fork is by construction the row the pointer belongs to.
|
|
583
|
+
|
|
584
|
+
- [ ] **Step 5: Typecheck and run the suite**
|
|
585
|
+
|
|
586
|
+
Run: `npx tsc --noEmit -p tsconfig.json && npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
|
|
587
|
+
Expected: no type errors; tests PASS.
|
|
588
|
+
|
|
589
|
+
- [ ] **Step 6: Commit**
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
git add platform/ui/server/routes/admin/session-reseat.ts
|
|
593
|
+
git commit -m "feat(1789): re-seat writes the channel forward pointer and names the source row"
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
### Task 5: Spawn builders accept an override
|
|
599
|
+
|
|
600
|
+
**Files:**
|
|
601
|
+
- Modify: `app/lib/whatsapp/gateway/spawn-request.ts`
|
|
602
|
+
- Modify: `app/lib/telegram/gateway/spawn-request.ts`
|
|
603
|
+
- Test: `app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts`
|
|
604
|
+
- Test: `app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
|
|
605
|
+
|
|
606
|
+
**Interfaces:**
|
|
607
|
+
- Produces: both builders accept an optional `overrideSessionId?: string | null` on their existing input object. When a non-empty string, it becomes `sessionId`; otherwise `adminSessionIdFor(...)` as today.
|
|
608
|
+
|
|
609
|
+
- [ ] **Step 1: Write the failing tests**
|
|
610
|
+
|
|
611
|
+
Append to `app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts`:
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
const FORK = 'fc397054-1111-4111-8111-111111111111'
|
|
615
|
+
|
|
616
|
+
describe('buildWaSpawnRequest — session override (Task 1789)', () => {
|
|
617
|
+
it('uses the override when one is set for the sender', () => {
|
|
618
|
+
const req = buildWaSpawnRequest({
|
|
619
|
+
accountId: 'acct', senderId: '+447700900123', role: 'admin', personId: null,
|
|
620
|
+
gatewayUrl: 'http://g', serverPath: '/s', overrideSessionId: FORK,
|
|
621
|
+
})
|
|
622
|
+
expect(req.sessionId).toBe(FORK)
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('falls back to the deterministic id when the override is null or absent', () => {
|
|
626
|
+
const base = { accountId: 'acct', senderId: '+447700900123', role: 'admin' as const, personId: null, gatewayUrl: 'http://g', serverPath: '/s' }
|
|
627
|
+
expect(buildWaSpawnRequest({ ...base, overrideSessionId: null }).sessionId).toBe(adminSessionIdFor('acct', '+447700900123'))
|
|
628
|
+
expect(buildWaSpawnRequest(base).sessionId).toBe(adminSessionIdFor('acct', '+447700900123'))
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Append the mirror to `app/lib/telegram/gateway/__tests__/spawn-request.test.ts`:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
const FORK = 'fc397054-1111-4111-8111-111111111111'
|
|
637
|
+
|
|
638
|
+
describe('buildTelegramSpawnRequest — session override (Task 1789)', () => {
|
|
639
|
+
it('uses the override when one is set for the sender', () => {
|
|
640
|
+
const req = buildTelegramSpawnRequest({
|
|
641
|
+
accountId: 'acct', senderId: '123', role: 'admin', personId: null,
|
|
642
|
+
gatewayUrl: 'http://g', serverPath: '/s', overrideSessionId: FORK,
|
|
643
|
+
})
|
|
644
|
+
expect(req.sessionId).toBe(FORK)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('falls back to the deterministic id when the override is null or absent', () => {
|
|
648
|
+
const base = { accountId: 'acct', senderId: '123', role: 'admin' as const, personId: null, gatewayUrl: 'http://g', serverPath: '/s' }
|
|
649
|
+
expect(buildTelegramSpawnRequest({ ...base, overrideSessionId: null }).sessionId).toBe(adminSessionIdFor('acct', '123', undefined))
|
|
650
|
+
expect(buildTelegramSpawnRequest(base).sessionId).toBe(adminSessionIdFor('acct', '123', undefined))
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
656
|
+
|
|
657
|
+
Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
|
|
658
|
+
Expected: FAIL — TypeScript rejects `overrideSessionId`, or `sessionId` is the deterministic id.
|
|
659
|
+
|
|
660
|
+
- [ ] **Step 3: Add the parameter to the WhatsApp builder**
|
|
661
|
+
|
|
662
|
+
In `app/lib/whatsapp/gateway/spawn-request.ts`, add to the input type and replace the `sessionId` line and its comment:
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
export function buildWaSpawnRequest(input: {
|
|
666
|
+
accountId: string
|
|
667
|
+
senderId: string
|
|
668
|
+
role: 'admin' | 'public'
|
|
669
|
+
personId: string | null
|
|
670
|
+
gatewayUrl: string
|
|
671
|
+
serverPath: string
|
|
672
|
+
/** Task 1789 — the sender's channel forward pointer, when the operator has
|
|
673
|
+
* re-seated this row. Read by the caller (server/index.ts) so this builder
|
|
674
|
+
* stays pure. */
|
|
675
|
+
overrideSessionId?: string | null
|
|
676
|
+
}): {
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
// Task 1746 — the deterministic per-sender id is the DEFAULT id a sender's
|
|
681
|
+
// session has. Task 1526's per-firing seat override used to replace it on a
|
|
682
|
+
// scheduled cold start, which spawned a second session per firing; that
|
|
683
|
+
// session then attached under the sender's own hub key and swallowed their
|
|
684
|
+
// real messages until it detached, splitting the conversation in two.
|
|
685
|
+
//
|
|
686
|
+
// Task 1789's override is not that. It is a stable per-sender forward
|
|
687
|
+
// pointer written once by an operator re-seat and resolving identically for
|
|
688
|
+
// every subsequent inbound, so it can never yield a second id for a sender.
|
|
689
|
+
// Do not remove it without reading server/channel-session-override.ts.
|
|
690
|
+
sessionId: input.overrideSessionId || adminSessionIdFor(input.accountId, input.senderId, personId),
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
- [ ] **Step 4: Add the parameter to the Telegram builder**
|
|
694
|
+
|
|
695
|
+
In `app/lib/telegram/gateway/spawn-request.ts`, add the identical `overrideSessionId?: string | null` field with the same doc comment, and replace the `sessionId` line:
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
// Task 1746 / 1789 — the deterministic per-sender id is the DEFAULT; an
|
|
699
|
+
// operator re-seat sets a stable per-sender forward pointer that wins here.
|
|
700
|
+
// Mirror of the WhatsApp builder; see its comment for the full distinction
|
|
701
|
+
// between this pointer and Task 1526's removed per-firing seat.
|
|
702
|
+
sessionId: input.overrideSessionId || adminSessionIdFor(input.accountId, input.senderId, personId),
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
706
|
+
|
|
707
|
+
Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
|
|
708
|
+
Expected: PASS, including every pre-existing assertion.
|
|
709
|
+
|
|
710
|
+
- [ ] **Step 6: Commit**
|
|
711
|
+
|
|
712
|
+
```bash
|
|
713
|
+
git add platform/ui/app/lib/whatsapp/gateway/spawn-request.ts platform/ui/app/lib/telegram/gateway/spawn-request.ts platform/ui/app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts platform/ui/app/lib/telegram/gateway/__tests__/spawn-request.test.ts
|
|
714
|
+
git commit -m "feat(1789): spawn builders accept an optional session override"
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
---
|
|
718
|
+
|
|
719
|
+
### Task 6: `server/index.ts` reads the override at both spawn sites
|
|
720
|
+
|
|
721
|
+
**Files:**
|
|
722
|
+
- Modify: `server/index.ts:418` (WhatsApp spawn), `:700` (Telegram spawn), plus imports near `:2843-2858`
|
|
723
|
+
|
|
724
|
+
**Interfaces:**
|
|
725
|
+
- Consumes: `readChannelOverrideId` (Task 1), builders' `overrideSessionId` (Task 5), `listValidAccounts`.
|
|
726
|
+
- Produces: `resolveChannelOverride(accountId: string, channel: 'whatsapp' | 'telegram', senderId: string): string | null` — a module-local helper, also passed to both gateways in Task 7.
|
|
727
|
+
|
|
728
|
+
- [ ] **Step 1: Add the imports**
|
|
729
|
+
|
|
730
|
+
Alongside the existing imports at the bottom import block of `server/index.ts`:
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
import { readChannelOverrideId } from './channel-session-override.js'
|
|
734
|
+
import { listValidAccounts } from '../app/lib/claude-agent/account.js'
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
(If `listValidAccounts` is already imported in this file, do not duplicate it — check with `grep -n "listValidAccounts" server/index.ts` first.)
|
|
738
|
+
|
|
739
|
+
- [ ] **Step 2: Add the resolver helper**
|
|
740
|
+
|
|
741
|
+
Place it above the WhatsApp gateway construction block (before `:418`):
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
// Task 1789 — resolve a sender's channel forward pointer. Keyed on the EFFECTIVE
|
|
745
|
+
// account (the account the session runs under), matching the write side in
|
|
746
|
+
// session-reseat.ts, which keys on the source sidecar's accountId. An
|
|
747
|
+
// account-manager-routed sub-account sender therefore resolves its own pointer
|
|
748
|
+
// and never the house's. Best-effort: any failure yields null and the caller
|
|
749
|
+
// falls back to the deterministic id.
|
|
750
|
+
function resolveChannelOverride(accountId: string, channel: 'whatsapp' | 'telegram', senderId: string): string | null {
|
|
751
|
+
try {
|
|
752
|
+
const dir = listValidAccounts().find((a) => a.accountId === accountId)?.accountDir
|
|
753
|
+
return dir ? readChannelOverrideId(dir, channel, senderId) : null
|
|
754
|
+
} catch {
|
|
755
|
+
return null
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
- [ ] **Step 3: Thread it into the WhatsApp spawn**
|
|
761
|
+
|
|
762
|
+
Replace `server/index.ts:418`:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
const req = buildWaSpawnRequest({
|
|
766
|
+
accountId: effectiveAccountId, senderId, role, personId, gatewayUrl, serverPath,
|
|
767
|
+
// Task 1789 — admin rows only: a public session's id keys on personId as
|
|
768
|
+
// well as senderId, so the per-sender pointer is not applicable to it.
|
|
769
|
+
overrideSessionId: role === 'admin' ? resolveChannelOverride(effectiveAccountId, 'whatsapp', senderId) : null,
|
|
770
|
+
})
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
- [ ] **Step 4: Thread it into the Telegram spawn**
|
|
774
|
+
|
|
775
|
+
Replace `server/index.ts:700`:
|
|
776
|
+
|
|
777
|
+
```ts
|
|
778
|
+
const req = buildTelegramSpawnRequest({
|
|
779
|
+
accountId, senderId, role, personId, gatewayUrl, serverPath,
|
|
780
|
+
// Task 1789 — admin rows only; see the WhatsApp twin.
|
|
781
|
+
overrideSessionId: role === 'admin' ? resolveChannelOverride(accountId, 'telegram', senderId) : null,
|
|
782
|
+
})
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
- [ ] **Step 5: Typecheck**
|
|
786
|
+
|
|
787
|
+
Run: `npx tsc --noEmit -p tsconfig.json`
|
|
788
|
+
Expected: no errors.
|
|
789
|
+
|
|
790
|
+
- [ ] **Step 6: Commit**
|
|
791
|
+
|
|
792
|
+
```bash
|
|
793
|
+
git add platform/ui/server/index.ts
|
|
794
|
+
git commit -m "feat(1789): resolve the channel forward pointer at both spawn sites"
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
799
|
+
### Task 7: Gateway `op=inbound` lines name the real session
|
|
800
|
+
|
|
801
|
+
**Files:**
|
|
802
|
+
- Modify: `app/lib/whatsapp/gateway/wa-gateway.ts` (deps interface `:68`, `op=inbound` `:312-318`)
|
|
803
|
+
- Modify: `app/lib/telegram/gateway/telegram-gateway.ts` (deps interface, `op=inbound` `:181-188`)
|
|
804
|
+
- Modify: `server/index.ts` (both gateway constructions)
|
|
805
|
+
- Test: `app/lib/whatsapp/gateway/__tests__/` — add to the existing gateway test file
|
|
806
|
+
|
|
807
|
+
**Interfaces:**
|
|
808
|
+
- Produces: both gateway deps interfaces gain `resolveSessionOverride?: (accountId: string, senderId: string) => string | null`.
|
|
809
|
+
|
|
810
|
+
- [ ] **Step 1: Write the failing test**
|
|
811
|
+
|
|
812
|
+
Create `app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts`:
|
|
813
|
+
|
|
814
|
+
```ts
|
|
815
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
816
|
+
import { WaGateway } from '../wa-gateway'
|
|
817
|
+
import { adminSessionIdFor } from '../../../channel-pty-bridge/admin-session-id'
|
|
818
|
+
|
|
819
|
+
const FORK = 'fc397054-1111-4111-8111-111111111111'
|
|
820
|
+
const SENDER = '447504472444'
|
|
821
|
+
|
|
822
|
+
function makeGateway(resolveSessionOverride?: (accountId: string, senderId: string) => string | null) {
|
|
823
|
+
return new WaGateway({
|
|
824
|
+
gatewayUrl: 'http://x',
|
|
825
|
+
serverPath: '/x',
|
|
826
|
+
ensureChannelSession: vi.fn(async () => {}),
|
|
827
|
+
sendDocument: vi.fn(async () => ({ ok: true as const })),
|
|
828
|
+
...(resolveSessionOverride ? { resolveSessionOverride } : {}),
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function deliver(gw: WaGateway) {
|
|
833
|
+
gw.hub.attach(SENDER, () => {})
|
|
834
|
+
gw.hub.markReady(SENDER, Date.now())
|
|
835
|
+
await gw.handleInbound({
|
|
836
|
+
accountId: 'house', effectiveAccountId: 'acct', senderId: SENDER, role: 'admin',
|
|
837
|
+
text: 'hi', reply: vi.fn(async () => {}), media: [],
|
|
838
|
+
})
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// The gateway's op=inbound line must name the session the message actually
|
|
842
|
+
// reaches. Without the override consult it named the stranded deterministic id.
|
|
843
|
+
describe('wa-gateway op=inbound sessionId (Task 1789)', () => {
|
|
844
|
+
let errs: string[]
|
|
845
|
+
beforeEach(() => {
|
|
846
|
+
errs = []
|
|
847
|
+
vi.spyOn(console, 'error').mockImplementation((m: unknown) => { errs.push(String(m)) })
|
|
848
|
+
})
|
|
849
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
850
|
+
|
|
851
|
+
it('names the override when one is set', async () => {
|
|
852
|
+
await deliver(makeGateway(() => FORK))
|
|
853
|
+
expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${FORK}`)
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it('names the deterministic id when the override resolves null', async () => {
|
|
857
|
+
await deliver(makeGateway(() => null))
|
|
858
|
+
expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${adminSessionIdFor('acct', SENDER)}`)
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
it('names the deterministic id when no resolver is injected', async () => {
|
|
862
|
+
await deliver(makeGateway())
|
|
863
|
+
expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${adminSessionIdFor('acct', SENDER)}`)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('passes the EFFECTIVE account to the resolver, not the socket account', async () => {
|
|
867
|
+
const spy = vi.fn(() => null)
|
|
868
|
+
await deliver(makeGateway(spy))
|
|
869
|
+
expect(spy).toHaveBeenCalledWith('acct', SENDER)
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
875
|
+
|
|
876
|
+
Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts`
|
|
877
|
+
Expected: FAIL — `resolveSessionOverride` is not a known dep, and the line names the deterministic id in all three cases.
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 3: Add the dep to both gateway interfaces**
|
|
880
|
+
|
|
881
|
+
In `WaGatewayDeps` (`app/lib/whatsapp/gateway/wa-gateway.ts:68`) and the Telegram equivalent, add:
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
/** Task 1789 — the sender's channel forward pointer, when the operator has
|
|
885
|
+
* re-seated this row. Injected by server/index.ts so the gateway keeps no
|
|
886
|
+
* filesystem knowledge and stays testable with a stub. Absent => the
|
|
887
|
+
* deterministic id, which is what every pre-1789 caller got. */
|
|
888
|
+
resolveSessionOverride?: (accountId: string, senderId: string) => string | null
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
- [ ] **Step 4: Consult it on the log line**
|
|
892
|
+
|
|
893
|
+
In `app/lib/whatsapp/gateway/wa-gateway.ts`, replace the `resolvedSessionId` assignment (`:312`):
|
|
894
|
+
|
|
895
|
+
```ts
|
|
896
|
+
// Task 1746 — name the session this inbound resolves to. Two distinct
|
|
897
|
+
// sessionId values for one sender IS the signature of the split defect.
|
|
898
|
+
// Task 1789 — consult the sender's forward pointer first, so this names the
|
|
899
|
+
// session the message actually REACHES rather than the stranded
|
|
900
|
+
// deterministic id an operator re-seat left behind.
|
|
901
|
+
const resolvedSessionId =
|
|
902
|
+
(input.role === 'admin' ? this.deps.resolveSessionOverride?.(input.effectiveAccountId, senderId) : null) ||
|
|
903
|
+
adminSessionIdFor(
|
|
904
|
+
input.effectiveAccountId,
|
|
905
|
+
senderId,
|
|
906
|
+
input.role === 'public' && input.personId ? input.personId : undefined,
|
|
907
|
+
)
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
In `app/lib/telegram/gateway/telegram-gateway.ts`, apply the mirror at `:181`, using `input.accountId` (Telegram's own account variable at that site) and `input.senderId`:
|
|
911
|
+
|
|
912
|
+
```ts
|
|
913
|
+
// Task 1746 / 1789 — name the session this inbound reaches; the forward
|
|
914
|
+
// pointer wins over the deterministic id. See the WhatsApp twin.
|
|
915
|
+
const resolvedSessionId =
|
|
916
|
+
(input.role === 'admin' ? this.deps.resolveSessionOverride?.(input.accountId, input.senderId) : null) ||
|
|
917
|
+
adminSessionIdFor(
|
|
918
|
+
input.accountId,
|
|
919
|
+
input.senderId,
|
|
920
|
+
input.role === 'public' && input.personId ? input.personId : undefined,
|
|
921
|
+
)
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
- [ ] **Step 5: Inject the resolver at both gateway constructions**
|
|
925
|
+
|
|
926
|
+
In `server/index.ts`, add to the `WaGateway` deps object:
|
|
927
|
+
|
|
928
|
+
```ts
|
|
929
|
+
resolveSessionOverride: (accountId, senderId) => resolveChannelOverride(accountId, 'whatsapp', senderId),
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
and to the Telegram gateway deps object:
|
|
933
|
+
|
|
934
|
+
```ts
|
|
935
|
+
resolveSessionOverride: (accountId, senderId) => resolveChannelOverride(accountId, 'telegram', senderId),
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
- [ ] **Step 6: Run tests and typecheck**
|
|
939
|
+
|
|
940
|
+
Run: `npx vitest run app/lib/whatsapp/gateway/ app/lib/telegram/gateway/ && npx tsc --noEmit -p tsconfig.json`
|
|
941
|
+
Expected: PASS, no type errors.
|
|
942
|
+
|
|
943
|
+
- [ ] **Step 7: Commit**
|
|
944
|
+
|
|
945
|
+
```bash
|
|
946
|
+
git add platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts platform/ui/app/lib/telegram/gateway/telegram-gateway.ts platform/ui/server/index.ts platform/ui/app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts
|
|
947
|
+
git commit -m "feat(1789): op=inbound names the session the message reaches"
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
---
|
|
951
|
+
|
|
952
|
+
### Task 8: Standing check — `op=sender-unseated`
|
|
953
|
+
|
|
954
|
+
A sender whose channel server detached and never came back is invisible to action logging, because nothing happens. The sweep cannot see this today: `runDuplicateSenderAudit` walks sidecars on disk and has no event history. So the gateway records detach times and the sweep queries them.
|
|
955
|
+
|
|
956
|
+
**Files:**
|
|
957
|
+
- Modify: `app/lib/whatsapp/gateway/routes.ts` (`WaChannelRouteDeps`, the attach/detach sites `:48-56`)
|
|
958
|
+
- Modify: `app/lib/whatsapp/gateway/wa-gateway.ts` (`routes()` `:151-170`, new state + accessor)
|
|
959
|
+
- Modify: `app/lib/whatsapp/gateway/duplicate-sender-audit.ts`
|
|
960
|
+
- Modify: `server/index.ts` (`runDuplicateSenderAudit`)
|
|
961
|
+
- Test: `app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`
|
|
962
|
+
|
|
963
|
+
**Interfaces:**
|
|
964
|
+
- Produces:
|
|
965
|
+
- `WaChannelRouteDeps.onAttached?: (senderId: string) => void`, `onDetached?: (senderId: string) => void`
|
|
966
|
+
- `WaGateway.unseatedSenders(nowMs: number, olderThanMs: number): Array<{ senderId: string; sinceMs: number }>`
|
|
967
|
+
- `logUnseatedSenders(entries: Array<{ senderId: string; sinceMs: number }>, nowMs: number): void`
|
|
968
|
+
|
|
969
|
+
- [ ] **Step 1: Write the failing test**
|
|
970
|
+
|
|
971
|
+
Append to `app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`:
|
|
972
|
+
|
|
973
|
+
First extend the file's existing vitest import to include `vi`, `beforeEach`, and `afterEach`, and its `duplicate-sender-audit` import to include `logUnseatedSenders`. Then append:
|
|
974
|
+
|
|
975
|
+
```ts
|
|
976
|
+
describe('logUnseatedSenders (Task 1789)', () => {
|
|
977
|
+
let errs: string[]
|
|
978
|
+
beforeEach(() => {
|
|
979
|
+
errs = []
|
|
980
|
+
vi.spyOn(console, 'error').mockImplementation((m: unknown) => { errs.push(String(m)) })
|
|
981
|
+
})
|
|
982
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
983
|
+
|
|
984
|
+
it('emits one line per unseated sender', () => {
|
|
985
|
+
logUnseatedSenders([{ senderId: '447504472444', sinceMs: 1_000 }], 901_000)
|
|
986
|
+
expect(errs).toHaveLength(1)
|
|
987
|
+
expect(errs[0]).toContain('op=sender-unseated')
|
|
988
|
+
expect(errs[0]).toContain('senderId=447504472444')
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
it('is silent when nothing is unseated', () => {
|
|
992
|
+
logUnseatedSenders([], 901_000)
|
|
993
|
+
expect(errs).toHaveLength(0)
|
|
994
|
+
})
|
|
995
|
+
})
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
Also add a unit test for the gateway's own accounting, in `app/lib/whatsapp/gateway/__tests__/wa-gateway-unseated.test.ts`:
|
|
999
|
+
|
|
1000
|
+
```ts
|
|
1001
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
1002
|
+
import { WaGateway } from '../wa-gateway'
|
|
1003
|
+
|
|
1004
|
+
function makeGateway() {
|
|
1005
|
+
return new WaGateway({
|
|
1006
|
+
gatewayUrl: 'http://x',
|
|
1007
|
+
serverPath: '/x',
|
|
1008
|
+
ensureChannelSession: vi.fn(async () => {}),
|
|
1009
|
+
sendDocument: vi.fn(async () => ({ ok: true as const })),
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
describe('WaGateway.unseatedSenders (Task 1789)', () => {
|
|
1014
|
+
it('reports nothing when no sender has ever detached', () => {
|
|
1015
|
+
expect(makeGateway().unseatedSenders(1_000_000, 900_000)).toEqual([])
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it('reports a sender detached longer than the interval', () => {
|
|
1019
|
+
const gw = makeGateway()
|
|
1020
|
+
gw.recordDetached('A', 1_000)
|
|
1021
|
+
expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([{ senderId: 'A', sinceMs: 1_000 }])
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
it('does not report a sender detached within the interval', () => {
|
|
1025
|
+
const gw = makeGateway()
|
|
1026
|
+
gw.recordDetached('A', 900_000)
|
|
1027
|
+
expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([])
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
it('does not report a sender that re-attached', () => {
|
|
1031
|
+
const gw = makeGateway()
|
|
1032
|
+
gw.recordDetached('A', 1_000)
|
|
1033
|
+
gw.recordAttached('A')
|
|
1034
|
+
expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([])
|
|
1035
|
+
})
|
|
1036
|
+
})
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1040
|
+
|
|
1041
|
+
Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`
|
|
1042
|
+
Expected: FAIL — `logUnseatedSenders` is not exported.
|
|
1043
|
+
|
|
1044
|
+
- [ ] **Step 3: Add the emitter**
|
|
1045
|
+
|
|
1046
|
+
Append to `app/lib/whatsapp/gateway/duplicate-sender-audit.ts`:
|
|
1047
|
+
|
|
1048
|
+
```ts
|
|
1049
|
+
/** Task 1789 — a sender whose channel server detached and never re-attached has
|
|
1050
|
+
* no seat, and nothing happens, so no transition line ever reports it. This is
|
|
1051
|
+
* the standing reconciliation: every known sender has a seat, or is named here
|
|
1052
|
+
* with how long it has been without one. 0 entries = healthy = silent. */
|
|
1053
|
+
export function logUnseatedSenders(entries: Array<{ senderId: string; sinceMs: number }>, nowMs: number): void {
|
|
1054
|
+
for (const e of entries) {
|
|
1055
|
+
const secs = Math.round((nowMs - e.sinceMs) / 1000)
|
|
1056
|
+
console.error(`[whatsapp-native] op=sender-unseated senderId=${e.senderId} since=${new Date(e.sinceMs).toISOString()} forSeconds=${secs}`)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
- [ ] **Step 4: Record detach times in the gateway**
|
|
1062
|
+
|
|
1063
|
+
Add the hooks to `WaChannelRouteDeps` in `app/lib/whatsapp/gateway/routes.ts`:
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
/** Task 1789 — seat transitions, so the gateway can answer "which senders
|
|
1067
|
+
* have had no channel server since when" for the standing audit. */
|
|
1068
|
+
onAttached?: (senderId: string) => void
|
|
1069
|
+
onDetached?: (senderId: string) => void
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
Call them at the existing sites in `createWaChannelRoutes`:
|
|
1073
|
+
|
|
1074
|
+
```ts
|
|
1075
|
+
console.error(`[whatsapp-native] op=channel-attached senderId=${senderId}`)
|
|
1076
|
+
deps.onAttached?.(senderId)
|
|
1077
|
+
stream.onAbort(() => {
|
|
1078
|
+
deps.hub.detach(senderId)
|
|
1079
|
+
console.error(`[whatsapp-native] op=channel-detached senderId=${senderId}`)
|
|
1080
|
+
deps.onDetached?.(senderId)
|
|
1081
|
+
})
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
Add the state and accessor to `WaGateway` in `app/lib/whatsapp/gateway/wa-gateway.ts`, and wire the hooks in `routes()`:
|
|
1085
|
+
|
|
1086
|
+
```ts
|
|
1087
|
+
/** Task 1789 — senderId -> the ms at which its channel server last detached,
|
|
1088
|
+
* cleared on attach. The standing audit reads this; nothing else does. */
|
|
1089
|
+
private readonly detachedAt = new Map<string, number>()
|
|
1090
|
+
|
|
1091
|
+
/** Seat lost. `atMs` is injected so the audit's accounting is testable. */
|
|
1092
|
+
recordDetached(senderId: string, atMs: number): void {
|
|
1093
|
+
this.detachedAt.set(senderId, atMs)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Seat regained — the sender is no longer unseated. */
|
|
1097
|
+
recordAttached(senderId: string): void {
|
|
1098
|
+
this.detachedAt.delete(senderId)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Senders with no channel server for longer than `olderThanMs`. */
|
|
1102
|
+
unseatedSenders(nowMs: number, olderThanMs: number): Array<{ senderId: string; sinceMs: number }> {
|
|
1103
|
+
const out: Array<{ senderId: string; sinceMs: number }> = []
|
|
1104
|
+
for (const [senderId, sinceMs] of this.detachedAt) {
|
|
1105
|
+
if (nowMs - sinceMs > olderThanMs) out.push({ senderId, sinceMs })
|
|
1106
|
+
}
|
|
1107
|
+
return out.sort((a, b) => a.senderId.localeCompare(b.senderId))
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
onAttached: (senderId) => this.recordAttached(senderId),
|
|
1113
|
+
onDetached: (senderId) => this.recordDetached(senderId, Date.now()),
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
- [ ] **Step 5: Query it from the sweep**
|
|
1117
|
+
|
|
1118
|
+
In `server/index.ts`'s `runDuplicateSenderAudit`, after the existing `logDuplicateSenderGroups(...)` call:
|
|
1119
|
+
|
|
1120
|
+
```ts
|
|
1121
|
+
// Task 1789 — same sweep, second standing check: senders detached longer
|
|
1122
|
+
// than one interval with no re-attach.
|
|
1123
|
+
const now = Date.now()
|
|
1124
|
+
logUnseatedSenders(waGateway.unseatedSenders(now, 15 * 60_000), now)
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
and extend the existing import:
|
|
1128
|
+
|
|
1129
|
+
```ts
|
|
1130
|
+
import { findDuplicateSenderGroups, logDuplicateSenderGroups, logUnseatedSenders, type SenderSessionRecord } from '../app/lib/whatsapp/gateway/duplicate-sender-audit.js'
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
- [ ] **Step 6: Run tests and typecheck**
|
|
1134
|
+
|
|
1135
|
+
Run: `npx vitest run app/lib/whatsapp/gateway/ && npx tsc --noEmit -p tsconfig.json`
|
|
1136
|
+
Expected: PASS, no type errors.
|
|
1137
|
+
|
|
1138
|
+
- [ ] **Step 7: Commit**
|
|
1139
|
+
|
|
1140
|
+
```bash
|
|
1141
|
+
git add platform/ui/app/lib/whatsapp/gateway/routes.ts platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts platform/ui/app/lib/whatsapp/gateway/duplicate-sender-audit.ts platform/ui/app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts platform/ui/server/index.ts
|
|
1142
|
+
git commit -m "feat(1789): standing op=sender-unseated check in the duplicate-sender sweep"
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
---
|
|
1146
|
+
|
|
1147
|
+
### Task 9: Correct the contradictory comments and the docs
|
|
1148
|
+
|
|
1149
|
+
`session-reseat.ts`'s header said the fork is always webchat-bound; `SessionReseatControl.tsx:10-13` said the same form serves both row kinds. The route's assumption won, which is the defect. Both must now describe the delivered behaviour. The `session-reseat.ts` header and both `spawn-request.ts` comments were already corrected in Tasks 3 and 5; this task closes the remaining two surfaces.
|
|
1150
|
+
|
|
1151
|
+
**Files:**
|
|
1152
|
+
- Modify: `app/components/SessionReseatControl.tsx:7-23`
|
|
1153
|
+
- Modify: `../../.docs/admin-webchat-native-channel.md`
|
|
1154
|
+
|
|
1155
|
+
- [ ] **Step 1: Correct the component comment**
|
|
1156
|
+
|
|
1157
|
+
Replace the second sentence of the `SessionReseatControl` doc comment:
|
|
1158
|
+
|
|
1159
|
+
```tsx
|
|
1160
|
+
* Task 928 — per-row Re-seat form for the operator dashboard. Re-seat is the
|
|
1161
|
+
* fork mechanism (Task 876): it forks the session onto a fresh id born on the
|
|
1162
|
+
* chosen model/mode/effort, history preserved, then navigates to the fork. The
|
|
1163
|
+
* same form serves webchat rows (the Sessions panel) and channel rows
|
|
1164
|
+
* (WhatsApp/Telegram), since both drive the one /api/admin/session-reseat fork.
|
|
1165
|
+
*
|
|
1166
|
+
* Task 1789 — the route now honours that. It reads the source row's sidecar and
|
|
1167
|
+
* forks an admin whatsapp/telegram row as THAT channel, carrying the source's
|
|
1168
|
+
* senderId, and writes a per-sender forward pointer so the sender's next inbound
|
|
1169
|
+
* resolves to the fork. Before this the route hardcoded a webchat payload for
|
|
1170
|
+
* every row, which replaced the sender's channel server and detached its seat.
|
|
1171
|
+
* A channel fork has no reply tool until that next inbound rebinds it; the
|
|
1172
|
+
* lifeline names that window as op=channel-fork-unbound.
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
- [ ] **Step 2: Document the override**
|
|
1176
|
+
|
|
1177
|
+
Append a section to `maxy-code/.docs/admin-webchat-native-channel.md`:
|
|
1178
|
+
|
|
1179
|
+
```markdown
|
|
1180
|
+
## Channel-row re-seat and the per-sender forward pointer (Task 1789)
|
|
1181
|
+
|
|
1182
|
+
A channel session's id is `adminSessionIdFor(accountId, senderId)` — a pure hash,
|
|
1183
|
+
so every inbound from a sender recomputes the same id. An operator re-seat forks
|
|
1184
|
+
the row onto a freshly minted id that hash will never return.
|
|
1185
|
+
|
|
1186
|
+
Two mechanisms make a channel re-seat work, and neither is sufficient alone.
|
|
1187
|
+
`session-reseat.ts` reads the source row's `.meta.json` and, when it is an
|
|
1188
|
+
**admin** `whatsapp` or `telegram` row, builds the fork payload with that channel
|
|
1189
|
+
and the source's own `senderId` instead of the webchat shape. `server/channel-session-override.ts`
|
|
1190
|
+
then holds a per-account `<channel>:<senderId>` → fork-id pointer, written at that
|
|
1191
|
+
fork and consulted at both spawn sites in `server/index.ts`, so the sender's next
|
|
1192
|
+
inbound resolves to the fork.
|
|
1193
|
+
|
|
1194
|
+
Public rows are out of scope: their session id keys on `personId` as well as
|
|
1195
|
+
`senderId`, so a per-sender pointer is ambiguous for them. They re-seat as
|
|
1196
|
+
webchat, unchanged.
|
|
1197
|
+
|
|
1198
|
+
The pointer is not Task 1526's per-firing seat that Task 1746 removed. That seat
|
|
1199
|
+
was salted per scheduled dispatch and minted a new session every firing, which
|
|
1200
|
+
attached under the sender's own hub key and swallowed their real messages. This
|
|
1201
|
+
pointer is stable — one session, repointed once — and can never yield a second id
|
|
1202
|
+
for a sender.
|
|
1203
|
+
|
|
1204
|
+
A channel fork carries no `waChannel` / `telegramChannel` descriptor, because the
|
|
1205
|
+
re-seat route holds no gateway url or server path. So the fork has no reply tool
|
|
1206
|
+
until the next inbound rebinds it through `ensureChannelSession`. That window is
|
|
1207
|
+
logged as `op=channel-fork-unbound`; the manager's own `op=channel-binding-lost`
|
|
1208
|
+
invariant is gated on its channel store and cannot fire for a fresh fork id.
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
- [ ] **Step 3: Verify the docs hook is satisfied**
|
|
1212
|
+
|
|
1213
|
+
Run: `git status --short` and confirm both a code file and a docs file are staged.
|
|
1214
|
+
|
|
1215
|
+
- [ ] **Step 4: Commit**
|
|
1216
|
+
|
|
1217
|
+
```bash
|
|
1218
|
+
git add platform/ui/app/components/SessionReseatControl.tsx ../../.docs/admin-webchat-native-channel.md
|
|
1219
|
+
git commit -m "docs(1789): correct the re-seat comments and document the forward pointer"
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
---
|
|
1223
|
+
|
|
1224
|
+
### Task 10: Full verification
|
|
1225
|
+
|
|
1226
|
+
- [ ] **Step 1: Full test suite**
|
|
1227
|
+
|
|
1228
|
+
Run: `npm test`
|
|
1229
|
+
Expected: PASS. Compare failures against the pre-sprint baseline; any pre-existing failure is recorded, not fixed here.
|
|
1230
|
+
|
|
1231
|
+
- [ ] **Step 2: Typecheck and build**
|
|
1232
|
+
|
|
1233
|
+
Run: `npx tsc --noEmit -p tsconfig.json && npm run build`
|
|
1234
|
+
Expected: no type errors, build succeeds.
|
|
1235
|
+
|
|
1236
|
+
- [ ] **Step 3: Lint changed files**
|
|
1237
|
+
|
|
1238
|
+
Run: `npx eslint $(git diff --name-only main...HEAD | grep -E '\.tsx?$' | sed 's|^platform/ui/||')`
|
|
1239
|
+
Expected: no new errors in changed files.
|
|
1240
|
+
|
|
1241
|
+
- [ ] **Step 4: Confirm the webchat payload is unchanged**
|
|
1242
|
+
|
|
1243
|
+
Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts -t 'mints a new id'`
|
|
1244
|
+
Expected: PASS — the original Task 876 assertion still holds byte-for-byte.
|