@rubytech/create-sitedesk-code 0.1.508 → 0.1.510

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/dist/index.js +53 -0
  2. package/package.json +1 -1
  3. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1902-account-owned-entry-declarations.md +555 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-26-task-2023-sidebar-sessions-async-reads.md +394 -0
  6. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1902-account-owned-entry-declarations-design.md +232 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-26-task-2014-ui-suite-nondeterminism-design.md +169 -0
  9. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  10. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +12 -7
  11. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  12. package/payload/platform/plugins/business-assistant/PLUGIN.md +1 -0
  13. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -0
  14. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
  15. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +21 -0
  17. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
  18. package/payload/platform/plugins/docs/references/admin-ui.md +10 -5
  19. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  20. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -0
  21. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +112 -4
  22. package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
  23. package/payload/platform/scripts/check-brand-cache.sh +138 -0
  24. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +61 -32
  25. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +17 -30
  27. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/config.d.ts +50 -4
  29. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/config.js +58 -15
  31. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  32. package/payload/server/maxy-edge.js +26 -11
  33. package/payload/server/public/activity.html +5 -5
  34. package/payload/server/public/agents.html +4 -4
  35. package/payload/server/public/assets/AdminLoginScreens-CWMpccrR.css.br +0 -0
  36. package/payload/server/public/assets/AdminLoginScreens-CWMpccrR.css.gz +0 -0
  37. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js +1 -0
  38. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js.br +0 -0
  39. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js.gz +0 -0
  40. package/payload/server/public/assets/{AdminShell-HeELCCjJ.js → AdminShell-BMPEWNZk.js} +1 -1
  41. package/payload/server/public/assets/AdminShell-BMPEWNZk.js.br +0 -0
  42. package/payload/server/public/assets/AdminShell-BMPEWNZk.js.gz +0 -0
  43. package/payload/server/public/assets/_baseFor-Be3EKK3K.js.br +0 -0
  44. package/payload/server/public/assets/_baseFor-Be3EKK3K.js.gz +0 -0
  45. package/payload/server/public/assets/{activity-BEIDAxDA.js → activity-ByWYF5dC.js} +1 -1
  46. package/payload/server/public/assets/activity-ByWYF5dC.js.br +0 -0
  47. package/payload/server/public/assets/activity-ByWYF5dC.js.gz +0 -0
  48. package/payload/server/public/assets/admin-DGr1HuPg.js +1 -0
  49. package/payload/server/public/assets/admin-DGr1HuPg.js.br +0 -0
  50. package/payload/server/public/assets/admin-DGr1HuPg.js.gz +0 -0
  51. package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
  52. package/payload/server/public/assets/agents-C0rTfrYd.js.br +0 -0
  53. package/payload/server/public/assets/agents-C0rTfrYd.js.gz +0 -0
  54. package/payload/server/public/assets/arc-DaI5MLTq.js.br +0 -0
  55. package/payload/server/public/assets/arc-DaI5MLTq.js.gz +0 -0
  56. package/payload/server/public/assets/architecture-YZFGNWBL-DWZIX5aV.js.br +0 -0
  57. package/payload/server/public/assets/architectureDiagram-Q4EWVU46-CBGVUEN9.js.br +0 -0
  58. package/payload/server/public/assets/architectureDiagram-Q4EWVU46-CBGVUEN9.js.gz +0 -0
  59. package/payload/server/public/assets/array-X0JlPOfd.js.br +0 -0
  60. package/payload/server/public/assets/blockDiagram-DXYQGD6D-DS6dS4GZ.js.br +0 -0
  61. package/payload/server/public/assets/blockDiagram-DXYQGD6D-DS6dS4GZ.js.gz +0 -0
  62. package/payload/server/public/assets/{browser-GkVTGqlb.js → browser-4tTaem8N.js} +1 -1
  63. package/payload/server/public/assets/browser-4tTaem8N.js.br +0 -0
  64. package/payload/server/public/assets/browser-4tTaem8N.js.gz +0 -0
  65. package/payload/server/public/assets/c4Diagram-AHTNJAMY-hBSBUZPt.js.br +0 -0
  66. package/payload/server/public/assets/c4Diagram-AHTNJAMY-hBSBUZPt.js.gz +0 -0
  67. package/payload/server/public/assets/{calendar-CSqJeFEg.js → calendar-BEjnkKG1.js} +1 -1
  68. package/payload/server/public/assets/calendar-BEjnkKG1.js.br +0 -0
  69. package/payload/server/public/assets/calendar-BEjnkKG1.js.gz +0 -0
  70. package/payload/server/public/assets/channel-BEokjAeE.js.br +0 -0
  71. package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
  72. package/payload/server/public/assets/chat-DBgzjDIE.js.br +0 -0
  73. package/payload/server/public/assets/chat-DBgzjDIE.js.gz +0 -0
  74. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
  75. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
  76. package/payload/server/public/assets/chevron-right-CQfPGsFb.js.br +0 -0
  77. package/payload/server/public/assets/chunk-2KRD3SAO-B6koUoB7.js.br +0 -0
  78. package/payload/server/public/assets/chunk-2KRD3SAO-B6koUoB7.js.gz +0 -0
  79. package/payload/server/public/assets/chunk-336JU56O-BF6BRh8u.js.br +0 -0
  80. package/payload/server/public/assets/chunk-336JU56O-BF6BRh8u.js.gz +0 -0
  81. package/payload/server/public/assets/chunk-426QAEUC-pbQ4PWBU.js.br +0 -0
  82. package/payload/server/public/assets/chunk-426QAEUC-pbQ4PWBU.js.gz +0 -0
  83. package/payload/server/public/assets/chunk-4BX2VUAB-7sO95xR2.js.br +0 -0
  84. package/payload/server/public/assets/chunk-4BX2VUAB-7sO95xR2.js.gz +0 -0
  85. package/payload/server/public/assets/chunk-4TB4RGXK-GCTndJYx.js.br +0 -0
  86. package/payload/server/public/assets/chunk-4TB4RGXK-GCTndJYx.js.gz +0 -0
  87. package/payload/server/public/assets/chunk-55IACEB6-ggIJf_TC.js.br +0 -0
  88. package/payload/server/public/assets/chunk-55IACEB6-ggIJf_TC.js.gz +0 -0
  89. package/payload/server/public/assets/chunk-5FUZZQ4R-DeGmkimt.js.br +0 -0
  90. package/payload/server/public/assets/chunk-5FUZZQ4R-DeGmkimt.js.gz +0 -0
  91. package/payload/server/public/assets/chunk-5PVQY5BW-aFIQKk_U.js.br +0 -0
  92. package/payload/server/public/assets/chunk-5PVQY5BW-aFIQKk_U.js.gz +0 -0
  93. package/payload/server/public/assets/chunk-67CJDMHE-LS8rUbii.js.br +2 -0
  94. package/payload/server/public/assets/chunk-67CJDMHE-LS8rUbii.js.gz +0 -0
  95. package/payload/server/public/assets/chunk-7N4EOEYR-JKOVngS0.js.br +2 -0
  96. package/payload/server/public/assets/chunk-7N4EOEYR-JKOVngS0.js.gz +0 -0
  97. package/payload/server/public/assets/chunk-AA7GKIK3-CoXnTVK6.js.br +0 -0
  98. package/payload/server/public/assets/chunk-AA7GKIK3-CoXnTVK6.js.gz +0 -0
  99. package/payload/server/public/assets/chunk-BSJP7CBP-CcsqObFg.js.br +0 -0
  100. package/payload/server/public/assets/chunk-BSJP7CBP-CcsqObFg.js.gz +0 -0
  101. package/payload/server/public/assets/chunk-CIAEETIT-CpsybR7q.js.br +0 -0
  102. package/payload/server/public/assets/chunk-CIAEETIT-CpsybR7q.js.gz +0 -0
  103. package/payload/server/public/assets/chunk-CltuBf4Z.js.br +0 -0
  104. package/payload/server/public/assets/chunk-CltuBf4Z.js.gz +0 -0
  105. package/payload/server/public/assets/chunk-EDXVE4YY-BZ2I9PSq.js.br +0 -0
  106. package/payload/server/public/assets/chunk-EDXVE4YY-BZ2I9PSq.js.gz +0 -0
  107. package/payload/server/public/assets/chunk-ENJZ2VHE-DbTwt6OZ.js.br +0 -0
  108. package/payload/server/public/assets/chunk-ENJZ2VHE-DbTwt6OZ.js.gz +0 -0
  109. package/payload/server/public/assets/chunk-FMBD7UC4-D27VnSCa.js.br +0 -0
  110. package/payload/server/public/assets/chunk-FMBD7UC4-D27VnSCa.js.gz +0 -0
  111. package/payload/server/public/assets/chunk-FOC6F5B3-C0tU2UFl.js.br +0 -0
  112. package/payload/server/public/assets/chunk-FOC6F5B3-C0tU2UFl.js.gz +0 -0
  113. package/payload/server/public/assets/chunk-ICPOFSXX-UPPu2OIq.js.br +0 -0
  114. package/payload/server/public/assets/chunk-ICPOFSXX-UPPu2OIq.js.gz +0 -0
  115. package/payload/server/public/assets/chunk-K5T4RW27-DIKxqyYw.js.br +0 -0
  116. package/payload/server/public/assets/chunk-K5T4RW27-DIKxqyYw.js.gz +0 -0
  117. package/payload/server/public/assets/chunk-KGLVRYIC-CiL4YVLo.js.br +0 -0
  118. package/payload/server/public/assets/chunk-KGLVRYIC-CiL4YVLo.js.gz +0 -0
  119. package/payload/server/public/assets/chunk-LIHQZDEY-ejKezVlD.js.br +0 -0
  120. package/payload/server/public/assets/chunk-LIHQZDEY-ejKezVlD.js.gz +0 -0
  121. package/payload/server/public/assets/chunk-ORNJ4GCN-Cc0vqc-D.js.br +0 -0
  122. package/payload/server/public/assets/chunk-ORNJ4GCN-Cc0vqc-D.js.gz +0 -0
  123. package/payload/server/public/assets/chunk-OYMX7WX6-Drxn1qG0.js.br +0 -0
  124. package/payload/server/public/assets/chunk-OYMX7WX6-Drxn1qG0.js.gz +0 -0
  125. package/payload/server/public/assets/chunk-QZHKN3VN-Dhlh7Pkk.js.br +0 -0
  126. package/payload/server/public/assets/chunk-QZHKN3VN-Dhlh7Pkk.js.gz +0 -0
  127. package/payload/server/public/assets/chunk-U2HBQHQK-DnbQIkRP.js.br +0 -0
  128. package/payload/server/public/assets/chunk-U2HBQHQK-DnbQIkRP.js.gz +0 -0
  129. package/payload/server/public/assets/chunk-X2U36JSP-CauTSVWg.js.br +0 -0
  130. package/payload/server/public/assets/chunk-X2U36JSP-CauTSVWg.js.gz +0 -0
  131. package/payload/server/public/assets/chunk-XPW4576I-DZqqB9Zi.js.br +0 -0
  132. package/payload/server/public/assets/chunk-XPW4576I-DZqqB9Zi.js.gz +0 -0
  133. package/payload/server/public/assets/chunk-YZCP3GAM-BGHo4Li-.js.br +0 -0
  134. package/payload/server/public/assets/chunk-YZCP3GAM-BGHo4Li-.js.gz +0 -0
  135. package/payload/server/public/assets/chunk-ZZ45TVLE-DWGJKPd7.js.br +0 -0
  136. package/payload/server/public/assets/chunk-ZZ45TVLE-DWGJKPd7.js.gz +0 -0
  137. package/payload/server/public/assets/classDiagram-6PBFFD2Q-D2z_ZV1x.js.br +0 -0
  138. package/payload/server/public/assets/classDiagram-6PBFFD2Q-D2z_ZV1x.js.gz +0 -0
  139. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BJwJoNEK.js.br +0 -0
  140. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BJwJoNEK.js.gz +0 -0
  141. package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
  142. package/payload/server/public/assets/clock-Dn6FHB51.js.br +0 -0
  143. package/payload/server/public/assets/clock-Dn6FHB51.js.gz +0 -0
  144. package/payload/server/public/assets/clone-Bn7XXVhr.js.br +0 -0
  145. package/payload/server/public/assets/cose-bilkent-S5V4N54A-D-95BzXL.js.br +0 -0
  146. package/payload/server/public/assets/cose-bilkent-S5V4N54A-D-95BzXL.js.gz +0 -0
  147. package/payload/server/public/assets/cytoscape.esm-CI2IWMLB.js.br +0 -0
  148. package/payload/server/public/assets/cytoscape.esm-CI2IWMLB.js.gz +0 -0
  149. package/payload/server/public/assets/dagre-BZYNBHvT.js.br +0 -0
  150. package/payload/server/public/assets/dagre-BZYNBHvT.js.gz +0 -0
  151. package/payload/server/public/assets/dagre-KV5264BT-DyU4HQLQ.js.br +0 -0
  152. package/payload/server/public/assets/dagre-KV5264BT-DyU4HQLQ.js.gz +0 -0
  153. package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
  154. package/payload/server/public/assets/data-D_e6Vdfd.js.br +0 -0
  155. package/payload/server/public/assets/data-D_e6Vdfd.js.gz +0 -0
  156. package/payload/server/public/assets/defaultLocale-B6RGN4id.js.br +0 -0
  157. package/payload/server/public/assets/defaultLocale-B6RGN4id.js.gz +0 -0
  158. package/payload/server/public/assets/diagram-5BDNPKRD-CCttJ9XA.js.br +0 -0
  159. package/payload/server/public/assets/diagram-5BDNPKRD-CCttJ9XA.js.gz +0 -0
  160. package/payload/server/public/assets/diagram-G4DWMVQ6-DvkpgSLO.js.br +0 -0
  161. package/payload/server/public/assets/diagram-G4DWMVQ6-DvkpgSLO.js.gz +0 -0
  162. package/payload/server/public/assets/diagram-MMDJMWI5-DHmW5Y9k.js.br +0 -0
  163. package/payload/server/public/assets/diagram-MMDJMWI5-DHmW5Y9k.js.gz +0 -0
  164. package/payload/server/public/assets/diagram-TYMM5635-C6T4hwzO.js.br +0 -0
  165. package/payload/server/public/assets/diagram-TYMM5635-C6T4hwzO.js.gz +0 -0
  166. package/payload/server/public/assets/dist-QvbmK1IP.js.br +0 -0
  167. package/payload/server/public/assets/dist-QvbmK1IP.js.gz +0 -0
  168. package/payload/server/public/assets/erDiagram-SMLLAGMA-5v_o7s3T.js.br +0 -0
  169. package/payload/server/public/assets/erDiagram-SMLLAGMA-5v_o7s3T.js.gz +0 -0
  170. package/payload/server/public/assets/{file-text-DiscM2pP.js → file-text-DMKw3nIk.js} +1 -1
  171. package/payload/server/public/assets/file-text-DMKw3nIk.js.br +0 -0
  172. package/payload/server/public/assets/file-text-DMKw3nIk.js.gz +0 -0
  173. package/payload/server/public/assets/flatten-BtIc9jX7.js.br +0 -0
  174. package/payload/server/public/assets/flatten-BtIc9jX7.js.gz +0 -0
  175. package/payload/server/public/assets/flowDiagram-DWJPFMVM-3UhCDsjt.js.br +0 -0
  176. package/payload/server/public/assets/flowDiagram-DWJPFMVM-3UhCDsjt.js.gz +0 -0
  177. package/payload/server/public/assets/ganttDiagram-T4ZO3ILL-F2zgHIIQ.js.br +0 -0
  178. package/payload/server/public/assets/ganttDiagram-T4ZO3ILL-F2zgHIIQ.js.gz +0 -0
  179. package/payload/server/public/assets/gitGraph-7Q5UKJZL-C-FvNTHZ.js.br +0 -0
  180. package/payload/server/public/assets/gitGraphDiagram-UUTBAWPF-oQc1N3dT.js.br +0 -0
  181. package/payload/server/public/assets/gitGraphDiagram-UUTBAWPF-oQc1N3dT.js.gz +0 -0
  182. package/payload/server/public/assets/{graph-CRP5DM8C.js → graph-C3vW8lvw.js} +1 -1
  183. package/payload/server/public/assets/graph-C3vW8lvw.js.br +0 -0
  184. package/payload/server/public/assets/graph-C3vW8lvw.js.gz +0 -0
  185. package/payload/server/public/assets/{graph-labels-Du-6KoBE.js → graph-labels-BZgd0L6n.js} +1 -1
  186. package/payload/server/public/assets/graph-labels-BZgd0L6n.js.br +0 -0
  187. package/payload/server/public/assets/graph-labels-BZgd0L6n.js.gz +0 -0
  188. package/payload/server/public/assets/graphlib-DY4-AT2Q.js.br +0 -0
  189. package/payload/server/public/assets/graphlib-DY4-AT2Q.js.gz +0 -0
  190. package/payload/server/public/assets/info-OMHHGYJF-BjLPgHAC.js.br +0 -0
  191. package/payload/server/public/assets/infoDiagram-42DDH7IO-CXNKLTui.js.br +0 -0
  192. package/payload/server/public/assets/infoDiagram-42DDH7IO-CXNKLTui.js.gz +0 -0
  193. package/payload/server/public/assets/init-BmUWJJHz.js.br +0 -0
  194. package/payload/server/public/assets/init-BmUWJJHz.js.gz +0 -0
  195. package/payload/server/public/assets/isEmpty-Bu4fZ8Kf.js.br +0 -0
  196. package/payload/server/public/assets/isEmpty-Bu4fZ8Kf.js.gz +0 -0
  197. package/payload/server/public/assets/ishikawaDiagram-UXIWVN3A-wJhSOkuw.js.br +0 -0
  198. package/payload/server/public/assets/ishikawaDiagram-UXIWVN3A-wJhSOkuw.js.gz +0 -0
  199. package/payload/server/public/assets/journeyDiagram-VCZTEJTY-DgadC6Pz.js.br +0 -0
  200. package/payload/server/public/assets/journeyDiagram-VCZTEJTY-DgadC6Pz.js.gz +0 -0
  201. package/payload/server/public/assets/kanban-definition-6JOO6SKY-Bs4nQqXI.js.br +0 -0
  202. package/payload/server/public/assets/kanban-definition-6JOO6SKY-Bs4nQqXI.js.gz +0 -0
  203. package/payload/server/public/assets/katex-BjZuzSQn.js.br +0 -0
  204. package/payload/server/public/assets/katex-BjZuzSQn.js.gz +0 -0
  205. package/payload/server/public/assets/line-Bnmxy1hO.js.br +0 -0
  206. package/payload/server/public/assets/line-Bnmxy1hO.js.gz +0 -0
  207. package/payload/server/public/assets/linear-RvwZxFzh.js.br +0 -0
  208. package/payload/server/public/assets/linear-RvwZxFzh.js.gz +0 -0
  209. package/payload/server/public/assets/{maximize-2-D-H_uIV-.js → maximize-2-la3IBA3M.js} +1 -1
  210. package/payload/server/public/assets/maximize-2-la3IBA3M.js.br +0 -0
  211. package/payload/server/public/assets/maximize-2-la3IBA3M.js.gz +0 -0
  212. package/payload/server/public/assets/mermaid-parser.core-DeNV8RtA.js.br +0 -0
  213. package/payload/server/public/assets/mermaid-parser.core-DeNV8RtA.js.gz +0 -0
  214. package/payload/server/public/assets/mermaid.core-BU9IiSCP.js.br +0 -0
  215. package/payload/server/public/assets/mermaid.core-BU9IiSCP.js.gz +0 -0
  216. package/payload/server/public/assets/mindmap-definition-QFDTVHPH-D1PMUxxm.js.br +0 -0
  217. package/payload/server/public/assets/mindmap-definition-QFDTVHPH-D1PMUxxm.js.gz +0 -0
  218. package/payload/server/public/assets/{operator-D5rA76YY.js → operator-D9NgVL9_.js} +1 -1
  219. package/payload/server/public/assets/operator-D9NgVL9_.js.br +0 -0
  220. package/payload/server/public/assets/operator-D9NgVL9_.js.gz +0 -0
  221. package/payload/server/public/assets/ordinal-C2TEWKNu.js.br +0 -0
  222. package/payload/server/public/assets/ordinal-C2TEWKNu.js.gz +0 -0
  223. package/payload/server/public/assets/packet-4T2RLAQJ-BBAZASfb.js.br +0 -0
  224. package/payload/server/public/assets/{page-CHK1415C.js → page-BWFHRIAH.js} +1 -1
  225. package/payload/server/public/assets/page-BWFHRIAH.js.br +0 -0
  226. package/payload/server/public/assets/page-BWFHRIAH.js.gz +0 -0
  227. package/payload/server/public/assets/{page-DZE3H_n4.js → page-DC11gesX.js} +1 -1
  228. package/payload/server/public/assets/page-DC11gesX.js.br +0 -0
  229. package/payload/server/public/assets/page-DC11gesX.js.gz +0 -0
  230. package/payload/server/public/assets/path-CoPyR7c2.js.br +0 -0
  231. package/payload/server/public/assets/path-CoPyR7c2.js.gz +0 -0
  232. package/payload/server/public/assets/pdf-render-C257cidb.js.br +0 -0
  233. package/payload/server/public/assets/pdf-render-C257cidb.js.gz +0 -0
  234. package/payload/server/public/assets/pdf.worker.min-yatZIOMy.mjs.br +0 -0
  235. package/payload/server/public/assets/pdf.worker.min-yatZIOMy.mjs.gz +0 -0
  236. package/payload/server/public/assets/pie-ZZUOXDRM-CCJUnMeG.js.br +0 -0
  237. package/payload/server/public/assets/pieDiagram-DEJITSTG-BzdNzoKA.js.br +0 -0
  238. package/payload/server/public/assets/pieDiagram-DEJITSTG-BzdNzoKA.js.gz +0 -0
  239. package/payload/server/public/assets/preload-helper-BQrMkyGX.js.br +0 -0
  240. package/payload/server/public/assets/preload-helper-BQrMkyGX.js.gz +0 -0
  241. package/payload/server/public/assets/{public-DFHXVkdD.js → public-DQbbLHQN.js} +1 -1
  242. package/payload/server/public/assets/public-DQbbLHQN.js.br +0 -0
  243. package/payload/server/public/assets/public-DQbbLHQN.js.gz +0 -0
  244. package/payload/server/public/assets/quadrantDiagram-34T5L4WZ-oFr1MJRO.js.br +0 -0
  245. package/payload/server/public/assets/quadrantDiagram-34T5L4WZ-oFr1MJRO.js.gz +0 -0
  246. package/payload/server/public/assets/radar-PYXPWWZC-BsRU8Aep.js.br +0 -0
  247. package/payload/server/public/assets/reduce-BGpN5lgn.js.br +0 -0
  248. package/payload/server/public/assets/reduce-BGpN5lgn.js.gz +0 -0
  249. package/payload/server/public/assets/requirementDiagram-MS252O5E-C9QBQxDh.js.br +0 -0
  250. package/payload/server/public/assets/requirementDiagram-MS252O5E-C9QBQxDh.js.gz +0 -0
  251. package/payload/server/public/assets/{rotate-ccw-CesEJsuO.js → rotate-ccw-OQD5si8N.js} +1 -1
  252. package/payload/server/public/assets/rotate-ccw-OQD5si8N.js.br +0 -0
  253. package/payload/server/public/assets/rotate-ccw-OQD5si8N.js.gz +0 -0
  254. package/payload/server/public/assets/rough.esm-jSt7UMyi.js.br +0 -0
  255. package/payload/server/public/assets/rough.esm-jSt7UMyi.js.gz +0 -0
  256. package/payload/server/public/assets/{routines-DYMYY_r8.js → routines-asrBGZUR.js} +1 -1
  257. package/payload/server/public/assets/routines-asrBGZUR.js.br +0 -0
  258. package/payload/server/public/assets/routines-asrBGZUR.js.gz +0 -0
  259. package/payload/server/public/assets/sankeyDiagram-XADWPNL6-c_y3bvVv.js.br +0 -0
  260. package/payload/server/public/assets/sankeyDiagram-XADWPNL6-c_y3bvVv.js.gz +0 -0
  261. package/payload/server/public/assets/sequenceDiagram-FGHM5R23-YwlCBPHE.js.br +0 -0
  262. package/payload/server/public/assets/sequenceDiagram-FGHM5R23-YwlCBPHE.js.gz +0 -0
  263. package/payload/server/public/assets/{skills-67jtJ16D.js → skills-D4ECwoxa.js} +1 -1
  264. package/payload/server/public/assets/skills-D4ECwoxa.js.br +0 -0
  265. package/payload/server/public/assets/skills-D4ECwoxa.js.gz +0 -0
  266. package/payload/server/public/assets/src-C2WwTWSY.js.br +0 -0
  267. package/payload/server/public/assets/src-C2WwTWSY.js.gz +0 -0
  268. package/payload/server/public/assets/stateDiagram-FHFEXIEX-BFE3e-FM.js.br +0 -0
  269. package/payload/server/public/assets/stateDiagram-FHFEXIEX-BFE3e-FM.js.gz +0 -0
  270. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CpjB4ykI.js.br +4 -0
  271. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CpjB4ykI.js.gz +0 -0
  272. package/payload/server/public/assets/{tasks-D2C2a-uk.js → tasks-U3QdbUsO.js} +1 -1
  273. package/payload/server/public/assets/tasks-U3QdbUsO.js.br +0 -0
  274. package/payload/server/public/assets/tasks-U3QdbUsO.js.gz +0 -0
  275. package/payload/server/public/assets/{time-entry-format-BixTGQAd.js → time-entry-format-BgdTKgYr.js} +1 -1
  276. package/payload/server/public/assets/time-entry-format-BgdTKgYr.js.br +0 -0
  277. package/payload/server/public/assets/time-entry-format-BgdTKgYr.js.gz +0 -0
  278. package/payload/server/public/assets/timeline-definition-GMOUNBTQ-azqhmRwZ.js.br +0 -0
  279. package/payload/server/public/assets/timeline-definition-GMOUNBTQ-azqhmRwZ.js.gz +0 -0
  280. package/payload/server/public/assets/treeView-SZITEDCU-BDzhKA6v.js.br +0 -0
  281. package/payload/server/public/assets/treemap-W4RFUUIX-epHpdeWa.js.br +0 -0
  282. package/payload/server/public/assets/{triangle-alert-Co9RWb9q.js → triangle-alert-Ck_3VhT9.js} +1 -1
  283. package/payload/server/public/assets/triangle-alert-Ck_3VhT9.js.br +0 -0
  284. package/payload/server/public/assets/triangle-alert-Ck_3VhT9.js.gz +0 -0
  285. package/payload/server/public/assets/{useCopyFeedback-7zwf9_w4.js → useCopyFeedback-BdUwyNpa.js} +1 -1
  286. package/payload/server/public/assets/useCopyFeedback-BdUwyNpa.js.br +0 -0
  287. package/payload/server/public/assets/useCopyFeedback-BdUwyNpa.js.gz +0 -0
  288. package/payload/server/public/assets/useSubAccountSwitcher-CJKjEPNN.js.br +0 -0
  289. package/payload/server/public/assets/useSubAccountSwitcher-CJKjEPNN.js.gz +0 -0
  290. package/payload/server/public/assets/{useSubAccountSwitcher-DE4v5_Fz.css → useSubAccountSwitcher-DOWQXDT8.css} +1 -1
  291. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css.br +0 -0
  292. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css.gz +0 -0
  293. package/payload/server/public/assets/{useVoiceRecorder-CZDDiwMD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
  294. package/payload/server/public/assets/useVoiceRecorder-ChnBrzbZ.js.br +0 -0
  295. package/payload/server/public/assets/useVoiceRecorder-ChnBrzbZ.js.gz +0 -0
  296. package/payload/server/public/assets/vennDiagram-DHZGUBPP-Da2RfFs8.js.br +0 -0
  297. package/payload/server/public/assets/vennDiagram-DHZGUBPP-Da2RfFs8.js.gz +0 -0
  298. package/payload/server/public/assets/wardley-RL74JXVD-DNSZCrxV.js.br +0 -0
  299. package/payload/server/public/assets/wardleyDiagram-NUSXRM2D-HdrdBYES.js.br +0 -0
  300. package/payload/server/public/assets/wardleyDiagram-NUSXRM2D-HdrdBYES.js.gz +0 -0
  301. package/payload/server/public/assets/{wrench-CjQqdj4P.js → wrench-Cpee3C3J.js} +1 -1
  302. package/payload/server/public/assets/wrench-Cpee3C3J.js.br +2 -0
  303. package/payload/server/public/assets/wrench-Cpee3C3J.js.gz +0 -0
  304. package/payload/server/public/assets/xychartDiagram-5P7HB3ND-DMk6rTcQ.js.br +0 -0
  305. package/payload/server/public/assets/xychartDiagram-5P7HB3ND-DMk6rTcQ.js.gz +0 -0
  306. package/payload/server/public/browser.html +4 -4
  307. package/payload/server/public/calendar.html +7 -7
  308. package/payload/server/public/chat.html +13 -13
  309. package/payload/server/public/data.html +11 -11
  310. package/payload/server/public/graph.html +9 -9
  311. package/payload/server/public/index.html +14 -14
  312. package/payload/server/public/operator.html +14 -14
  313. package/payload/server/public/public.html +13 -13
  314. package/payload/server/public/routines.html +6 -6
  315. package/payload/server/public/skills.html +5 -5
  316. package/payload/server/public/tasks.html +6 -6
  317. package/payload/server/server.js +817 -715
  318. package/payload/server/public/assets/AdminLoginScreens-DKeCt1uf.js +0 -1
  319. package/payload/server/public/assets/admin-C0-WU1sf.js +0 -1
  320. package/payload/server/public/assets/agents-DjUvMFTj.js +0 -1
  321. package/payload/server/public/assets/chat-C0Ps84U5.js +0 -1
  322. package/payload/server/public/assets/chevron-left-DQ2uNlu9.js +0 -1
  323. package/payload/server/public/assets/chevron-right-C4QCiRGD.js +0 -1
  324. package/payload/server/public/assets/clock-BEeQ7JxX.js +0 -1
  325. package/payload/server/public/assets/data-CGuNfym7.js +0 -1
  326. /package/payload/server/public/assets/{useSubAccountSwitcher-Bg72hdQ8.js → useSubAccountSwitcher-CJKjEPNN.js} +0 -0
@@ -0,0 +1,200 @@
1
+ # Task 1926 — same-account portal push concurrency guard (design)
2
+
3
+ **Date:** 2026-07-26
4
+ **Task:** [`.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md`](../../../../.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md)
5
+ **Touches:** `platform/plugins/cloudflare/bin/portal-index-push.mjs`, `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`
6
+
7
+ ## Problem
8
+
9
+ `pushAccount` stages an account's file index at `next = currentGeneration + 1`, flips
10
+ the `directory_state` pointer to `next`, then sweeps every other generation. Task 1923
11
+ added a pre-stage `DELETE FROM directory WHERE accountId = ? AND generation = ?` bound
12
+ to `next`, so a stage that died mid-batch could self-heal instead of colliding forever
13
+ on `UNIQUE (accountId, relPath, generation)`.
14
+
15
+ Two pushes of one account break that. The second writer reads `current = 5` before the
16
+ first writer's flip, computes `next = 6`, and by the time its pre-stage DELETE runs the
17
+ pointer already names generation 6. The DELETE removes the live tree. A client listing
18
+ in the gap between that DELETE and the re-insert sees an empty portal.
19
+
20
+ The overlap is reachable: the admin server's 60s loop calls `pushAllAccounts`, and a
21
+ hand-run `node portal-index-push.mjs --account <id>` calls the same `pushAccount` from a
22
+ separate OS process.
23
+
24
+ ## What success looks like
25
+
26
+ Two concurrent pushes of one account never leave a reader seeing an empty or partial
27
+ tree, and never delete rows at the pointer's generation.
28
+
29
+ ## Why the three candidate approaches in the task file were rejected
30
+
31
+ **A per-account advisory lock.** The two racers are separate OS processes. A JavaScript
32
+ mutex inside the admin server does not exist in the CLI process, so it guards nothing.
33
+ A lock that does work across processes has to live in D1, which means new columns, a
34
+ lease, and a lease expiry. A lease expiry reintroduces the wedge class Task 1923 just
35
+ removed: a holder that dies wedges the account until the lease runs out, and a push
36
+ slower than its lease keeps writing after another writer has taken over.
37
+
38
+ **`next` from `MAX(generation)` alone.** This stops two writers sharing a staging
39
+ generation, but the slower writer still flips the pointer down onto its own generation
40
+ after the faster writer has already swept it. The reader still sees an empty tree, so
41
+ the success criterion is not met.
42
+
43
+ **Scoping the clear to `generation = next AND generation != current`.** `current` is a
44
+ value read at the top of the push. By the time the DELETE runs, the pointer has moved.
45
+ Re-reading the pointer immediately before the DELETE narrows the window without closing
46
+ it, because the peer can flip between the re-read and the DELETE.
47
+
48
+ ## Design
49
+
50
+ Three changes to `pushAccount`. No lock, no lease, no schema change, no new table.
51
+
52
+ ### 1. Claim a generation above everything that exists
53
+
54
+ Keep the existing pointer read, and add one statement beside it:
55
+
56
+ ```sql
57
+ SELECT MAX(generation) AS g FROM directory WHERE accountId = ?
58
+ ```
59
+
60
+ `next = Math.max(current, maxStagedGeneration) + 1`.
61
+
62
+ A peer that has already inserted at least one row is visible in that MAX, so the two
63
+ writers claim different generations and neither stages into the other's rows.
64
+
65
+ Two statements rather than one nested `UNION ALL`: each is trivially readable, each is
66
+ trivially fakeable in a test, and the pointer read stays the push's first statement,
67
+ which an existing test asserts.
68
+
69
+ ### 2. The flip only ever moves the pointer forward
70
+
71
+ ```sql
72
+ INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?)
73
+ ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration
74
+ WHERE excluded.currentGeneration > directory_state.currentGeneration
75
+ ```
76
+
77
+ SQLite has supported a `WHERE` clause on upsert's `DO UPDATE` since 3.24, and D1 is
78
+ SQLite. This is what makes a losing writer harmless: it cannot drag readers back onto a
79
+ generation the winner has already swept.
80
+
81
+ The D1 client returns rows, not affected-row meta, so the flip is followed by a pointer
82
+ readback. That readback is the branch point:
83
+
84
+ - **Pointer equals `next`.** We won. Log `op=flip`, sweep, verify, return. Unchanged
85
+ behaviour.
86
+ - **Pointer is above `next`.** A peer won. Log
87
+ `op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
88
+ and return without sweeping and without verifying. Both of those would measure the
89
+ peer's tree against our expected row count, and `op=verify` would read as a failure
90
+ when nothing failed.
91
+
92
+ Our staged rows at the losing generation are left in place. They are invisible (no
93
+ reader sees a generation the pointer does not name) and the winner's next sweep collects
94
+ them.
95
+
96
+ ### 3. The sweep only ever removes older generations
97
+
98
+ ```sql
99
+ DELETE FROM directory WHERE accountId = ? AND generation < ?
100
+ ```
101
+
102
+ `< next` rather than `!= next`, so a writer never deletes a peer's higher in-flight
103
+ stage. Rows above `next` from an abandoned stage still converge: the following cycle
104
+ claims a generation above them and sweeps them once it flips.
105
+
106
+ ### 4. The Task 1923 pre-stage clear is removed
107
+
108
+ Because `next` is now always above every existing generation for the account, the orphan
109
+ `SELECT COUNT(*) ... generation = next` can only ever return 0 and the pre-stage DELETE
110
+ can never match a row. Leaving unreachable code whose comment claims it is the wedge
111
+ heal would be false narration, so the orphan count, the DELETE, the `op=stage-clear` log
112
+ line and its two tests go.
113
+
114
+ Task 1923's outcome is preserved by construction and is stronger than before. A stage
115
+ that died mid-batch leaves rows at generation 6; the next run claims 7, so there is no
116
+ UNIQUE collision to freeze on, and the gen-6 orphans are swept after the flip. The heal
117
+ now happens with no DELETE before the flip at all, which is exactly what removes this
118
+ task's hazard rather than narrowing it.
119
+
120
+ This was confirmed with the operator before the spec was written, because Task 1926
121
+ lists Task 1923's heal as out of scope.
122
+
123
+ ## Cases covered, and the one left fail-closed
124
+
125
+ **Peer reads the generation after we have staged a row.** It claims a higher generation.
126
+ Both pushes complete. The forward-only flip picks the winner. The reader sees the old
127
+ complete tree, then one complete new tree. Neither push fails.
128
+
129
+ **Both read the generation before either inserts.** Both claim the same generation. The
130
+ second writer's first INSERT hits `UNIQUE (accountId, relPath, generation)` and that push
131
+ throws. Both walks resolve the same exposed set and `walkExposed` emits the exposed dirs
132
+ in the same order, so the second writer collides on its first row and contributes
133
+ nothing. The reader is safe: the winner's tree is complete and the pointer never moved
134
+ backward. The loser logs `op=failed` and the next 60s cycle succeeds.
135
+
136
+ This second case is left fail-closed rather than retried. The task's success criterion is
137
+ about what a reader can see, and a loud failure that self-corrects one cycle later meets
138
+ it. A retry-on-collision path would be an invented requirement.
139
+
140
+ **The one residual, stated rather than fixed.** The claim above relies on both walks
141
+ starting with the same row. If the account's `SCHEMA.md` or `data-portal.json` changes in
142
+ the window between the two generation reads, the two walks can begin with different rows,
143
+ so the second writer contributes some non-overlapping rows at the shared generation before
144
+ colliding on a shared one. The winner then flips onto its own complete tree plus those few
145
+ extra rows. That is not an empty tree and not a partial one, so it does not breach the
146
+ success criterion, and the next cycle's sweep removes the whole generation. No task file is
147
+ filed for it because nothing is deferred: the case is inside what this design covers, and
148
+ its outcome is a stale row for one cycle rather than a reader-visible break.
149
+
150
+ ## Observability
151
+
152
+ `op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
153
+ is emitted when a push's flip was superseded by a peer. This deviates from the task
154
+ file's `action=<waited|skipped>` wording: the design never waits and never skips work, so
155
+ neither value describes what happened. `superseded` names the actual outcome, and
156
+ `pointer=` names the generation that won, which is what an operator needs to correlate
157
+ the two runs.
158
+
159
+ `op=stage-clear` is retired with the code that emitted it. The task file's line that a
160
+ `stage-clear` with `cleared=` equal to the live tree size must never appear is satisfied
161
+ absolutely: no `stage-clear` line can appear at all.
162
+
163
+ ## Testing
164
+
165
+ Both new tests drive two interleaved `pushAccount` calls against one shared fake store
166
+ and snapshot the reader's view after every statement. The assertion is the success
167
+ criterion stated directly: the visible tree is never empty and never a strict subset of
168
+ a complete tree.
169
+
170
+ 1. **The task file's interleave.** Writer B reads the generation, then pauses. Writer A
171
+ runs to completion. B resumes. Red on current code, because B's pre-stage DELETE binds
172
+ the generation the pointer now names and the snapshot goes empty. Green after, because
173
+ B collides on UNIQUE and A's tree is untouched. This test also asserts that no DELETE
174
+ is ever issued naming the pointer's current generation, which is the other half of the
175
+ success criterion.
176
+
177
+ 2. **The higher-claim interleave.** Writer A stages fully but pauses before its flip.
178
+ Writer B reads the generation, sees A's staged rows in the MAX, claims the next one up,
179
+ and completes. A resumes and its flip is a no-op. Asserts the pointer never moves
180
+ backward, that A emits `op=stage-contended` with `action=superseded`, and that A
181
+ issues no sweep.
182
+
183
+ The existing 27 tests stay green apart from the two Task 1923 stage-clear tests, which
184
+ are removed with the code they cover.
185
+
186
+ ## Scope boundaries
187
+
188
+ **In scope:** the generation claim, the forward-only flip, the backward-only sweep, the
189
+ removal of the pre-stage clear, and the two-writer regression tests.
190
+
191
+ **Out of scope:** INSERT batching (Task 1842). Cross-account contention (separate
192
+ generation counters, no contention). Any change to `runTargets`, `recordSuccess`, or the
193
+ portal reader in `files.ts`.
194
+
195
+ ## Deployment
196
+
197
+ `portal-index-push.mjs` ships inside the installer payload and is inlined into the admin
198
+ server bundle. No D1 migration is required: every statement runs against the existing
199
+ `directory` and `directory_state` tables as defined in
200
+ `platform/plugins/cloudflare/skills/data-portal/schema.sql`.
@@ -0,0 +1,169 @@
1
+ # Task 2014 design — cut the jsdom the `platform/ui` suite does not use
2
+
3
+ Date: 2026-07-26
4
+ Task: `.tasks/pending/2014-platform-ui-suite-is-nondeterministic-under-parallel-load.md`
5
+ Supersedes brief: 1765, folded into 2014 on 2026-07-26
6
+
7
+ ## The measurement this design rests on
8
+
9
+ All figures from the dev Mac, Node 22.22.0, vitest 4.1.2, worktree
10
+ `task-2014-ui-suite-nondeterminism`, 2026-07-26.
11
+
12
+ ### Most apparent worktree flakiness is missing build output, not flakiness
13
+
14
+ `platform/node_modules` and `platform/lib/*/dist` are gitignored build output. A
15
+ fresh worktree has neither. Four full-suite runs, each after adding one more
16
+ piece of provisioning:
17
+
18
+ | Run | Worktree state | Failed | Duration |
19
+ |---|---|---|---|
20
+ | 1 | `platform/ui` deps only | 63 files / 32 tests | 435s |
21
+ | 2 | + 3 libs built | 5 files / 8 tests | 180s |
22
+ | 3 | + `platform` deps | 5 files / 3 tests | 111s |
23
+ | 4 | + all 24 libs built | 0 | 143s |
24
+
25
+ Runs 1 and 2 failed on `Cannot find module .../dist/index.js` and
26
+ `Failed to resolve import "neo4j-driver"`. Those are absent artifacts, not
27
+ timing. Only run 3's three failures are the defect this task describes, and all
28
+ three are in the brief's population: both `/login/start` sub-account cases in
29
+ `whatsapp-self-or-house-scope.test.ts` at 5013ms and 515ms, and
30
+ `whatsapp-conversation-graph-state-projection.test.ts` "sub-account all-senders
31
+ mode returns only its bound thread" at 5017ms. 5000ms is the vitest default
32
+ `testTimeout`.
33
+
34
+ ### Environment setup costs six times what the tests cost
35
+
36
+ Run 4, fully green:
37
+
38
+ ```
39
+ tests 267s
40
+ environment 1571s
41
+ import 257s
42
+ setup 196s
43
+ transform 42s
44
+ ```
45
+
46
+ `vitest.config.ts` sets `environment: 'jsdom'` globally. 530 test files pay it.
47
+
48
+ ### Roughly half the suite boots a DOM it never touches
49
+
50
+ 266 `.test.ts` files do not declare `@vitest-environment node`. Both runs
51
+ back to back, same machine load:
52
+
53
+ | | Duration | environment CPU |
54
+ |---|---|---|
55
+ | as-is (jsdom) | 103.0s | 1317.6s |
56
+ | `--environment node` | 30.9s | 0.074s |
57
+
58
+ Three consecutive node-environment passes over those 266 files agreed on 17
59
+ `.ts` files that genuinely need a DOM. A file that appeared in only one pass
60
+ (`route-cache-watchers.test.ts`) is the known flake, not a DOM dependency. A
61
+ `.tsx` file in the failure list arrived because vitest CLI arguments are
62
+ substring filters rather than exact paths.
63
+
64
+ So the suite's problem is not 4757 tests. The tests are 267s of CPU. It is that
65
+ about half the files run a browser DOM environment for nothing, and that is the
66
+ CPU starving the workers the named tests race against.
67
+
68
+ ### Machine context
69
+
70
+ The dev Mac ran these measurements at load average 100 to 350 with 60 `claude`
71
+ processes across six peer worktree sessions. Self-contention from 20 vitest
72
+ forks is not the only contender for CPU, and any verification has to record the
73
+ load it ran under or a red run cannot be attributed.
74
+
75
+ ## Design
76
+
77
+ ### 1. Route the test environment by file extension
78
+
79
+ `platform/ui/vitest.config.ts` replaces its single global `environment: 'jsdom'`
80
+ with two projects:
81
+
82
+ - `dom`: `**/*.{test,spec}.?(c|m)[jt]sx`, environment `jsdom`.
83
+ - `node`: `**/*.{test,spec}.?(c|m)[jt]s` plus `../scripts/__tests__/**`,
84
+ environment `node`.
85
+
86
+ The existing `resolve.dedupe`, `setupFiles`, `globals`, and `exclude` settings
87
+ carry into both projects unchanged.
88
+
89
+ The rule is structural, not a maintained list: a test that renders JSX gets a
90
+ DOM, and anything else opts in explicitly. No `.tsx` file is edited.
91
+
92
+ ### 2. Seventeen `.ts` files opt back into jsdom
93
+
94
+ Each gets `// @vitest-environment jsdom` with a short reason. Verified on vitest
95
+ 4.1.2 that a per-file docblock overrides a project's `environment` setting.
96
+
97
+ ```
98
+ app/__tests__/spinning-class-global.test.ts
99
+ app/__tests__/wa-viewer-width.test.ts
100
+ app/graph/__tests__/side-panel-helpers.test.ts
101
+ app/hooks/__tests__/useCopyFeedback.test.ts
102
+ app/hooks/__tests__/useMediaQuery.test.ts
103
+ app/hooks/__tests__/useVoiceRecorder.deviceId.test.ts
104
+ app/hooks/__tests__/useVoiceRecorder.mic-result.test.ts
105
+ app/lib/__tests__/admin-auth-login-remote-auth.test.ts
106
+ app/lib/__tests__/switch-account-scope.test.ts
107
+ app/lib/__tests__/use-admin-auth-claude-connected.test.ts
108
+ app/lib/__tests__/use-admin-auth-heartbeat-remote-auth.test.ts
109
+ app/lib/__tests__/useAdminFetch.test.ts
110
+ app/lib/__tests__/useSubAccountSwitcher.test.ts
111
+ app/public/__tests__/extract-seed-question.test.ts
112
+ app/whatsapp/__tests__/useTranscriptStream-activity.test.ts
113
+ app/whatsapp/__tests__/useTranscriptStream-endpoint.test.ts
114
+ app/whatsapp/__tests__/useTranscriptStream.test.ts
115
+ ```
116
+
117
+ A `.ts` test that needs a DOM and lacks the docblock fails loudly on
118
+ `document is not defined`. It does not pass silently.
119
+
120
+ ### 3. A prebuild gate so the waste cannot creep back
121
+
122
+ New `platform/ui/scripts/check-jsdom-budget.mjs`, joining the 24 checks already
123
+ in the `prebuild` script. It counts the files that will boot jsdom, which is
124
+ every `.test.tsx`/`.spec.tsx` plus every `.ts` test declaring the jsdom
125
+ docblock, and fails the build when that count exceeds a ceiling written in the
126
+ script. Adding a jsdom file becomes a deliberate one-line bump with a reason,
127
+ not an accident. The failure message names the files above the ceiling and the
128
+ CPU cost per jsdom file measured here, so a reader knows what the number buys.
129
+
130
+ ### 4. The 141 existing `@vitest-environment node` docblocks stay
131
+
132
+ They remain accurate and are now redundant. Removing them is 141 files of churn
133
+ for no behaviour change.
134
+
135
+ ## What this does not claim
136
+
137
+ Removing the environment waste reduces the contention the named tests race
138
+ against. It is not proven, before measurement, that the ten named tests stop
139
+ failing. If group A's long-press tests still race React Testing Library's
140
+ 1000ms `findBy` default once contention drops, that is a second and separate
141
+ lever, and it gets measured and brought back rather than stacked on top as a
142
+ speculative second fix.
143
+
144
+ ## Verification
145
+
146
+ **Group A and the suite.** Ten consecutive `npm test` runs in `platform/ui` on
147
+ the merged branch, `uptime` captured before and after each run, exit code and
148
+ any `×` lines collected to one file. Success is ten `exit=0` and zero `×`
149
+ matches across the collection, reported alongside the load each ran under. A red
150
+ run with its load recorded is attributable; a red run without is not.
151
+
152
+ **Group B.** Ten runs do not test a one-in-twenty failure rate. Instead measure
153
+ the wall-clock cost of the four group B seed calls under full-suite parallel
154
+ load, before and after the change, and state the headroom inside the 5000ms
155
+ default. The measurement is the evidence, not the run count.
156
+
157
+ **The jsdom cut itself.** The suite must still report 530 files and 4757 tests.
158
+ A file silently dropping out of the run because a glob missed it would look like
159
+ a pass.
160
+
161
+ ## Out of scope
162
+
163
+ - The `platform/lib` build artifacts that made runs 1 to 3 look flaky. That is
164
+ worktree provisioning friction and gets its own task file.
165
+ - A blanket `testTimeout` bump, excluded by the brief.
166
+ - Deleting, skipping, or loosening the four group B tests, excluded by the
167
+ brief: they are the only place the cross-account 403 ordering is asserted.
168
+ - Removing the 141 redundant node docblocks.
169
+ - Any product change to the whatsapp routes, the data browser, or graph-write.
@@ -2,6 +2,7 @@
2
2
  name: admin
3
3
  surface: platform
4
4
  description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), admin-hostname (the admin-host twin of public-hostname — resolves the host that serves /calendar and the rest of the admin shell behind sign-in from the same ingress + alias + operator files, so agents point the operator at the admin /calendar URL instead of the public host that 404s it), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, the multi-account managed-service lifecycle tools (account_list, account_create, account_delete, account_purge, account_channel_routing, account_channel_routing_set — one Rubytech operator hosting N client accounts on one house install; create/delete/purge are operator-only and purge is two-step with a typed confirmation; account_create requires the client business owner's real name (ownerName), refusing a blank or the placeholder "Owner" and seeding it onto the sub-account's owner AdminUser and owner Person, and requires the owner's IANA timezone (timezone) — refusing a blank or a non-IANA value — seeding it onto the owner UserProfile marked owner-set so scheduling reads the owner's real zone from the start rather than the box's server default, while a managing house admin (its userId) is optional — when supplied it is validated as a live house admin and recorded as the sub-account's managingAdminUserId, and when omitted the sub-account is unmanaged and its passive-intake tasks route to the install's primary admin; account_reassign_admin repoints a client sub-account's managing admin to another live house admin (the repair path for a dangling managingAdminUserId, or to record one on an unmanaged account); account_roster is the canonical per-account identity view — one row per sub-account giving business name, role, the managing house admin resolved to a name with status (assigned/absent/dangling), the business owner from the graph with status (named/nameless), and the WhatsApp account-manager binding if any, each field carrying what it means and does not (managing admin is the responsible house team member not the owner; account manager is WhatsApp routing only with no admin rights), so the agent never stitches admin-list + account.json + whatsapp-config; account_set_owner_name names (or corrects) a client sub-account's business owner by writing the owned Person — the canonical name source — refusing a blank or the placeholder "Owner", the guarded repair for a nameless owner (the standing identity-audit op=nameless-owned-person detector); account_channel_routing_set repoints the install's single WhatsApp/Telegram inbound stream to a different sub-account), admin-add (refuses on a client sub-account, where admin access is already install-wide so a per-operator seat grants nothing — the sub-account's one identity, its business owner, is set at account creation; the house account, where operators hold PIN logins and admins[] rows, is unchanged), admin-remove (refuses to remove a house admin who still manages one or more client sub-accounts, naming them so the operator reassigns each with account_reassign_admin first — never silently orphans them), admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, cpu-triage-start and cpu-triage-read (the two-call device CPU diagnostic — start spawns a detached sampling run across at least one full 120s reconcile cycle and returns a run handle immediately, read returns running/complete/failed with the per-core distribution; no admin tool blocks through a sampling window, and no spot reading is obtainable), plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), skill-search (free-text intent → skill resolver: ranks every plugin skill's SKILL.md name+description against a query and returns the top owners with their canonical skills/<slug>/ path, so intent→skill mapping survives the available-skills menu leaving context after a compaction), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the SiteDesk platform."
