@reddoorla/maintenance 0.85.2 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +8 -0
  2. package/dist/airtable-ZBUPQJSC.js +9 -0
  3. package/dist/{announce-GIAYN67S.js → announce-I2Z4JN3I.js} +58 -30
  4. package/dist/announce-I2Z4JN3I.js.map +1 -0
  5. package/dist/attachments-NLZIJ5NU.js +9 -0
  6. package/dist/{bump-deps-SWJB6H5V.js → bump-deps-CGVELRLK.js} +9 -9
  7. package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
  8. package/dist/chunk-2NQUCS7O.js +243 -0
  9. package/dist/chunk-2NQUCS7O.js.map +1 -0
  10. package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
  11. package/dist/{chunk-DTTIFYAM.js → chunk-3JQ2FO3D.js} +44 -405
  12. package/dist/chunk-3JQ2FO3D.js.map +1 -0
  13. package/dist/chunk-3SF7D4DY.js +359 -0
  14. package/dist/chunk-3SF7D4DY.js.map +1 -0
  15. package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
  16. package/dist/chunk-5UINJNAJ.js.map +1 -0
  17. package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
  18. package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
  19. package/dist/{chunk-PBWDDDQH.js → chunk-7BZUOZPU.js} +2 -2
  20. package/dist/{chunk-577HCGZO.js → chunk-7NYN2P6I.js} +44 -26
  21. package/dist/chunk-7NYN2P6I.js.map +1 -0
  22. package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
  23. package/dist/{chunk-Q276MZ7E.js → chunk-7UAYMAKR.js} +2 -2
  24. package/dist/{chunk-MGR2H7F4.js → chunk-BBUSGEES.js} +58 -45
  25. package/dist/chunk-BBUSGEES.js.map +1 -0
  26. package/dist/chunk-BNM5POSJ.js +284 -0
  27. package/dist/chunk-BNM5POSJ.js.map +1 -0
  28. package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
  29. package/dist/{chunk-NWB4O5EY.js → chunk-C6CLRKPV.js} +5 -5
  30. package/dist/chunk-C6CLRKPV.js.map +1 -0
  31. package/dist/{chunk-7J53SUT6.js → chunk-CROKUHJB.js} +4 -4
  32. package/dist/chunk-DEWJMMG3.js +274 -0
  33. package/dist/chunk-DEWJMMG3.js.map +1 -0
  34. package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
  35. package/dist/chunk-DJZWYIPP.js.map +1 -0
  36. package/dist/chunk-EVBCK2KC.js +68 -0
  37. package/dist/chunk-EVBCK2KC.js.map +1 -0
  38. package/dist/chunk-F3JWOPTG.js +202 -0
  39. package/dist/chunk-F3JWOPTG.js.map +1 -0
  40. package/dist/{chunk-GZNBCHAZ.js → chunk-HEWSRXYV.js} +4 -4
  41. package/dist/chunk-HHMMUR4P.js +335 -0
  42. package/dist/chunk-HHMMUR4P.js.map +1 -0
  43. package/dist/chunk-HSI6Q7MI.js +1238 -0
  44. package/dist/chunk-HSI6Q7MI.js.map +1 -0
  45. package/dist/{chunk-MPBXOJ3N.js → chunk-IY6256GK.js} +33 -2
  46. package/dist/chunk-IY6256GK.js.map +1 -0
  47. package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
  48. package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
  49. package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
  50. package/dist/chunk-LBYOLBW7.js.map +1 -0
  51. package/dist/{chunk-6QRLRT3P.js → chunk-MT2JINSS.js} +16 -265
  52. package/dist/chunk-MT2JINSS.js.map +1 -0
  53. package/dist/{chunk-TP5M2NQB.js → chunk-NCMZMLY7.js} +3 -3
  54. package/dist/{chunk-COZPL62X.js → chunk-NDWYJLRF.js} +24 -139
  55. package/dist/chunk-NDWYJLRF.js.map +1 -0
  56. package/dist/{chunk-3YXDSCM6.js → chunk-NXNPDCF3.js} +29 -11
  57. package/dist/chunk-NXNPDCF3.js.map +1 -0
  58. package/dist/chunk-NYUYNYQL.js +48 -0
  59. package/dist/chunk-NYUYNYQL.js.map +1 -0
  60. package/dist/chunk-P6AORG32.js +415 -0
  61. package/dist/chunk-P6AORG32.js.map +1 -0
  62. package/dist/chunk-PTDMVGKL.js +31 -0
  63. package/dist/chunk-PTDMVGKL.js.map +1 -0
  64. package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
  65. package/dist/chunk-PZR4EBPY.js.map +1 -0
  66. package/dist/{chunk-LTB6KVJW.js → chunk-SDDPUAWH.js} +13 -11
  67. package/dist/{chunk-LTB6KVJW.js.map → chunk-SDDPUAWH.js.map} +1 -1
  68. package/dist/{chunk-WYYQK4D3.js → chunk-TD2NNXLZ.js} +13 -13
  69. package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
  70. package/dist/chunk-UDRHAQT7.js +172 -0
  71. package/dist/chunk-UDRHAQT7.js.map +1 -0
  72. package/dist/chunk-VAD43MOR.js +72 -0
  73. package/dist/chunk-VAD43MOR.js.map +1 -0
  74. package/dist/chunk-VSBFM7DS.js +229 -0
  75. package/dist/chunk-VSBFM7DS.js.map +1 -0
  76. package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
  77. package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
  78. package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
  79. package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
  80. package/dist/{chunk-62VF32IC.js → chunk-YFAXOD7Z.js} +10 -6
  81. package/dist/chunk-YFAXOD7Z.js.map +1 -0
  82. package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
  83. package/dist/chunk-ZBDZTJUO.js +129 -0
  84. package/dist/chunk-ZBDZTJUO.js.map +1 -0
  85. package/dist/chunk-ZGBEIWSC.js +146 -0
  86. package/dist/chunk-ZGBEIWSC.js.map +1 -0
  87. package/dist/cli/bin.js +51 -28
  88. package/dist/cli/bin.js.map +1 -1
  89. package/dist/cli/commands/audit.d.ts +30 -1
  90. package/dist/cli/commands/audit.js +69 -33
  91. package/dist/cli/commands/audit.js.map +1 -1
  92. package/dist/client-AUE3XUPO.js +11 -0
  93. package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-QFJOEP5V.js} +9 -9
  94. package/dist/db-UXNIZ2RG.js +217 -0
  95. package/dist/db-UXNIZ2RG.js.map +1 -0
  96. package/dist/{digest-UVHYZUYE.js → digest-FW6QMZZJ.js} +26 -22
  97. package/dist/{digest-UVHYZUYE.js.map → digest-FW6QMZZJ.js.map} +1 -1
  98. package/dist/dump-P6EBSIOH.js +68 -0
  99. package/dist/dump-P6EBSIOH.js.map +1 -0
  100. package/dist/email-KJXASVH5.js +195 -0
  101. package/dist/email-KJXASVH5.js.map +1 -0
  102. package/dist/{ensure-site-S25MDDHW.js → ensure-site-4DCEBAPM.js} +25 -16
  103. package/dist/ensure-site-4DCEBAPM.js.map +1 -0
  104. package/dist/fleet-events-cF2Om88w.d.ts +24 -0
  105. package/dist/fleet-state-7TJ2GYT2.js +39 -0
  106. package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-2EAWCWBW.js} +42 -133
  107. package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
  108. package/dist/{github-signals-G4ILZAJ6.js → github-signals-E2WPYTBK.js} +42 -21
  109. package/dist/github-signals-E2WPYTBK.js.map +1 -0
  110. package/dist/{header-image-T7XD37JL.js → header-image-6IC53W27.js} +36 -13
  111. package/dist/header-image-6IC53W27.js.map +1 -0
  112. package/dist/header-images-SQBCE5OD.js +88 -0
  113. package/dist/header-images-SQBCE5OD.js.map +1 -0
  114. package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-RFXL2F4A.js} +9 -9
  115. package/dist/health-mirror-L5JBEJVA.js +41 -0
  116. package/dist/health-mirror-L5JBEJVA.js.map +1 -0
  117. package/dist/import-airtable-OHVY344G.js +37 -0
  118. package/dist/index.d.ts +324 -21
  119. package/dist/index.js +215 -76
  120. package/dist/index.js.map +1 -1
  121. package/dist/{init-JN26NTWF.js → init-XRAQZYID.js} +21 -18
  122. package/dist/{init-JN26NTWF.js.map → init-XRAQZYID.js.map} +1 -1
  123. package/dist/{launch-JA7Z4CIM.js → launch-IX4Y5JJC.js} +65 -30
  124. package/dist/launch-IX4Y5JJC.js.map +1 -0
  125. package/dist/lighthouse-VHI77PFB.js +11 -0
  126. package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-3GUWHETW.js} +3 -3
  127. package/dist/mailchimp-XYCTEEJY.js +72 -0
  128. package/dist/mailchimp-XYCTEEJY.js.map +1 -0
  129. package/dist/migrate-GQH237A6.js +7 -0
  130. package/dist/migrate-GQH237A6.js.map +1 -0
  131. package/dist/notify-C645PGD5.js +22 -0
  132. package/dist/notify-C645PGD5.js.map +1 -0
  133. package/dist/{onboard-53CITIM7.js → onboard-O4IAHJMO.js} +9 -9
  134. package/dist/orchestrate-YLWQ6KK2.js +31 -0
  135. package/dist/orchestrate-YLWQ6KK2.js.map +1 -0
  136. package/dist/parity-56KA2DGR.js +12 -0
  137. package/dist/parity-56KA2DGR.js.map +1 -0
  138. package/dist/pipeline-3AANZFCR.js +12 -0
  139. package/dist/pipeline-3AANZFCR.js.map +1 -0
  140. package/dist/{preflight-3XPDUHFJ.js → preflight-TNORKGCX.js} +17 -13
  141. package/dist/{preflight-3XPDUHFJ.js.map → preflight-TNORKGCX.js.map} +1 -1
  142. package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-G2AK53VL.js} +12 -12
  143. package/dist/{prismic-models-PVES6R5V.js → prismic-models-O6Z3OK3A.js} +18 -13
  144. package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
  145. package/dist/prospect/types.d.ts +267 -0
  146. package/dist/prospect/types.js +1 -0
  147. package/dist/prospect/types.js.map +1 -0
  148. package/dist/prospect-audit-KSXUNQ5J.js +174 -0
  149. package/dist/prospect-audit-KSXUNQ5J.js.map +1 -0
  150. package/dist/prospect-audits-3PMIO73D.js +19 -0
  151. package/dist/prospect-audits-3PMIO73D.js.map +1 -0
  152. package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
  153. package/dist/recipes/sync-configs.js +3 -3
  154. package/dist/render-IFOGFB5T.js +10 -0
  155. package/dist/render-IFOGFB5T.js.map +1 -0
  156. package/dist/render-from-row-BWSOIYDS.js +17 -0
  157. package/dist/render-from-row-BWSOIYDS.js.map +1 -0
  158. package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-6GQWTRKX.js} +12 -12
  159. package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
  160. package/dist/replay-C2LXMJZP.js +66 -0
  161. package/dist/replay-C2LXMJZP.js.map +1 -0
  162. package/dist/{report-6CKSWJNG.js → report-R3E3Y7HR.js} +100 -32
  163. package/dist/report-R3E3Y7HR.js.map +1 -0
  164. package/dist/report-mirror-4MNORYQ4.js +49 -0
  165. package/dist/report-mirror-4MNORYQ4.js.map +1 -0
  166. package/dist/reports/header-image/assets/headline-announcement.png +0 -0
  167. package/dist/reports/header-image/assets/headline-launch.png +0 -0
  168. package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
  169. package/dist/reports/header-image/assets/headline-testing.png +0 -0
  170. package/dist/reports/header-image/assets/plate-clean.png +0 -0
  171. package/dist/rerender-3FM6DNRH.js +32 -0
  172. package/dist/rerender-3FM6DNRH.js.map +1 -0
  173. package/dist/{self-updating-PPQVV6UA.js → self-updating-UCIWESAX.js} +9 -9
  174. package/dist/{selftest-CQ6NOX52.js → selftest-VGHWPN72.js} +31 -24
  175. package/dist/selftest-VGHWPN72.js.map +1 -0
  176. package/dist/site-mirror-NIREX5ME.js +52 -0
  177. package/dist/site-mirror-NIREX5ME.js.map +1 -0
  178. package/dist/smoke-B2GQOSIZ.js +20 -0
  179. package/dist/smoke-B2GQOSIZ.js.map +1 -0
  180. package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-U3UKD3NS.js} +9 -9
  181. package/dist/{submissions-GJ6ITBRH.js → submissions-IQEJX6FK.js} +3 -3
  182. package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-GOKXTBHW.js} +9 -9
  183. package/dist/sync-CTUVXMCK.js +44 -0
  184. package/dist/sync-CTUVXMCK.js.map +1 -0
  185. package/dist/{sync-configs-CJXUX6MD.js → sync-configs-SPTLFJCC.js} +9 -9
  186. package/dist/{upgrade-WLX27UPC.js → upgrade-W6ZR3FJH.js} +9 -9
  187. package/dist/webhook-VOGOW3W6.js +38 -0
  188. package/dist/webhook-VOGOW3W6.js.map +1 -0
  189. package/dist/{websites-SADZR4XQ.js → websites-D6WYFAES.js} +6 -2
  190. package/dist/websites-D6WYFAES.js.map +1 -0
  191. package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
  192. package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
  193. package/package.json +9 -2
  194. package/dist/airtable-HRKSA7V4.js +0 -9
  195. package/dist/announce-GIAYN67S.js.map +0 -1
  196. package/dist/chunk-37S6L6G4.js.map +0 -1
  197. package/dist/chunk-3YXDSCM6.js.map +0 -1
  198. package/dist/chunk-4DHQCN5H.js.map +0 -1
  199. package/dist/chunk-4SOCPK7A.js +0 -108
  200. package/dist/chunk-4SOCPK7A.js.map +0 -1
  201. package/dist/chunk-577HCGZO.js.map +0 -1
  202. package/dist/chunk-62VF32IC.js.map +0 -1
  203. package/dist/chunk-6QRLRT3P.js.map +0 -1
  204. package/dist/chunk-COZPL62X.js.map +0 -1
  205. package/dist/chunk-D4FVAIEJ.js.map +0 -1
  206. package/dist/chunk-DTTIFYAM.js.map +0 -1
  207. package/dist/chunk-MGR2H7F4.js.map +0 -1
  208. package/dist/chunk-MPBXOJ3N.js.map +0 -1
  209. package/dist/chunk-NWB4O5EY.js.map +0 -1
  210. package/dist/chunk-SEEA6RXF.js.map +0 -1
  211. package/dist/chunk-U5SZ4FOP.js +0 -26
  212. package/dist/chunk-U5SZ4FOP.js.map +0 -1
  213. package/dist/client-X5WUJWDE.js +0 -11
  214. package/dist/db-JSIZYT23.js +0 -23
  215. package/dist/db-JSIZYT23.js.map +0 -1
  216. package/dist/ensure-site-S25MDDHW.js.map +0 -1
  217. package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
  218. package/dist/github-signals-G4ILZAJ6.js.map +0 -1
  219. package/dist/header-image-T7XD37JL.js.map +0 -1
  220. package/dist/launch-JA7Z4CIM.js.map +0 -1
  221. package/dist/migrate-LJUFGXQZ.js +0 -7
  222. package/dist/orchestrate-KDNXZQX7.js +0 -27
  223. package/dist/prismic-models-PVES6R5V.js.map +0 -1
  224. package/dist/report-6CKSWJNG.js.map +0 -1
  225. package/dist/reports/header-image/assets/plate.png +0 -0
  226. package/dist/selftest-CQ6NOX52.js.map +0 -1
  227. /package/dist/{airtable-HRKSA7V4.js.map → airtable-ZBUPQJSC.js.map} +0 -0
  228. /package/dist/{client-X5WUJWDE.js.map → attachments-NLZIJ5NU.js.map} +0 -0
  229. /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-CGVELRLK.js.map} +0 -0
  230. /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
  231. /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
  232. /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
  233. /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
  234. /package/dist/{chunk-PBWDDDQH.js.map → chunk-7BZUOZPU.js.map} +0 -0
  235. /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
  236. /package/dist/{chunk-Q276MZ7E.js.map → chunk-7UAYMAKR.js.map} +0 -0
  237. /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
  238. /package/dist/{chunk-7J53SUT6.js.map → chunk-CROKUHJB.js.map} +0 -0
  239. /package/dist/{chunk-GZNBCHAZ.js.map → chunk-HEWSRXYV.js.map} +0 -0
  240. /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
  241. /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
  242. /package/dist/{chunk-TP5M2NQB.js.map → chunk-NCMZMLY7.js.map} +0 -0
  243. /package/dist/{chunk-WYYQK4D3.js.map → chunk-TD2NNXLZ.js.map} +0 -0
  244. /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
  245. /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
  246. /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
  247. /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
  248. /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
  249. /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
  250. /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → client-AUE3XUPO.js.map} +0 -0
  251. /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
  252. /package/dist/{migrate-LJUFGXQZ.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
  253. /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
  254. /package/dist/{orchestrate-KDNXZQX7.js.map → import-airtable-OHVY344G.js.map} +0 -0
  255. /package/dist/{websites-SADZR4XQ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
  256. /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
  257. /package/dist/{onboard-53CITIM7.js.map → onboard-O4IAHJMO.js.map} +0 -0
  258. /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
  259. /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
  260. /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-UCIWESAX.js.map} +0 -0
  261. /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
  262. /package/dist/{submissions-GJ6ITBRH.js.map → submissions-IQEJX6FK.js.map} +0 -0
  263. /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
  264. /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
  265. /package/dist/{upgrade-WLX27UPC.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/github/renovate-dispatch.ts","../src/cli/commands/renovate-dispatch.ts"],"sourcesContent":["import {\n allActionableVulnsTransitive,\n isDashboardVisible,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { isRenovatePR } from \"../alerts/renovate.js\";\nimport type { PullRequestSummary } from \"./gh.js\";\n\n/**\n * On-demand Renovate trigger for fleet sites.\n *\n * The nightly security sweep (`fleet-security.yml`) writes each active site's\n * vulnerability counts to Airtable. This module turns that into action: for the\n * sites the sweep just flagged with *actionable* (critical or high) vulns, it\n * dispatches that repo's `renovate.yml` `workflow_dispatch` so Renovate runs off\n * its twice-daily schedule. Renovate's OSV vulnerability alerts bypass the\n * schedule, so the remediation PR opens immediately — and because GitHub's\n * platform auto-merge is disabled fleet-wide, Renovate merges it from inside that\n * same run (or a later scheduled one) per the shared preset, which is what keeps\n * the preset's \"majors are never auto-merged\" rule authoritative. Moderate/low\n * vulns are left to the normal schedule — not urgent enough to dispatch + churn\n * CI nightly.\n */\n\n/** The per-repo workflow file the fleet uses for its self-hosted Renovate run. */\nexport const RENOVATE_WORKFLOW_FILE = \"renovate.yml\";\n\n/**\n * Does the repo have a HEALTHY open Renovate PR — one that is NOT conflicting?\n *\n * A healthy PR means remediation is genuinely in flight (moving toward merge), so\n * re-dispatching would just churn CI — skip it. A CONFLICTING Renovate PR is STUCK\n * (its branch fell behind the base after another PR merged the same lockfile), so\n * it does NOT count as healthy: re-dispatching triggers Renovate to rebase it.\n * `UNKNOWN` mergeability (GitHub still computing it) is treated as healthy — don't\n * churn on uncertainty. Reuses {@link isRenovatePR} for the branch-prefix check.\n */\nexport function hasHealthyRenovatePr(prs: PullRequestSummary[]): boolean {\n return prs.some((pr) => isRenovatePR(pr) && pr.mergeable !== \"CONFLICTING\");\n}\n\n/** A site selected for an on-demand Renovate run, with the reason (vuln counts). */\nexport type RenovateDispatchTarget = {\n repo: string; // owner/repo, from the Websites \"Git repo\" field\n siteName: string;\n critical: number;\n high: number;\n};\n\n/**\n * Pick the active, repo-backed sites whose latest security sweep found a\n * critical or high vulnerability. A `null` vuln count is treated as zero (a site\n * that was never swept, or had its counts cleared, is not dispatched). Inactive\n * sites and sites without a `Git repo` are excluded — there's nothing to kick.\n *\n * Counts are only guaranteed fresh for sites the sweep actually audited (active +\n * URL present); a site whose write-back failed/was skipped this run is selected on\n * its last-known counts. That's safe — a stale-high false dispatch is an idempotent\n * Renovate no-op, and the open-PR skip in `dispatchRenovateAcross` avoids re-firing\n * when remediation is already in flight.\n */\nexport function selectRenovateTargets(sites: WebsiteRow[]): RenovateDispatchTarget[] {\n const targets: RenovateDispatchTarget[] = [];\n for (const s of sites) {\n if (!isDashboardVisible(s)) continue; // active (maintenance / launch period) only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // repo-backed only — the dispatch target is its renovate.yml\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n if (critical + high <= 0) continue; // actionable (critical/high) vulns only\n targets.push({ repo, siteName: s.name, critical, high });\n }\n return targets;\n}\n\n/** Injected GitHub operations — real ones come from `makeGitHub`, fakes from tests. */\nexport type RenovateDispatchDeps = {\n /** True iff the repo has a HEALTHY (non-conflicting) open Renovate PR —\n * remediation in flight, so don't re-dispatch. A conflicting/stuck PR returns\n * false so the dispatch goes through and Renovate rebases it. */\n hasHealthyOpenRenovatePr: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n dispatch: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport type RenovateDispatchResult = {\n dispatched: string[];\n /** Repos skipped because a Renovate PR is already open — no point re-firing. */\n skipped: string[];\n failed: { repo: string; error: string }[];\n};\n\n/**\n * Dispatch `renovate.yml` for each target on its default branch.\n *\n * Skips a repo with a HEALTHY (non-conflicting) open Renovate PR — remediation is\n * in flight, so re-dispatching would just churn CI (keeps a persistent vuln from\n * re-firing every nightly run while its fix PR waits, e.g. a human-gated major\n * bump). A repo whose open Renovate PR is CONFLICTING/stuck is NOT skipped:\n * dispatching triggers Renovate to rebase it. Best-effort and isolated: a repo\n * that lacks `renovate.yml`, or whose\n * dispatch/PR-check is refused (the token's `actions:write` scope), is recorded in\n * `failed` and the rest still run. Never throws — the caller (a follow-up step on\n * the security sweep) must not be able to fail the sweep over a dispatch hiccup.\n */\nexport async function dispatchRenovateAcross(\n targets: RenovateDispatchTarget[],\n deps: RenovateDispatchDeps,\n): Promise<RenovateDispatchResult> {\n const dispatched: string[] = [];\n const skipped: string[] = [];\n const failed: { repo: string; error: string }[] = [];\n for (const t of targets) {\n try {\n if (await deps.hasHealthyOpenRenovatePr(t.repo)) {\n skipped.push(t.repo);\n continue;\n }\n const ref = await deps.defaultBranch(t.repo);\n await deps.dispatch(t.repo, RENOVATE_WORKFLOW_FILE, ref);\n dispatched.push(t.repo);\n } catch (e) {\n failed.push({ repo: t.repo, error: e instanceof Error ? e.message : String(e) });\n }\n }\n return { dispatched, skipped, failed };\n}\n\n/** Machine-readable counts line the workflow greps to annotate partial failures. */\nexport function formatRenovateDispatchSummary(result: RenovateDispatchResult): string {\n return `RENOVATE_DISPATCH_SUMMARY dispatched=${result.dispatched.length} skipped=${result.skipped.length} failed=${result.failed.length}`;\n}\n\n/**\n * Plan the per-site auto-fix-attempt counter writes from a dispatch result. PURE.\n * For each site:\n * - vulns now 0 (or never counted) → reset to 0 (episode resolved)\n * - else dispatched this run → attempts + 1 (a fresh failed-so-far attempt)\n * - else (skipped / failed / untouched) → unchanged\n * The RESET applies fleet-wide — regardless of Status or Git repo — so a site\n * archived or un-repo'd after its episode can't carry a stale counter into its\n * next vuln (Alamo sat at 7 from a long-closed episode). A null vuln count\n * (never-audited) reads as zero, consistent with the existing null-as-zero\n * semantics, so a lingering counter on such a row also clears. The INCREMENT\n * keeps its active + repo-backed filters (only those sites are dispatched).\n * Returns only the rows whose value CHANGES (a steady fleet writes nothing —\n * a 0→0 site emits no write). A skipped repo (healthy Renovate PR in flight) is\n * NOT a failed attempt — a fix is genuinely moving toward merge — so its counter\n * holds. A dispatched site whose critical/high advisories are ALL proven\n * TRANSITIVE also holds: Renovate has no direct-dep bump to open, so the run is a\n * known green no-op waiting on the weekly lockfile window, not a failed fix\n * attempt (Sonder rode 5 such no-ops to a false \"auto-fix failed\", 2026-08-10).\n * Missing/unknown relationship data keeps the increment — never mute on absence.\n */\nexport function computeAutoFixAttemptUpdates(\n sites: WebsiteRow[],\n result: RenovateDispatchResult,\n): { id: string; attempts: number }[] {\n const dispatched = new Set(result.dispatched);\n const updates: { id: string; attempts: number }[] = [];\n for (const s of sites) {\n const current = s.securityAutoFixAttempts ?? 0;\n const vulns = (s.securityVulnsCritical ?? 0) + (s.securityVulnsHigh ?? 0);\n if (vulns === 0) {\n // Episode resolved (or never confirmed): reset REGARDLESS of status/repo —\n // a site archived or un-repo'd after its episode must not carry a stale\n // counter into its next vuln (Alamo sat at 7 from a long-closed episode).\n if (current !== 0) updates.push({ id: s.id, attempts: 0 });\n continue;\n }\n if (!isDashboardVisible(s)) continue; // increments stay active-only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // …and repo-backed-only\n if (!dispatched.has(repo)) continue;\n // Transitive-only episode: the dispatch can't produce a fix PR (no direct dep\n // to bump) — don't count the no-op as a failed attempt.\n if (allActionableVulnsTransitive(s.securityAdvisories)) continue;\n updates.push({ id: s.id, attempts: current + 1 });\n }\n return updates;\n}\n\n/**\n * Apply the planned counter updates with an injected writer, BEST-EFFORT: a writer\n * that throws (e.g. the Airtable field not yet created, or a transient error) is\n * counted in `failed` and never propagates — the security sweep must not fail over\n * counter bookkeeping. Returns the applied/failed tallies for the summary line.\n */\nexport async function applyAutoFixAttemptUpdates(\n updates: { id: string; attempts: number }[],\n write: (id: string, attempts: number) => Promise<void>,\n): Promise<{ written: number; failed: number }> {\n let written = 0;\n let failed = 0;\n for (const u of updates) {\n try {\n await write(u.id, u.attempts);\n written++;\n } catch {\n failed++;\n }\n }\n return { written, failed };\n}\n\n/** Machine-readable counts line the workflow can grep, mirroring RENOVATE_DISPATCH_SUMMARY. */\nexport function formatAutoFixAttemptsSummary(tally: { written: number; failed: number }): string {\n return `AUTO_FIX_ATTEMPTS_SUMMARY written=${tally.written} failed=${tally.failed}`;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport { listWebsites, updateAutoFixAttempts } from \"../../reports/airtable/websites.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n selectRenovateTargets,\n dispatchRenovateAcross,\n formatRenovateDispatchSummary,\n hasHealthyRenovatePr,\n computeAutoFixAttemptUpdates,\n applyAutoFixAttemptUpdates,\n formatAutoFixAttemptsSummary,\n type RenovateDispatchResult,\n} from \"../../github/renovate-dispatch.js\";\n\n/**\n * `renovate-dispatch --fleet`: read the Websites table, pick the active,\n * repo-backed sites the latest security sweep flagged with critical/high vulns,\n * and fire each one's `renovate.yml` `workflow_dispatch` so Renovate's OSV\n * vulnerability alerts open the remediation PR now (instead of waiting for the\n * weekly schedule). Designed to run as a best-effort follow-up step on\n * `fleet-security.yml` AFTER the sweep has written fresh counts to Airtable.\n *\n * Always exits 0: a missing token is a clean skip; a partial dispatch failure\n * (a repo without `renovate.yml`, or a token lacking `actions:write`) is reported\n * in the summary for the workflow to annotate, never failing the security sweep.\n */\nexport async function runRenovateDispatchCommand(opts: {\n fleet?: boolean | undefined;\n /** Inject a pre-opened Airtable base (tests). Defaults to env config. */\n base?: AirtableBase;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet) {\n return { output: \"renovate-dispatch currently supports only --fleet\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"renovate-dispatch skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet dispatch) configured.\",\n code: 0,\n };\n }\n\n const base = opts.base ?? openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const targets = selectRenovateTargets(websites);\n\n const lines: string[] = [];\n let result: RenovateDispatchResult = { dispatched: [], skipped: [], failed: [] };\n if (targets.length === 0) {\n lines.push(\n \"No active repo-backed sites with critical/high vulnerabilities — nothing to dispatch.\",\n );\n } else {\n const gh = makeGitHub({ token });\n result = await dispatchRenovateAcross(targets, {\n hasHealthyOpenRenovatePr: async (repo) =>\n hasHealthyRenovatePr(await gh.openPullRequests(repo)),\n defaultBranch: (repo) => gh.defaultBranch(repo),\n dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref),\n });\n\n const failedRepos = new Set(result.failed.map((f) => f.repo));\n const skippedRepos = new Set(result.skipped);\n for (const t of targets) {\n const status = failedRepos.has(t.repo)\n ? \"FAILED \"\n : skippedRepos.has(t.repo)\n ? \"skipped (open Renovate PR) \"\n : \"dispatched \";\n lines.push(`${status} ${t.repo} (critical=${t.critical} high=${t.high}) — ${t.siteName}`);\n }\n for (const f of result.failed) lines.push(` ↳ ${f.repo}: ${f.error}`);\n }\n\n // Counter bookkeeping runs on EVERY fleet run — the reset-on-clean branch was\n // previously unreachable behind the zero-targets early return, so a fully-clean\n // fleet never cleared stale counters (Alamo stuck at 7, 2026-07). Best-effort:\n // a failed write is tallied, never thrown. Uses the full `websites` list so\n // 0-vuln sites reset.\n const attemptUpdates = computeAutoFixAttemptUpdates(websites, result);\n const attemptTally = await applyAutoFixAttemptUpdates(attemptUpdates, (id, attempts) =>\n updateAutoFixAttempts(base, id, attempts),\n );\n\n lines.push(formatRenovateDispatchSummary(result));\n lines.push(formatAutoFixAttemptsSummary(attemptTally));\n\n return { output: lines.join(\"\\n\"), code: 0 };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/github/renovate-dispatch.ts","../src/cli/commands/renovate-dispatch.ts"],"sourcesContent":["import {\n allActionableVulnsTransitive,\n isDashboardVisible,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { isRenovatePR } from \"../alerts/renovate.js\";\nimport type { PullRequestSummary } from \"./gh.js\";\n\n/**\n * On-demand Renovate trigger for fleet sites.\n *\n * The nightly security sweep (`fleet-security.yml`) writes each active site's\n * vulnerability counts to Airtable. This module turns that into action: for the\n * sites the sweep just flagged with *actionable* (critical or high) vulns, it\n * dispatches that repo's `renovate.yml` `workflow_dispatch` so Renovate runs off\n * its twice-daily schedule. Renovate's OSV vulnerability alerts bypass the\n * schedule, so the remediation PR opens immediately — and because GitHub's\n * platform auto-merge is disabled fleet-wide, Renovate merges it from inside that\n * same run (or a later scheduled one) per the shared preset, which is what keeps\n * the preset's \"majors are never auto-merged\" rule authoritative. Moderate/low\n * vulns are left to the normal schedule — not urgent enough to dispatch + churn\n * CI nightly.\n */\n\n/** The per-repo workflow file the fleet uses for its self-hosted Renovate run. */\nexport const RENOVATE_WORKFLOW_FILE = \"renovate.yml\";\n\n/**\n * Does the repo have a HEALTHY open Renovate PR — one that is NOT conflicting?\n *\n * A healthy PR means remediation is genuinely in flight (moving toward merge), so\n * re-dispatching would just churn CI — skip it. A CONFLICTING Renovate PR is STUCK\n * (its branch fell behind the base after another PR merged the same lockfile), so\n * it does NOT count as healthy: re-dispatching triggers Renovate to rebase it.\n * `UNKNOWN` mergeability (GitHub still computing it) is treated as healthy — don't\n * churn on uncertainty. Reuses {@link isRenovatePR} for the branch-prefix check.\n */\nexport function hasHealthyRenovatePr(prs: PullRequestSummary[]): boolean {\n return prs.some((pr) => isRenovatePR(pr) && pr.mergeable !== \"CONFLICTING\");\n}\n\n/** A site selected for an on-demand Renovate run, with the reason (vuln counts). */\nexport type RenovateDispatchTarget = {\n repo: string; // owner/repo, from the Websites \"Git repo\" field\n siteName: string;\n critical: number;\n high: number;\n};\n\n/**\n * Pick the active, repo-backed sites whose latest security sweep found a\n * critical or high vulnerability. A `null` vuln count is treated as zero (a site\n * that was never swept, or had its counts cleared, is not dispatched). Inactive\n * sites and sites without a `Git repo` are excluded — there's nothing to kick.\n *\n * Counts are only guaranteed fresh for sites the sweep actually audited (active +\n * URL present); a site whose write-back failed/was skipped this run is selected on\n * its last-known counts. That's safe — a stale-high false dispatch is an idempotent\n * Renovate no-op, and the open-PR skip in `dispatchRenovateAcross` avoids re-firing\n * when remediation is already in flight.\n */\nexport function selectRenovateTargets(sites: WebsiteRow[]): RenovateDispatchTarget[] {\n const targets: RenovateDispatchTarget[] = [];\n for (const s of sites) {\n if (!isDashboardVisible(s)) continue; // active (maintenance / launch period) only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // repo-backed only — the dispatch target is its renovate.yml\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n if (critical + high <= 0) continue; // actionable (critical/high) vulns only\n targets.push({ repo, siteName: s.name, critical, high });\n }\n return targets;\n}\n\n/** Injected GitHub operations — real ones come from `makeGitHub`, fakes from tests. */\nexport type RenovateDispatchDeps = {\n /** True iff the repo has a HEALTHY (non-conflicting) open Renovate PR —\n * remediation in flight, so don't re-dispatch. A conflicting/stuck PR returns\n * false so the dispatch goes through and Renovate rebases it. */\n hasHealthyOpenRenovatePr: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n dispatch: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport type RenovateDispatchResult = {\n dispatched: string[];\n /** Repos skipped because a Renovate PR is already open — no point re-firing. */\n skipped: string[];\n failed: { repo: string; error: string }[];\n};\n\n/**\n * Dispatch `renovate.yml` for each target on its default branch.\n *\n * Skips a repo with a HEALTHY (non-conflicting) open Renovate PR — remediation is\n * in flight, so re-dispatching would just churn CI (keeps a persistent vuln from\n * re-firing every nightly run while its fix PR waits, e.g. a human-gated major\n * bump). A repo whose open Renovate PR is CONFLICTING/stuck is NOT skipped:\n * dispatching triggers Renovate to rebase it. Best-effort and isolated: a repo\n * that lacks `renovate.yml`, or whose\n * dispatch/PR-check is refused (the token's `actions:write` scope), is recorded in\n * `failed` and the rest still run. Never throws — the caller (a follow-up step on\n * the security sweep) must not be able to fail the sweep over a dispatch hiccup.\n */\nexport async function dispatchRenovateAcross(\n targets: RenovateDispatchTarget[],\n deps: RenovateDispatchDeps,\n): Promise<RenovateDispatchResult> {\n const dispatched: string[] = [];\n const skipped: string[] = [];\n const failed: { repo: string; error: string }[] = [];\n for (const t of targets) {\n try {\n if (await deps.hasHealthyOpenRenovatePr(t.repo)) {\n skipped.push(t.repo);\n continue;\n }\n const ref = await deps.defaultBranch(t.repo);\n await deps.dispatch(t.repo, RENOVATE_WORKFLOW_FILE, ref);\n dispatched.push(t.repo);\n } catch (e) {\n failed.push({ repo: t.repo, error: e instanceof Error ? e.message : String(e) });\n }\n }\n return { dispatched, skipped, failed };\n}\n\n/** Machine-readable counts line the workflow greps to annotate partial failures. */\nexport function formatRenovateDispatchSummary(result: RenovateDispatchResult): string {\n return `RENOVATE_DISPATCH_SUMMARY dispatched=${result.dispatched.length} skipped=${result.skipped.length} failed=${result.failed.length}`;\n}\n\n/**\n * Plan the per-site auto-fix-attempt counter writes from a dispatch result. PURE.\n * For each site:\n * - vulns now 0 (or never counted) → reset to 0 (episode resolved)\n * - else dispatched this run → attempts + 1 (a fresh failed-so-far attempt)\n * - else (skipped / failed / untouched) → unchanged\n * The RESET applies fleet-wide — regardless of Status or Git repo — so a site\n * archived or un-repo'd after its episode can't carry a stale counter into its\n * next vuln (Alamo sat at 7 from a long-closed episode). A null vuln count\n * (never-audited) reads as zero, consistent with the existing null-as-zero\n * semantics, so a lingering counter on such a row also clears. The INCREMENT\n * keeps its active + repo-backed filters (only those sites are dispatched).\n * Returns only the rows whose value CHANGES (a steady fleet writes nothing —\n * a 0→0 site emits no write). A skipped repo (healthy Renovate PR in flight) is\n * NOT a failed attempt — a fix is genuinely moving toward merge — so its counter\n * holds. A dispatched site whose critical/high advisories are ALL proven\n * TRANSITIVE also holds: Renovate has no direct-dep bump to open, so the run is a\n * known green no-op waiting on the weekly lockfile window, not a failed fix\n * attempt (Sonder rode 5 such no-ops to a false \"auto-fix failed\", 2026-08-10).\n * Missing/unknown relationship data keeps the increment — never mute on absence.\n */\nexport function computeAutoFixAttemptUpdates(\n sites: WebsiteRow[],\n result: RenovateDispatchResult,\n): { id: string; attempts: number }[] {\n const dispatched = new Set(result.dispatched);\n const updates: { id: string; attempts: number }[] = [];\n for (const s of sites) {\n const current = s.securityAutoFixAttempts ?? 0;\n const vulns = (s.securityVulnsCritical ?? 0) + (s.securityVulnsHigh ?? 0);\n if (vulns === 0) {\n // Episode resolved (or never confirmed): reset REGARDLESS of status/repo —\n // a site archived or un-repo'd after its episode must not carry a stale\n // counter into its next vuln (Alamo sat at 7 from a long-closed episode).\n if (current !== 0) updates.push({ id: s.id, attempts: 0 });\n continue;\n }\n if (!isDashboardVisible(s)) continue; // increments stay active-only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // …and repo-backed-only\n if (!dispatched.has(repo)) continue;\n // Transitive-only episode: the dispatch can't produce a fix PR (no direct dep\n // to bump) — don't count the no-op as a failed attempt.\n if (allActionableVulnsTransitive(s.securityAdvisories)) continue;\n updates.push({ id: s.id, attempts: current + 1 });\n }\n return updates;\n}\n\n/**\n * Apply the planned counter updates with an injected writer, BEST-EFFORT: a writer\n * that throws (e.g. the Airtable field not yet created, or a transient error) is\n * counted in `failed` and never propagates — the security sweep must not fail over\n * counter bookkeeping. Returns the applied/failed tallies for the summary line.\n */\nexport async function applyAutoFixAttemptUpdates(\n updates: { id: string; attempts: number }[],\n write: (id: string, attempts: number) => Promise<void>,\n): Promise<{ written: number; failed: number }> {\n let written = 0;\n let failed = 0;\n for (const u of updates) {\n try {\n await write(u.id, u.attempts);\n written++;\n } catch {\n failed++;\n }\n }\n return { written, failed };\n}\n\n/** Machine-readable counts line the workflow can grep, mirroring RENOVATE_DISPATCH_SUMMARY. */\nexport function formatAutoFixAttemptsSummary(tally: { written: number; failed: number }): string {\n return `AUTO_FIX_ATTEMPTS_SUMMARY written=${tally.written} failed=${tally.failed}`;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\nimport { listWebsites, updateAutoFixAttempts } from \"../../reports/airtable/websites.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n selectRenovateTargets,\n dispatchRenovateAcross,\n formatRenovateDispatchSummary,\n hasHealthyRenovatePr,\n computeAutoFixAttemptUpdates,\n applyAutoFixAttemptUpdates,\n formatAutoFixAttemptsSummary,\n type RenovateDispatchResult,\n} from \"../../github/renovate-dispatch.js\";\n\n/**\n * `renovate-dispatch --fleet`: read the Websites table, pick the active,\n * repo-backed sites the latest security sweep flagged with critical/high vulns,\n * and fire each one's `renovate.yml` `workflow_dispatch` so Renovate's OSV\n * vulnerability alerts open the remediation PR now (instead of waiting for the\n * weekly schedule). Designed to run as a best-effort follow-up step on\n * `fleet-security.yml` AFTER the sweep has written fresh counts to Airtable.\n *\n * Always exits 0: a missing token is a clean skip; a partial dispatch failure\n * (a repo without `renovate.yml`, or a token lacking `actions:write`) is reported\n * in the summary for the workflow to annotate, never failing the security sweep.\n */\nexport async function runRenovateDispatchCommand(opts: {\n fleet?: boolean | undefined;\n /** Inject a pre-opened Airtable base (tests). Defaults to env config. */\n base?: AirtableBase;\n /** #539 Phase 5: Turso write-through for the auto-fix counter. Injected from\n * bin.ts rather than defaulted here — this function is called directly by\n * tests, and a default would open a real libSQL handle inside the suite. */\n siteMirror?: SiteMirror;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet) {\n return { output: \"renovate-dispatch currently supports only --fleet\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"renovate-dispatch skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet dispatch) configured.\",\n code: 0,\n };\n }\n\n const base = opts.base ?? openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const targets = selectRenovateTargets(websites);\n\n const lines: string[] = [];\n let result: RenovateDispatchResult = { dispatched: [], skipped: [], failed: [] };\n if (targets.length === 0) {\n lines.push(\n \"No active repo-backed sites with critical/high vulnerabilities — nothing to dispatch.\",\n );\n } else {\n const gh = makeGitHub({ token });\n result = await dispatchRenovateAcross(targets, {\n hasHealthyOpenRenovatePr: async (repo) =>\n hasHealthyRenovatePr(await gh.openPullRequests(repo)),\n defaultBranch: (repo) => gh.defaultBranch(repo),\n dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref),\n });\n\n const failedRepos = new Set(result.failed.map((f) => f.repo));\n const skippedRepos = new Set(result.skipped);\n for (const t of targets) {\n const status = failedRepos.has(t.repo)\n ? \"FAILED \"\n : skippedRepos.has(t.repo)\n ? \"skipped (open Renovate PR) \"\n : \"dispatched \";\n lines.push(`${status} ${t.repo} (critical=${t.critical} high=${t.high}) — ${t.siteName}`);\n }\n for (const f of result.failed) lines.push(` ↳ ${f.repo}: ${f.error}`);\n }\n\n // Counter bookkeeping runs on EVERY fleet run — the reset-on-clean branch was\n // previously unreachable behind the zero-targets early return, so a fully-clean\n // fleet never cleared stale counters (Alamo stuck at 7, 2026-07). Best-effort:\n // a failed write is tallied, never thrown. Uses the full `websites` list so\n // 0-vuln sites reset.\n const attemptUpdates = computeAutoFixAttemptUpdates(websites, result);\n const attemptTally = await applyAutoFixAttemptUpdates(attemptUpdates, async (id, attempts) => {\n const fields = await updateAutoFixAttempts(base, id, attempts);\n await opts.siteMirror?.health(id, fields);\n });\n\n lines.push(formatRenovateDispatchSummary(result));\n lines.push(formatAutoFixAttemptsSummary(attemptTally));\n\n return { output: lines.join(\"\\n\"), code: 0 };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,66 @@
1
+ import {
2
+ ingestSubmission
3
+ } from "./chunk-BNM5POSJ.js";
4
+ import "./chunk-SNSTJLQW.js";
5
+ import "./chunk-2OP4JKIQ.js";
6
+
7
+ // src/db/deadletter.ts
8
+ async function listUnreplayedDeadLetters(db) {
9
+ const rows = await db.selectFrom("submission_deadletter").select(["id", "site_slug", "payload", "turnstile", "error", "received_at"]).where("replayed_at", "is", null).orderBy("received_at", "asc").execute();
10
+ return rows.map((r) => ({
11
+ id: r.id,
12
+ siteSlug: r.site_slug,
13
+ payload: JSON.parse(r.payload),
14
+ turnstile: JSON.parse(r.turnstile),
15
+ error: r.error,
16
+ receivedAt: r.received_at
17
+ }));
18
+ }
19
+ async function markDeadLetterReplayed(db, id, outcome, submissionId, now) {
20
+ await db.updateTable("submission_deadletter").set({
21
+ replayed_at: now.toISOString(),
22
+ replay_outcome: outcome,
23
+ replay_submission_id: submissionId
24
+ }).where("id", "=", id).execute();
25
+ }
26
+
27
+ // src/forms/replay.ts
28
+ async function replayDeadLetters(db, deps) {
29
+ const rows = await listUnreplayedDeadLetters(db);
30
+ const result = { replayed: [], stillFailing: [] };
31
+ for (const row of rows) {
32
+ const outcome = await replayOne(db, deps, row);
33
+ if (outcome.terminal) {
34
+ result.replayed.push({
35
+ id: row.id,
36
+ outcome: outcome.outcome,
37
+ submissionId: outcome.submissionId
38
+ });
39
+ } else {
40
+ result.stillFailing.push({ id: row.id, error: outcome.error });
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ async function replayOne(db, deps, row) {
46
+ const { deadLetter: _stripped, ...safeDeps } = deps;
47
+ void _stripped;
48
+ let res;
49
+ try {
50
+ res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);
51
+ } catch (err) {
52
+ return { terminal: false, error: String(err) };
53
+ }
54
+ const now = deps.now();
55
+ if (res.status === "accepted") {
56
+ await markDeadLetterReplayed(db, row.id, "accepted", res.submissionId, now);
57
+ return { terminal: true, outcome: "accepted", submissionId: res.submissionId };
58
+ }
59
+ const outcome = res.status === "rejected" ? `rejected:${res.reason}` : "unknown-site";
60
+ await markDeadLetterReplayed(db, row.id, outcome, null, now);
61
+ return { terminal: true, outcome, submissionId: null };
62
+ }
63
+ export {
64
+ replayDeadLetters
65
+ };
66
+ //# sourceMappingURL=replay-C2LXMJZP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/deadletter.ts","../src/forms/replay.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { TurnstileVerification } from \"../forms/turnstile.js\";\n\n/** One captured-but-unresolved lead: the site lookup threw before the normal\n * pipeline could run (Phase 0 of #539 — persist before enrich). */\nexport type DeadLetterRow = {\n id: string;\n siteSlug: string;\n /** The raw wire payload, exactly as the handler received it. */\n payload: unknown;\n /** The Turnstile verification computed at receipt — tokens expire in 300s,\n * so replay reuses this answer rather than re-verifying. */\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: string;\n};\n\nexport type DeadLetterInput = {\n siteSlug: string;\n payload: unknown;\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: Date;\n};\n\n/** Opaque, collision-free id (mirrors newSubmissionId). */\nexport function newDeadLetterId(): string {\n return `dl_${crypto.randomUUID()}`;\n}\n\n/** Persist a lead the normal pipeline could not place. This is the LAST writer\n * on the ingest path — if it throws too, both stores are down and the caller's\n * 502 is honest. */\nexport async function createDeadLetter(db: Db, input: DeadLetterInput): Promise<{ id: string }> {\n const id = newDeadLetterId();\n await db\n .insertInto(\"submission_deadletter\")\n .values({\n id,\n site_slug: input.siteSlug,\n payload: JSON.stringify(input.payload),\n turnstile: JSON.stringify(input.turnstile),\n error: input.error,\n received_at: input.receivedAt.toISOString(),\n replayed_at: null,\n replay_outcome: null,\n replay_submission_id: null,\n })\n .execute();\n return { id };\n}\n\n/** Rows not yet replayed, oldest first — replay preserves arrival order so the\n * duplicate/velocity signals see submissions in the order they happened. */\nexport async function listUnreplayedDeadLetters(db: Db): Promise<DeadLetterRow[]> {\n const rows = await db\n .selectFrom(\"submission_deadletter\")\n .select([\"id\", \"site_slug\", \"payload\", \"turnstile\", \"error\", \"received_at\"])\n .where(\"replayed_at\", \"is\", null)\n .orderBy(\"received_at\", \"asc\")\n .execute();\n return rows.map((r) => ({\n id: r.id,\n siteSlug: r.site_slug,\n payload: JSON.parse(r.payload) as unknown,\n turnstile: JSON.parse(r.turnstile) as TurnstileVerification,\n error: r.error,\n receivedAt: r.received_at,\n }));\n}\n\n/** Mark a row's replay TERMINAL — it will never be picked up again. Only call\n * for outcomes that re-running cannot improve (accepted, rejected,\n * unknown-site). A replay whose lookup threw again is left untouched so the\n * next run retries it. */\nexport async function markDeadLetterReplayed(\n db: Db,\n id: string,\n outcome: string,\n submissionId: string | null,\n now: Date,\n): Promise<void> {\n await db\n .updateTable(\"submission_deadletter\")\n .set({\n replayed_at: now.toISOString(),\n replay_outcome: outcome,\n replay_submission_id: submissionId,\n })\n .where(\"id\", \"=\", id)\n .execute();\n}\n","import type { Db } from \"../db/client.js\";\nimport {\n listUnreplayedDeadLetters,\n markDeadLetterReplayed,\n type DeadLetterRow,\n} from \"../db/deadletter.js\";\nimport { ingestSubmission, type IngestDeps } from \"./ingest.js\";\n\nexport type ReplayResult = {\n /** Terminal outcomes this run: the row will never be picked up again. */\n replayed: Array<{ id: string; outcome: string; submissionId: string | null }>;\n /** Rows whose lookup threw AGAIN — left unreplayed for the next run. */\n stillFailing: Array<{ id: string; error: string }>;\n};\n\n/**\n * Run every unreplayed dead-letter back through `ingestSubmission` — the SAME\n * pipeline a live submission takes, so a replayed lead gets real spam\n * classification, notify, and fan-out, not a bespoke re-implementation.\n *\n * Each row replays with the Turnstile verification computed at receipt (tokens\n * expire in 300s; re-verifying is impossible by design, see deadletter.ts).\n *\n * `deps` must NOT wire `deadLetter`: a lookup that throws during replay would\n * otherwise dead-letter the SAME lead a second time while the original row stays\n * unreplayed — every retry would mint a duplicate. Enforced here by stripping the\n * key rather than trusting the caller. A thrown lookup leaves the row untouched\n * for the next run; every RESOLVED outcome (accepted, rejected, unknown-site) is\n * terminal — re-running cannot improve on an answer the store actually gave.\n *\n * Oldest-first (listUnreplayedDeadLetters orders by received_at) so the\n * duplicate/velocity spam signals see submissions in arrival order.\n */\nexport async function replayDeadLetters(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n): Promise<ReplayResult> {\n const rows = await listUnreplayedDeadLetters(db);\n const result: ReplayResult = { replayed: [], stillFailing: [] };\n\n for (const row of rows) {\n const outcome = await replayOne(db, deps, row);\n if (outcome.terminal) {\n result.replayed.push({\n id: row.id,\n outcome: outcome.outcome,\n submissionId: outcome.submissionId,\n });\n } else {\n result.stillFailing.push({ id: row.id, error: outcome.error });\n }\n }\n return result;\n}\n\nasync function replayOne(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n row: DeadLetterRow,\n): Promise<\n | { terminal: true; outcome: string; submissionId: string | null }\n | { terminal: false; error: string }\n> {\n // Strip deadLetter defensively even though the type forbids it — a structurally\n // wider object passed through `as` must not re-arm the duplicate loop.\n const { deadLetter: _stripped, ...safeDeps } = deps as IngestDeps;\n void _stripped;\n let res;\n try {\n res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);\n } catch (err) {\n return { terminal: false, error: String(err) };\n }\n const now = deps.now();\n if (res.status === \"accepted\") {\n await markDeadLetterReplayed(db, row.id, \"accepted\", res.submissionId, now);\n return { terminal: true, outcome: \"accepted\", submissionId: res.submissionId };\n }\n const outcome = res.status === \"rejected\" ? `rejected:${res.reason}` : \"unknown-site\";\n await markDeadLetterReplayed(db, row.id, outcome, null, now);\n return { terminal: true, outcome, submissionId: null };\n}\n"],"mappings":";;;;;;;AAsDA,eAAsB,0BAA0B,IAAkC;AAChF,QAAM,OAAO,MAAM,GAChB,WAAW,uBAAuB,EAClC,OAAO,CAAC,MAAM,aAAa,WAAW,aAAa,SAAS,aAAa,CAAC,EAC1E,MAAM,eAAe,MAAM,IAAI,EAC/B,QAAQ,eAAe,KAAK,EAC5B,QAAQ;AACX,SAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACtB,IAAI,EAAE;AAAA,IACN,UAAU,EAAE;AAAA,IACZ,SAAS,KAAK,MAAM,EAAE,OAAO;AAAA,IAC7B,WAAW,KAAK,MAAM,EAAE,SAAS;AAAA,IACjC,OAAO,EAAE;AAAA,IACT,YAAY,EAAE;AAAA,EAChB,EAAE;AACJ;AAMA,eAAsB,uBACpB,IACA,IACA,SACA,cACA,KACe;AACf,QAAM,GACH,YAAY,uBAAuB,EACnC,IAAI;AAAA,IACH,aAAa,IAAI,YAAY;AAAA,IAC7B,gBAAgB;AAAA,IAChB,sBAAsB;AAAA,EACxB,CAAC,EACA,MAAM,MAAM,KAAK,EAAE,EACnB,QAAQ;AACb;;;AC1DA,eAAsB,kBACpB,IACA,MACuB;AACvB,QAAM,OAAO,MAAM,0BAA0B,EAAE;AAC/C,QAAM,SAAuB,EAAE,UAAU,CAAC,GAAG,cAAc,CAAC,EAAE;AAE9D,aAAW,OAAO,MAAM;AACtB,UAAM,UAAU,MAAM,UAAU,IAAI,MAAM,GAAG;AAC7C,QAAI,QAAQ,UAAU;AACpB,aAAO,SAAS,KAAK;AAAA,QACnB,IAAI,IAAI;AAAA,QACR,SAAS,QAAQ;AAAA,QACjB,cAAc,QAAQ;AAAA,MACxB,CAAC;AAAA,IACH,OAAO;AACL,aAAO,aAAa,KAAK,EAAE,IAAI,IAAI,IAAI,OAAO,QAAQ,MAAM,CAAC;AAAA,IAC/D;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAe,UACb,IACA,MACA,KAIA;AAGA,QAAM,EAAE,YAAY,WAAW,GAAG,SAAS,IAAI;AAC/C,OAAK;AACL,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,iBAAiB,UAAU,IAAI,UAAU,IAAI,SAAS,IAAI,SAAS;AAAA,EACjF,SAAS,KAAK;AACZ,WAAO,EAAE,UAAU,OAAO,OAAO,OAAO,GAAG,EAAE;AAAA,EAC/C;AACA,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,IAAI,WAAW,YAAY;AAC7B,UAAM,uBAAuB,IAAI,IAAI,IAAI,YAAY,IAAI,cAAc,GAAG;AAC1E,WAAO,EAAE,UAAU,MAAM,SAAS,YAAY,cAAc,IAAI,aAAa;AAAA,EAC/E;AACA,QAAM,UAAU,IAAI,WAAW,aAAa,YAAY,IAAI,MAAM,KAAK;AACvE,QAAM,uBAAuB,IAAI,IAAI,IAAI,SAAS,MAAM,GAAG;AAC3D,SAAO,EAAE,UAAU,MAAM,SAAS,cAAc,KAAK;AACvD;","names":[]}
@@ -1,36 +1,38 @@
1
1
  import {
2
2
  operatorEmail
3
3
  } from "./chunk-5ML7FMBD.js";
4
+ import {
5
+ draftReportForSite,
6
+ readGaConfig
7
+ } from "./chunk-BBUSGEES.js";
4
8
  import {
5
9
  findDueReports,
6
10
  nextDueDate,
7
11
  reportPeriodKey
8
- } from "./chunk-D4FVAIEJ.js";
9
- import {
10
- draftReportForSite,
11
- readGaConfig
12
- } from "./chunk-MGR2H7F4.js";
12
+ } from "./chunk-PZR4EBPY.js";
13
13
  import {
14
14
  reportTier
15
- } from "./chunk-62VF32IC.js";
16
- import "./chunk-37S6L6G4.js";
15
+ } from "./chunk-YFAXOD7Z.js";
17
16
  import {
18
17
  listAllReports
19
- } from "./chunk-6QRLRT3P.js";
20
- import "./chunk-MPBXOJ3N.js";
18
+ } from "./chunk-F3JWOPTG.js";
19
+ import "./chunk-VAD43MOR.js";
20
+ import "./chunk-IY6256GK.js";
21
+ import {
22
+ openBase,
23
+ readAirtableConfig
24
+ } from "./chunk-HN6I2XWI.js";
25
+ import "./chunk-MT2JINSS.js";
21
26
  import {
22
27
  escapeHtml
23
- } from "./chunk-SEEA6RXF.js";
24
- import "./chunk-U5SZ4FOP.js";
28
+ } from "./chunk-DJZWYIPP.js";
29
+ import "./chunk-5UINJNAJ.js";
25
30
  import {
26
31
  listWebsites,
27
32
  siteSlug,
28
33
  updateNextDueDates
29
- } from "./chunk-577HCGZO.js";
30
- import {
31
- openBase,
32
- readAirtableConfig
33
- } from "./chunk-HN6I2XWI.js";
34
+ } from "./chunk-7NYN2P6I.js";
35
+ import "./chunk-EVBCK2KC.js";
34
36
  import "./chunk-6VAL7XJT.js";
