@aglyn/plugins-ai 1.0.0-beta.285 → 1.0.0-beta.288

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 (228) hide show
  1. package/package.json +17 -17
  2. package/src/lib/assets/site-styles/nonprofit.webp +0 -0
  3. package/src/lib/assets/site-styles/professional.webp +0 -0
  4. package/src/lib/assets/site-styles/store.webp +0 -0
  5. package/src/lib/assets/site-styles/trades.webp +0 -0
  6. package/src/lib/components/ai-theme-proposal-card.component.js +12 -3
  7. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  8. package/src/lib/declarations.js +5 -2
  9. package/src/lib/declarations.js.map +1 -1
  10. package/src/lib/declarations.server.js +12 -0
  11. package/src/lib/declarations.server.js.map +1 -1
  12. package/src/lib/jobs/ai-build-capabilities.d.ts +3 -1
  13. package/src/lib/jobs/ai-build-capabilities.js +27 -2
  14. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  15. package/src/lib/jobs/ai-business-profile-prefill.js +6 -3
  16. package/src/lib/jobs/ai-business-profile-prefill.js.map +1 -1
  17. package/src/lib/jobs/ai-image-reads.d.ts +59 -0
  18. package/src/lib/jobs/ai-image-reads.js +138 -0
  19. package/src/lib/jobs/ai-image-reads.js.map +1 -0
  20. package/src/lib/jobs/ai-job-build-step.d.ts +29 -0
  21. package/src/lib/jobs/ai-job-build-step.js +83 -12
  22. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  23. package/src/lib/jobs/ai-job-duplicate-step.d.ts +2 -1
  24. package/src/lib/jobs/ai-job-duplicate-step.js +6 -6
  25. package/src/lib/jobs/ai-job-duplicate-step.js.map +1 -1
  26. package/src/lib/jobs/ai-job-import-step.d.ts +4 -2
  27. package/src/lib/jobs/ai-job-import-step.js +15 -11
  28. package/src/lib/jobs/ai-job-import-step.js.map +1 -1
  29. package/src/lib/jobs/ai-job-insight-step.d.ts +10 -0
  30. package/src/lib/jobs/ai-job-insight-step.js +23 -5
  31. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  32. package/src/lib/jobs/ai-job-layout-language.js +2 -1
  33. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  34. package/src/lib/jobs/ai-job-media-step.d.ts +2 -1
  35. package/src/lib/jobs/ai-job-media-step.js +6 -8
  36. package/src/lib/jobs/ai-job-media-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-navigation-step.d.ts +2 -1
  38. package/src/lib/jobs/ai-job-navigation-step.js +7 -9
  39. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-page-language.js +7 -1
  41. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  42. package/src/lib/jobs/ai-job-page-sections.js +16 -3
  43. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  44. package/src/lib/jobs/ai-job-page-step.js +9 -1
  45. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  46. package/src/lib/jobs/ai-job-plan-step.d.ts +30 -8
  47. package/src/lib/jobs/ai-job-plan-step.js +78 -13
  48. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  49. package/src/lib/jobs/ai-job-privacy-step.d.ts +4 -2
  50. package/src/lib/jobs/ai-job-privacy-step.js +14 -15
  51. package/src/lib/jobs/ai-job-privacy-step.js.map +1 -1
  52. package/src/lib/jobs/ai-job-products-step.js +5 -3
  53. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-settings-step.d.ts +2 -1
  55. package/src/lib/jobs/ai-job-settings-step.js +7 -8
  56. package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-site-content.d.ts +34 -1
  58. package/src/lib/jobs/ai-job-site-content.js +67 -8
  59. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  60. package/src/lib/jobs/ai-job-site-datasets.d.ts +53 -3
  61. package/src/lib/jobs/ai-job-site-datasets.js +300 -30
  62. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  63. package/src/lib/jobs/ai-job-site-step.js +6 -3
  64. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  65. package/src/lib/jobs/ai-job-site-store-pages.js +3 -3
  66. package/src/lib/jobs/ai-job-site-store-pages.js.map +1 -1
  67. package/src/lib/jobs/ai-job-text-step.js +2 -1
  68. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  69. package/src/lib/jobs/ai-job-tracking-step.d.ts +2 -1
  70. package/src/lib/jobs/ai-job-tracking-step.js +7 -8
  71. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
  72. package/src/lib/jobs/ai-jobs-beat.d.ts +11 -7
  73. package/src/lib/jobs/ai-jobs-beat.js +18 -8
  74. package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
  75. package/src/lib/jobs/ai-jobs-notify.d.ts +66 -1
  76. package/src/lib/jobs/ai-jobs-notify.js +136 -3
  77. package/src/lib/jobs/ai-jobs-notify.js.map +1 -1
  78. package/src/lib/jobs/ai-layout-stock-photos.d.ts +155 -7
  79. package/src/lib/jobs/ai-layout-stock-photos.js +1517 -220
  80. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  81. package/src/lib/jobs/ai-product-image.d.ts +14 -0
  82. package/src/lib/jobs/ai-product-image.js +73 -21
  83. package/src/lib/jobs/ai-product-image.js.map +1 -1
  84. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +9 -1
  85. package/src/lib/jobs/ai-theme-brand-inputs.js +28 -2
  86. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  87. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.d.ts +175 -0
  88. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.js +601 -0
  89. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.js.map +1 -0
  90. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.d.ts +46 -0
  91. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.js +2862 -0
  92. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.js.map +1 -0
  93. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.d.ts +74 -0
  94. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.js +888 -0
  95. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.js.map +1 -0
  96. package/src/lib/jobs/fixtures/ai-site-agl4092-datasets.d.ts +120 -0
  97. package/src/lib/jobs/fixtures/ai-site-agl4092-datasets.js +410 -0
  98. package/src/lib/jobs/fixtures/ai-site-agl4092-datasets.js.map +1 -0
  99. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.d.ts +29 -0
  100. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.js +81 -0
  101. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.js.map +1 -0
  102. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.d.ts +87 -0
  103. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.js +225 -0
  104. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.js.map +1 -0
  105. package/src/lib/jobs/fixtures/ai-stock-photo-agl4092-recordings.d.ts +26 -0
  106. package/src/lib/jobs/fixtures/ai-stock-photo-agl4092-recordings.js +714 -0
  107. package/src/lib/jobs/fixtures/ai-stock-photo-agl4092-recordings.js.map +1 -0
  108. package/src/lib/layout-language/ai-layout-compiler.d.ts +11 -0
  109. package/src/lib/layout-language/ai-layout-compiler.js +194 -18
  110. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  111. package/src/lib/layout-language/ai-layout-copy.d.ts +37 -0
  112. package/src/lib/layout-language/ai-layout-copy.js +383 -3
  113. package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
  114. package/src/lib/layout-language/ai-layout-design.d.ts +19 -0
  115. package/src/lib/layout-language/ai-layout-design.js +31 -11
  116. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  117. package/src/lib/layout-language/ai-layout-listings.d.ts +7 -1
  118. package/src/lib/layout-language/ai-layout-listings.js +9 -1
  119. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  120. package/src/lib/layout-language/ai-layout-pictures.d.ts +39 -1
  121. package/src/lib/layout-language/ai-layout-pictures.js +141 -4
  122. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  123. package/src/lib/layout-language/ai-layout-storefront.d.ts +18 -4
  124. package/src/lib/layout-language/ai-layout-storefront.js +100 -20
  125. package/src/lib/layout-language/ai-layout-storefront.js.map +1 -1
  126. package/src/lib/model/ai-build-job.d.ts +7 -0
  127. package/src/lib/model/ai-build-job.js +36 -7
  128. package/src/lib/model/ai-build-job.js.map +1 -1
  129. package/src/lib/model/ai-build-plan.d.ts +28 -4
  130. package/src/lib/model/ai-build-plan.js +49 -9
  131. package/src/lib/model/ai-build-plan.js.map +1 -1
  132. package/src/lib/model/ai-build-progress.js +3 -1
  133. package/src/lib/model/ai-build-progress.js.map +1 -1
  134. package/src/lib/model/ai-job-notice.d.ts +8 -0
  135. package/src/lib/model/ai-job-notice.js +33 -0
  136. package/src/lib/model/ai-job-notice.js.map +1 -1
  137. package/src/lib/model/ai-job-plan-lines.d.ts +2 -1
  138. package/src/lib/model/ai-job-plan-lines.js +6 -1
  139. package/src/lib/model/ai-job-plan-lines.js.map +1 -1
  140. package/src/lib/model/ai-jobs.types.d.ts +8 -0
  141. package/src/lib/model/ai-jobs.types.js.map +1 -1
  142. package/src/lib/model/ai-permissions.d.ts +13 -0
  143. package/src/lib/model/ai-permissions.js +29 -1
  144. package/src/lib/model/ai-permissions.js.map +1 -1
  145. package/src/lib/model/ai-plan-said-sections.d.ts +12 -3
  146. package/src/lib/model/ai-plan-said-sections.js +22 -5
  147. package/src/lib/model/ai-plan-said-sections.js.map +1 -1
  148. package/src/lib/model/ai-products.d.ts +18 -0
  149. package/src/lib/model/ai-products.js +32 -0
  150. package/src/lib/model/ai-products.js.map +1 -1
  151. package/src/lib/model/ai-site-context.d.ts +6 -2
  152. package/src/lib/model/ai-site-context.js +8 -2
  153. package/src/lib/model/ai-site-context.js.map +1 -1
  154. package/src/lib/model/ai-site-inspiration.d.ts +3 -2
  155. package/src/lib/model/ai-site-inspiration.js +3 -2
  156. package/src/lib/model/ai-site-inspiration.js.map +1 -1
  157. package/src/lib/plugin.js +4 -13
  158. package/src/lib/plugin.js.map +1 -1
  159. package/src/lib/providers/anthropic.js +9 -2
  160. package/src/lib/providers/anthropic.js.map +1 -1
  161. package/src/lib/providers/catalog.js +14 -5
  162. package/src/lib/providers/catalog.js.map +1 -1
  163. package/src/lib/providers/contract.d.ts +24 -1
  164. package/src/lib/providers/contract.js.map +1 -1
  165. package/src/lib/providers/routing.js +5 -2
  166. package/src/lib/providers/routing.js.map +1 -1
  167. package/src/lib/runtime/ai-dataset-generation.d.ts +120 -1
  168. package/src/lib/runtime/ai-dataset-generation.js +352 -3
  169. package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
  170. package/src/lib/runtime/ai-dev-replay.d.ts +1 -0
  171. package/src/lib/runtime/ai-dev-replay.js +4 -2
  172. package/src/lib/runtime/ai-dev-replay.js.map +1 -1
  173. package/src/lib/runtime/ai-doctrine-validators.d.ts +47 -3
  174. package/src/lib/runtime/ai-doctrine-validators.js +345 -28
  175. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  176. package/src/lib/runtime/ai-doctrine.d.ts +5 -4
  177. package/src/lib/runtime/ai-doctrine.js +10 -3
  178. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  179. package/src/lib/runtime/ai-icon-library.d.ts +27 -1
  180. package/src/lib/runtime/ai-icon-library.js +30 -3
  181. package/src/lib/runtime/ai-icon-library.js.map +1 -1
  182. package/src/lib/runtime/ai-products-generation.d.ts +11 -1
  183. package/src/lib/runtime/ai-products-generation.js +51 -4
  184. package/src/lib/runtime/ai-products-generation.js.map +1 -1
  185. package/src/lib/runtime/assist-answer-cache.d.ts +2 -0
  186. package/src/lib/runtime/assist-answer-cache.js +5 -2
  187. package/src/lib/runtime/assist-answer-cache.js.map +1 -1
  188. package/src/lib/server/ai-allotments.js +7 -2
  189. package/src/lib/server/ai-allotments.js.map +1 -1
  190. package/src/lib/server/ai-experiment-versions.js +5 -5
  191. package/src/lib/server/ai-experiment-versions.js.map +1 -1
  192. package/src/lib/server/ai-host-permissions-roster.d.ts +25 -0
  193. package/src/lib/server/ai-host-permissions-roster.js +33 -0
  194. package/src/lib/server/ai-host-permissions-roster.js.map +1 -0
  195. package/src/lib/server/ai-host-permissions.js +8 -6
  196. package/src/lib/server/ai-host-permissions.js.map +1 -1
  197. package/src/lib/server/ai-image-reads-route.d.ts +17 -0
  198. package/src/lib/server/ai-image-reads-route.js +106 -0
  199. package/src/lib/server/ai-image-reads-route.js.map +1 -0
  200. package/src/lib/server/ai-seo-apply.js +5 -5
  201. package/src/lib/server/ai-seo-apply.js.map +1 -1
  202. package/src/lib/server/ai-site-import-route.js +7 -4
  203. package/src/lib/server/ai-site-import-route.js.map +1 -1
  204. package/src/lib/server/assist-build.d.ts +15 -1
  205. package/src/lib/server/assist-build.js +30 -3
  206. package/src/lib/server/assist-build.js.map +1 -1
  207. package/src/lib/server/assist-chat.d.ts +11 -7
  208. package/src/lib/server/assist-chat.js +185 -48
  209. package/src/lib/server/assist-chat.js.map +1 -1
  210. package/src/lib/server/assist-docs-query.d.ts +51 -0
  211. package/src/lib/server/assist-docs-query.js +57 -0
  212. package/src/lib/server/assist-docs-query.js.map +1 -0
  213. package/src/lib/server/assist-jobs-context.d.ts +102 -0
  214. package/src/lib/server/assist-jobs-context.js +227 -0
  215. package/src/lib/server/assist-jobs-context.js.map +1 -0
  216. package/src/lib/server/assist-view-context.d.ts +8 -4
  217. package/src/lib/server/assist-view-context.js +12 -7
  218. package/src/lib/server/assist-view-context.js.map +1 -1
  219. package/src/lib/server.js +12 -0
  220. package/src/lib/server.js.map +1 -1
  221. package/src/lib/tools/ai-dataset-tool.d.ts +11 -1
  222. package/src/lib/tools/ai-dataset-tool.js +24 -7
  223. package/src/lib/tools/ai-dataset-tool.js.map +1 -1
  224. package/src/lib/tools/ai-duplicate-resource-tool.js +5 -6
  225. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  226. package/src/lib/components/ai-collaborator-permissions-column.component.d.ts +0 -32
  227. package/src/lib/components/ai-collaborator-permissions-column.component.js +0 -132
  228. package/src/lib/components/ai-collaborator-permissions-column.component.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-beat.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { randomUUID } from 'crypto'\nimport { featureLockdownRefusal, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs, type SweepAiJobsResult } from './ai-jobs'\nimport { sweepAiEmptySites, type AiEmptySiteSweepResult } from './ai-empty-site-sweep'\n\n/**\n * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).\n *\n * A job's first step runs inline in the console route when it can; every\n * step after it, every step too long for an inline door, and any step a\n * budget cut short, runs here. The sweep bounds itself at\n * `AI_JOB_SWEEP_BUDGET_MS` of wall clock and hands the rest to the next beat\n * — the ordering of the queue is the cursor, so nothing is stored between\n * beats and nothing can be skipped.\n *\n * ## Why the console runs it\n *\n * A step calls the AI provider, and the provider's key is held by the\n * console alone: the tenant app serves every published site, and a\n * credential it holds is one request-handling bug away from a visitor. So\n * the beat is a console route (`server/ai-jobs-beat-route.ts`, registered\n * by the console surface), driven every minute by its own Cloud Scheduler\n * job in `cloud/functions` on the console's cron secret — the reason the\n * sending-domain sweep is a console route and not a job on the tenant's\n * platform beat. A beat must resolve to the minute: a step handed back by\n * a budget, or a page pass waiting on the next, should resume on the next\n * minute, not the next quarter hour.\n *\n * ## The kill switch\n *\n * `ai-generate` on the staff lockdown page is the lever for a provider\n * incident or a spend runaway on the generative doors, and this beat is the\n * one generative caller with no request to refuse. So it asks the same\n * feature verdict a door would — the platform scope and then the feature\n * document — and does nothing while either is locked. A job it leaves\n * queued stays queued: its lease is not taken, so nothing expires and\n * nothing is lost, and the first beat after the lock lifts picks it up.\n *\n * The same switch paused for ONE workspace (AGL-3037) holds that workspace's\n * jobs and no other's: `runAiJobStep` asks the verdict with the job's org\n * before it claims a step, and leaves a paused workspace's job queued.\n */\n\n/** The beat's row in `SCHEDULED_JOBS`, and the mark it leaves for `/api/health/crons`. */\nexport const AI_JOBS_BEAT_CRON_ID = 'ai-jobs-beat'\n\n/**\n * The console path the beat is registered at: the one path this plugin\n * registers outside its own prefixes. `/api/admin/` is where the console's\n * scheduled sweeps live, and the console's edge lets a request past its bot\n * challenge there when it carries the cron secret's header — a route\n * anywhere else would need a firewall rule of its own before a scheduler\n * could reach it. The console app serves this exact path from a named route\n * with the sweep's function time, so the plugin dispatcher never serves it.\n */\nexport const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat'\n\n/** What one beat did: nothing while `ai-generate` is locked, else the sweep's account. */\nexport type AiJobsBeatResult =\n | { held: true }\n | ({ held: false; emptySites: AiEmptySiteSweepResult | null } & SweepAiJobsResult)\n\n/**\n * One beat: the switch, then the empty sites a guided start left that are\n * due for removal (AGL-3936), then one sweep under its own lease owner. The\n * owner is fresh per beat and fixed for the whole sweep, so two beats\n * overlapping inside a lease never read each other's lease as their own.\n *\n * The removals come first, a few a beat inside\n * `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the\n * sweep's budget, so the route's arithmetic inside `maxDuration` holds. A\n * removal that fails never holds up a job's step.\n */\nexport async function runAiJobsBeat(): Promise<AiJobsBeatResult> {\n const locked = await featureLockdownRefusal({ feature: 'ai-generate' })\n if (locked) {\n console.warn('ai jobs beat held: ai-generate is locked')\n return { held: true }\n }\n const firestore = firebaseAdmin.app().firestore()\n const startedAt = Date.now()\n const emptySites = await sweepAiEmptySites({ firestore }).catch((error: unknown) => {\n console.error('ai jobs beat: the empty site sweep failed', error)\n return null\n })\n if (emptySites?.removed) console.info(`ai jobs: ${emptySites.removed} empty site(s) removed`)\n const result = await sweepAiJobs({\n firestore,\n owner: `beat:${randomUUID()}`,\n budgetMs: Math.max(0, AI_JOB_SWEEP_BUDGET_MS - (Date.now() - startedAt)),\n })\n if (result.due) {\n console.info(\n `ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` +\n `${result.paused} held by a workspace's AI pause, ` +\n `${result.remaining} left for the next beat` +\n (result.budgetExhausted ? ' (budget exhausted)' : ''),\n )\n }\n return { held: false, emptySites, ...result }\n}\n"],"names":["randomUUID","featureLockdownRefusal","firebaseAdmin","AI_JOB_SWEEP_BUDGET_MS","sweepAiJobs","sweepAiEmptySites","AI_JOBS_BEAT_CRON_ID","AI_JOBS_BEAT_PATH","runAiJobsBeat","locked","feature","console","warn","held","firestore","app","startedAt","Date","now","emptySites","catch","error","removed","info","result","owner","budgetMs","Math","max","due","ran","skipped","paused","remaining","budgetExhausted"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,sBAAsB,EAAEC,aAAa,QAAQ,2BAA0B;AAChF,SAASC,sBAAsB,EAAEC,WAAW,QAAgC,eAAW;AACvF,SAASC,iBAAiB,QAAqC,2BAAuB;AAEtF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,wFAAwF,GACxF,OAAO,MAAMC,uBAAuB,eAAc;AAElD;;;;;;;;CAQC,GACD,OAAO,MAAMC,oBAAoB,qBAAoB;AAOrD;;;;;;;;;;CAUC,GACD,OAAO,eAAeC;IACpB,MAAMC,SAAS,MAAMR,uBAAuB;QAAES,SAAS;IAAc;IACrE,IAAID,QAAQ;QACVE,QAAQC,IAAI,CAAC;QACb,OAAO;YAAEC,MAAM;QAAK;IACtB;IACA,MAAMC,YAAYZ,cAAca,GAAG,GAAGD,SAAS;IAC/C,MAAME,YAAYC,KAAKC,GAAG;IAC1B,MAAMC,aAAa,MAAMd,kBAAkB;QAAES;IAAU,GAAGM,KAAK,CAAC,CAACC;QAC/DV,QAAQU,KAAK,CAAC,6CAA6CA;QAC3D,OAAO;IACT;IACA,IAAIF,8BAAAA,WAAYG,OAAO,EAAEX,QAAQY,IAAI,CAAC,CAAC,SAAS,EAAEJ,WAAWG,OAAO,CAAC,sBAAsB,CAAC;IAC5F,MAAME,SAAS,MAAMpB,YAAY;QAC/BU;QACAW,OAAO,CAAC,KAAK,EAAEzB,cAAc;QAC7B0B,UAAUC,KAAKC,GAAG,CAAC,GAAGzB,yBAA0Bc,CAAAA,KAAKC,GAAG,KAAKF,SAAQ;IACvE;IACA,IAAIQ,OAAOK,GAAG,EAAE;QACdlB,QAAQY,IAAI,CACV,CAAC,SAAS,EAAEC,OAAOM,GAAG,CAAC,cAAc,EAAEN,OAAOO,OAAO,CAAC,UAAU,CAAC,GAC/D,GAAGP,OAAOQ,MAAM,CAAC,iCAAiC,CAAC,GACnD,GAAGR,OAAOS,SAAS,CAAC,uBAAuB,CAAC,GAC3CT,CAAAA,OAAOU,eAAe,GAAG,wBAAwB,EAAC;IAEzD;IACA,OAAO;QAAErB,MAAM;QAAOM;OAAeK;AACvC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-beat.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { randomUUID } from 'crypto'\nimport { featureLockdownRefusal, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs, type SweepAiJobsResult } from './ai-jobs'\nimport { sweepAiEmptySites, type AiEmptySiteSweepResult } from './ai-empty-site-sweep'\nimport { sweepAiPlanReminders, type AiPlanReminderSweepResult } from './ai-jobs-notify'\n\n/**\n * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).\n *\n * A job's first step runs inline in the console route when it can; every\n * step after it, every step too long for an inline door, and any step a\n * budget cut short, runs here. The sweep bounds itself at\n * `AI_JOB_SWEEP_BUDGET_MS` of wall clock and hands the rest to the next beat\n * — the ordering of the queue is the cursor, so nothing is stored between\n * beats and nothing can be skipped.\n *\n * ## Why the console runs it\n *\n * A step calls the AI provider, and the provider's key is held by the\n * console alone: the tenant app serves every published site, and a\n * credential it holds is one request-handling bug away from a visitor. So\n * the beat is a console route (`server/ai-jobs-beat-route.ts`, registered\n * by the console surface), driven every minute by its own Cloud Scheduler\n * job in `cloud/functions` on the console's cron secret — the reason the\n * sending-domain sweep is a console route and not a job on the tenant's\n * platform beat. A beat must resolve to the minute: a step handed back by\n * a budget, or a page pass waiting on the next, should resume on the next\n * minute, not the next quarter hour.\n *\n * ## The kill switch\n *\n * `ai-generate` on the staff lockdown page is the lever for a provider\n * incident or a spend runaway on the generative doors, and this beat is the\n * one generative caller with no request to refuse. So it asks the same\n * feature verdict a door would — the platform scope and then the feature\n * document — and does nothing while either is locked. A job it leaves\n * queued stays queued: its lease is not taken, so nothing expires and\n * nothing is lost, and the first beat after the lock lifts picks it up.\n *\n * The same switch paused for ONE workspace (AGL-3037) holds that workspace's\n * jobs and no other's: `runAiJobStep` asks the verdict with the job's org\n * before it claims a step, and leaves a paused workspace's job queued.\n */\n\n/** The beat's row in `SCHEDULED_JOBS`, and the mark it leaves for `/api/health/crons`. */\nexport const AI_JOBS_BEAT_CRON_ID = 'ai-jobs-beat'\n\n/**\n * The console path the beat is registered at: the one path this plugin\n * registers outside its own prefixes. `/api/admin/` is where the console's\n * scheduled sweeps live, and the console's edge lets a request past its bot\n * challenge there when it carries the cron secret's header — a route\n * anywhere else would need a firewall rule of its own before a scheduler\n * could reach it. The console app serves this exact path from a named route\n * with the sweep's function time, so the plugin dispatcher never serves it.\n */\nexport const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat'\n\n/** What one beat did: nothing while `ai-generate` is locked, else the sweep's account. */\nexport type AiJobsBeatResult =\n | { held: true }\n | ({\n held: false\n emptySites: AiEmptySiteSweepResult | null\n planReminders: AiPlanReminderSweepResult | null\n } & SweepAiJobsResult)\n\n/**\n * One beat: the switch, then the empty sites a guided start left that are\n * due for removal (AGL-3936), then the one reminder a plan waiting a day\n * for its confirmation is owed (AGL-4076), then one sweep under its own\n * lease owner. The owner is fresh per beat and fixed for the whole sweep, so\n * two beats overlapping inside a lease never read each other's lease as\n * their own.\n *\n * The removals and the reminders come first, each a few a beat inside its\n * own budget, and whatever they took comes off the sweep's budget, so the\n * route's arithmetic inside `maxDuration` holds. A removal or a reminder that\n * fails never holds up a job's step.\n */\nexport async function runAiJobsBeat(): Promise<AiJobsBeatResult> {\n const locked = await featureLockdownRefusal({ feature: 'ai-generate' })\n if (locked) {\n console.warn('ai jobs beat held: ai-generate is locked')\n return { held: true }\n }\n const firestore = firebaseAdmin.app().firestore()\n const startedAt = Date.now()\n const emptySites = await sweepAiEmptySites({ firestore }).catch((error: unknown) => {\n console.error('ai jobs beat: the empty site sweep failed', error)\n return null\n })\n if (emptySites?.removed) console.info(`ai jobs: ${emptySites.removed} empty site(s) removed`)\n const planReminders = await sweepAiPlanReminders({ firestore }).catch((error: unknown) => {\n console.error('ai jobs beat: the plan reminders failed', error)\n return null\n })\n const result = await sweepAiJobs({\n firestore,\n owner: `beat:${randomUUID()}`,\n budgetMs: Math.max(0, AI_JOB_SWEEP_BUDGET_MS - (Date.now() - startedAt)),\n })\n if (result.due) {\n console.info(\n `ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` +\n `${result.paused} held by a workspace's AI pause, ` +\n `${result.remaining} left for the next beat` +\n (result.budgetExhausted ? ' (budget exhausted)' : ''),\n )\n }\n return { held: false, emptySites, planReminders, ...result }\n}\n"],"names":["randomUUID","featureLockdownRefusal","firebaseAdmin","AI_JOB_SWEEP_BUDGET_MS","sweepAiJobs","sweepAiEmptySites","sweepAiPlanReminders","AI_JOBS_BEAT_CRON_ID","AI_JOBS_BEAT_PATH","runAiJobsBeat","locked","feature","console","warn","held","firestore","app","startedAt","Date","now","emptySites","catch","error","removed","info","planReminders","result","owner","budgetMs","Math","max","due","ran","skipped","paused","remaining","budgetExhausted"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,sBAAsB,EAAEC,aAAa,QAAQ,2BAA0B;AAChF,SAASC,sBAAsB,EAAEC,WAAW,QAAgC,eAAW;AACvF,SAASC,iBAAiB,QAAqC,2BAAuB;AACtF,SAASC,oBAAoB,QAAwC,sBAAkB;AAEvF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,wFAAwF,GACxF,OAAO,MAAMC,uBAAuB,eAAc;AAElD;;;;;;;;CAQC,GACD,OAAO,MAAMC,oBAAoB,qBAAoB;AAWrD;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC;IACpB,MAAMC,SAAS,MAAMT,uBAAuB;QAAEU,SAAS;IAAc;IACrE,IAAID,QAAQ;QACVE,QAAQC,IAAI,CAAC;QACb,OAAO;YAAEC,MAAM;QAAK;IACtB;IACA,MAAMC,YAAYb,cAAcc,GAAG,GAAGD,SAAS;IAC/C,MAAME,YAAYC,KAAKC,GAAG;IAC1B,MAAMC,aAAa,MAAMf,kBAAkB;QAAEU;IAAU,GAAGM,KAAK,CAAC,CAACC;QAC/DV,QAAQU,KAAK,CAAC,6CAA6CA;QAC3D,OAAO;IACT;IACA,IAAIF,8BAAAA,WAAYG,OAAO,EAAEX,QAAQY,IAAI,CAAC,CAAC,SAAS,EAAEJ,WAAWG,OAAO,CAAC,sBAAsB,CAAC;IAC5F,MAAME,gBAAgB,MAAMnB,qBAAqB;QAAES;IAAU,GAAGM,KAAK,CAAC,CAACC;QACrEV,QAAQU,KAAK,CAAC,2CAA2CA;QACzD,OAAO;IACT;IACA,MAAMI,SAAS,MAAMtB,YAAY;QAC/BW;QACAY,OAAO,CAAC,KAAK,EAAE3B,cAAc;QAC7B4B,UAAUC,KAAKC,GAAG,CAAC,GAAG3B,yBAA0Be,CAAAA,KAAKC,GAAG,KAAKF,SAAQ;IACvE;IACA,IAAIS,OAAOK,GAAG,EAAE;QACdnB,QAAQY,IAAI,CACV,CAAC,SAAS,EAAEE,OAAOM,GAAG,CAAC,cAAc,EAAEN,OAAOO,OAAO,CAAC,UAAU,CAAC,GAC/D,GAAGP,OAAOQ,MAAM,CAAC,iCAAiC,CAAC,GACnD,GAAGR,OAAOS,SAAS,CAAC,uBAAuB,CAAC,GAC3CT,CAAAA,OAAOU,eAAe,GAAG,wBAAwB,EAAC;IAEzD;IACA,OAAO;QAAEtB,MAAM;QAAOM;QAAYK;OAAkBC;AACtD"}
@@ -17,6 +17,7 @@
17
17
  import { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications';
18
18
  import { raiseOperatorAlert } from '@aglyn/tenant-data-admin/server/operator-alerts';
19
19
  import { type AiJobNoticeSite } from '../model/ai-job-notice';
20
+ import type { AiJob } from '../model/ai-jobs.types';
20
21
  import { type AiJobTransitionListener } from './ai-jobs';
21
22
  /**
22
23
  * The person who started an AI job is told when it needs them, when it
@@ -40,7 +41,71 @@ export declare function aiJobTransitionNotifier(notify?: typeof notifyUsers, rea
40
41
  * its address, and where it is live — its custom domain once that serves,
41
42
  * else its platform subdomain. `null` for a site that is gone.
42
43
  */
43
- export declare function readJobSite(hostId: string): Promise<AiJobNoticeSite | null>;
44
+ export declare function readJobSite(hostId: string, firestore?: FirebaseFirestore.Firestore): Promise<AiJobNoticeSite | null>;
45
+ /**
46
+ * How long a plan waits for its confirmation before its one reminder: a day
47
+ * after the job last changed, which for a plan nobody touched is the moment
48
+ * it was ready.
49
+ */
50
+ export declare const AI_PLAN_REMINDER_AFTER_MS: number;
51
+ /**
52
+ * How long after it is due a reminder may still go out. A plan that has
53
+ * waited longer than a day and this is never reminded: the person was told
54
+ * when it was ready, and a reminder about a plan left for a week reads as
55
+ * noise, so the plans already waiting when this shipped are not mailed all
56
+ * at once. It is also the beats a lockdown of `ai-generate` may hold the
57
+ * reminders for before one is missed rather than sent late.
58
+ */
59
+ export declare const AI_PLAN_REMINDER_WINDOW_MS: number;
60
+ /** The waiting jobs one beat reads, oldest first. */
61
+ export declare const AI_PLAN_REMINDER_SCAN = 25;
62
+ /** The reminders one beat sends; the rest are the next minute's. */
63
+ export declare const AI_PLAN_REMINDER_MAX = 5;
64
+ /** Wall clock one beat gives the reminders before it runs any job's step. */
65
+ export declare const AI_PLAN_REMINDER_BUDGET_MS = 10000;
66
+ /**
67
+ * Whether a job is owed its plan reminder now (AGL-4076): its plan waits for
68
+ * the person who started it (`needs_review`, reason `plan`), has waited
69
+ * between a day and a day and {@link AI_PLAN_REMINDER_WINDOW_MS}, and the job
70
+ * was never reminded. Never a plan that is confirmed for its person (a guided
71
+ * start's), a job someone asked to cancel, or one whose site was removed: the
72
+ * same jobs the first notice skips, and the ones that moved on.
73
+ */
74
+ export declare function aiPlanReminderDue(job: Pick<AiJob, 'kind' | 'inputs' | 'status' | 'review' | 'createdBy' | 'updatedAt' | 'remindedAt' | 'cancelRequested' | 'siteRemoved'>, nowMs: number): boolean;
75
+ export interface AiPlanReminderSweepResult {
76
+ /** Jobs read that were waiting in the window, reminded or not. */
77
+ scanned: number;
78
+ /** Reminders sent. */
79
+ reminded: number;
80
+ }
81
+ export interface SweepAiPlanRemindersOptions {
82
+ firestore: FirebaseFirestore.Firestore;
83
+ now?: () => number;
84
+ budgetMs?: number;
85
+ notify?: typeof notifyUsers;
86
+ /** The job's site; `readJobSite` unless a spec replaces it. */
87
+ readSite?: (hostId: string) => Promise<AiJobNoticeSite | null>;
88
+ }
89
+ /**
90
+ * One beat's plan reminders (AGL-4076). A plan that waits for its
91
+ * confirmation is told about once when it is ready (`aiJobTransitionNotifier`)
92
+ * and once more a day later, here, and never again: 13 plans in the 10/10 to
93
+ * 10/28 window waited 4 to 428 hours after their single notice.
94
+ *
95
+ * The waiting jobs are read by the collection-group index the sweep already
96
+ * uses, on (status, updatedAt), inside the window a reminder may go out in,
97
+ * oldest first; every condition is then read again on the job, in the
98
+ * transaction that marks `remindedAt`, so two beats never both send one and a
99
+ * job confirmed, cancelled or deleted in the meantime gets none. The mark is
100
+ * written before the notice: a beat that dies between the two loses a
101
+ * reminder rather than sending a second. `notifyUsers` holds the notice to the
102
+ * person's own settings for AI job needs you, by channel, workspace and site,
103
+ * as it holds the first one; a job whose site is gone is not reminded.
104
+ *
105
+ * The mark leaves `updatedAt` alone, so the job's age in AI jobs stays the
106
+ * age of its plan.
107
+ */
108
+ export declare function sweepAiPlanReminders(options: SweepAiPlanRemindersOptions): Promise<AiPlanReminderSweepResult>;
44
109
  /**
45
110
  * Staff are told when a job fails on our side (`ai.jobFailed`), and when a
46
111
  * build finishes `done` with parts that failed on our side
@@ -20,7 +20,7 @@ import { raiseOperatorAlert } from "@aglyn/tenant-data-admin/server/operator-ale
20
20
  import { AI_BUILD_PARTLY_FAILED, AI_JOB_FAILED } from "../operator-alerts.js";
21
21
  import { liveCustomDomain } from "@aglyn/aglyn/app-utils/host-naming";
22
22
  import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
23
- import { aiJobNotice } from "../model/ai-job-notice.js";
23
+ import { aiJobNotice, aiJobPlanReminderNotice } from "../model/ai-job-notice.js";
24
24
  import { aiBusinessProfilePrefiller } from "./ai-business-profile-prefill.js";
25
25
  import { aiJobAutoConfirms } from "./ai-job-auto-confirm.js";
26
26
  import { registerAiJobTransitionListener } from "./ai-jobs.js";
@@ -78,9 +78,9 @@ import { registerAiJobTransitionListener } from "./ai-jobs.js";
78
78
  * A job's site as its notice names it: the name its managers gave it, else
79
79
  * its address, and where it is live — its custom domain once that serves,
80
80
  * else its platform subdomain. `null` for a site that is gone.
81
- */ export async function readJobSite(hostId) {
81
+ */ export async function readJobSite(hostId, firestore = firebaseAdmin.app().firestore()) {
82
82
  var _host_data;
83
- const host = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).get();
83
+ const host = await firestore.collection('hosts').doc(hostId).get();
84
84
  if (!host.exists) return null;
85
85
  const data = (_host_data = host.data()) != null ? _host_data : {};
86
86
  const text = (value)=>typeof value === 'string' ? value.trim() : '';
@@ -91,6 +91,139 @@ import { registerAiJobTransitionListener } from "./ai-jobs.js";
91
91
  url: domain ? `https://${domain}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : null
92
92
  };
93
93
  }
94
+ // ── The one plan-ready reminder (AGL-4076) ───────────────────────────────
95
+ /**
96
+ * How long a plan waits for its confirmation before its one reminder: a day
97
+ * after the job last changed, which for a plan nobody touched is the moment
98
+ * it was ready.
99
+ */ export const AI_PLAN_REMINDER_AFTER_MS = 24 * 60 * 60000;
100
+ /**
101
+ * How long after it is due a reminder may still go out. A plan that has
102
+ * waited longer than a day and this is never reminded: the person was told
103
+ * when it was ready, and a reminder about a plan left for a week reads as
104
+ * noise, so the plans already waiting when this shipped are not mailed all
105
+ * at once. It is also the beats a lockdown of `ai-generate` may hold the
106
+ * reminders for before one is missed rather than sent late.
107
+ */ export const AI_PLAN_REMINDER_WINDOW_MS = 6 * 60 * 60000;
108
+ /** The waiting jobs one beat reads, oldest first. */ export const AI_PLAN_REMINDER_SCAN = 25;
109
+ /** The reminders one beat sends; the rest are the next minute's. */ export const AI_PLAN_REMINDER_MAX = 5;
110
+ /** Wall clock one beat gives the reminders before it runs any job's step. */ export const AI_PLAN_REMINDER_BUDGET_MS = 10000;
111
+ /** The org's jobs, as `AI_JOBS_COLLECTION` in `ai-jobs.ts` names them. */ const JOBS_COLLECTION = 'aiJobs';
112
+ function instantMs(value) {
113
+ if (value === null || value === undefined) return null;
114
+ if (value instanceof Date) return value.getTime();
115
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
116
+ if (typeof value === 'string') {
117
+ const parsed = Date.parse(value);
118
+ return Number.isFinite(parsed) ? parsed : null;
119
+ }
120
+ return typeof value.toMillis === 'function' ? value.toMillis() : null;
121
+ }
122
+ /**
123
+ * Whether a job is owed its plan reminder now (AGL-4076): its plan waits for
124
+ * the person who started it (`needs_review`, reason `plan`), has waited
125
+ * between a day and a day and {@link AI_PLAN_REMINDER_WINDOW_MS}, and the job
126
+ * was never reminded. Never a plan that is confirmed for its person (a guided
127
+ * start's), a job someone asked to cancel, or one whose site was removed: the
128
+ * same jobs the first notice skips, and the ones that moved on.
129
+ */ export function aiPlanReminderDue(job, nowMs) {
130
+ var _instantMs;
131
+ var _job_review;
132
+ if (job.status !== 'needs_review' || ((_job_review = job.review) == null ? void 0 : _job_review.reason) !== 'plan' || !job.createdBy) return false;
133
+ if (job.remindedAt || job.cancelRequested || job.siteRemoved || aiJobAutoConfirms(job)) return false;
134
+ const waitedMs = nowMs - ((_instantMs = instantMs(job.updatedAt)) != null ? _instantMs : nowMs);
135
+ return waitedMs >= AI_PLAN_REMINDER_AFTER_MS && waitedMs < AI_PLAN_REMINDER_AFTER_MS + AI_PLAN_REMINDER_WINDOW_MS;
136
+ }
137
+ /**
138
+ * One beat's plan reminders (AGL-4076). A plan that waits for its
139
+ * confirmation is told about once when it is ready (`aiJobTransitionNotifier`)
140
+ * and once more a day later, here, and never again: 13 plans in the 10/10 to
141
+ * 10/28 window waited 4 to 428 hours after their single notice.
142
+ *
143
+ * The waiting jobs are read by the collection-group index the sweep already
144
+ * uses, on (status, updatedAt), inside the window a reminder may go out in,
145
+ * oldest first; every condition is then read again on the job, in the
146
+ * transaction that marks `remindedAt`, so two beats never both send one and a
147
+ * job confirmed, cancelled or deleted in the meantime gets none. The mark is
148
+ * written before the notice: a beat that dies between the two loses a
149
+ * reminder rather than sending a second. `notifyUsers` holds the notice to the
150
+ * person's own settings for AI job needs you, by channel, workspace and site,
151
+ * as it holds the first one; a job whose site is gone is not reminded.
152
+ *
153
+ * The mark leaves `updatedAt` alone, so the job's age in AI jobs stays the
154
+ * age of its plan.
155
+ */ export async function sweepAiPlanReminders(options) {
156
+ var _options_now, _options_budgetMs, _options_notify, _options_readSite;
157
+ const now = (_options_now = options.now) != null ? _options_now : Date.now;
158
+ const startedAt = now();
159
+ const budgetMs = (_options_budgetMs = options.budgetMs) != null ? _options_budgetMs : AI_PLAN_REMINDER_BUDGET_MS;
160
+ const notify = (_options_notify = options.notify) != null ? _options_notify : notifyUsers;
161
+ const readSite = (_options_readSite = options.readSite) != null ? _options_readSite : (hostId)=>readJobSite(hostId, options.firestore);
162
+ const dueBefore = startedAt - AI_PLAN_REMINDER_AFTER_MS;
163
+ const waiting = await options.firestore.collectionGroup(JOBS_COLLECTION).where('status', '==', 'needs_review').where('updatedAt', '<=', new Date(dueBefore)).where('updatedAt', '>', new Date(dueBefore - AI_PLAN_REMINDER_WINDOW_MS)).orderBy('updatedAt', 'asc').limit(AI_PLAN_REMINDER_SCAN).get();
164
+ const result = {
165
+ scanned: waiting.size,
166
+ reminded: 0
167
+ };
168
+ for (const doc of waiting.docs){
169
+ if (result.reminded >= AI_PLAN_REMINDER_MAX || now() - startedAt >= budgetMs) break;
170
+ const listed = _extends({}, doc.data(), {
171
+ $id: doc.id
172
+ });
173
+ if (!aiPlanReminderDue(listed, now())) continue;
174
+ try {
175
+ // A job's site, read before the mark, so a job whose site is gone is never marked or told.
176
+ const site = listed.hostId ? await readSite(listed.hostId) : null;
177
+ if (listed.hostId && !site) continue;
178
+ const job = await options.firestore.runTransaction(async (tx)=>{
179
+ const fresh = await tx.get(doc.ref);
180
+ if (!fresh.exists) return null;
181
+ const current = _extends({}, fresh.data(), {
182
+ $id: fresh.id
183
+ });
184
+ if (!aiPlanReminderDue(current, now())) return null;
185
+ tx.update(doc.ref, {
186
+ remindedAt: new Date(now())
187
+ });
188
+ return current;
189
+ });
190
+ if (!job) continue;
191
+ const notice = aiJobPlanReminderNotice(job, site);
192
+ if (site) {
193
+ await notify([
194
+ job.createdBy
195
+ ], notice, {
196
+ emailDetails: [
197
+ {
198
+ label: 'Site',
199
+ value: site.name
200
+ },
201
+ ...site.url ? [
202
+ {
203
+ label: 'Address',
204
+ value: site.url
205
+ }
206
+ ] : []
207
+ ]
208
+ });
209
+ } else {
210
+ await notify([
211
+ job.createdBy
212
+ ], notice);
213
+ }
214
+ result.reminded += 1;
215
+ } catch (error) {
216
+ // One job's fault is isolated; an unmarked job is read again next beat, inside its window.
217
+ console.error('ai plan reminder failed', {
218
+ orgId: listed.orgId,
219
+ jobId: listed.$id,
220
+ error
221
+ });
222
+ }
223
+ }
224
+ if (result.reminded) console.info(`ai jobs: ${result.reminded} plan reminder(s) sent`);
225
+ return result;
226
+ }
94
227
  /** The longest runner error an alert quotes; the log has the rest. */ const ALERT_ERROR_MAX = 300;
95
228
  /** At most this many failed parts are named in a partial-build alert. */ const ALERT_ITEMS_MAX = 5;
96
229
  const capped = (text)=>text.length > ALERT_ERROR_MAX ? `${text.slice(0, ALERT_ERROR_MAX)}…` : text;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-notify.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport { raiseOperatorAlert } from '@aglyn/tenant-data-admin/server/operator-alerts'\nimport { AI_BUILD_PARTLY_FAILED, AI_JOB_FAILED } from '../operator-alerts'\nimport { liveCustomDomain } from '@aglyn/aglyn/app-utils/host-naming'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { aiJobNotice, type AiJobNoticeSite } from '../model/ai-job-notice'\nimport { aiBusinessProfilePrefiller } from './ai-business-profile-prefill'\nimport { aiJobAutoConfirms } from './ai-job-auto-confirm'\nimport { registerAiJobTransitionListener, type AiJobTransitionListener } from './ai-jobs'\n\n/**\n * The person who started an AI job is told when it needs them, when it\n * finishes and when it stops (AGL-3593), through the console's notifications\n * — the bell, and the generic notification email for a person who switched\n * that channel on for the category. No other channel: there is no push.\n *\n * The machine calls this where the job's state changes, once per change\n * (`registerAiJobTransitionListener`): the request that ran a step inline,\n * or the beat that ran it with nobody watching — which is exactly when a\n * person needs telling. The recipient is the job's creator, whose job it is\n * and who alone confirms its plan; a teammate is not told about someone\n * else's draft.\n *\n * `notifyUsers` never throws, and honors the person's per-category,\n * per-workspace and per-site preferences.\n */\nexport function aiJobTransitionNotifier(\n notify: typeof notifyUsers = notifyUsers,\n readSite: (hostId: string) => Promise<AiJobNoticeSite | null> = readJobSite,\n): AiJobTransitionListener {\n return async ({ job, to }) => {\n if (!job.createdBy) return\n // A guided site start's plan is confirmed for it the moment it is kept\n // (AGL-3594): there is nothing for the person to confirm.\n if (to === 'needs-review' && job.review?.reason === 'plan' && aiJobAutoConfirms(job)) return\n // The site a job worked on, by name and address (AGL-3889): the email is\n // read away from the console, often by someone with several sites.\n const site = job.hostId ? await readSite(job.hostId).catch(() => null) : null\n const notice = aiJobNotice(job, to, site)\n if (!site) {\n await notify([job.createdBy], notice)\n return\n }\n await notify([job.createdBy], notice, {\n emailDetails: [\n { label: 'Site', value: site.name },\n ...(site.url ? [{ label: 'Address', value: site.url }] : []),\n ],\n })\n }\n}\n\n/**\n * A job's site as its notice names it: the name its managers gave it, else\n * its address, and where it is live — its custom domain once that serves,\n * else its platform subdomain. `null` for a site that is gone.\n */\nexport async function readJobSite(hostId: string): Promise<AiJobNoticeSite | null> {\n const host = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return null\n const data = (host.data() ?? {}) as Record<string, unknown>\n const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')\n const subdomain = text(data['subdomain'])\n const domain = liveCustomDomain(data as never)\n return {\n name: text(data['displayName']) || text(data['name']) || subdomain || hostId,\n url: domain ? `https://${domain}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : null,\n }\n}\n\n/** The longest runner error an alert quotes; the log has the rest. */\nconst ALERT_ERROR_MAX = 300\n\n/** At most this many failed parts are named in a partial-build alert. */\nconst ALERT_ITEMS_MAX = 5\n\nconst capped = (text: string): string =>\n text.length > ALERT_ERROR_MAX ? `${text.slice(0, ALERT_ERROR_MAX)}…` : text\n\n/**\n * Staff are told when a job fails on our side (`ai.jobFailed`), and when a\n * build finishes `done` with parts that failed on our side\n * (`ai.buildPartlyFailed`) — each deduped per job kind, so a broken provider\n * or a broken step is heard about the hour it starts. A failure that is the\n * customer's — the model declined the brief or a part of it, the site\n * switched AI off — raises nothing. `raiseOperatorAlert` never throws.\n */\nexport function aiJobFailureAlerter(\n raise: typeof raiseOperatorAlert = raiseOperatorAlert,\n): AiJobTransitionListener {\n return async ({ job, to, failure }) => {\n if (to === 'done') {\n const items = job.items ?? []\n const ours = items.filter((row) => row.status === 'failed' && row.failure?.ours)\n if (!ours.length) return\n const named = ours\n .slice(0, ALERT_ITEMS_MAX)\n .map((row) => `${row.label} (${row.failure?.reason}${row.failure?.detail ? `: ${row.failure.detail}` : ''})`)\n .join('; ')\n await raise(AI_BUILD_PARTLY_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n failed: ours.length,\n total: items.length,\n items: capped(ours.length > ALERT_ITEMS_MAX ? `${named}; and ${ours.length - ALERT_ITEMS_MAX} more` : named),\n },\n })\n return\n }\n if (to !== 'failed' || !failure?.ours) return\n const error = failure.error ?? job.error ?? 'no error recorded'\n await raise(AI_JOB_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n step: failure.stepIndex === null ? 'n/a' : job.steps[failure.stepIndex]?.name ?? failure.stepIndex,\n error: capped(error),\n },\n })\n }\n}\n\n/**\n * Registers what the jobs machine tells about each change: the notifier, the\n * staff alert for a failure on our side, and the business profile's prefill\n * from a site job (AGL-3661). One listener, each in turn; the alert and the\n * prefill swallow their own failures, so neither costs the person their notice.\n */\nexport function registerAiJobsNotify(): void {\n const notify = aiJobTransitionNotifier()\n const alert = aiJobFailureAlerter()\n const prefill = aiBusinessProfilePrefiller(() => firebaseAdmin.app().firestore())\n registerAiJobTransitionListener(async (change) => {\n await notify(change)\n await alert(change)\n await prefill(change)\n })\n}\n"],"names":["firebaseAdmin","notifyUsers","raiseOperatorAlert","AI_BUILD_PARTLY_FAILED","AI_JOB_FAILED","liveCustomDomain","TENANT_APEX","aiJobNotice","aiBusinessProfilePrefiller","aiJobAutoConfirms","registerAiJobTransitionListener","aiJobTransitionNotifier","notify","readSite","readJobSite","job","to","createdBy","review","reason","site","hostId","catch","notice","emailDetails","label","value","name","url","host","app","firestore","collection","doc","get","exists","data","text","trim","subdomain","domain","ALERT_ERROR_MAX","ALERT_ITEMS_MAX","capped","length","slice","aiJobFailureAlerter","raise","failure","items","ours","filter","row","status","named","map","detail","join","dedupeKey","kind","orgId","context","jobId","$id","failed","total","error","step","stepIndex","steps","registerAiJobsNotify","alert","prefill","change"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-notify.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport { raiseOperatorAlert } from '@aglyn/tenant-data-admin/server/operator-alerts'\nimport { AI_BUILD_PARTLY_FAILED, AI_JOB_FAILED } from '../operator-alerts'\nimport { liveCustomDomain } from '@aglyn/aglyn/app-utils/host-naming'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { aiJobNotice, aiJobPlanReminderNotice, type AiJobNoticeSite } from '../model/ai-job-notice'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport { aiBusinessProfilePrefiller } from './ai-business-profile-prefill'\nimport { aiJobAutoConfirms } from './ai-job-auto-confirm'\nimport { registerAiJobTransitionListener, type AiJobTransitionListener } from './ai-jobs'\n\n/**\n * The person who started an AI job is told when it needs them, when it\n * finishes and when it stops (AGL-3593), through the console's notifications\n * — the bell, and the generic notification email for a person who switched\n * that channel on for the category. No other channel: there is no push.\n *\n * The machine calls this where the job's state changes, once per change\n * (`registerAiJobTransitionListener`): the request that ran a step inline,\n * or the beat that ran it with nobody watching — which is exactly when a\n * person needs telling. The recipient is the job's creator, whose job it is\n * and who alone confirms its plan; a teammate is not told about someone\n * else's draft.\n *\n * `notifyUsers` never throws, and honors the person's per-category,\n * per-workspace and per-site preferences.\n */\nexport function aiJobTransitionNotifier(\n notify: typeof notifyUsers = notifyUsers,\n readSite: (hostId: string) => Promise<AiJobNoticeSite | null> = readJobSite,\n): AiJobTransitionListener {\n return async ({ job, to }) => {\n if (!job.createdBy) return\n // A guided site start's plan is confirmed for it the moment it is kept\n // (AGL-3594): there is nothing for the person to confirm.\n if (to === 'needs-review' && job.review?.reason === 'plan' && aiJobAutoConfirms(job)) return\n // The site a job worked on, by name and address (AGL-3889): the email is\n // read away from the console, often by someone with several sites.\n const site = job.hostId ? await readSite(job.hostId).catch(() => null) : null\n const notice = aiJobNotice(job, to, site)\n if (!site) {\n await notify([job.createdBy], notice)\n return\n }\n await notify([job.createdBy], notice, {\n emailDetails: [\n { label: 'Site', value: site.name },\n ...(site.url ? [{ label: 'Address', value: site.url }] : []),\n ],\n })\n }\n}\n\n/**\n * A job's site as its notice names it: the name its managers gave it, else\n * its address, and where it is live — its custom domain once that serves,\n * else its platform subdomain. `null` for a site that is gone.\n */\nexport async function readJobSite(\n hostId: string,\n firestore: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<AiJobNoticeSite | null> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n if (!host.exists) return null\n const data = (host.data() ?? {}) as Record<string, unknown>\n const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')\n const subdomain = text(data['subdomain'])\n const domain = liveCustomDomain(data as never)\n return {\n name: text(data['displayName']) || text(data['name']) || subdomain || hostId,\n url: domain ? `https://${domain}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : null,\n }\n}\n\n// ── The one plan-ready reminder (AGL-4076) ───────────────────────────────\n\n/**\n * How long a plan waits for its confirmation before its one reminder: a day\n * after the job last changed, which for a plan nobody touched is the moment\n * it was ready.\n */\nexport const AI_PLAN_REMINDER_AFTER_MS = 24 * 60 * 60_000\n\n/**\n * How long after it is due a reminder may still go out. A plan that has\n * waited longer than a day and this is never reminded: the person was told\n * when it was ready, and a reminder about a plan left for a week reads as\n * noise, so the plans already waiting when this shipped are not mailed all\n * at once. It is also the beats a lockdown of `ai-generate` may hold the\n * reminders for before one is missed rather than sent late.\n */\nexport const AI_PLAN_REMINDER_WINDOW_MS = 6 * 60 * 60_000\n\n/** The waiting jobs one beat reads, oldest first. */\nexport const AI_PLAN_REMINDER_SCAN = 25\n\n/** The reminders one beat sends; the rest are the next minute's. */\nexport const AI_PLAN_REMINDER_MAX = 5\n\n/** Wall clock one beat gives the reminders before it runs any job's step. */\nexport const AI_PLAN_REMINDER_BUDGET_MS = 10_000\n\n/** The org's jobs, as `AI_JOBS_COLLECTION` in `ai-jobs.ts` names them. */\nconst JOBS_COLLECTION = 'aiJobs'\n\ntype Instant = Date | { toMillis(): number } | number | string | null | undefined\n\nfunction instantMs(value: Instant): number | null {\n if (value === null || value === undefined) return null\n if (value instanceof Date) return value.getTime()\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n if (typeof value === 'string') {\n const parsed = Date.parse(value)\n return Number.isFinite(parsed) ? parsed : null\n }\n return typeof value.toMillis === 'function' ? value.toMillis() : null\n}\n\n/**\n * Whether a job is owed its plan reminder now (AGL-4076): its plan waits for\n * the person who started it (`needs_review`, reason `plan`), has waited\n * between a day and a day and {@link AI_PLAN_REMINDER_WINDOW_MS}, and the job\n * was never reminded. Never a plan that is confirmed for its person (a guided\n * start's), a job someone asked to cancel, or one whose site was removed: the\n * same jobs the first notice skips, and the ones that moved on.\n */\nexport function aiPlanReminderDue(\n job: Pick<AiJob, 'kind' | 'inputs' | 'status' | 'review' | 'createdBy' | 'updatedAt' | 'remindedAt' | 'cancelRequested' | 'siteRemoved'>,\n nowMs: number,\n): boolean {\n if (job.status !== 'needs_review' || job.review?.reason !== 'plan' || !job.createdBy) return false\n if (job.remindedAt || job.cancelRequested || job.siteRemoved || aiJobAutoConfirms(job)) return false\n const waitedMs = nowMs - (instantMs(job.updatedAt as Instant) ?? nowMs)\n return waitedMs >= AI_PLAN_REMINDER_AFTER_MS && waitedMs < AI_PLAN_REMINDER_AFTER_MS + AI_PLAN_REMINDER_WINDOW_MS\n}\n\nexport interface AiPlanReminderSweepResult {\n /** Jobs read that were waiting in the window, reminded or not. */\n scanned: number\n /** Reminders sent. */\n reminded: number\n}\n\nexport interface SweepAiPlanRemindersOptions {\n firestore: FirebaseFirestore.Firestore\n now?: () => number\n budgetMs?: number\n notify?: typeof notifyUsers\n /** The job's site; `readJobSite` unless a spec replaces it. */\n readSite?: (hostId: string) => Promise<AiJobNoticeSite | null>\n}\n\n/**\n * One beat's plan reminders (AGL-4076). A plan that waits for its\n * confirmation is told about once when it is ready (`aiJobTransitionNotifier`)\n * and once more a day later, here, and never again: 13 plans in the 10/10 to\n * 10/28 window waited 4 to 428 hours after their single notice.\n *\n * The waiting jobs are read by the collection-group index the sweep already\n * uses, on (status, updatedAt), inside the window a reminder may go out in,\n * oldest first; every condition is then read again on the job, in the\n * transaction that marks `remindedAt`, so two beats never both send one and a\n * job confirmed, cancelled or deleted in the meantime gets none. The mark is\n * written before the notice: a beat that dies between the two loses a\n * reminder rather than sending a second. `notifyUsers` holds the notice to the\n * person's own settings for AI job needs you, by channel, workspace and site,\n * as it holds the first one; a job whose site is gone is not reminded.\n *\n * The mark leaves `updatedAt` alone, so the job's age in AI jobs stays the\n * age of its plan.\n */\nexport async function sweepAiPlanReminders(options: SweepAiPlanRemindersOptions): Promise<AiPlanReminderSweepResult> {\n const now = options.now ?? Date.now\n const startedAt = now()\n const budgetMs = options.budgetMs ?? AI_PLAN_REMINDER_BUDGET_MS\n const notify = options.notify ?? notifyUsers\n const readSite = options.readSite ?? ((hostId: string) => readJobSite(hostId, options.firestore))\n const dueBefore = startedAt - AI_PLAN_REMINDER_AFTER_MS\n const waiting = await options.firestore\n .collectionGroup(JOBS_COLLECTION)\n .where('status', '==', 'needs_review')\n .where('updatedAt', '<=', new Date(dueBefore))\n .where('updatedAt', '>', new Date(dueBefore - AI_PLAN_REMINDER_WINDOW_MS))\n .orderBy('updatedAt', 'asc')\n .limit(AI_PLAN_REMINDER_SCAN)\n .get()\n const result: AiPlanReminderSweepResult = { scanned: waiting.size, reminded: 0 }\n for (const doc of waiting.docs) {\n if (result.reminded >= AI_PLAN_REMINDER_MAX || now() - startedAt >= budgetMs) break\n const listed = { ...(doc.data() as Omit<AiJob, '$id'>), $id: doc.id } as AiJob\n if (!aiPlanReminderDue(listed, now())) continue\n try {\n // A job's site, read before the mark, so a job whose site is gone is never marked or told.\n const site = listed.hostId ? await readSite(listed.hostId) : null\n if (listed.hostId && !site) continue\n const job = await options.firestore.runTransaction(async (tx) => {\n const fresh = await tx.get(doc.ref)\n if (!fresh.exists) return null\n const current = { ...(fresh.data() as Omit<AiJob, '$id'>), $id: fresh.id } as AiJob\n if (!aiPlanReminderDue(current, now())) return null\n tx.update(doc.ref, { remindedAt: new Date(now()) })\n return current\n })\n if (!job) continue\n const notice = aiJobPlanReminderNotice(job, site)\n if (site) {\n await notify([job.createdBy], notice, {\n emailDetails: [\n { label: 'Site', value: site.name },\n ...(site.url ? [{ label: 'Address', value: site.url }] : []),\n ],\n })\n } else {\n await notify([job.createdBy], notice)\n }\n result.reminded += 1\n } catch (error) {\n // One job's fault is isolated; an unmarked job is read again next beat, inside its window.\n console.error('ai plan reminder failed', { orgId: listed.orgId, jobId: listed.$id, error })\n }\n }\n if (result.reminded) console.info(`ai jobs: ${result.reminded} plan reminder(s) sent`)\n return result\n}\n\n/** The longest runner error an alert quotes; the log has the rest. */\nconst ALERT_ERROR_MAX = 300\n\n/** At most this many failed parts are named in a partial-build alert. */\nconst ALERT_ITEMS_MAX = 5\n\nconst capped = (text: string): string =>\n text.length > ALERT_ERROR_MAX ? `${text.slice(0, ALERT_ERROR_MAX)}…` : text\n\n/**\n * Staff are told when a job fails on our side (`ai.jobFailed`), and when a\n * build finishes `done` with parts that failed on our side\n * (`ai.buildPartlyFailed`) — each deduped per job kind, so a broken provider\n * or a broken step is heard about the hour it starts. A failure that is the\n * customer's — the model declined the brief or a part of it, the site\n * switched AI off — raises nothing. `raiseOperatorAlert` never throws.\n */\nexport function aiJobFailureAlerter(\n raise: typeof raiseOperatorAlert = raiseOperatorAlert,\n): AiJobTransitionListener {\n return async ({ job, to, failure }) => {\n if (to === 'done') {\n const items = job.items ?? []\n const ours = items.filter((row) => row.status === 'failed' && row.failure?.ours)\n if (!ours.length) return\n const named = ours\n .slice(0, ALERT_ITEMS_MAX)\n .map((row) => `${row.label} (${row.failure?.reason}${row.failure?.detail ? `: ${row.failure.detail}` : ''})`)\n .join('; ')\n await raise(AI_BUILD_PARTLY_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n failed: ours.length,\n total: items.length,\n items: capped(ours.length > ALERT_ITEMS_MAX ? `${named}; and ${ours.length - ALERT_ITEMS_MAX} more` : named),\n },\n })\n return\n }\n if (to !== 'failed' || !failure?.ours) return\n const error = failure.error ?? job.error ?? 'no error recorded'\n await raise(AI_JOB_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n step: failure.stepIndex === null ? 'n/a' : job.steps[failure.stepIndex]?.name ?? failure.stepIndex,\n error: capped(error),\n },\n })\n }\n}\n\n/**\n * Registers what the jobs machine tells about each change: the notifier, the\n * staff alert for a failure on our side, and the business profile's prefill\n * from a site job (AGL-3661). One listener, each in turn; the alert and the\n * prefill swallow their own failures, so neither costs the person their notice.\n */\nexport function registerAiJobsNotify(): void {\n const notify = aiJobTransitionNotifier()\n const alert = aiJobFailureAlerter()\n const prefill = aiBusinessProfilePrefiller(() => firebaseAdmin.app().firestore())\n registerAiJobTransitionListener(async (change) => {\n await notify(change)\n await alert(change)\n await prefill(change)\n })\n}\n"],"names":["firebaseAdmin","notifyUsers","raiseOperatorAlert","AI_BUILD_PARTLY_FAILED","AI_JOB_FAILED","liveCustomDomain","TENANT_APEX","aiJobNotice","aiJobPlanReminderNotice","aiBusinessProfilePrefiller","aiJobAutoConfirms","registerAiJobTransitionListener","aiJobTransitionNotifier","notify","readSite","readJobSite","job","to","createdBy","review","reason","site","hostId","catch","notice","emailDetails","label","value","name","url","firestore","app","host","collection","doc","get","exists","data","text","trim","subdomain","domain","AI_PLAN_REMINDER_AFTER_MS","AI_PLAN_REMINDER_WINDOW_MS","AI_PLAN_REMINDER_SCAN","AI_PLAN_REMINDER_MAX","AI_PLAN_REMINDER_BUDGET_MS","JOBS_COLLECTION","instantMs","undefined","Date","getTime","Number","isFinite","parsed","parse","toMillis","aiPlanReminderDue","nowMs","status","remindedAt","cancelRequested","siteRemoved","waitedMs","updatedAt","sweepAiPlanReminders","options","now","startedAt","budgetMs","dueBefore","waiting","collectionGroup","where","orderBy","limit","result","scanned","size","reminded","docs","listed","$id","id","runTransaction","tx","fresh","ref","current","update","error","console","orgId","jobId","info","ALERT_ERROR_MAX","ALERT_ITEMS_MAX","capped","length","slice","aiJobFailureAlerter","raise","failure","items","ours","filter","row","named","map","detail","join","dedupeKey","kind","context","failed","total","step","stepIndex","steps","registerAiJobsNotify","alert","prefill","change"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,WAAW,QAAQ,gDAA+C;AAC3E,SAASC,kBAAkB,QAAQ,kDAAiD;AACpF,SAASC,sBAAsB,EAAEC,aAAa,QAAQ,wBAAoB;AAC1E,SAASC,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,WAAW,QAAQ,qCAAoC;AAChE,SAASC,WAAW,EAAEC,uBAAuB,QAA8B,4BAAwB;AAEnG,SAASC,0BAA0B,QAAQ,mCAA+B;AAC1E,SAASC,iBAAiB,QAAQ,2BAAuB;AACzD,SAASC,+BAA+B,QAAsC,eAAW;AAEzF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBACdC,SAA6BZ,WAAW,EACxCa,WAAgEC,WAAW;IAE3E,OAAO,OAAO,EAAEC,GAAG,EAAEC,EAAE,EAAE;YAIMD;QAH7B,IAAI,CAACA,IAAIE,SAAS,EAAE;QACpB,uEAAuE;QACvE,0DAA0D;QAC1D,IAAID,OAAO,kBAAkBD,EAAAA,cAAAA,IAAIG,MAAM,qBAAVH,YAAYI,MAAM,MAAK,UAAUV,kBAAkBM,MAAM;QACtF,yEAAyE;QACzE,mEAAmE;QACnE,MAAMK,OAAOL,IAAIM,MAAM,GAAG,MAAMR,SAASE,IAAIM,MAAM,EAAEC,KAAK,CAAC,IAAM,QAAQ;QACzE,MAAMC,SAASjB,YAAYS,KAAKC,IAAII;QACpC,IAAI,CAACA,MAAM;YACT,MAAMR,OAAO;gBAACG,IAAIE,SAAS;aAAC,EAAEM;YAC9B;QACF;QACA,MAAMX,OAAO;YAACG,IAAIE,SAAS;SAAC,EAAEM,QAAQ;YACpCC,cAAc;gBACZ;oBAAEC,OAAO;oBAAQC,OAAON,KAAKO,IAAI;gBAAC;mBAC9BP,KAAKQ,GAAG,GAAG;oBAAC;wBAAEH,OAAO;wBAAWC,OAAON,KAAKQ,GAAG;oBAAC;iBAAE,GAAG,EAAE;aAC5D;QACH;IACF;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAed,YACpBO,MAAc,EACdQ,YAAyC9B,cAAc+B,GAAG,GAAGD,SAAS,EAAE;QAI1DE;IAFd,MAAMA,OAAO,MAAMF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACZ,QAAQa,GAAG;IAChE,IAAI,CAACH,KAAKI,MAAM,EAAE,OAAO;IACzB,MAAMC,QAAQL,aAAAA,KAAKK,IAAI,cAATL,aAAe,CAAC;IAC9B,MAAMM,OAAO,CAACX,QAAoB,OAAOA,UAAU,WAAWA,MAAMY,IAAI,KAAK;IAC7E,MAAMC,YAAYF,KAAKD,IAAI,CAAC,YAAY;IACxC,MAAMI,SAASpC,iBAAiBgC;IAChC,OAAO;QACLT,MAAMU,KAAKD,IAAI,CAAC,cAAc,KAAKC,KAAKD,IAAI,CAAC,OAAO,KAAKG,aAAalB;QACtEO,KAAKY,SAAS,CAAC,QAAQ,EAAEA,QAAQ,GAAGD,YAAY,CAAC,QAAQ,EAAEA,UAAU,CAAC,EAAElC,aAAa,GAAG;IAC1F;AACF;AAEA,4EAA4E;AAE5E;;;;CAIC,GACD,OAAO,MAAMoC,4BAA4B,KAAK,KAAK,MAAM;AAEzD;;;;;;;CAOC,GACD,OAAO,MAAMC,6BAA6B,IAAI,KAAK,MAAM;AAEzD,mDAAmD,GACnD,OAAO,MAAMC,wBAAwB,GAAE;AAEvC,kEAAkE,GAClE,OAAO,MAAMC,uBAAuB,EAAC;AAErC,2EAA2E,GAC3E,OAAO,MAAMC,6BAA6B,MAAM;AAEhD,wEAAwE,GACxE,MAAMC,kBAAkB;AAIxB,SAASC,UAAUrB,KAAc;IAC/B,IAAIA,UAAU,QAAQA,UAAUsB,WAAW,OAAO;IAClD,IAAItB,iBAAiBuB,MAAM,OAAOvB,MAAMwB,OAAO;IAC/C,IAAI,OAAOxB,UAAU,UAAU,OAAOyB,OAAOC,QAAQ,CAAC1B,SAASA,QAAQ;IACvE,IAAI,OAAOA,UAAU,UAAU;QAC7B,MAAM2B,SAASJ,KAAKK,KAAK,CAAC5B;QAC1B,OAAOyB,OAAOC,QAAQ,CAACC,UAAUA,SAAS;IAC5C;IACA,OAAO,OAAO3B,MAAM6B,QAAQ,KAAK,aAAa7B,MAAM6B,QAAQ,KAAK;AACnE;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdzC,GAAwI,EACxI0C,KAAa;QAIaV;QAFWhC;IAArC,IAAIA,IAAI2C,MAAM,KAAK,kBAAkB3C,EAAAA,cAAAA,IAAIG,MAAM,qBAAVH,YAAYI,MAAM,MAAK,UAAU,CAACJ,IAAIE,SAAS,EAAE,OAAO;IAC7F,IAAIF,IAAI4C,UAAU,IAAI5C,IAAI6C,eAAe,IAAI7C,IAAI8C,WAAW,IAAIpD,kBAAkBM,MAAM,OAAO;IAC/F,MAAM+C,WAAWL,UAASV,aAAAA,UAAUhC,IAAIgD,SAAS,aAAvBhB,aAAuCU;IACjE,OAAOK,YAAYrB,6BAA6BqB,WAAWrB,4BAA4BC;AACzF;AAkBA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAesB,qBAAqBC,OAAoC;QACjEA,cAEKA,mBACFA,iBACEA;IAJjB,MAAMC,OAAMD,eAAAA,QAAQC,GAAG,YAAXD,eAAehB,KAAKiB,GAAG;IACnC,MAAMC,YAAYD;IAClB,MAAME,YAAWH,oBAAAA,QAAQG,QAAQ,YAAhBH,oBAAoBpB;IACrC,MAAMjC,UAASqD,kBAAAA,QAAQrD,MAAM,YAAdqD,kBAAkBjE;IACjC,MAAMa,YAAWoD,oBAAAA,QAAQpD,QAAQ,YAAhBoD,oBAAqB,CAAC5C,SAAmBP,YAAYO,QAAQ4C,QAAQpC,SAAS;IAC/F,MAAMwC,YAAYF,YAAY1B;IAC9B,MAAM6B,UAAU,MAAML,QAAQpC,SAAS,CACpC0C,eAAe,CAACzB,iBAChB0B,KAAK,CAAC,UAAU,MAAM,gBACtBA,KAAK,CAAC,aAAa,MAAM,IAAIvB,KAAKoB,YAClCG,KAAK,CAAC,aAAa,KAAK,IAAIvB,KAAKoB,YAAY3B,6BAC7C+B,OAAO,CAAC,aAAa,OACrBC,KAAK,CAAC/B,uBACNT,GAAG;IACN,MAAMyC,SAAoC;QAAEC,SAASN,QAAQO,IAAI;QAAEC,UAAU;IAAE;IAC/E,KAAK,MAAM7C,OAAOqC,QAAQS,IAAI,CAAE;QAC9B,IAAIJ,OAAOG,QAAQ,IAAIlC,wBAAwBsB,QAAQC,aAAaC,UAAU;QAC9E,MAAMY,SAAS,aAAM/C,IAAIG,IAAI;YAA2B6C,KAAKhD,IAAIiD,EAAE;;QACnE,IAAI,CAAC1B,kBAAkBwB,QAAQd,QAAQ;QACvC,IAAI;YACF,2FAA2F;YAC3F,MAAM9C,OAAO4D,OAAO3D,MAAM,GAAG,MAAMR,SAASmE,OAAO3D,MAAM,IAAI;YAC7D,IAAI2D,OAAO3D,MAAM,IAAI,CAACD,MAAM;YAC5B,MAAML,MAAM,MAAMkD,QAAQpC,SAAS,CAACsD,cAAc,CAAC,OAAOC;gBACxD,MAAMC,QAAQ,MAAMD,GAAGlD,GAAG,CAACD,IAAIqD,GAAG;gBAClC,IAAI,CAACD,MAAMlD,MAAM,EAAE,OAAO;gBAC1B,MAAMoD,UAAU,aAAMF,MAAMjD,IAAI;oBAA2B6C,KAAKI,MAAMH,EAAE;;gBACxE,IAAI,CAAC1B,kBAAkB+B,SAASrB,QAAQ,OAAO;gBAC/CkB,GAAGI,MAAM,CAACvD,IAAIqD,GAAG,EAAE;oBAAE3B,YAAY,IAAIV,KAAKiB;gBAAO;gBACjD,OAAOqB;YACT;YACA,IAAI,CAACxE,KAAK;YACV,MAAMQ,SAAShB,wBAAwBQ,KAAKK;YAC5C,IAAIA,MAAM;gBACR,MAAMR,OAAO;oBAACG,IAAIE,SAAS;iBAAC,EAAEM,QAAQ;oBACpCC,cAAc;wBACZ;4BAAEC,OAAO;4BAAQC,OAAON,KAAKO,IAAI;wBAAC;2BAC9BP,KAAKQ,GAAG,GAAG;4BAAC;gCAAEH,OAAO;gCAAWC,OAAON,KAAKQ,GAAG;4BAAC;yBAAE,GAAG,EAAE;qBAC5D;gBACH;YACF,OAAO;gBACL,MAAMhB,OAAO;oBAACG,IAAIE,SAAS;iBAAC,EAAEM;YAChC;YACAoD,OAAOG,QAAQ,IAAI;QACrB,EAAE,OAAOW,OAAO;YACd,2FAA2F;YAC3FC,QAAQD,KAAK,CAAC,2BAA2B;gBAAEE,OAAOX,OAAOW,KAAK;gBAAEC,OAAOZ,OAAOC,GAAG;gBAAEQ;YAAM;QAC3F;IACF;IACA,IAAId,OAAOG,QAAQ,EAAEY,QAAQG,IAAI,CAAC,CAAC,SAAS,EAAElB,OAAOG,QAAQ,CAAC,sBAAsB,CAAC;IACrF,OAAOH;AACT;AAEA,oEAAoE,GACpE,MAAMmB,kBAAkB;AAExB,uEAAuE,GACvE,MAAMC,kBAAkB;AAExB,MAAMC,SAAS,CAAC3D,OACdA,KAAK4D,MAAM,GAAGH,kBAAkB,GAAGzD,KAAK6D,KAAK,CAAC,GAAGJ,iBAAiB,CAAC,CAAC,GAAGzD;AAEzE;;;;;;;CAOC,GACD,OAAO,SAAS8D,oBACdC,QAAmCnG,kBAAkB;IAErD,OAAO,OAAO,EAAEc,GAAG,EAAEC,EAAE,EAAEqF,OAAO,EAAE;YAyBlBA,MAAAA;YASiCtF;QAjC/C,IAAIC,OAAO,QAAQ;gBACHD;YAAd,MAAMuF,SAAQvF,aAAAA,IAAIuF,KAAK,YAATvF,aAAa,EAAE;YAC7B,MAAMwF,OAAOD,MAAME,MAAM,CAAC,CAACC;oBAAmCA;uBAA3BA,IAAI/C,MAAM,KAAK,cAAY+C,eAAAA,IAAIJ,OAAO,qBAAXI,aAAaF,IAAI;;YAC/E,IAAI,CAACA,KAAKN,MAAM,EAAE;YAClB,MAAMS,QAAQH,KACXL,KAAK,CAAC,GAAGH,iBACTY,GAAG,CAAC,CAACF;oBAAyBA,cAAsBA;uBAAvC,GAAGA,IAAIhF,KAAK,CAAC,EAAE,GAAEgF,eAAAA,IAAIJ,OAAO,qBAAXI,aAAatF,MAAM,GAAGsF,EAAAA,gBAAAA,IAAIJ,OAAO,qBAAXI,cAAaG,MAAM,IAAG,CAAC,EAAE,EAAEH,IAAIJ,OAAO,CAACO,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC;eAC3GC,IAAI,CAAC;YACR,MAAMT,MAAMlG,wBAAwB;gBAClC4G,WAAW/F,IAAIgG,IAAI;gBACnBpB,OAAO5E,IAAI4E,KAAK;eACZ5E,IAAIM,MAAM,GAAG;gBAAEA,QAAQN,IAAIM,MAAM;YAAC,IAAI,CAAC;gBAC3C2F,SAAS;oBACPD,MAAMhG,IAAIgG,IAAI;oBACdnB,OAAO7E,IAAIkE,GAAG;oBACdU,OAAO5E,IAAI4E,KAAK;oBAChBsB,QAAQV,KAAKN,MAAM;oBACnBiB,OAAOZ,MAAML,MAAM;oBACnBK,OAAON,OAAOO,KAAKN,MAAM,GAAGF,kBAAkB,GAAGW,MAAM,MAAM,EAAEH,KAAKN,MAAM,GAAGF,gBAAgB,KAAK,CAAC,GAAGW;gBACxG;;YAEF;QACF;QACA,IAAI1F,OAAO,YAAY,EAACqF,2BAAAA,QAASE,IAAI,GAAE;QACvC,MAAMd,SAAQY,QAAAA,iBAAAA,QAAQZ,KAAK,YAAbY,iBAAiBtF,IAAI0E,KAAK,YAA1BY,OAA8B;QAC5C,MAAMD,MAAMjG,eAAe;YACzB2G,WAAW/F,IAAIgG,IAAI;YACnBpB,OAAO5E,IAAI4E,KAAK;WACZ5E,IAAIM,MAAM,GAAG;YAAEA,QAAQN,IAAIM,MAAM;QAAC,IAAI,CAAC;YAC3C2F,SAAS;gBACPD,MAAMhG,IAAIgG,IAAI;gBACdnB,OAAO7E,IAAIkE,GAAG;gBACdU,OAAO5E,IAAI4E,KAAK;gBAChBwB,MAAMd,QAAQe,SAAS,KAAK,OAAO,kBAAQrG,+BAAAA,IAAIsG,KAAK,CAAChB,QAAQe,SAAS,CAAC,qBAA5BrG,6BAA8BY,IAAI,oBAAI0E,QAAQe,SAAS;gBAClG3B,OAAOO,OAAOP;YAChB;;IAEJ;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAAS6B;IACd,MAAM1G,SAASD;IACf,MAAM4G,QAAQpB;IACd,MAAMqB,UAAUhH,2BAA2B,IAAMT,cAAc+B,GAAG,GAAGD,SAAS;IAC9EnB,gCAAgC,OAAO+G;QACrC,MAAM7G,OAAO6G;QACb,MAAMF,MAAME;QACZ,MAAMD,QAAQC;IAChB;AACF"}