5
+ account-owned-files: [{"file": "webchat-channel-bindings.json", "description": "Routes this account's inbound admin webchat messages. Written by the session manager's webchat-channel-store."}, {"file": "wa-channel-bindings.json", "description": "Routes this account's inbound WhatsApp messages. Written by the session manager's wa-channel-store, which runs on every brand whether or not the whatsapp plugin ships."}, {"file": "telegram-channel-bindings.json", "description": "Routes this account's inbound Telegram messages. Written by the session manager's telegram-channel-store, which runs on every brand whether or not the telegram plugin ships."}, {"file": "canonical-webchat-session.json", "description": "Pins the account's canonical webchat session. Written by the admin UI server."}, {"file": "session-titles.json", "description": "Operator renames of sessions. Written by the session manager's title stores."}, {"file": "agents-disabled.json", "description": "Names the agents the operator switched off. Written by the admin agents route; provision reads it to re-apply the disables and never creates it."}]
5
6
  tools:
6
7
  - name: system-status
7
8
  publicAllowlist: false
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:b15d0e491d957f75523ecf7056f8004bafcdf448767c0c3dd64b40a73926b58b
4
+ content-hash: sha256:86e768fb27dbcb38fc304ace12dbca3462943fcb58435cea6925ebf2469a0647
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -3189,8 +3189,8 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
3189
3189
 