35
37
 
36
38
  // src/alerts/analytics-health.ts
@@ -76,12 +78,34 @@ function dashboardBaseUrl() {
76
78
  }
77
79
  async function runReportCommand(slug, opts) {
78
80
  if (opts.digest) {
79
- const { runDigest } = await import("./digest-UVHYZUYE.js");
81
+ const { runDigest } = await import("./digest-FW6QMZZJ.js");
80
82
  return runDigest({ baseUrl: dashboardBaseUrl() });
81
83
  }
82
84
  if (opts.sendReady) {
83
- const { sendApprovedReports } = await import("./orchestrate-KDNXZQX7.js");
84
- return sendApprovedReports();
85
+ const { sendApprovedReports } = await import("./orchestrate-YLWQ6KK2.js");
86
+ const { makeSiteMirror } = await import("./site-mirror-NIREX5ME.js");
87
+ return sendApprovedReports({ siteMirror: await makeSiteMirror() });
88
+ }
89
+ if (opts.rerender) {
90
+ const { rerenderReport, formatRerenderResult } = await import("./rerender-3FM6DNRH.js");
91
+ const { renderReportFromRow } = await import("./render-from-row-BWSOIYDS.js");
92
+ const { openDb, readDbConfig } = await import("./client-AUE3XUPO.js");
93
+ const { getReportById, getSiteById, storeRenderedHtml } = await import("./fleet-state-7TJ2GYT2.js");
94
+ const { loadHeaderImage } = await import("./header-images-SQBCE5OD.js");
95
+ const { fetchAttachmentBytes } = await import("./attachments-NLZIJ5NU.js");
96
+ const db = await openDb(readDbConfig());
97
+ const result = await rerenderReport(
98
+ {
99
+ getReport: (id) => getReportById(db, id),
100
+ getSite: (id) => getSiteById(db, id),
101
+ loadHeaderPlate: async (id) => (await loadHeaderImage(db, id))?.bytes ?? null,
102
+ fetchAirtableHeader: async (url) => (await fetchAttachmentBytes(url)).bytes,
103
+ render: (site, report, plate) => renderReportFromRow(site, report, plate),
104
+ store: (id, html) => storeRenderedHtml(db, id, html)
105
+ },
106
+ opts.rerender
107
+ );
108
+ return { output: formatRerenderResult(result), code: result.status === "rendered" ? 0 : 1 };
85
109
  }
86
110
  if (opts.due) {
87
111
  return runDueDraft();
@@ -105,7 +129,16 @@ async function runReportCommand(slug, opts) {
105
129
  }
106
130
  async function runDueDraft() {
107
131
  const base = openBase(readAirtableConfig());
108
- const result = await draftDueReports(base, /* @__PURE__ */ new Date());
132
+ const { makeScheduleMirrorBestEffort } = await import("./health-mirror-L5JBEJVA.js");
133
+ const { makeReportMirror } = await import("./report-mirror-4MNORYQ4.js");
134
+ const { makeSiteMirror } = await import("./site-mirror-NIREX5ME.js");
135
+ const result = await draftDueReports(
136
+ base,
137
+ /* @__PURE__ */ new Date(),
138
+ await makeScheduleMirrorBestEffort(),
139
+ await makeReportMirror(),
140
+ await makeSiteMirror()
141
+ );
109
142
  await alertOnFleetAnalyticsFailure(result.health);
110
143
  return { output: result.output, code: result.code };
111
144
  }
@@ -127,23 +160,49 @@ async function alertOnFleetAnalyticsFailure(health) {
127
160
  console.warn(`\u26A0 analytics-failure alert send failed: ${e.message}`);
128
161
  }
129
162
  }
130
- async function writeNextDueDates(base, websites, reports, today) {
163
+ async function writeNextDueDates(base, websites, reports, today, scheduleMirror = null) {
131
164
  const ymd = (d) => d ? d.toISOString().slice(0, 10) : null;
165
+ let wrote = 0;
166
+ let skipped = 0;
167
+ let failed = 0;
168
+ let mirrored = 0;
169
+ let mirrorFailed = 0;
170
+ let mirrorMissed = 0;
132
171
  for (const site of websites) {
133
172
  try {
134
- await updateNextDueDates(base, site.id, {
135
- maintenanceAt: ymd(nextDueDate(site, reports, "Maintenance", today)),
136
- testingAt: ymd(nextDueDate(site, reports, "Testing", today))
137
- });
173
+ const maintenanceAt = ymd(nextDueDate(site, reports, "Maintenance", today));
174
+ const testingAt = ymd(nextDueDate(site, reports, "Testing", today));
175
+ if (maintenanceAt === site.nextMaintenanceAt && testingAt === site.nextTestingAt) {
176
+ skipped++;
177
+ continue;
178
+ }
179
+ const fields = await updateNextDueDates(base, site.id, { maintenanceAt, testingAt });
180
+ wrote++;
181
+ if (scheduleMirror) {
182
+ try {
183
+ if (await scheduleMirror(site.id, fields, today.toISOString())) mirrored++;
184
+ else mirrorMissed++;
185
+ } catch (e) {
186
+ mirrorFailed++;
187
+ console.warn(`\u26A0 [schedule-mirror] ${site.name}: ${e.message}`);
188
+ }
189
+ }
138
190
  } catch (e) {
191
+ failed++;
139
192
  console.warn(`\u26A0 next-due write skipped for ${site.name}: ${e.message}`);
140
193
  }
141
194
  }
195
+ const mirrorNote = scheduleMirror ? ` mirrored=${mirrored} mirror_failed=${mirrorFailed} mirror_missed=${mirrorMissed}` : " mirror=absent";
196
+ console.log(`NEXT_DUE_WRITE wrote=${wrote} skipped=${skipped} failed=${failed}${mirrorNote}`);
142
197
  }
143
- async function draftDueReports(base, today) {
198
+ async function draftDueReports(base, today, scheduleMirror = null, reportMirror, siteMirror) {
199
+ const mirrorOpt = {
200
+ ...reportMirror ? { reportMirror } : {},
201
+ ...siteMirror ? { siteMirror } : {}
202
+ };
144
203
  const websites = await listWebsites(base);
145
204
  const reports = await listAllReports(base);
146
- await writeNextDueDates(base, websites, reports, today);
205
+ await writeNextDueDates(base, websites, reports, today, scheduleMirror);
147
206
  const due = findDueReports(websites, reports, today);
148
207
  const gaConfigured = readGaConfig() !== null;
149
208
  const isAnalyticsConfigured = (s) => gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);
@@ -185,7 +244,8 @@ async function draftDueReports(base, today) {
185
244
  const result = await draftReportForSite(base, item.site, item.reportType, {
186
245
  period,
187
246
  completeRowId: existing.id,
188
- existingRow: existing
247
+ existingRow: existing,
248
+ ...mirrorOpt
189
249
  });
190
250
  existing.draftReady = result.queued === true;
191
251
  lines.push(
@@ -216,7 +276,10 @@ async function draftDueReports(base, today) {
216
276
  continue;
217
277
  }
218
278
  try {
219
- const result = await draftReportForSite(base, item.site, item.reportType, { period });
279
+ const result = await draftReportForSite(base, item.site, item.reportType, {
280
+ period,
281
+ ...mirrorOpt
282
+ });
220
283
  lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));
221
284
  if (result.reportRow) reports.push(result.reportRow);
222
285
  if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
@@ -258,11 +321,15 @@ async function runSingleSiteDraft(slug, opts) {
258
321
  if (!site) {
259
322
  throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
260
323
  }
324
+ const reportMirror = opts.previewOnly ? null : await (await import("./report-mirror-4MNORYQ4.js")).makeReportMirror();
325
+ const siteMirror = opts.previewOnly ? null : await (await import("./site-mirror-NIREX5ME.js")).makeSiteMirror();
261
326
  const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {
262
327
  previewOnly: opts.previewOnly,
263
328
  // Only forced on. Left undefined, draftReportForSite keeps its default
264
329
  // (enrich iff there is a base), so the real drafting path is untouched.
265
- ...opts.enrich ? { enrich: true } : {}
330
+ ...opts.enrich ? { enrich: true } : {},
331
+ ...reportMirror ? { reportMirror } : {},
332
+ ...siteMirror ? { siteMirror } : {}
266
333
  });
267
334
  if (opts.previewOnly) {
268
335
  return { output: `Preview written to ${result.htmlPath}`, code: 0 };
@@ -280,6 +347,7 @@ async function runSingleSiteDraft(slug, opts) {
280
347
  export {
281
348
  draftDueReports,
282
349
  parseSingleSiteReportType,
283
- runReportCommand
350
+ runReportCommand,
351
+ writeNextDueDates
284
352
  };
285
- //# sourceMappingURL=report-6CKSWJNG.js.map
353
+ //# sourceMappingURL=report-R3E3Y7HR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateNextDueDates,\n type WebsiteRow,\n} from \"../../reports/airtable/websites.js\";\nimport { listAllReports, type ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\nimport type { ScheduleMirror } from \"../../audits/health-mirror.js\";\nimport type { ReportMirror } from \"../../reports/report-mirror.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\nimport { operatorEmail } from \"../../util/operator.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n enrich?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n rerender?: string;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n return runDigest({ baseUrl: dashboardBaseUrl() });\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n // #539 Phase 5: a Launch send flips Status + stamps `Launched at` on the\n // Websites row; without this it reaches Turso only via the hourly sync.\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n return sendApprovedReports({ siteMirror: await makeSiteMirror() });\n }\n\n // Refresh ONE unsent report's stored body so the console preview reflects\n // commentary edited after drafting. Runs here rather than in a Netlify\n // function because rendering needs sharp — see rerender.ts.\n if (opts.rerender) {\n const { rerenderReport, formatRerenderResult } = await import(\"../../reports/send/rerender.js\");\n const { renderReportFromRow } = await import(\"../../reports/send/render-from-row.js\");\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { getReportById, getSiteById, storeRenderedHtml } =\n await import(\"../../db/fleet-state.js\");\n const { loadHeaderImage } = await import(\"../../db/header-images.js\");\n const { fetchAttachmentBytes } = await import(\"../../reports/airtable/attachments.js\");\n const db = await openDb(readDbConfig());\n const result = await rerenderReport(\n {\n getReport: (id) => getReportById(db, id),\n getSite: (id) => getSiteById(db, id),\n loadHeaderPlate: async (id) => (await loadHeaderImage(db, id))?.bytes ?? null,\n fetchAirtableHeader: async (url) => (await fetchAttachmentBytes(url)).bytes,\n render: (site, report, plate) => renderReportFromRow(site, report, plate),\n store: (id, html) => storeRenderedHtml(db, id, html),\n },\n opts.rerender,\n );\n // Exit 1 on anything but a render, so a dispatched run that refused is RED\n // rather than a green run that silently changed nothing.\n return { output: formatRerenderResult(result), code: result.status === \"rendered\" ? 0 : 1 };\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, {\n previewOnly: Boolean(opts.preview),\n enrich: Boolean(opts.enrich),\n reportType,\n });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--enrich] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n // Phase 3 dual-write (#539): mirror real next-due writes into site_schedule.\n // Null when libSQL creds are absent — the Airtable path is unchanged.\n const { makeScheduleMirrorBestEffort } = await import(\"../../audits/health-mirror.js\");\n // Phase 5 dual-write (#539): mirror this batch's report writes — the created\n // rows, their bodies, and the queue flags. Unlike the schedule mirror this is\n // never null — creds-absent is reported on the REPORT_MIRROR line rather than\n // being indistinguishable from success, the failure mode that hid #585.\n const { makeReportMirror } = await import(\"../../reports/report-mirror.js\");\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const result = await draftDueReports(\n base,\n new Date(),\n await makeScheduleMirrorBestEffort(),\n await makeReportMirror(),\n await makeSiteMirror(),\n );\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = operatorEmail();\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n *\n * The diff-guard (#539 Phase 3): a site whose computed dates equal what its row\n * already holds is SKIPPED — before it, every one of the 44 sites got a nightly\n * write while only the handful whose schedule actually moved needed one (a\n * never-maintained site re-wrote null over null forever). This also scopes the\n * write to maintained sites by construction: no schedule → computed null →\n * equal to the stored null → skipped. Each real write dual-writes through\n * `scheduleMirror` into site_schedule (null mirror = Turso creds absent; the\n * hourly sync converges either way). The NEXT_DUE_WRITE line is observability,\n * not a CI gate — nothing greps it yet.\n */\nexport async function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n let wrote = 0;\n let skipped = 0;\n let failed = 0;\n let mirrored = 0;\n let mirrorFailed = 0;\n let mirrorMissed = 0;\n for (const site of websites) {\n // The whole per-site body sits in ONE try — compute included — so a bad\n // row can only cost its own write, exactly the pre-diff-guard blast radius.\n try {\n const maintenanceAt = ymd(nextDueDate(site, reports, \"Maintenance\", today));\n const testingAt = ymd(nextDueDate(site, reports, \"Testing\", today));\n if (maintenanceAt === site.nextMaintenanceAt && testingAt === site.nextTestingAt) {\n skipped++;\n continue;\n }\n const fields = await updateNextDueDates(base, site.id, { maintenanceAt, testingAt });\n wrote++;\n if (scheduleMirror) {\n try {\n // false = the UPDATE matched no site_schedule row (site created in\n // Airtable since the last hourly import) — missed, not mirrored.\n if (await scheduleMirror(site.id, fields, today.toISOString())) mirrored++;\n else mirrorMissed++;\n } catch (e) {\n mirrorFailed++;\n console.warn(`⚠ [schedule-mirror] ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n failed++;\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n // failed= keeps a write outage visible: without it wrote+skipped silently\n // undercounts, and a FULL outage prints wrote=0 skipped=0 — indistinguishable\n // from an empty fleet.\n // A null mirror writes nothing and throws nothing, so its only trace used to\n // be an ABSENT suffix — which reads exactly like a healthy run. `mirror=absent`\n // states it instead. Counters stay off in that case deliberately: `mirrored=0`\n // would claim a write that returned nothing, not one that was never attempted.\n const mirrorNote = scheduleMirror\n ? ` mirrored=${mirrored} mirror_failed=${mirrorFailed} mirror_missed=${mirrorMissed}`\n : \" mirror=absent\";\n console.log(`NEXT_DUE_WRITE wrote=${wrote} skipped=${skipped} failed=${failed}${mirrorNote}`);\n}\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n /** #539 Phase 5: Turso write-through for rows this batch CREATES. Forwarded\n * to every draftReportForSite call — the nightly batch is the only unattended\n * creator of report rows, so a dropped pass-through leaves each new draft\n * invisible to the Turso-backed console. */\n reportMirror?: ReportMirror,\n /** #539 Phase 5: the Websites-row twin — drafting stamps `Analytics soft-fail\n * at` on the SITE row, a different Turso table from the report. */\n siteMirror?: SiteMirror,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const mirrorOpt = {\n ...(reportMirror ? { reportMirror } : {}),\n ...(siteMirror ? { siteMirror } : {}),\n };\n const websites = await listWebsites(base);\n // ONE unfiltered fetch for the whole fleet. Per-site queries can't be pushed to\n // Airtable anyway (linked-record fields aren't formula-filterable by record id),\n // and findDueReports + the period guard below match on siteId in memory.\n const reports = await listAllReports(base);\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today, scheduleMirror);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n ...mirrorOpt,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n ...mirrorOpt,\n });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; enrich: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n // Phase 5 dual-write (#539) — but only on the path that actually creates a\n // row. A preview writes nothing to Airtable, so opening a libSQL handle for\n // it would be pure cost (and `report --preview` is the one draft path that\n // deliberately does no store IO at all).\n const reportMirror = opts.previewOnly\n ? null\n : await (await import(\"../../reports/report-mirror.js\")).makeReportMirror();\n const siteMirror = opts.previewOnly\n ? null\n : await (await import(\"../../db/site-mirror.js\")).makeSiteMirror();\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n // Only forced on. Left undefined, draftReportForSite keeps its default\n // (enrich iff there is a base), so the real drafting path is untouched.\n ...(opts.enrich ? { enrich: true } : {}),\n ...(reportMirror ? { reportMirror } : {}),\n ...(siteMirror ? { siteMirror } : {}),\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,49 @@
1
+ import {
2
+ mirrorReportInsert,
3
+ mirrorReportPatch,
4
+ storeRenderedHtml
5
+ } from "./chunk-HHMMUR4P.js";
6
+ import "./chunk-P6AORG32.js";
7
+ import "./chunk-F3JWOPTG.js";
8
+ import "./chunk-VAD43MOR.js";
9
+ import "./chunk-7NYN2P6I.js";
10
+ import {
11
+ openDb,
12
+ readDbConfig
13
+ } from "./chunk-JHGHLKFS.js";
14
+ import "./chunk-3SF7D4DY.js";
15
+ import "./chunk-6VAL7XJT.js";
16
+
17
+ // src/reports/report-mirror.ts
18
+ async function makeReportMirror(open = () => openDb(readDbConfig())) {
19
+ let db = null;
20
+ let why = "";
21
+ try {
22
+ db = await open();
23
+ } catch (e) {
24
+ why = e.message;
25
+ }
26
+ const run = async (reportId, op, work) => {
27
+ if (!db) {
28
+ console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=absent reason=${why}`);
29
+ return;
30
+ }
31
+ try {
32
+ await work(db);
33
+ console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=1`);
34
+ } catch (e) {
35
+ console.log(
36
+ `REPORT_MIRROR report=${reportId} op=${op} mirrored=0 error=${e.message}`
37
+ );
38
+ }
39
+ };
40
+ return {
41
+ created: (rec) => run(rec.id, "created", (d) => mirrorReportInsert(d, rec)),
42
+ body: (reportId, html) => run(reportId, "body", (d) => storeRenderedHtml(d, reportId, html)),
43
+ patch: (reportId, patch) => run(reportId, "patch", (d) => mirrorReportPatch(d, reportId, patch))
44
+ };
45
+ }
46
+ export {
47
+ makeReportMirror
48
+ };
49
+ //# sourceMappingURL=report-mirror-4MNORYQ4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/report-mirror.ts"],"sourcesContent":["/** #539 Phase 5: the Turso write-through for the Reports write surface.\n *\n * Before this, the only report mirrors were UPDATEs on the request path\n * (approve, override, delivery status, commentary). Everything the DRAFTING\n * path writes — the row itself, the rendered body, the queue flag, a re-run's\n * refreshed scores — reached Turso only via the hourly sync. Two of those are\n * visible to the operator today: a fresh draft's row does not exist, and its\n * preview route answers \"No rendered body stored\" for up to an hour. At the\n * freeze they stop being windows.\n *\n * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.\n * #585 is the reason: Phase 3's next-due mirror silently no-opped in production\n * for weeks because the factory returned null without creds, and a dead\n * dual-write then looked exactly like a healthy one — the only tell was an\n * ABSENT log suffix nobody was watching for. Here creds-absent is a state the\n * mirror REPORTS, so every write emits one REPORT_MIRROR line and an absent\n * line means the wiring is gone, not that conditions were quiet.\n */\nimport { openDb, readDbConfig, type Db } from \"../db/client.js\";\nimport {\n mirrorReportInsert,\n mirrorReportPatch,\n storeRenderedHtml,\n type ReportMirrorPatch,\n} from \"../db/fleet-state.js\";\nimport type { CreatedDraftMirror } from \"./airtable/reports.js\";\n\n/** The three shapes a report write takes. Injected as ONE object rather than\n * three parameters because they share a db handle and always travel together:\n * a caller holding `created` but not `body` produces exactly the half-mirrored\n * state (row present, preview 404) this module exists to prevent. */\nexport type ReportMirror = {\n /** A row Airtable just CREATED, as Airtable echoed it back. */\n created: CreatedDraftMirror;\n /** A freshly rendered body for an existing row. */\n body: (reportId: string, html: string) => Promise<void>;\n /** Columns just written to an existing row. */\n patch: (reportId: string, patch: ReportMirrorPatch) => Promise<void>;\n};\n\n/** Build the drafting-path mirror. Never throws and never returns null:\n * Airtable is still authoritative through Phase 5, so a mirror problem must\n * not cost a draft the operator is waiting on — the hourly sync converges\n * whatever this misses. `open` is injectable for tests. */\nexport async function makeReportMirror(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n): Promise<ReportMirror> {\n let db: Db | null = null;\n let why = \"\";\n try {\n db = await open();\n } catch (e) {\n why = (e as Error).message;\n }\n\n const run = async (reportId: string, op: string, work: (db: Db) => Promise<void>) => {\n if (!db) {\n console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=absent reason=${why}`);\n return;\n }\n try {\n await work(db);\n console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=1`);\n } catch (e) {\n console.log(\n `REPORT_MIRROR report=${reportId} op=${op} mirrored=0 error=${(e as Error).message}`,\n );\n }\n };\n\n return {\n created: (rec) => run(rec.id, \"created\", (d) => mirrorReportInsert(d, rec)),\n body: (reportId, html) => run(reportId, \"body\", (d) => storeRenderedHtml(d, reportId, html)),\n patch: (reportId, patch) =>\n run(reportId, \"patch\", (d) => mirrorReportPatch(d, reportId, patch)),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA4CA,eAAsB,iBACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GAC9B;AACvB,MAAI,KAAgB;AACpB,MAAI,MAAM;AACV,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,UAAO,EAAY;AAAA,EACrB;AAEA,QAAM,MAAM,OAAO,UAAkB,IAAY,SAAoC;AACnF,QAAI,CAAC,IAAI;AACP,cAAQ,IAAI,wBAAwB,QAAQ,OAAO,EAAE,2BAA2B,GAAG,EAAE;AACrF;AAAA,IACF;AACA,QAAI;AACF,YAAM,KAAK,EAAE;AACb,cAAQ,IAAI,wBAAwB,QAAQ,OAAO,EAAE,aAAa;AAAA,IACpE,SAAS,GAAG;AACV,cAAQ;AAAA,QACN,wBAAwB,QAAQ,OAAO,EAAE,qBAAsB,EAAY,OAAO;AAAA,MACpF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS,CAAC,QAAQ,IAAI,IAAI,IAAI,WAAW,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAAA,IAC1E,MAAM,CAAC,UAAU,SAAS,IAAI,UAAU,QAAQ,CAAC,MAAM,kBAAkB,GAAG,UAAU,IAAI,CAAC;AAAA,IAC3F,OAAO,CAAC,UAAU,UAChB,IAAI,UAAU,SAAS,CAAC,MAAM,kBAAkB,GAAG,UAAU,KAAK,CAAC;AAAA,EACvE;AACF;","names":[]}
@@ -0,0 +1,32 @@
1
+ // src/reports/send/rerender.ts
2
+ async function rerenderReport(deps, reportId) {
3
+ const report = await deps.getReport(reportId);
4
+ if (!report) return { status: "not-found", reportId };
5
+ if (report.sentAt !== null) return { status: "already-sent", reportId };
6
+ const site = await deps.getSite(report.siteId);
7
+ if (!site) return { status: "not-found", reportId };
8
+ const stored = await deps.loadHeaderPlate(site.id);
9
+ let plate;
10
+ let headerSource;
11
+ if (stored) {
12
+ plate = stored;
13
+ headerSource = "turso";
14
+ } else if (site.headerImage) {
15
+ plate = await deps.fetchAirtableHeader(site.headerImage.url);
16
+ headerSource = "airtable";
17
+ } else {
18
+ return { status: "no-header", reportId };
19
+ }
20
+ const { html } = await deps.render(site, report, plate);
21
+ await deps.store(reportId, html);
22
+ return { status: "rendered", reportId, bytes: html.length, headerSource };
23
+ }
24
+ function formatRerenderResult(r) {
25
+ const suffix = r.status === "rendered" ? ` bytes=${r.bytes} header=${r.headerSource}` : "";
26
+ return `REPORT_RERENDER report=${r.reportId} status=${r.status}${suffix}`;
27
+ }
28
+ export {
29
+ formatRerenderResult,
30
+ rerenderReport
31
+ };
32
+ //# sourceMappingURL=rerender-3FM6DNRH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/send/rerender.ts"],"sourcesContent":["import type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\n\n/**\n * Refresh a report's stored HTML body on demand (#539 Phase 4 report review).\n *\n * The console's preview serves `reports.rendered_html`, which is written once at\n * draft time — so commentary edited afterwards does not appear. This regenerates\n * it through `renderReportFromRow`, the same path `sendOne` uses, so the preview\n * is what the client will actually receive rather than an approximation of it.\n *\n * It runs as a batch job (Actions), not in a Netlify function, and that is a\n * deliberate constraint rather than an accident: rendering needs sharp, a native\n * module no function currently bundles, and the alternative — approximating the\n * header geometry to avoid sharp — trades away exactly the fidelity a preview\n * exists to provide.\n *\n * IO is injected so the decision logic is testable without sharp, Airtable or a\n * database; the CLI binds the real implementations.\n */\nexport type RerenderDeps = {\n getReport: (reportId: string) => Promise<ReportRow | null>;\n getSite: (siteId: string) => Promise<WebsiteRow | null>;\n /** The clean header plate from Turso (design D5), or null when unstored. */\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>;\n /** Fallback: the site's Airtable header attachment. */\n fetchAirtableHeader: (url: string) => Promise<Uint8Array>;\n render: (\n site: WebsiteRow,\n report: ReportRow,\n headerPlate: Uint8Array,\n ) => Promise<{ html: string }>;\n store: (reportId: string, html: string) => Promise<void>;\n};\n\nexport type RerenderResult =\n | { status: \"rendered\"; reportId: string; bytes: number; headerSource: \"turso\" | \"airtable\" }\n /** Already sent: its stored body is the record of what the client received. */\n | { status: \"already-sent\"; reportId: string }\n | { status: \"no-header\"; reportId: string }\n | { status: \"not-found\"; reportId: string };\n\nexport async function rerenderReport(\n deps: RerenderDeps,\n reportId: string,\n): Promise<RerenderResult> {\n const report = await deps.getReport(reportId);\n if (!report) return { status: \"not-found\", reportId };\n\n // Refuse a sent report BEFORE doing any work. Its stored body is the record of\n // what the client actually received; regenerating it would overwrite that with\n // something nobody was sent — and today's row would not even reproduce it,\n // since commentary and scores have moved on since.\n if (report.sentAt !== null) return { status: \"already-sent\", reportId };\n\n const site = await deps.getSite(report.siteId);\n if (!site) return { status: \"not-found\", reportId };\n\n // Turso first: the bytes are already local, and the Airtable attachment URL is\n // signed and expiring, so fetching it when we hold the same image is pure\n // latency plus a dependency on a URL that may already be dead.\n const stored = await deps.loadHeaderPlate(site.id);\n let plate: Uint8Array;\n let headerSource: \"turso\" | \"airtable\";\n if (stored) {\n plate = stored;\n headerSource = \"turso\";\n } else if (site.headerImage) {\n plate = await deps.fetchAirtableHeader(site.headerImage.url);\n headerSource = \"airtable\";\n } else {\n // Named, not rendered around: a report with no header is already blocked at\n // approve, and a preview that quietly omitted it would disagree with both\n // the email and that block.\n return { status: \"no-header\", reportId };\n }\n\n const { html } = await deps.render(site, report, plate);\n await deps.store(reportId, html);\n return { status: \"rendered\", reportId, bytes: html.length, headerSource };\n}\n\n/** One line per run, machine-greppable, emitted for every outcome — an absent\n * line means the job never ran, never that it ran and did nothing. */\nexport function formatRerenderResult(r: RerenderResult): string {\n const suffix = r.status === \"rendered\" ? ` bytes=${r.bytes} header=${r.headerSource}` : \"\";\n return `REPORT_RERENDER report=${r.reportId} status=${r.status}${suffix}`;\n}\n"],"mappings":";AA0CA,eAAsB,eACpB,MACA,UACyB;AACzB,QAAM,SAAS,MAAM,KAAK,UAAU,QAAQ;AAC5C,MAAI,CAAC,OAAQ,QAAO,EAAE,QAAQ,aAAa,SAAS;AAMpD,MAAI,OAAO,WAAW,KAAM,QAAO,EAAE,QAAQ,gBAAgB,SAAS;AAEtE,QAAM,OAAO,MAAM,KAAK,QAAQ,OAAO,MAAM;AAC7C,MAAI,CAAC,KAAM,QAAO,EAAE,QAAQ,aAAa,SAAS;AAKlD,QAAM,SAAS,MAAM,KAAK,gBAAgB,KAAK,EAAE;AACjD,MAAI;AACJ,MAAI;AACJ,MAAI,QAAQ;AACV,YAAQ;AACR,mBAAe;AAAA,EACjB,WAAW,KAAK,aAAa;AAC3B,YAAQ,MAAM,KAAK,oBAAoB,KAAK,YAAY,GAAG;AAC3D,mBAAe;AAAA,EACjB,OAAO;AAIL,WAAO,EAAE,QAAQ,aAAa,SAAS;AAAA,EACzC;AAEA,QAAM,EAAE,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,QAAQ,KAAK;AACtD,QAAM,KAAK,MAAM,UAAU,IAAI;AAC/B,SAAO,EAAE,QAAQ,YAAY,UAAU,OAAO,KAAK,QAAQ,aAAa;AAC1E;AAIO,SAAS,qBAAqB,GAA2B;AAC9D,QAAM,SAAS,EAAE,WAAW,aAAa,UAAU,EAAE,KAAK,WAAW,EAAE,YAAY,KAAK;AACxF,SAAO,0BAA0B,EAAE,QAAQ,WAAW,EAAE,MAAM,GAAG,MAAM;AACzE;","names":[]}