3190
3190
  | Route | Behaviour |
3191
3191
  |-------|-----------|
3192
- | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `worstTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3193
- | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, worstTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3192
+ | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3193
+ | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3194
3194
  | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
3195
3195
  | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
3196
3196
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
@@ -3220,10 +3220,15 @@ Agents page survived undiagnosed on a multi-account install.
3220
3220
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
3221
3221
  graph-cleanup throw).
3222
3222
  - `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
3223
- risk=<green|amber|red> worstTool=<tool> tools=<n> unresolved=<comma-list|none>
3223
+ risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
3224
3224
  disabled=<bool>` — one line per classified agent per listing, so it fires on
3225
- every page load. `worstTool` makes a wrong class diagnosable without
3226
- re-deriving it. **A non-empty `unresolved=` is the one to act on**: the agent
3225
+ every page load. `riskiestTool` makes a wrong class diagnosable without
3226
+ re-deriving it. Its value is the FULL canonical tool name, while the card
3227
+ renders only the segment after the plugin prefix. One load
3228
+ showing `riskiest tool: memory-update` on the card and
3229
+ `riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
3230
+ confirmation that both derive from one value.
3231
+ **A non-empty `unresolved=` is the one to act on**: the agent
3227
3232
  is forced red, and the cause is that the built-in table or the registry
3228
3233
  mapping has drifted, not that the agent changed.
3229
3234
  - `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
@@ -5446,7 +5451,7 @@ If the initial Cloudflare login fails during setup, SiteDesk will fall back to a
5446
5451
 
5447
5452
  `maxy-edge.service` (always-on front door) classifies upstream errors and serves a brand-aware response. There are two distinct user-visible shapes; the right one depends on what failed.
5448
5453
 
5449
- **Branded holding page (brand logo + "Starting") for ~10 s during an upgrade — this is expected and self-healing.** The edge process binds the public port immediately, but `maxy.service` (the upstream UI) takes ~10 s after restart to apply the neo4j schema and mount its 11 routes. Any browser navigation that lands during that window gets a self-contained HTML holding page that polls `/api/health` and reloads automatically once the upstream binds. The page renders the brand logo (inlined as a base64 data URI at edge boot from `<install>/server/public/brand/<assets.logo>`) and the brand display/body fonts (loaded from fonts.googleapis.com) — both paths bypass the unavailable upstream so the page never makes a same-origin asset fetch. When `brand.logoContainsName` is true the logo replaces the productName text; otherwise the page falls back to "SiteDesk is starting". No operator action required. The diagnostic line in `~/.maxy/logs/edge.log` is `[edge] upstream http error path=… err=connect ECONNREFUSED 127.0.0.1:<UPSTREAM_PORT> err-class=econnrefused-coldstart upstream=…` and disappears as soon as upstream binds. Boot-time confirmation that the logo resolved: `[edge] brand=<name> holding-logo=inlined assets-dir=<path>` — `holding-logo=missing` means the logo file wasn't found at `assets-dir`, the page degrades to text-only.
5454
+ **Branded holding page (brand logo + "Starting") for ~10 s during an upgrade — this is expected and self-healing.** The edge process binds the public port immediately, but `maxy.service` (the upstream UI) takes ~10 s after restart to apply the neo4j schema and mount its 11 routes. Any browser navigation that lands during that window gets a self-contained HTML holding page that polls `/api/health` and reloads automatically once the upstream binds. The page renders the brand logo (inlined as a base64 data URI at edge boot from `<install>/server/public/brand/<assets.logo>`) and the brand display/body fonts (loaded from fonts.googleapis.com) — both paths bypass the unavailable upstream so the page never makes a same-origin asset fetch. When `brand.logoContainsName` is true the logo replaces the productName text; otherwise the page falls back to "SiteDesk is starting". No operator action required. The diagnostic line in `~/.maxy/logs/edge.log` is `[edge] ts=<ISO-8601> upstream http error path=… err=connect ECONNREFUSED 127.0.0.1:<UPSTREAM_PORT> err-class=econnrefused-coldstart upstream=…` and disappears as soon as upstream binds. `ts=` is what lets a run of these be read as an outage with a start, an end and a duration, and cross-read against `journalctl --user -u <brand>-code.service`; `path=` carries the endpoint only, never the query string, because this line fires on paths whose query can hold a download signature, an OAuth code, or a customer phone number. Boot-time confirmation that the logo resolved: `[edge] brand=<name> holding-logo=inlined assets-dir=<path>` — `holding-logo=missing` means the logo file wasn't found at `assets-dir`, the page degrades to text-only.
5450
5455
 
5451
5456
  **Branded plain-text 502 ("Bad Gateway (SiteDesk unavailable)") — real upstream failure, not cold-start.** Any error class other than `ECONNREFUSED` (timeouts, resets, host-unreachable) returns the existing 502 path. The diagnostic line carries `err-class=other`. Read the log with `tail -200 ~/.maxy/logs/edge.log | rg 'err-class=other'` and check `~/.maxy/logs/server.log` for upstream stack traces — the upstream itself is the source.
5452
5457
 
@@ -9,6 +9,18 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-26 (0.1.510)
13
+
14
+ - The admin screens load faster: page assets are now sent pre-compressed, and the sessions list reads from disk without holding up everything else on the server.
15
+ - Fixed several admin screens that could answer for the wrong account when an install has more than one, including the calendar booking link, the logs search, and the chat settings.
16
+ - When an account folder cannot be found, the error now says which condition it actually hit instead of blaming a missing file.
17
+
18
+ ## 2026-07-26 (0.1.509)
19
+
20
+ - The Agents page now names the tool that set an agent's risk level, and shows it by its short name instead of the full internal path.
21
+ - Publishing a site twice at the same time can no longer delete the live copy; the later publish now stands down instead of clearing the tree out from under the first.
22
+ - A slow dashboard now records where the time actually went, so a sluggish load can be traced rather than guessed at.
23
+
12
24
  ## 2026-07-25 (0.1.508)
13
25
 
14
26
  - SiteDesk's screens now use a single, cleaner typeface throughout, and the old olive-green colour is gone.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: business-assistant
3
3
  description: "Business assistant for small businesses. Handles customer enquiries, appointment booking, quote formatting, invoice generation, and daily briefings. Responds instantly, triages by urgency, and never lets a message fall through the cracks."
4
+ account-owned-dirs: [{"dir": "e-sign", "description": "One folder per signed document, each holding that document's render-once base.pdf, its base.sha256, and the per-signer stamped copies. Written by the e-sign skill; the layout is skill-owned and recreated on the next document."}]
4
5
  metadata: {"platform":{"always":true,"embed":["admin"],"pluginKey":"business-assistant","optional":true,"recommended":true}}
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: cloudflare
3
3
  description: Cloudflare operations — tunnel setup/reset, DNS, Pages hosting, D1 data capture, and dashboard guidance. Zero agent-facing MCP tools; every operation is the agent invoking `cloudflared` or `wrangler` directly via Bash, calling the Cloudflare API with a reused per-scope narrow token, or quoting a dashboard click-path the operator performs themselves.
4
+ account-owned-files: [{"file": "data-portal.json", "description": "The account's data-portal folder index, written by bin/portal-index-push.mjs on every push."}]
4
5
  tools: []
5
6
  mcp-manifest: skip
6
7
  ---
@@ -122,7 +122,8 @@ export async function walkExposed(accountDir, exposed) {
122
122
  * client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
123
123
  * log: (line: string) => void, nowIso: string,
124
124
  * exposeFolders?: string[], previousExposed?: string[] }} opts
125
- * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
125
+ * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean,
126
+ * generation?: number, superseded?: boolean }>}
126
127
  */
127
128
  export async function pushAccount(opts) {
128
129
  const { accountDir, accountId, client, log, nowIso } = opts
@@ -196,29 +197,26 @@ export async function pushAccount(opts) {
196
197
  [accountId],
197
198
  )
198
199
  const current = Number(state[0]?.currentGeneration ?? 0)
199
- const next = current + 1
200
-
201
- // Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
202
- // INSERT leaves rows at `next` while the pointer stays at `current`. Every
203
- // later run recomputes the same `next`, and its first colliding INSERT then
204
- // hits UNIQUE(accountId, relPath, generation), so the index freezes at the
205
- // stale generation until an operator hand-clears the orphans. Clearing `next`
206
- // before staging lets a partial stage self-heal on the next cycle. Guarded on
207
- // a non-zero count so the DELETE never precedes the flip on a clean cycle and
208
- // the log line always means a recovered wedge. The count comes from a SELECT
209
- // because the D1 client returns rows, not the affected-row meta.
210
- const orphan = await client.query(
211
- 'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
212
- [accountId, next],
200
+
201
+ // Claim a generation above EVERY generation this account already holds, not
202
+ // merely above the pointer (Task 1926). Two pushes of one account are
203
+ // reachable: the admin server's 60s loop and a hand-run `--account` are
204
+ // separate processes, so no in-process lock can see both. `current + 1` let
205
+ // the second writer claim the generation the first had just flipped onto and
206
+ // then delete it. A peer that has inserted even one row is visible in this
207
+ // MAX, so the two runs claim different generations.
208
+ //
209
+ // This also retires the Task 1923 pre-stage clear. That clear existed because
210
+ // a later run recomputed the SAME generation as a dead partial stage and
211
+ // collided on UNIQUE(accountId, relPath, generation); claiming above the
212
+ // orphans leaves nothing to collide with, and the post-flip sweep collects
213
+ // them. The clear was itself the DELETE a concurrent peer could aim at the
214
+ // live tree, so removing it removes the hazard rather than narrowing it.
215
+ const staged = await client.query(
216
+ 'SELECT MAX(generation) AS g FROM directory WHERE accountId = ?',
217
+ [accountId],
213
218
  )
214
- const orphanCount = Number(orphan[0]?.n ?? 0)
215
- if (orphanCount > 0) {
216
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
217
- accountId,
218
- next,
219
- ])
220
- log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
221
- }
219
+ const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1
222
220
 
223
221
  for (const row of rows) {
224
222
  await client.query(
@@ -231,18 +229,56 @@ export async function pushAccount(opts) {
231
229
 
232
230
  // THE atomic point. Upsert rather than insert-or-update, so an account
233
231
  // publishing for the first time and one republishing take the same path.
232
+ //
233
+ // The WHERE moves the pointer FORWARD ONLY (Task 1926). Without it a writer
234
+ // that was overtaken while staging drags every reader back onto a generation
235
+ // the winner has already swept, which is an empty tree. SQLite has carried a
236
+ // WHERE on upsert's DO UPDATE since 3.24, and D1 is SQLite.
234
237
  await client.query(
235
238
  'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
236
- 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration',
239
+ 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration ' +
240
+ 'WHERE excluded.currentGeneration > directory_state.currentGeneration',
237
241
  [accountId, next],
238
242
  )
243
+
244
+ // Read back rather than assume: the D1 client returns rows, not affected-row
245
+ // meta, so a suppressed flip is otherwise indistinguishable from an applied
246
+ // one.
247
+ const flipped = await client.query(
248
+ 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
249
+ [accountId],
250
+ )
251
+ const pointer = Number(flipped[0]?.currentGeneration ?? 0)
252
+ if (pointer !== next) {
253
+ // A peer published while this run was staging. Its tree is live and
254
+ // complete, ours is invisible, and its rows are collected by the peer's
255
+ // next sweep. Sweeping or verifying from here would act on, and measure,
256
+ // the PEER's tree — reporting a failure where nothing failed. Logged rather
257
+ // than silent: a suppressed run is otherwise indistinguishable from one
258
+ // that never started.
259
+ log(
260
+ `${TAG} op=stage-contended account=${accountId} generation=${next} ` +
261
+ `pointer=${pointer} action=superseded`,
262
+ )
263
+ return {
264
+ exposed: r.exposed,
265
+ rows: rows.length,
266
+ schemaPresent: true,
267
+ generation: next,
268
+ superseded: true,
269
+ }
270
+ }
239
271
  log(`${TAG} op=flip account=${accountId} generation=${next}`)
240
272
 
241
273
  // Housekeeping only. A failure here leaves stale rows nothing reads, which is
242
274
  // waste rather than incorrectness — so it must not fail the publish that has
243
275
  // already succeeded.
244
276
  try {
245
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation != ?', [
277
+ // Strictly older only (Task 1926): `!= next` would delete a peer's HIGHER
278
+ // in-flight stage. Rows above `next` left by an abandoned stage still
279
+ // converge, because the next cycle claims a generation above them and
280
+ // sweeps them once it flips.
281
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation < ?', [
246
282
  accountId,
247
283
  next,
248
284
  ])
@@ -291,9 +327,13 @@ export function readState(platformRoot) {
291
327
  }
292
328
 
293
329
  /**
294
- * Written ONLY after op=verify, so a stale entry means the push stopped
295
- * succeeding rather than merely stopped logging. Read-modify-write per account
296
- * so one account's failure does not erase another's record.
330
+ * Written after a publish that reached the flip, so a stale entry means the
331
+ * push stopped succeeding rather than merely stopped logging. That includes a
332
+ * run superseded by a concurrent peer (Task 1926), which emits
333
+ * `op=stage-contended` instead of `op=verify`: the index WAS refreshed inside
334
+ * this cycle, by the peer, and recording otherwise would report a healthy
335
+ * account as never-ran. Read-modify-write per account so one account's failure
336
+ * does not erase another's record.
297
337
  * @param {string} platformRoot @param {string} accountId
298
338
  * @param {{lastPushMs: number, rows: number, exposed: string[]}} entry
299
339
  */