@aglyn/plugins-ai 1.0.0-beta.270 → 1.0.0-beta.271

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 (216) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.d.ts +1 -1
  3. package/src/lib/activity/ai-activity-actions.js +1 -0
  4. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  5. package/src/lib/assets/site-styles/beauty.webp +0 -0
  6. package/src/lib/assets/site-styles/blog.webp +0 -0
  7. package/src/lib/assets/site-styles/business.webp +0 -0
  8. package/src/lib/assets/site-styles/coming-soon.webp +0 -0
  9. package/src/lib/assets/site-styles/education.webp +0 -0
  10. package/src/lib/assets/site-styles/events.webp +0 -0
  11. package/src/lib/assets/site-styles/fitness.webp +0 -0
  12. package/src/lib/assets/site-styles/landing.webp +0 -0
  13. package/src/lib/assets/site-styles/music.webp +0 -0
  14. package/src/lib/assets/site-styles/nonprofit.webp +0 -0
  15. package/src/lib/assets/site-styles/personal.webp +0 -0
  16. package/src/lib/assets/site-styles/photography.webp +0 -0
  17. package/src/lib/assets/site-styles/portfolio.webp +0 -0
  18. package/src/lib/assets/site-styles/professional.webp +0 -0
  19. package/src/lib/assets/site-styles/realestate.webp +0 -0
  20. package/src/lib/assets/site-styles/restaurant.webp +0 -0
  21. package/src/lib/assets/site-styles/store.webp +0 -0
  22. package/src/lib/assets/site-styles/studio.webp +0 -0
  23. package/src/lib/assets/site-styles/trades.webp +0 -0
  24. package/src/lib/assets/site-styles/wellness.webp +0 -0
  25. package/src/lib/assets/site-styles/yoga.webp +0 -0
  26. package/src/lib/components/ai-credits-card.component.d.ts +4 -3
  27. package/src/lib/components/ai-credits-card.component.js +15 -15
  28. package/src/lib/components/ai-credits-card.component.js.map +1 -1
  29. package/src/lib/components/ai-job-input-form.component.d.ts +36 -0
  30. package/src/lib/components/ai-job-input-form.component.js +100 -0
  31. package/src/lib/components/ai-job-input-form.component.js.map +1 -0
  32. package/src/lib/components/ai-job-requests.d.ts +4 -0
  33. package/src/lib/components/ai-job-requests.js +4 -0
  34. package/src/lib/components/ai-job-requests.js.map +1 -1
  35. package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
  36. package/src/lib/components/ai-site-start-card.component.js +99 -31
  37. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  38. package/src/lib/components/ai-site-start-steps.component.d.ts +43 -2
  39. package/src/lib/components/ai-site-start-steps.component.js +651 -15
  40. package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
  41. package/src/lib/components/ai-site-start-tracking.d.ts +10 -0
  42. package/src/lib/components/ai-site-start-tracking.js +14 -0
  43. package/src/lib/components/ai-site-start-tracking.js.map +1 -1
  44. package/src/lib/components/ai-site-style-previews.js +26 -22
  45. package/src/lib/components/ai-site-style-previews.js.map +1 -1
  46. package/src/lib/components/ai-upsell-dialog.component.d.ts +7 -5
  47. package/src/lib/components/ai-upsell-dialog.component.js +8 -6
  48. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
  49. package/src/lib/components/assist-build-card.component.js +11 -1
  50. package/src/lib/components/assist-build-card.component.js.map +1 -1
  51. package/src/lib/components/assist-jobs-drawer.component.js +9 -1
  52. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  53. package/src/lib/components/assist-panel.component.js +8 -4
  54. package/src/lib/components/assist-panel.component.js.map +1 -1
  55. package/src/lib/components/staff-org-ai-card.component.js +4 -7
  56. package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
  57. package/src/lib/declarations.d.ts +2 -2
  58. package/src/lib/declarations.js +9 -5
  59. package/src/lib/declarations.js.map +1 -1
  60. package/src/lib/jobs/ai-build-capabilities.js +3 -1
  61. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  62. package/src/lib/jobs/ai-business-profile-prefill.d.ts +1 -1
  63. package/src/lib/jobs/ai-business-profile-prefill.js +14 -3
  64. package/src/lib/jobs/ai-business-profile-prefill.js.map +1 -1
  65. package/src/lib/jobs/ai-job-build-step.js +5 -3
  66. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-form-step.d.ts +15 -1
  68. package/src/lib/jobs/ai-job-form-step.js +75 -8
  69. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  70. package/src/lib/jobs/ai-job-page-language.d.ts +13 -0
  71. package/src/lib/jobs/ai-job-page-language.js +37 -1
  72. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  73. package/src/lib/jobs/ai-job-page-step.d.ts +17 -0
  74. package/src/lib/jobs/ai-job-page-step.js +37 -16
  75. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  76. package/src/lib/jobs/ai-job-plan-step.d.ts +25 -2
  77. package/src/lib/jobs/ai-job-plan-step.js +103 -29
  78. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  79. package/src/lib/jobs/ai-job-settings-step.js +4 -3
  80. package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
  81. package/src/lib/jobs/ai-job-site-datasets.d.ts +27 -0
  82. package/src/lib/jobs/ai-job-site-datasets.js +140 -14
  83. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  84. package/src/lib/jobs/ai-job-site-look.js +18 -5
  85. package/src/lib/jobs/ai-job-site-look.js.map +1 -1
  86. package/src/lib/jobs/ai-job-site-step.d.ts +16 -1
  87. package/src/lib/jobs/ai-job-site-step.js +95 -22
  88. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  89. package/src/lib/jobs/ai-job-text-step.d.ts +8 -1
  90. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  91. package/src/lib/jobs/ai-job-tracking-step.d.ts +14 -3
  92. package/src/lib/jobs/ai-job-tracking-step.js +28 -5
  93. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
  94. package/src/lib/jobs/ai-jobs.d.ts +8 -1
  95. package/src/lib/jobs/ai-jobs.js +63 -8
  96. package/src/lib/jobs/ai-jobs.js.map +1 -1
  97. package/src/lib/jobs/ai-layout-stock-photos.d.ts +27 -2
  98. package/src/lib/jobs/ai-layout-stock-photos.js +190 -19
  99. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  100. package/src/lib/jobs/ai-site-inspiration-signals.d.ts +98 -0
  101. package/src/lib/jobs/ai-site-inspiration-signals.js +585 -0
  102. package/src/lib/jobs/ai-site-inspiration-signals.js.map +1 -0
  103. package/src/lib/jobs/ai-site-inspiration.d.ts +157 -0
  104. package/src/lib/jobs/ai-site-inspiration.js +346 -0
  105. package/src/lib/jobs/ai-site-inspiration.js.map +1 -0
  106. package/src/lib/jobs/ai-site-publish.d.ts +26 -1
  107. package/src/lib/jobs/ai-site-publish.js +162 -13
  108. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  109. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +16 -0
  110. package/src/lib/jobs/ai-theme-brand-inputs.js +18 -4
  111. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  112. package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.d.ts +97 -0
  113. package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js +137 -0
  114. package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js.map +1 -0
  115. package/src/lib/jobs/fixtures/ai-site-inspiration-pages.d.ts +38 -0
  116. package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js +103 -0
  117. package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js.map +1 -0
  118. package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.d.ts +25 -0
  119. package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js +174 -0
  120. package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js.map +1 -0
  121. package/src/lib/layout-language/ai-layout-compiler.d.ts +1 -1
  122. package/src/lib/layout-language/ai-layout-compiler.js +438 -25
  123. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  124. package/src/lib/layout-language/ai-layout-contact.d.ts +57 -0
  125. package/src/lib/layout-language/ai-layout-contact.js +137 -0
  126. package/src/lib/layout-language/ai-layout-contact.js.map +1 -0
  127. package/src/lib/layout-language/ai-layout-copy.d.ts +12 -0
  128. package/src/lib/layout-language/ai-layout-copy.js +34 -2
  129. package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
  130. package/src/lib/layout-language/ai-layout-design.d.ts +65 -8
  131. package/src/lib/layout-language/ai-layout-design.js +411 -97
  132. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  133. package/src/lib/layout-language/ai-layout-frame.js +23 -0
  134. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  135. package/src/lib/layout-language/ai-layout-labels.d.ts +2 -0
  136. package/src/lib/layout-language/ai-layout-labels.js +12 -6
  137. package/src/lib/layout-language/ai-layout-labels.js.map +1 -1
  138. package/src/lib/layout-language/ai-layout-language.d.ts +3 -2
  139. package/src/lib/layout-language/ai-layout-language.js +4 -3
  140. package/src/lib/layout-language/ai-layout-language.js.map +1 -1
  141. package/src/lib/layout-language/ai-layout-links.d.ts +13 -0
  142. package/src/lib/layout-language/ai-layout-links.js +30 -0
  143. package/src/lib/layout-language/ai-layout-links.js.map +1 -1
  144. package/src/lib/layout-language/ai-layout-pictures.d.ts +49 -6
  145. package/src/lib/layout-language/ai-layout-pictures.js +145 -22
  146. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  147. package/src/lib/model/ai-build-job.d.ts +16 -1
  148. package/src/lib/model/ai-build-job.js +40 -0
  149. package/src/lib/model/ai-build-job.js.map +1 -1
  150. package/src/lib/model/ai-job-input.d.ts +33 -0
  151. package/src/lib/model/ai-job-input.js +59 -0
  152. package/src/lib/model/ai-job-input.js.map +1 -0
  153. package/src/lib/model/ai-job-notice.d.ts +4 -0
  154. package/src/lib/model/ai-job-notice.js +11 -1
  155. package/src/lib/model/ai-job-notice.js.map +1 -1
  156. package/src/lib/model/ai-jobs.types.d.ts +40 -1
  157. package/src/lib/model/ai-jobs.types.js.map +1 -1
  158. package/src/lib/model/ai-site-details.d.ts +122 -0
  159. package/src/lib/model/ai-site-details.js +300 -0
  160. package/src/lib/model/ai-site-details.js.map +1 -0
  161. package/src/lib/model/ai-site-entity.d.ts +181 -0
  162. package/src/lib/model/ai-site-entity.js +390 -0
  163. package/src/lib/model/ai-site-entity.js.map +1 -0
  164. package/src/lib/model/ai-site-inspiration.d.ts +72 -0
  165. package/src/lib/model/ai-site-inspiration.js +109 -0
  166. package/src/lib/model/ai-site-inspiration.js.map +1 -0
  167. package/src/lib/model/ai-site-job.d.ts +13 -1
  168. package/src/lib/model/ai-site-job.js +15 -2
  169. package/src/lib/model/ai-site-job.js.map +1 -1
  170. package/src/lib/model/ai-site-kinds.js +109 -21
  171. package/src/lib/model/ai-site-kinds.js.map +1 -1
  172. package/src/lib/model/ai-site-look.d.ts +10 -0
  173. package/src/lib/model/ai-site-look.js +24 -0
  174. package/src/lib/model/ai-site-look.js.map +1 -1
  175. package/src/lib/model/ai-site-start-seo.d.ts +19 -5
  176. package/src/lib/model/ai-site-start-seo.js +25 -7
  177. package/src/lib/model/ai-site-start-seo.js.map +1 -1
  178. package/src/lib/model/ai-site-start-steps.d.ts +9 -3
  179. package/src/lib/model/ai-site-start-steps.js +67 -5
  180. package/src/lib/model/ai-site-start-steps.js.map +1 -1
  181. package/src/lib/model/ai-site-start.d.ts +68 -4
  182. package/src/lib/model/ai-site-start.js +166 -13
  183. package/src/lib/model/ai-site-start.js.map +1 -1
  184. package/src/lib/model/ai-upsell-copy.js +4 -1
  185. package/src/lib/model/ai-upsell-copy.js.map +1 -1
  186. package/src/lib/plan-entitlements.d.ts +4 -3
  187. package/src/lib/plan-entitlements.js +3 -8
  188. package/src/lib/plan-entitlements.js.map +1 -1
  189. package/src/lib/runtime/ai-dataset-generation.d.ts +8 -1
  190. package/src/lib/runtime/ai-dataset-generation.js +12 -2
  191. package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
  192. package/src/lib/runtime/ai-doctrine-validators.d.ts +26 -33
  193. package/src/lib/runtime/ai-doctrine-validators.js +54 -44
  194. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  195. package/src/lib/runtime/ai-doctrine.js +1 -1
  196. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  197. package/src/lib/runtime/ai-node-tree.d.ts +7 -0
  198. package/src/lib/runtime/ai-node-tree.js +9 -1
  199. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  200. package/src/lib/server/ai-create-route.d.ts +1 -1
  201. package/src/lib/server/ai-create-route.js +3 -4
  202. package/src/lib/server/ai-create-route.js.map +1 -1
  203. package/src/lib/server/ai-jobs-resume.js +27 -3
  204. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  205. package/src/lib/server/ai-jobs-route.js +7 -0
  206. package/src/lib/server/ai-jobs-route.js.map +1 -1
  207. package/src/lib/usage/assist-credits.d.ts +4 -4
  208. package/src/lib/usage/assist-credits.js +4 -4
  209. package/src/lib/usage/assist-credits.js.map +1 -1
  210. package/src/lib/usage/staff-org-ai-view.js +6 -3
  211. package/src/lib/usage/staff-org-ai-view.js.map +1 -1
  212. package/src/lib/usage/staff-org-ai.d.ts +4 -2
  213. package/src/lib/usage/staff-org-ai.js +7 -7
  214. package/src/lib/usage/staff-org-ai.js.map +1 -1
  215. package/src/lib/usage/staff-org-usage-ai.js +2 -2
  216. package/src/lib/usage/staff-org-usage-ai.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-resume.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\n// lockdown-423: via libs/plugins/ai/src/lib/runtime/ai-gate.ts\n// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict.\n\nimport { randomUUID } from 'crypto'\nimport { aiJobAdmissionRefusal, aiJobSiteRefusal, type AiJobAdmissionRefusal } from '../jobs/ai-job-admission'\nimport {\n AI_JOB_INLINE_BUDGET_MS,\n aiJobNextStepMinimumMs,\n aiJobSummary,\n getAiJob,\n aiBuildRetryable,\n resumeAiJob,\n retryAiBuildJob,\n runAiJobStep,\n writeAiJobAudit,\n} from '../jobs/ai-jobs'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport { aiBuildFreeCreditsRefusal, aiBuildFreeTaste } from '../jobs/ai-job-build-step'\nimport {\n aiBuildCreditRange,\n aiBuildFirstPagePlan,\n aiBuildRetryLedger,\n aiBuildUnits,\n aiLedgerUnits,\n} from '../model/ai-build-job'\nimport { aiCreditsConfirmation, type AiCreditsPrompt } from '../model/ai-credit-estimate'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport { aiGateLadder } from '../runtime/ai-gate'\nimport { releaseAssistMessage } from '../usage/assist-usage'\n\n/**\n * Resume a job that stopped for a person (AGL-2935): confirm its plan, or\n * try again the step whose answer broke a building rule on its re-ask.\n *\n * A door that SPENDS — the next step calls the provider — so it climbs the\n * whole ladder the create door climbs: `aiGenerative`,\n * `release_ai_generative`, the `ai-generate` switch, `ai.generate` on the\n * job's site, a per-uid window and a reservation. It then runs the next\n * step inline on that reservation, the way the create door runs the first,\n * and hands the reservation back whenever no step runs here.\n *\n * The body names the org and the job's site. A site that is not the job's\n * is refused, so a collaborator's permission on one site never confirms a\n * job on another. Audited per resume that changed something.\n */\n\nconst AI_JOBS_RESUME_RATE_LIMIT = { key: 'ai-jobs-resume', limit: 10, windowMs: 60_000 }\n\nexport async function POST(\n request: Request,\n context: { params: Promise<{ jobId: string }> },\n): Promise<Response> {\n let payload: Record<string, unknown> | null\n try {\n payload = (await request.json()) as Record<string, unknown>\n } catch {\n payload = null\n }\n const rawHost = payload?.['hostId']\n const hostId = typeof rawHost === 'string' && rawHost ? rawHost : null\n const gate = await aiGateLadder(\n { request, orgId: String(payload?.['orgId'] ?? ''), hostId },\n {\n feature: 'aiGenerative',\n releaseFlag: 'release_ai_generative',\n lockdownFeature: 'ai-generate',\n permission: 'ai.generate',\n rateLimit: AI_JOBS_RESUME_RATE_LIMIT,\n },\n )\n if (gate instanceof Response) return gate\n // No token is spent until a step runs, so every exit before one hands the\n // message back.\n const release = () =>\n releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(() => undefined)\n\n const { jobId } = await context.params\n const existing = await getAiJob(gate.firestore, gate.orgId, jobId)\n if (!existing) {\n await release()\n return Response.json({ error: 'Unknown job' }, { status: 404 })\n }\n if ((existing.hostId ?? null) !== hostId) {\n await release()\n return Response.json({ error: 'That job belongs to another site' }, { status: 400 })\n }\n // \"Build what fits\" (AGL-3722): a Free job past what is left, started on\n // the person's go-ahead, which the job keeps.\n const creditsConfirmed = payload?.['creditsConfirmed'] === true\n let confirmedCredits: AiCreditsPrompt | null = null\n const onCreditsConfirmed = (prompt: AiCreditsPrompt) => {\n confirmedCredits = prompt\n }\n // \"Build the home page first\" (AGL-3722): the plan waiting for its\n // confirmation narrowed to its home page and what that page needs.\n const reduceToFirstPage =\n payload?.['reduce'] === 'first-page' && existing.kind === 'build' && existing.status === 'needs_review'\n const reduced = reduceToFirstPage && existing.plan ? aiBuildFirstPagePlan(existing.plan) : null\n if (reduceToFirstPage && !reduced) {\n await release()\n return Response.json(\n { error: 'This plan has no smaller first build', job: aiJobSummary(existing, new Date()) },\n { status: 409 },\n )\n }\n // A refusal as the door answers it: a Free job past what is left carries\n // its prompt, and a plan card the figures it should now show.\n const refused = async (refusal: AiJobAdmissionRefusal) => {\n await release()\n const now = new Date()\n const summary = aiJobSummary(existing, now)\n const fresh = refusal.credits\n ? { left: refusal.credits.left, total: FREE_AI_TASTE_CREDITS_PER_MONTH, resetsOn: refusal.credits.resetsOn }\n : null\n const job = fresh && summary.review ? { ...summary, review: { ...summary.review, freeCredits: fresh } } : summary\n return Response.json(\n {\n error: refusal.error,\n ...(refusal.code ? { code: refusal.code } : {}),\n ...(refusal.credits ? { credits: refusal.credits } : {}),\n job,\n },\n { status: refusal.status },\n )\n }\n // Try again on a build (AGL-3616): its failed items and what they left\n // unbuilt, never what succeeded, and never its plan again.\n const retryFailedItems = payload?.['retry'] === 'failed-items'\n if (retryFailedItems) {\n const refusal = await aiBuildRetryRefusal(gate, existing, { confirmed: creditsConfirmed, onCreditsConfirmed }).catch(\n (error: unknown) => {\n console.error('ai build retry admission failed', { orgId: gate.orgId, jobId, error })\n return { status: 500 as const, error: 'The AI job could not be resumed' }\n },\n )\n if (refusal) {\n if (refusal.status === 500) {\n await release()\n return Response.json({ error: refusal.error, job: aiJobSummary(existing, new Date()) }, { status: 500 })\n }\n return refused(refusal)\n }\n }\n // A confirmed plan runs the step that writes, and an allowance free when the\n // job was created may be used by now (AGL-2909): the kind is asked again\n // before anything runs, and a refusal leaves the job waiting for review.\n if (existing.status === 'needs_review') {\n let refusal: Awaited<ReturnType<typeof aiJobAdmissionRefusal>>\n try {\n // A site that switched AI off first (AGL-3028), then the kind's own check.\n refusal =\n (await aiJobSiteRefusal({\n firestore: gate.firestore,\n org: gate.org,\n hostId: existing.hostId ?? null,\n })) ??\n (await aiJobAdmissionRefusal(existing.kind, {\n firestore: gate.firestore,\n orgId: gate.orgId,\n hostId: existing.hostId ?? null,\n inputs: existing.inputs ?? {},\n org: gate.org,\n // The plan being confirmed, for a kind that builds only some plans —\n // the smaller first build where the person chose it (AGL-3722).\n plan: reduced ?? existing.plan ?? null,\n uid: gate.uid,\n creditsConfirmed,\n onCreditsConfirmed,\n }))\n } catch (error) {\n await release()\n console.error('ai job admission failed', { orgId: gate.orgId, jobId, error })\n return Response.json({ error: 'The AI job could not be resumed' }, { status: 500 })\n }\n if (refusal) return refused(refusal)\n }\n const now = new Date()\n const confirmation = aiCreditsConfirmation(confirmedCredits, gate.uid, now)\n const { job, changed } = retryFailedItems\n ? await retryAiBuildJob(gate.firestore, gate.orgId, jobId, now, { creditsConfirmed: confirmation })\n : await resumeAiJob(gate.firestore, gate.orgId, jobId, { uid: gate.uid }, now, {\n publishConfirmed: payload?.['publish'] === true,\n creditsConfirmed: confirmation,\n ...(reduced ? { reducePlan: aiBuildFirstPagePlan } : {}),\n })\n if (!changed) {\n await release()\n return Response.json(\n { error: 'This job is not waiting for review', job: aiJobSummary(job, now) },\n { status: 409 },\n )\n }\n await writeAiJobAudit(gate.firestore, {\n action: 'ai.job.resume',\n actorUid: gate.uid,\n actorEmail: gate.decoded.email ?? null,\n orgId: gate.orgId,\n jobId,\n after: {\n status: job.status,\n reason: retryFailedItems ? 'retry-failed-items' : (existing.review?.reason ?? null),\n kind: existing.kind,\n },\n })\n if (job.status !== 'queued') {\n await release()\n return Response.json({ job: aiJobSummary(job, now) }, { status: 200 })\n }\n // A step that needs more time than this request has (AGL-2907) is left\n // queued for the beat, which starts it with a budget of its own: a provider\n // call this request's timeout cut off would be billed upstream and metered\n // nowhere.\n if (aiJobNextStepMinimumMs(job) > AI_JOB_INLINE_BUDGET_MS) {\n await release()\n return Response.json({ job: aiJobSummary(job, now) }, { status: 200 })\n }\n\n const run = await runAiJobStep(gate.firestore, gate.orgId, jobId, {\n owner: `route:${randomUUID()}`,\n now,\n reservation: gate.reservation,\n org: gate.org,\n signal: AbortSignal.timeout(AI_JOB_INLINE_BUDGET_MS),\n actor: { uid: gate.uid, email: gate.decoded.email ?? null },\n // The workspace's AI pause lets staff through, as the ladder above did.\n staff: gate.staff,\n })\n if (run.outcome === 'not-claimable') {\n // The beat claimed the step between the resume and this run; it runs\n // there, on a reservation of its own.\n await release()\n const latest = await getAiJob(gate.firestore, gate.orgId, jobId)\n return Response.json({ job: aiJobSummary(latest ?? job, now) }, { status: 200 })\n }\n return Response.json({ job: aiJobSummary(run.job) }, { status: 200 })\n}\n\n/**\n * Why a build's Try again cannot run (AGL-3616), or `null`: it is not a\n * finished build, the site switched AI off, or — on the Free taste — the\n * month's Free credits do not cover what the retried items are estimated at.\n */\nasync function aiBuildRetryRefusal(\n gate: { firestore: FirebaseFirestore.Firestore; orgId: string; org: object | null },\n job: AiJob,\n credits: { confirmed: boolean; onCreditsConfirmed: (prompt: AiCreditsPrompt) => void },\n): Promise<AiJobAdmissionRefusal | null> {\n const plan = job.plan?.status === 'confirmed' ? job.plan : null\n if (!aiBuildRetryable(job) || !plan || !job.items?.length) {\n return { status: 409, error: 'Only a finished build can try its failed items again' }\n }\n const site = await aiJobSiteRefusal({ firestore: gate.firestore, org: gate.org, hostId: job.hostId ?? null })\n if (site) return site\n const { retried } = aiBuildRetryLedger(job.items, job.kind === 'site' ? aiLedgerUnits(job.items) : aiBuildUnits(plan))\n if (!retried.length) return { status: 409, error: 'Nothing in this build is left to try again' }\n // A site's retried parts are its own pages and creations, held by the\n // reservation each pass takes; a build's are admitted on their measured p90\n // against the Free taste, or on the person's go-ahead (AGL-3722).\n if (job.kind === 'site' || !aiBuildFreeTaste(gate.org)) return null\n return aiBuildFreeCreditsRefusal({\n firestore: gate.firestore,\n orgId: gate.orgId,\n org: gate.org,\n range: aiBuildCreditRange(plan, { slots: new Set(retried) }),\n confirmed: credits.confirmed,\n onConfirmed: credits.onCreditsConfirmed,\n now: new Date(),\n })\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["randomUUID","aiJobAdmissionRefusal","aiJobSiteRefusal","AI_JOB_INLINE_BUDGET_MS","aiJobNextStepMinimumMs","aiJobSummary","getAiJob","aiBuildRetryable","resumeAiJob","retryAiBuildJob","runAiJobStep","writeAiJobAudit","aiBuildFreeCreditsRefusal","aiBuildFreeTaste","aiBuildCreditRange","aiBuildFirstPagePlan","aiBuildRetryLedger","aiBuildUnits","aiLedgerUnits","aiCreditsConfirmation","FREE_AI_TASTE_CREDITS_PER_MONTH","aiGateLadder","releaseAssistMessage","AI_JOBS_RESUME_RATE_LIMIT","key","limit","windowMs","POST","request","context","existing","gate","payload","json","rawHost","hostId","orgId","String","feature","releaseFlag","lockdownFeature","permission","rateLimit","Response","release","firestore","reservation","catch","undefined","jobId","params","error","status","creditsConfirmed","confirmedCredits","onCreditsConfirmed","prompt","reduceToFirstPage","kind","reduced","plan","job","Date","refused","refusal","now","summary","fresh","credits","left","total","resetsOn","review","freeCredits","code","retryFailedItems","aiBuildRetryRefusal","confirmed","console","org","inputs","uid","confirmation","changed","publishConfirmed","reducePlan","action","actorUid","actorEmail","decoded","email","after","reason","run","owner","signal","AbortSignal","timeout","actor","staff","outcome","latest","items","length","site","retried","range","slots","Set","onConfirmed","dynamic","maxDuration"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-resume.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\n// lockdown-423: via libs/plugins/ai/src/lib/runtime/ai-gate.ts\n// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict.\n\nimport { randomUUID } from 'crypto'\nimport { aiJobAdmissionRefusal, aiJobSiteRefusal, type AiJobAdmissionRefusal } from '../jobs/ai-job-admission'\nimport {\n AI_JOB_INLINE_BUDGET_MS,\n aiJobNextStepMinimumMs,\n aiJobSummary,\n getAiJob,\n aiBuildRetryable,\n resumeAiJob,\n retryAiBuildJob,\n runAiJobStep,\n writeAiJobAudit,\n} from '../jobs/ai-jobs'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport { aiBuildFreeCreditsRefusal, aiBuildFreeTaste } from '../jobs/ai-job-build-step'\nimport {\n aiBuildCreditRange,\n aiBuildFirstPagePlan,\n aiBuildRetryLedger,\n aiBuildUnits,\n aiLedgerUnits,\n} from '../model/ai-build-job'\nimport { aiCreditsConfirmation, type AiCreditsPrompt } from '../model/ai-credit-estimate'\nimport { aiJobInputProblems } from '../model/ai-job-input'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport { aiGateLadder } from '../runtime/ai-gate'\nimport { releaseAssistMessage } from '../usage/assist-usage'\n\n/**\n * Resume a job that stopped for a person (AGL-2935): confirm its plan, or\n * try again the step whose answer broke a building rule on its re-ask — or\n * answer what a job waiting in `needs_input` asks (`input`, AGL-3978), which\n * returns it to its plan card, or a 400 with `fieldErrors` by field.\n *\n * A door that SPENDS — the next step calls the provider — so it climbs the\n * whole ladder the create door climbs: `aiGenerative`,\n * `release_ai_generative`, the `ai-generate` switch, `ai.generate` on the\n * job's site, a per-uid window and a reservation. It then runs the next\n * step inline on that reservation, the way the create door runs the first,\n * and hands the reservation back whenever no step runs here.\n *\n * The body names the org and the job's site. A site that is not the job's\n * is refused, so a collaborator's permission on one site never confirms a\n * job on another. Audited per resume that changed something.\n */\n\nconst AI_JOBS_RESUME_RATE_LIMIT = { key: 'ai-jobs-resume', limit: 10, windowMs: 60_000 }\n\nexport async function POST(\n request: Request,\n context: { params: Promise<{ jobId: string }> },\n): Promise<Response> {\n let payload: Record<string, unknown> | null\n try {\n payload = (await request.json()) as Record<string, unknown>\n } catch {\n payload = null\n }\n const rawHost = payload?.['hostId']\n const hostId = typeof rawHost === 'string' && rawHost ? rawHost : null\n const gate = await aiGateLadder(\n { request, orgId: String(payload?.['orgId'] ?? ''), hostId },\n {\n feature: 'aiGenerative',\n releaseFlag: 'release_ai_generative',\n lockdownFeature: 'ai-generate',\n permission: 'ai.generate',\n rateLimit: AI_JOBS_RESUME_RATE_LIMIT,\n },\n )\n if (gate instanceof Response) return gate\n // No token is spent until a step runs, so every exit before one hands the\n // message back.\n const release = () =>\n releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(() => undefined)\n\n const { jobId } = await context.params\n const existing = await getAiJob(gate.firestore, gate.orgId, jobId)\n if (!existing) {\n await release()\n return Response.json({ error: 'Unknown job' }, { status: 404 })\n }\n if ((existing.hostId ?? null) !== hostId) {\n await release()\n return Response.json({ error: 'That job belongs to another site' }, { status: 400 })\n }\n // \"Build what fits\" (AGL-3722): a Free job past what is left, started on\n // the person's go-ahead, which the job keeps.\n const creditsConfirmed = payload?.['creditsConfirmed'] === true\n let confirmedCredits: AiCreditsPrompt | null = null\n const onCreditsConfirmed = (prompt: AiCreditsPrompt) => {\n confirmedCredits = prompt\n }\n // \"Build the home page first\" (AGL-3722): the plan waiting for its\n // confirmation narrowed to its home page and what that page needs.\n const reduceToFirstPage =\n payload?.['reduce'] === 'first-page' && existing.kind === 'build' && existing.status === 'needs_review'\n const reduced = reduceToFirstPage && existing.plan ? aiBuildFirstPagePlan(existing.plan) : null\n if (reduceToFirstPage && !reduced) {\n await release()\n return Response.json(\n { error: 'This plan has no smaller first build', job: aiJobSummary(existing, new Date()) },\n { status: 409 },\n )\n }\n // A refusal as the door answers it: a Free job past what is left carries\n // its prompt, and a plan card the figures it should now show.\n const refused = async (refusal: AiJobAdmissionRefusal) => {\n await release()\n const now = new Date()\n const summary = aiJobSummary(existing, now)\n const fresh = refusal.credits\n ? { left: refusal.credits.left, total: FREE_AI_TASTE_CREDITS_PER_MONTH, resetsOn: refusal.credits.resetsOn }\n : null\n const job = fresh && summary.review ? { ...summary, review: { ...summary.review, freeCredits: fresh } } : summary\n return Response.json(\n {\n error: refusal.error,\n ...(refusal.code ? { code: refusal.code } : {}),\n ...(refusal.credits ? { credits: refusal.credits } : {}),\n job,\n },\n { status: refusal.status },\n )\n }\n // Try again on a build (AGL-3616): its failed items and what they left\n // unbuilt, never what succeeded, and never its plan again.\n const retryFailedItems = payload?.['retry'] === 'failed-items'\n if (retryFailedItems) {\n const refusal = await aiBuildRetryRefusal(gate, existing, { confirmed: creditsConfirmed, onCreditsConfirmed }).catch(\n (error: unknown) => {\n console.error('ai build retry admission failed', { orgId: gate.orgId, jobId, error })\n return { status: 500 as const, error: 'The AI job could not be resumed' }\n },\n )\n if (refusal) {\n if (refusal.status === 500) {\n await release()\n return Response.json({ error: refusal.error, job: aiJobSummary(existing, new Date()) }, { status: 500 })\n }\n return refused(refusal)\n }\n }\n // A job waiting for a person's answer (AGL-3978): every field must read\n // before anything changes, and a refusal names each field it is about.\n const answering = existing.status === 'needs_input' && Boolean(existing.input?.fields?.length)\n const rawInput = payload?.['input']\n const answers = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : null\n if (answering && existing.input) {\n const fieldErrors = aiJobInputProblems(existing.input, answers)\n const first = Object.values(fieldErrors)[0]\n if (first) {\n await release()\n return Response.json({ error: first, fieldErrors, job: aiJobSummary(existing, new Date()) }, { status: 400 })\n }\n }\n // A confirmed plan runs the step that writes, and an allowance free when the\n // job was created may be used by now (AGL-2909): the kind is asked again\n // before anything runs, and a refusal leaves the job waiting for review.\n if (existing.status === 'needs_review') {\n let refusal: Awaited<ReturnType<typeof aiJobAdmissionRefusal>>\n try {\n // A site that switched AI off first (AGL-3028), then the kind's own check.\n refusal =\n (await aiJobSiteRefusal({\n firestore: gate.firestore,\n org: gate.org,\n hostId: existing.hostId ?? null,\n })) ??\n (await aiJobAdmissionRefusal(existing.kind, {\n firestore: gate.firestore,\n orgId: gate.orgId,\n hostId: existing.hostId ?? null,\n inputs: existing.inputs ?? {},\n org: gate.org,\n // The plan being confirmed, for a kind that builds only some plans —\n // the smaller first build where the person chose it (AGL-3722).\n plan: reduced ?? existing.plan ?? null,\n uid: gate.uid,\n creditsConfirmed,\n onCreditsConfirmed,\n }))\n } catch (error) {\n await release()\n console.error('ai job admission failed', { orgId: gate.orgId, jobId, error })\n return Response.json({ error: 'The AI job could not be resumed' }, { status: 500 })\n }\n if (refusal) return refused(refusal)\n }\n const now = new Date()\n const confirmation = aiCreditsConfirmation(confirmedCredits, gate.uid, now)\n const { job, changed } = retryFailedItems\n ? await retryAiBuildJob(gate.firestore, gate.orgId, jobId, now, { creditsConfirmed: confirmation })\n : await resumeAiJob(gate.firestore, gate.orgId, jobId, { uid: gate.uid }, now, {\n publishConfirmed: payload?.['publish'] === true,\n creditsConfirmed: confirmation,\n ...(reduced ? { reducePlan: aiBuildFirstPagePlan } : {}),\n ...(answering ? { input: answers } : {}),\n })\n if (!changed) {\n await release()\n return Response.json(\n { error: 'This job is not waiting for review', job: aiJobSummary(job, now) },\n { status: 409 },\n )\n }\n await writeAiJobAudit(gate.firestore, {\n action: 'ai.job.resume',\n actorUid: gate.uid,\n actorEmail: gate.decoded.email ?? null,\n orgId: gate.orgId,\n jobId,\n after: {\n status: job.status,\n reason: retryFailedItems ? 'retry-failed-items' : answering ? 'input' : (existing.review?.reason ?? null),\n kind: existing.kind,\n },\n })\n if (job.status !== 'queued') {\n await release()\n return Response.json({ job: aiJobSummary(job, now) }, { status: 200 })\n }\n // A step that needs more time than this request has (AGL-2907) is left\n // queued for the beat, which starts it with a budget of its own: a provider\n // call this request's timeout cut off would be billed upstream and metered\n // nowhere.\n if (aiJobNextStepMinimumMs(job) > AI_JOB_INLINE_BUDGET_MS) {\n await release()\n return Response.json({ job: aiJobSummary(job, now) }, { status: 200 })\n }\n\n const run = await runAiJobStep(gate.firestore, gate.orgId, jobId, {\n owner: `route:${randomUUID()}`,\n now,\n reservation: gate.reservation,\n org: gate.org,\n signal: AbortSignal.timeout(AI_JOB_INLINE_BUDGET_MS),\n actor: { uid: gate.uid, email: gate.decoded.email ?? null },\n // The workspace's AI pause lets staff through, as the ladder above did.\n staff: gate.staff,\n })\n if (run.outcome === 'not-claimable') {\n // The beat claimed the step between the resume and this run; it runs\n // there, on a reservation of its own.\n await release()\n const latest = await getAiJob(gate.firestore, gate.orgId, jobId)\n return Response.json({ job: aiJobSummary(latest ?? job, now) }, { status: 200 })\n }\n return Response.json({ job: aiJobSummary(run.job) }, { status: 200 })\n}\n\n/**\n * Why a build's Try again cannot run (AGL-3616), or `null`: it is not a\n * finished build, the site switched AI off, or — on the Free taste — the\n * month's Free credits do not cover what the retried items are estimated at.\n */\nasync function aiBuildRetryRefusal(\n gate: { firestore: FirebaseFirestore.Firestore; orgId: string; org: object | null },\n job: AiJob,\n credits: { confirmed: boolean; onCreditsConfirmed: (prompt: AiCreditsPrompt) => void },\n): Promise<AiJobAdmissionRefusal | null> {\n const plan = job.plan?.status === 'confirmed' ? job.plan : null\n if (!aiBuildRetryable(job) || !plan || !job.items?.length) {\n return { status: 409, error: 'Only a finished build can try its failed items again' }\n }\n const site = await aiJobSiteRefusal({ firestore: gate.firestore, org: gate.org, hostId: job.hostId ?? null })\n if (site) return site\n const { retried } = aiBuildRetryLedger(job.items, job.kind === 'site' ? aiLedgerUnits(job.items) : aiBuildUnits(plan))\n if (!retried.length) return { status: 409, error: 'Nothing in this build is left to try again' }\n // A site's retried parts are its own pages and creations, held by the\n // reservation each pass takes; a build's are admitted on their measured p90\n // against the Free taste, or on the person's go-ahead (AGL-3722).\n if (job.kind === 'site' || !aiBuildFreeTaste(gate.org)) return null\n return aiBuildFreeCreditsRefusal({\n firestore: gate.firestore,\n orgId: gate.orgId,\n org: gate.org,\n range: aiBuildCreditRange(plan, { slots: new Set(retried) }),\n confirmed: credits.confirmed,\n onConfirmed: credits.onCreditsConfirmed,\n now: new Date(),\n })\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["randomUUID","aiJobAdmissionRefusal","aiJobSiteRefusal","AI_JOB_INLINE_BUDGET_MS","aiJobNextStepMinimumMs","aiJobSummary","getAiJob","aiBuildRetryable","resumeAiJob","retryAiBuildJob","runAiJobStep","writeAiJobAudit","aiBuildFreeCreditsRefusal","aiBuildFreeTaste","aiBuildCreditRange","aiBuildFirstPagePlan","aiBuildRetryLedger","aiBuildUnits","aiLedgerUnits","aiCreditsConfirmation","aiJobInputProblems","FREE_AI_TASTE_CREDITS_PER_MONTH","aiGateLadder","releaseAssistMessage","AI_JOBS_RESUME_RATE_LIMIT","key","limit","windowMs","POST","request","context","existing","gate","payload","json","rawHost","hostId","orgId","String","feature","releaseFlag","lockdownFeature","permission","rateLimit","Response","release","firestore","reservation","catch","undefined","jobId","params","error","status","creditsConfirmed","confirmedCredits","onCreditsConfirmed","prompt","reduceToFirstPage","kind","reduced","plan","job","Date","refused","refusal","now","summary","fresh","credits","left","total","resetsOn","review","freeCredits","code","retryFailedItems","aiBuildRetryRefusal","confirmed","console","answering","Boolean","input","fields","length","rawInput","answers","Array","isArray","fieldErrors","first","Object","values","org","inputs","uid","confirmation","changed","publishConfirmed","reducePlan","action","actorUid","actorEmail","decoded","email","after","reason","run","owner","signal","AbortSignal","timeout","actor","staff","outcome","latest","items","site","retried","range","slots","Set","onConfirmed","dynamic","maxDuration"],"mappings":"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"}
@@ -36,6 +36,8 @@ import { aiJobsGate } from "./ai-jobs-gate.js";
36
36
  import { withoutErasedSites } from "./ai-jobs-live-sites.js";
37
37
  import { AI_JOB_ACTIVE_STATUSES } from "../model/ai-job-activity.js";
38
38
  import { reportServerError } from "@aglyn/tenant-data-admin/server/client-error-report";
39
+ import { isServerReleaseFlagOnForOrg } from "@aglyn/tenant-data-admin/server/release-flags";
40
+ import { AI_SITE_INSPIRATION_FLAG } from "../model/ai-site-inspiration.js";
39
41
  /**
40
42
  * A caught fault on this door, reported as well as logged (AGL-1921).
41
43
  *
@@ -396,6 +398,9 @@ export async function GET(request) {
396
398
  org: gate.org,
397
399
  now
398
400
  }) : null;
401
+ // Whether the guided start asks for the sites the person likes (AGL-3975):
402
+ // the workspace's own verdict, the one its site job reads them by.
403
+ const siteInspiration = await isServerReleaseFlagOnForOrg(AI_SITE_INSPIRATION_FLAG, gate.orgId).catch(()=>false);
399
404
  return Response.json(_extends({
400
405
  jobs: jobs.map((job)=>aiJobSummary(job, now)),
401
406
  // Whether the workspace spends the Free taste (AGL-3594), so the guided
@@ -403,6 +408,8 @@ export async function GET(request) {
403
408
  freeTaste
404
409
  }, freeCredits ? {
405
410
  freeCredits
411
+ } : {}, siteInspiration ? {
412
+ siteInspiration: true
406
413
  } : {}), {
407
414
  status: 200,
408
415
  headers: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-route.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\n// lockdown-423: via libs/plugins/ai/src/lib/runtime/ai-gate.ts\n// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict; the\n// GET climbs `ai-jobs-gate.ts`, which carries the same rung.\n\nimport { randomUUID } from 'crypto'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n AI_JOB_KINDS,\n type AiJobKind,\n type AiJobStatus,\n} from '../model/ai-jobs.types'\n// By their own entry points rather than the barrel (AGL-2903): the specs\n// stub the barrel closed-world, and the ladder, the machine and the runtime\n// are the things under test here, not things to be stubbed away.\nimport { aiGateLadder } from '../runtime/ai-gate'\nimport { aiJobAdmissionRefusal, aiJobSiteRefusal } from '../jobs/ai-job-admission'\nimport { AI_JOB_BRIEF_MAX_CHARS } from '../jobs/ai-job-text-step'\nimport { aiJobAdmittedInputs } from '../jobs/ai-job-auto-confirm'\nimport {\n AI_JOB_INLINE_BUDGET_MS,\n aiJobNextStepMinimumMs,\n aiJobSummary,\n createAiJob,\n listAiJobs,\n runAiJobStep,\n} from '../jobs/ai-jobs'\nimport { releaseAssistMessage } from '../usage/assist-usage'\nimport { readFreeAiCreditsLeft } from '../usage/free-ai-credits-left'\nimport { aiCreditsConfirmation, type AiCreditsPrompt } from '../model/ai-credit-estimate'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport { aiJobsGate } from './ai-jobs-gate'\nimport { withoutErasedSites } from './ai-jobs-live-sites'\nimport { AI_JOB_ACTIVE_STATUSES } from '../model/ai-job-activity'\nimport { reportServerError } from '@aglyn/tenant-data-admin/server/client-error-report'\n\n/**\n * A caught fault on this door, reported as well as logged (AGL-1921).\n *\n * `onRequestError` sees only an UNCAUGHT throw, and the Vercel log drain\n * forwards the status line but not a `console.error`, so the three 500s\n * this route answered on 2026-10-06 at 17:41–17:47Z reached the alert as\n * START/END/REPORT with no reason anywhere. Never throws.\n */\nasync function reportJobFault(what: string, error: unknown): Promise<void> {\n try {\n await reportServerError(\n {\n message: `${what}: ${error instanceof Error ? error.message : String(error)}`,\n stack: error instanceof Error ? error.stack : undefined,\n route: '/api/ai/jobs',\n routeType: 'route',\n method: 'POST',\n },\n { service: 'console-web' },\n )\n } catch {\n // The console line beside each call is the record of last resort.\n }\n}\n\n/**\n * AI generation jobs: create and list (AGL-2904).\n *\n * `POST` is a door that spends, so it climbs the whole ladder (AGL-2903) —\n * `aiGenerative`, `release_ai_generative`, the `ai-generate` switch, a\n * per-uid window, and a reservation — and then creates the job and runs\n * its first step INLINE under `AI_JOB_INLINE_BUDGET_MS`. A step that\n * finishes in time answers with the job `done` (or `queued` behind its\n * next step); one that does not is aborted, its reservation handed back,\n * and the job answers `queued` for the beat to resume. Either way the\n * caller gets the job document, and the events route is how it watches\n * from there.\n *\n * `POST` with `check: true` climbs the same ladder and asks the same\n * admission, then hands the reservation back and creates nothing: a 200\n * with `ok` true, or the refusal the create would have answered. \"Create site\"\n * asks it before it makes the site a guided start builds on (AGL-3859), so\n * a start that would be refused, or that asks for the Free go-ahead, leaves\n * no empty site behind. A checked job may name no site yet.\n *\n * `GET` lists the org's jobs, newest first, through the read gate: the\n * same rungs up to the lockdown verdict, no reservation. A job naming a\n * site that no longer exists is left out, so a page can come back shorter\n * than its `limit`.\n *\n * The request must NAME the org it is metered against (AGL-1934). There\n * is no fallback to \"the caller's first org\".\n */\n\nconst AI_JOBS_RATE_LIMIT = { key: 'ai-jobs', limit: 10, windowMs: 60_000 }\n\n/** Kind-specific inputs, bounded: a runner reads scalars, not a document. */\nconst MAX_INPUTS_JSON_CHARS = 8_000\n\n/** The admitted statuses for the list filter, as the union spells them. */\nconst AI_JOB_STATUSES: readonly AiJobStatus[] = [\n 'queued',\n 'running',\n 'needs_input',\n 'needs_review',\n 'done',\n 'failed',\n 'canceled',\n]\n\nexport interface CreateAiJobBody {\n orgId: string\n hostId: string | null\n kind: AiJobKind\n brief: string\n inputs: Record<string, string | number | boolean>\n /** A catalog model the creator picked (AGL-2942), or `null` for Auto. */\n model: string | null\n}\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The longest model id a body may carry; the catalog's ids are far shorter. */\nconst MAX_MODEL_CHARS = 100\n\n/** Validate + clamp the request body; a string names what is wrong. */\nexport function parseCreateAiJobBody(payload: unknown): CreateAiJobBody | string {\n const body = (payload ?? {}) as Record<string, unknown>\n const orgId = String(body.orgId ?? '').trim()\n if (!orgId) return 'Open a workspace before using this feature'\n const kind = String(body.kind ?? '')\n if (!(AI_JOB_KINDS as readonly string[]).includes(kind)) {\n return `kind must be one of ${AI_JOB_KINDS.join(', ')}`\n }\n const brief = String(body.brief ?? '').trim()\n if (!brief) return 'Write a brief for the job'\n if (brief.length > AI_JOB_BRIEF_MAX_CHARS) {\n return `Keep the brief under ${AI_JOB_BRIEF_MAX_CHARS.toLocaleString('en-US')} characters`\n }\n const rawHost = body.hostId\n const hostId =\n rawHost === undefined || rawHost === null || rawHost === ''\n ? null\n : typeof rawHost === 'string' && ID_CHARS.test(rawHost)\n ? rawHost\n : undefined\n if (hostId === undefined) return 'hostId is not a site id'\n // A theme is fields on one site's document, so a theme job that names no\n // site has nothing to propose a change to (AGL-2938).\n if (kind === 'theme' && !hostId) return 'Open a site before changing its theme'\n const rawInputs = body.inputs\n const inputs: Record<string, string | number | boolean> = {}\n if (rawInputs !== undefined && rawInputs !== null) {\n if (typeof rawInputs !== 'object' || Array.isArray(rawInputs)) {\n return 'inputs must be an object'\n }\n for (const [key, value] of Object.entries(rawInputs as Record<string, unknown>)) {\n if (\n typeof value !== 'string' &&\n typeof value !== 'number' &&\n typeof value !== 'boolean'\n ) {\n return `inputs.${key} must be a string, number or boolean`\n }\n inputs[key] = value\n }\n if (JSON.stringify(inputs).length > MAX_INPUTS_JSON_CHARS) {\n return 'inputs are too large'\n }\n }\n const rawModel = typeof body.model === 'string' ? body.model.trim() : ''\n if (rawModel.length > MAX_MODEL_CHARS) return 'model is not a model id'\n // Validated against the plan and the allotment allowlists when each step\n // runs, not here: the allowlists are read inside the step's reservation.\n const model = rawModel && rawModel !== 'auto' ? rawModel : null\n return { orgId, hostId, kind: kind as AiJobKind, brief, inputs, model }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n payload = null\n }\n const parsed = parseCreateAiJobBody(payload)\n // The org is parsed BEFORE the ladder so a request that named none is\n // refused with the ladder's 400 rather than a shape error; every other\n // shape fault waits until the caller has proven who they are.\n const orgId = typeof parsed === 'string' ? String((payload as Record<string, unknown> | null)?.orgId ?? '') : parsed.orgId\n // The site rides along for the same reason: a collaborator's `ai.generate`\n // is decided per site (AGL-2927), so the ladder needs it before it can say\n // whether this member may generate here at all.\n const hostId = typeof parsed === 'string' ? null : parsed.hostId\n const gate = await aiGateLadder(\n { request, orgId, hostId },\n {\n feature: 'aiGenerative',\n releaseFlag: 'release_ai_generative',\n lockdownFeature: 'ai-generate',\n permission: 'ai.generate',\n rateLimit: AI_JOBS_RATE_LIMIT,\n },\n )\n if (gate instanceof Response) return gate\n if (typeof parsed === 'string') {\n // Admitted, reserved, and then found to have sent nothing runnable: the\n // reservation goes back, because no token was spent.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ error: parsed }, { status: 400 })\n }\n // What only this kind can say about the request (AGL-2909): a site it\n // needs, inputs it reads, an allowance its draft counts against. Asked\n // before the job exists, so a refusal spends nothing.\n let refusal: Awaited<ReturnType<typeof aiJobAdmissionRefusal>>\n // A Free start past what is left, which the person chose to build as far as\n // it goes (AGL-3722): the prompt they confirmed, kept on the job.\n let confirmedCredits: AiCreditsPrompt | null = null\n // Asked before the site exists (AGL-3859): answers, creates nothing.\n const check = (payload as Record<string, unknown> | null)?.['check'] === true\n try {\n // A site that switched AI off first (AGL-3028), then the kind's own check.\n refusal =\n (await aiJobSiteRefusal({\n firestore: gate.firestore,\n org: gate.org,\n hostId: parsed.hostId,\n })) ??\n (await aiJobAdmissionRefusal(parsed.kind, {\n firestore: gate.firestore,\n orgId: gate.orgId,\n hostId: parsed.hostId,\n siteToCome: check && !parsed.hostId,\n inputs: parsed.inputs,\n org: gate.org,\n uid: gate.uid,\n creditsConfirmed: (payload as Record<string, unknown> | null)?.['creditsConfirmed'] === true,\n onCreditsConfirmed: (prompt) => {\n confirmedCredits = prompt\n },\n }))\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job admission failed', { orgId: gate.orgId, kind: parsed.kind, error })\n await reportJobFault('ai job admission failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n if (refusal) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n // A Free start past what is left asks before it starts (AGL-3722): the\n // prompt rides the 409, so the dialog offers its choices.\n return Response.json(\n { error: refusal.error, ...(refusal.code ? { code: refusal.code } : {}), ...(refusal.credits ? { credits: refusal.credits } : {}) },\n { status: refusal.status },\n )\n }\n if (check) {\n // Admitted, and nothing to run: the reservation goes back.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ ok: true }, { status: 200 })\n }\n\n const now = new Date()\n let created: Awaited<ReturnType<typeof createAiJob>>\n try {\n // The guided start's own plan confirmation (AGL-3594), kept only for a\n // site job on a site that has published nothing of the owner's yet.\n const inputs = await aiJobAdmittedInputs(gate.firestore, {\n kind: parsed.kind,\n hostId: parsed.hostId,\n inputs: parsed.inputs,\n })\n created = await createAiJob(\n gate.firestore,\n {\n orgId: gate.orgId,\n hostId: parsed.hostId,\n kind: parsed.kind,\n brief: parsed.brief,\n inputs,\n model: parsed.model,\n createdBy: gate.uid,\n createdByEmail: gate.decoded.email ?? null,\n creditsConfirmed: aiCreditsConfirmation(confirmedCredits, gate.uid, now),\n },\n now,\n )\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job create failed', { orgId: gate.orgId, error })\n await reportJobFault('ai job create failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n const jobId = created.$id\n\n // A first step that needs more time than this request has (AGL-2907) is left\n // queued for the beat, which starts it with a budget of its own: a provider\n // call this request's timeout cut off would be billed upstream and metered\n // nowhere. Nothing ran, so the reservation goes back.\n if (aiJobNextStepMinimumMs(created) > AI_JOB_INLINE_BUDGET_MS) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json(\n {\n job: aiJobSummary(created, now),\n meter: aiUsageMeter(gate.reservation, { lastCredits: null }),\n },\n { status: 200 },\n )\n }\n\n // The first step, inline, on the reservation the ladder already holds.\n // The runner hands that reservation back itself if the provider is never\n // reached, and re-queues the step when the budget ends first.\n const run = await runAiJobStep(gate.firestore, gate.orgId, jobId, {\n owner: `route:${randomUUID()}`,\n now,\n reservation: gate.reservation,\n org: gate.org,\n signal: AbortSignal.timeout(AI_JOB_INLINE_BUDGET_MS),\n // The caller is on the request, so what the inline step produces is\n // attributed with their address; the beat's steps carry the uid alone.\n actor: { uid: gate.uid, email: gate.decoded.email ?? null },\n // The workspace's AI pause lets staff through, as the ladder above did.\n staff: gate.staff,\n })\n if (run.outcome === 'not-claimable') {\n // A job created a moment ago has a claimable first step; anything else\n // is a fault in the machine, and the reservation must not be stranded.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job first step not claimable', { orgId: gate.orgId, jobId })\n await reportJobFault('ai job first step not claimable', new Error(`job ${jobId}`))\n return Response.json({ error: 'The AI job could not be started' }, { status: 500 })\n }\n return Response.json(\n {\n job: aiJobSummary(run.job),\n // The usage strip's envelope (AGL-2942): the reservation the ladder\n // took, with what the inline step spent.\n meter: aiUsageMeter(gate.reservation, {\n lastCredits: run.job.creditsSpent > 0 ? run.job.creditsSpent : null,\n }),\n },\n { status: 200 },\n )\n}\n\nexport async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const gate = await aiJobsGate(request, url.searchParams.get('orgId') ?? '')\n if (gate instanceof Response) return gate\n const rawStatus = url.searchParams.get('status') ?? ''\n const status = (AI_JOB_STATUSES as readonly string[]).includes(rawStatus)\n ? (rawStatus as AiJobStatus)\n : undefined\n // `active` (AGL-3593): every job not yet settled, which is what the\n // console's top-bar indicator and the Assist launcher count.\n const active = rawStatus === 'active'\n const limit = Number(url.searchParams.get('limit') ?? '')\n // `hostId` (AGL-3596): one site's jobs, for the site's AI jobs page — a\n // query on the field under the (hostId, createdAt) index, never a filter\n // of a loaded window. An id that is not one is refused rather than ignored,\n // which would answer with every site's jobs.\n const rawHost = url.searchParams.get('hostId')\n if (rawHost !== null && !ID_CHARS.test(rawHost)) {\n return Response.json({ error: 'hostId is not a site id' }, { status: 400 })\n }\n const now = new Date()\n // A job naming a site that has been deleted is not listed (AGL-3596), so\n // no surface counts or shows work on a site nobody can open.\n const jobs = await withoutErasedSites(\n gate.firestore,\n await listAiJobs(gate.firestore, gate.orgId, {\n ...(status ? { status } : {}),\n ...(active ? { statuses: AI_JOB_ACTIVE_STATUSES } : {}),\n ...(rawHost ? { hostId: rawHost } : {}),\n ...(Number.isFinite(limit) && limit > 0 ? { limit } : {}),\n }),\n )\n const freeTaste = resolveEffectivePlan(gate.org as never) === 'free'\n // What the Free workspace has left this month (AGL-3660): the less of its\n // own band and its owner's allowance across their Free workspaces, which is\n // what the steps' reservations refuse at — never the 300 a month starts with.\n const freeCredits = freeTaste\n ? await readFreeAiCreditsLeft(gate.firestore, { orgId: gate.orgId, org: gate.org as object | null, now })\n : null\n return Response.json(\n {\n jobs: jobs.map((job) => aiJobSummary(job, now)),\n // Whether the workspace spends the Free taste (AGL-3594), so the guided\n // start offers the Free page band from the request it already makes.\n freeTaste,\n ...(freeCredits ? { freeCredits } : {}),\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["randomUUID","resolveEffectivePlan","AI_JOB_KINDS","aiGateLadder","aiJobAdmissionRefusal","aiJobSiteRefusal","AI_JOB_BRIEF_MAX_CHARS","aiJobAdmittedInputs","AI_JOB_INLINE_BUDGET_MS","aiJobNextStepMinimumMs","aiJobSummary","createAiJob","listAiJobs","runAiJobStep","releaseAssistMessage","readFreeAiCreditsLeft","aiCreditsConfirmation","aiUsageMeter","aiJobsGate","withoutErasedSites","AI_JOB_ACTIVE_STATUSES","reportServerError","reportJobFault","what","error","message","Error","String","stack","undefined","route","routeType","method","service","AI_JOBS_RATE_LIMIT","key","limit","windowMs","MAX_INPUTS_JSON_CHARS","AI_JOB_STATUSES","ID_CHARS","MAX_MODEL_CHARS","parseCreateAiJobBody","payload","body","orgId","trim","kind","includes","join","brief","length","toLocaleString","rawHost","hostId","test","rawInputs","inputs","Array","isArray","value","Object","entries","JSON","stringify","rawModel","model","POST","request","gate","json","parsed","feature","releaseFlag","lockdownFeature","permission","rateLimit","Response","firestore","reservation","catch","status","refusal","confirmedCredits","check","org","siteToCome","uid","creditsConfirmed","onCreditsConfirmed","prompt","console","code","credits","ok","now","Date","created","createdBy","createdByEmail","decoded","email","jobId","$id","job","meter","lastCredits","run","owner","signal","AbortSignal","timeout","actor","staff","outcome","creditsSpent","GET","url","URL","searchParams","get","rawStatus","active","Number","jobs","statuses","isFinite","freeTaste","freeCredits","map","headers","dynamic","maxDuration"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-route.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\n// lockdown-423: via libs/plugins/ai/src/lib/runtime/ai-gate.ts\n// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict; the\n// GET climbs `ai-jobs-gate.ts`, which carries the same rung.\n\nimport { randomUUID } from 'crypto'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n AI_JOB_KINDS,\n type AiJobKind,\n type AiJobStatus,\n} from '../model/ai-jobs.types'\n// By their own entry points rather than the barrel (AGL-2903): the specs\n// stub the barrel closed-world, and the ladder, the machine and the runtime\n// are the things under test here, not things to be stubbed away.\nimport { aiGateLadder } from '../runtime/ai-gate'\nimport { aiJobAdmissionRefusal, aiJobSiteRefusal } from '../jobs/ai-job-admission'\nimport { AI_JOB_BRIEF_MAX_CHARS } from '../jobs/ai-job-text-step'\nimport { aiJobAdmittedInputs } from '../jobs/ai-job-auto-confirm'\nimport {\n AI_JOB_INLINE_BUDGET_MS,\n aiJobNextStepMinimumMs,\n aiJobSummary,\n createAiJob,\n listAiJobs,\n runAiJobStep,\n} from '../jobs/ai-jobs'\nimport { releaseAssistMessage } from '../usage/assist-usage'\nimport { readFreeAiCreditsLeft } from '../usage/free-ai-credits-left'\nimport { aiCreditsConfirmation, type AiCreditsPrompt } from '../model/ai-credit-estimate'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport { aiJobsGate } from './ai-jobs-gate'\nimport { withoutErasedSites } from './ai-jobs-live-sites'\nimport { AI_JOB_ACTIVE_STATUSES } from '../model/ai-job-activity'\nimport { reportServerError } from '@aglyn/tenant-data-admin/server/client-error-report'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { AI_SITE_INSPIRATION_FLAG } from '../model/ai-site-inspiration'\n\n/**\n * A caught fault on this door, reported as well as logged (AGL-1921).\n *\n * `onRequestError` sees only an UNCAUGHT throw, and the Vercel log drain\n * forwards the status line but not a `console.error`, so the three 500s\n * this route answered on 2026-10-06 at 17:41–17:47Z reached the alert as\n * START/END/REPORT with no reason anywhere. Never throws.\n */\nasync function reportJobFault(what: string, error: unknown): Promise<void> {\n try {\n await reportServerError(\n {\n message: `${what}: ${error instanceof Error ? error.message : String(error)}`,\n stack: error instanceof Error ? error.stack : undefined,\n route: '/api/ai/jobs',\n routeType: 'route',\n method: 'POST',\n },\n { service: 'console-web' },\n )\n } catch {\n // The console line beside each call is the record of last resort.\n }\n}\n\n/**\n * AI generation jobs: create and list (AGL-2904).\n *\n * `POST` is a door that spends, so it climbs the whole ladder (AGL-2903) —\n * `aiGenerative`, `release_ai_generative`, the `ai-generate` switch, a\n * per-uid window, and a reservation — and then creates the job and runs\n * its first step INLINE under `AI_JOB_INLINE_BUDGET_MS`. A step that\n * finishes in time answers with the job `done` (or `queued` behind its\n * next step); one that does not is aborted, its reservation handed back,\n * and the job answers `queued` for the beat to resume. Either way the\n * caller gets the job document, and the events route is how it watches\n * from there.\n *\n * `POST` with `check: true` climbs the same ladder and asks the same\n * admission, then hands the reservation back and creates nothing: a 200\n * with `ok` true, or the refusal the create would have answered. \"Create site\"\n * asks it before it makes the site a guided start builds on (AGL-3859), so\n * a start that would be refused, or that asks for the Free go-ahead, leaves\n * no empty site behind. A checked job may name no site yet.\n *\n * `GET` lists the org's jobs, newest first, through the read gate: the\n * same rungs up to the lockdown verdict, no reservation. A job naming a\n * site that no longer exists is left out, so a page can come back shorter\n * than its `limit`.\n *\n * The request must NAME the org it is metered against (AGL-1934). There\n * is no fallback to \"the caller's first org\".\n */\n\nconst AI_JOBS_RATE_LIMIT = { key: 'ai-jobs', limit: 10, windowMs: 60_000 }\n\n/** Kind-specific inputs, bounded: a runner reads scalars, not a document. */\nconst MAX_INPUTS_JSON_CHARS = 8_000\n\n/** The admitted statuses for the list filter, as the union spells them. */\nconst AI_JOB_STATUSES: readonly AiJobStatus[] = [\n 'queued',\n 'running',\n 'needs_input',\n 'needs_review',\n 'done',\n 'failed',\n 'canceled',\n]\n\nexport interface CreateAiJobBody {\n orgId: string\n hostId: string | null\n kind: AiJobKind\n brief: string\n inputs: Record<string, string | number | boolean>\n /** A catalog model the creator picked (AGL-2942), or `null` for Auto. */\n model: string | null\n}\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The longest model id a body may carry; the catalog's ids are far shorter. */\nconst MAX_MODEL_CHARS = 100\n\n/** Validate + clamp the request body; a string names what is wrong. */\nexport function parseCreateAiJobBody(payload: unknown): CreateAiJobBody | string {\n const body = (payload ?? {}) as Record<string, unknown>\n const orgId = String(body.orgId ?? '').trim()\n if (!orgId) return 'Open a workspace before using this feature'\n const kind = String(body.kind ?? '')\n if (!(AI_JOB_KINDS as readonly string[]).includes(kind)) {\n return `kind must be one of ${AI_JOB_KINDS.join(', ')}`\n }\n const brief = String(body.brief ?? '').trim()\n if (!brief) return 'Write a brief for the job'\n if (brief.length > AI_JOB_BRIEF_MAX_CHARS) {\n return `Keep the brief under ${AI_JOB_BRIEF_MAX_CHARS.toLocaleString('en-US')} characters`\n }\n const rawHost = body.hostId\n const hostId =\n rawHost === undefined || rawHost === null || rawHost === ''\n ? null\n : typeof rawHost === 'string' && ID_CHARS.test(rawHost)\n ? rawHost\n : undefined\n if (hostId === undefined) return 'hostId is not a site id'\n // A theme is fields on one site's document, so a theme job that names no\n // site has nothing to propose a change to (AGL-2938).\n if (kind === 'theme' && !hostId) return 'Open a site before changing its theme'\n const rawInputs = body.inputs\n const inputs: Record<string, string | number | boolean> = {}\n if (rawInputs !== undefined && rawInputs !== null) {\n if (typeof rawInputs !== 'object' || Array.isArray(rawInputs)) {\n return 'inputs must be an object'\n }\n for (const [key, value] of Object.entries(rawInputs as Record<string, unknown>)) {\n if (\n typeof value !== 'string' &&\n typeof value !== 'number' &&\n typeof value !== 'boolean'\n ) {\n return `inputs.${key} must be a string, number or boolean`\n }\n inputs[key] = value\n }\n if (JSON.stringify(inputs).length > MAX_INPUTS_JSON_CHARS) {\n return 'inputs are too large'\n }\n }\n const rawModel = typeof body.model === 'string' ? body.model.trim() : ''\n if (rawModel.length > MAX_MODEL_CHARS) return 'model is not a model id'\n // Validated against the plan and the allotment allowlists when each step\n // runs, not here: the allowlists are read inside the step's reservation.\n const model = rawModel && rawModel !== 'auto' ? rawModel : null\n return { orgId, hostId, kind: kind as AiJobKind, brief, inputs, model }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n payload = null\n }\n const parsed = parseCreateAiJobBody(payload)\n // The org is parsed BEFORE the ladder so a request that named none is\n // refused with the ladder's 400 rather than a shape error; every other\n // shape fault waits until the caller has proven who they are.\n const orgId = typeof parsed === 'string' ? String((payload as Record<string, unknown> | null)?.orgId ?? '') : parsed.orgId\n // The site rides along for the same reason: a collaborator's `ai.generate`\n // is decided per site (AGL-2927), so the ladder needs it before it can say\n // whether this member may generate here at all.\n const hostId = typeof parsed === 'string' ? null : parsed.hostId\n const gate = await aiGateLadder(\n { request, orgId, hostId },\n {\n feature: 'aiGenerative',\n releaseFlag: 'release_ai_generative',\n lockdownFeature: 'ai-generate',\n permission: 'ai.generate',\n rateLimit: AI_JOBS_RATE_LIMIT,\n },\n )\n if (gate instanceof Response) return gate\n if (typeof parsed === 'string') {\n // Admitted, reserved, and then found to have sent nothing runnable: the\n // reservation goes back, because no token was spent.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ error: parsed }, { status: 400 })\n }\n // What only this kind can say about the request (AGL-2909): a site it\n // needs, inputs it reads, an allowance its draft counts against. Asked\n // before the job exists, so a refusal spends nothing.\n let refusal: Awaited<ReturnType<typeof aiJobAdmissionRefusal>>\n // A Free start past what is left, which the person chose to build as far as\n // it goes (AGL-3722): the prompt they confirmed, kept on the job.\n let confirmedCredits: AiCreditsPrompt | null = null\n // Asked before the site exists (AGL-3859): answers, creates nothing.\n const check = (payload as Record<string, unknown> | null)?.['check'] === true\n try {\n // A site that switched AI off first (AGL-3028), then the kind's own check.\n refusal =\n (await aiJobSiteRefusal({\n firestore: gate.firestore,\n org: gate.org,\n hostId: parsed.hostId,\n })) ??\n (await aiJobAdmissionRefusal(parsed.kind, {\n firestore: gate.firestore,\n orgId: gate.orgId,\n hostId: parsed.hostId,\n siteToCome: check && !parsed.hostId,\n inputs: parsed.inputs,\n org: gate.org,\n uid: gate.uid,\n creditsConfirmed: (payload as Record<string, unknown> | null)?.['creditsConfirmed'] === true,\n onCreditsConfirmed: (prompt) => {\n confirmedCredits = prompt\n },\n }))\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job admission failed', { orgId: gate.orgId, kind: parsed.kind, error })\n await reportJobFault('ai job admission failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n if (refusal) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n // A Free start past what is left asks before it starts (AGL-3722): the\n // prompt rides the 409, so the dialog offers its choices.\n return Response.json(\n { error: refusal.error, ...(refusal.code ? { code: refusal.code } : {}), ...(refusal.credits ? { credits: refusal.credits } : {}) },\n { status: refusal.status },\n )\n }\n if (check) {\n // Admitted, and nothing to run: the reservation goes back.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ ok: true }, { status: 200 })\n }\n\n const now = new Date()\n let created: Awaited<ReturnType<typeof createAiJob>>\n try {\n // The guided start's own plan confirmation (AGL-3594), kept only for a\n // site job on a site that has published nothing of the owner's yet.\n const inputs = await aiJobAdmittedInputs(gate.firestore, {\n kind: parsed.kind,\n hostId: parsed.hostId,\n inputs: parsed.inputs,\n })\n created = await createAiJob(\n gate.firestore,\n {\n orgId: gate.orgId,\n hostId: parsed.hostId,\n kind: parsed.kind,\n brief: parsed.brief,\n inputs,\n model: parsed.model,\n createdBy: gate.uid,\n createdByEmail: gate.decoded.email ?? null,\n creditsConfirmed: aiCreditsConfirmation(confirmedCredits, gate.uid, now),\n },\n now,\n )\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job create failed', { orgId: gate.orgId, error })\n await reportJobFault('ai job create failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n const jobId = created.$id\n\n // A first step that needs more time than this request has (AGL-2907) is left\n // queued for the beat, which starts it with a budget of its own: a provider\n // call this request's timeout cut off would be billed upstream and metered\n // nowhere. Nothing ran, so the reservation goes back.\n if (aiJobNextStepMinimumMs(created) > AI_JOB_INLINE_BUDGET_MS) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json(\n {\n job: aiJobSummary(created, now),\n meter: aiUsageMeter(gate.reservation, { lastCredits: null }),\n },\n { status: 200 },\n )\n }\n\n // The first step, inline, on the reservation the ladder already holds.\n // The runner hands that reservation back itself if the provider is never\n // reached, and re-queues the step when the budget ends first.\n const run = await runAiJobStep(gate.firestore, gate.orgId, jobId, {\n owner: `route:${randomUUID()}`,\n now,\n reservation: gate.reservation,\n org: gate.org,\n signal: AbortSignal.timeout(AI_JOB_INLINE_BUDGET_MS),\n // The caller is on the request, so what the inline step produces is\n // attributed with their address; the beat's steps carry the uid alone.\n actor: { uid: gate.uid, email: gate.decoded.email ?? null },\n // The workspace's AI pause lets staff through, as the ladder above did.\n staff: gate.staff,\n })\n if (run.outcome === 'not-claimable') {\n // A job created a moment ago has a claimable first step; anything else\n // is a fault in the machine, and the reservation must not be stranded.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job first step not claimable', { orgId: gate.orgId, jobId })\n await reportJobFault('ai job first step not claimable', new Error(`job ${jobId}`))\n return Response.json({ error: 'The AI job could not be started' }, { status: 500 })\n }\n return Response.json(\n {\n job: aiJobSummary(run.job),\n // The usage strip's envelope (AGL-2942): the reservation the ladder\n // took, with what the inline step spent.\n meter: aiUsageMeter(gate.reservation, {\n lastCredits: run.job.creditsSpent > 0 ? run.job.creditsSpent : null,\n }),\n },\n { status: 200 },\n )\n}\n\nexport async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const gate = await aiJobsGate(request, url.searchParams.get('orgId') ?? '')\n if (gate instanceof Response) return gate\n const rawStatus = url.searchParams.get('status') ?? ''\n const status = (AI_JOB_STATUSES as readonly string[]).includes(rawStatus)\n ? (rawStatus as AiJobStatus)\n : undefined\n // `active` (AGL-3593): every job not yet settled, which is what the\n // console's top-bar indicator and the Assist launcher count.\n const active = rawStatus === 'active'\n const limit = Number(url.searchParams.get('limit') ?? '')\n // `hostId` (AGL-3596): one site's jobs, for the site's AI jobs page — a\n // query on the field under the (hostId, createdAt) index, never a filter\n // of a loaded window. An id that is not one is refused rather than ignored,\n // which would answer with every site's jobs.\n const rawHost = url.searchParams.get('hostId')\n if (rawHost !== null && !ID_CHARS.test(rawHost)) {\n return Response.json({ error: 'hostId is not a site id' }, { status: 400 })\n }\n const now = new Date()\n // A job naming a site that has been deleted is not listed (AGL-3596), so\n // no surface counts or shows work on a site nobody can open.\n const jobs = await withoutErasedSites(\n gate.firestore,\n await listAiJobs(gate.firestore, gate.orgId, {\n ...(status ? { status } : {}),\n ...(active ? { statuses: AI_JOB_ACTIVE_STATUSES } : {}),\n ...(rawHost ? { hostId: rawHost } : {}),\n ...(Number.isFinite(limit) && limit > 0 ? { limit } : {}),\n }),\n )\n const freeTaste = resolveEffectivePlan(gate.org as never) === 'free'\n // What the Free workspace has left this month (AGL-3660): the less of its\n // own band and its owner's allowance across their Free workspaces, which is\n // what the steps' reservations refuse at — never the 300 a month starts with.\n const freeCredits = freeTaste\n ? await readFreeAiCreditsLeft(gate.firestore, { orgId: gate.orgId, org: gate.org as object | null, now })\n : null\n // Whether the guided start asks for the sites the person likes (AGL-3975):\n // the workspace's own verdict, the one its site job reads them by.\n const siteInspiration = await isServerReleaseFlagOnForOrg(AI_SITE_INSPIRATION_FLAG, gate.orgId).catch(() => false)\n return Response.json(\n {\n jobs: jobs.map((job) => aiJobSummary(job, now)),\n // Whether the workspace spends the Free taste (AGL-3594), so the guided\n // start offers the Free page band from the request it already makes.\n freeTaste,\n ...(freeCredits ? { freeCredits } : {}),\n ...(siteInspiration ? { siteInspiration: true } : {}),\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["randomUUID","resolveEffectivePlan","AI_JOB_KINDS","aiGateLadder","aiJobAdmissionRefusal","aiJobSiteRefusal","AI_JOB_BRIEF_MAX_CHARS","aiJobAdmittedInputs","AI_JOB_INLINE_BUDGET_MS","aiJobNextStepMinimumMs","aiJobSummary","createAiJob","listAiJobs","runAiJobStep","releaseAssistMessage","readFreeAiCreditsLeft","aiCreditsConfirmation","aiUsageMeter","aiJobsGate","withoutErasedSites","AI_JOB_ACTIVE_STATUSES","reportServerError","isServerReleaseFlagOnForOrg","AI_SITE_INSPIRATION_FLAG","reportJobFault","what","error","message","Error","String","stack","undefined","route","routeType","method","service","AI_JOBS_RATE_LIMIT","key","limit","windowMs","MAX_INPUTS_JSON_CHARS","AI_JOB_STATUSES","ID_CHARS","MAX_MODEL_CHARS","parseCreateAiJobBody","payload","body","orgId","trim","kind","includes","join","brief","length","toLocaleString","rawHost","hostId","test","rawInputs","inputs","Array","isArray","value","Object","entries","JSON","stringify","rawModel","model","POST","request","gate","json","parsed","feature","releaseFlag","lockdownFeature","permission","rateLimit","Response","firestore","reservation","catch","status","refusal","confirmedCredits","check","org","siteToCome","uid","creditsConfirmed","onCreditsConfirmed","prompt","console","code","credits","ok","now","Date","created","createdBy","createdByEmail","decoded","email","jobId","$id","job","meter","lastCredits","run","owner","signal","AbortSignal","timeout","actor","staff","outcome","creditsSpent","GET","url","URL","searchParams","get","rawStatus","active","Number","jobs","statuses","isFinite","freeTaste","freeCredits","siteInspiration","map","headers","dynamic","maxDuration"],"mappings":"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"}
@@ -159,10 +159,10 @@ export declare function assistCreditsFromUsd(usd: number): number;
159
159
  * So the zero case is now reached only by a per-org override that writes
160
160
  * one, and the reading above is what keeps that org's assistant running.
161
161
  *
162
- * The Aglyn AI add-on (AGL-2896) needs no knowledge here either way: the
163
- * resolver has already added `AI_ADDON_CREDITS_PER_MONTH[plan]` to the
164
- * band, because the add-on widens the one pool rather than opening a
165
- * second.
162
+ * The Aglyn AI tier (AGL-2896, AGL-3965) needs no knowledge here either
163
+ * way: the resolver has already added the purchased tier's credits
164
+ * (`AI_TIERS[tier].credits`) to the band, because a tier widens the one pool
165
+ * rather than opening a second.
166
166
  *
167
167
  * The same reading is what makes the guard survive a stubbed entitlements
168
168
  * module. A test double that answers 0 for every quota produces `null` here,
@@ -166,10 +166,10 @@ import { planQuotaOf } from "@aglyn/aglyn/plugin-manager/plugin-plan-entitlement
166
166
  * So the zero case is now reached only by a per-org override that writes
167
167
  * one, and the reading above is what keeps that org's assistant running.
168
168
  *
169
- * The Aglyn AI add-on (AGL-2896) needs no knowledge here either way: the
170
- * resolver has already added `AI_ADDON_CREDITS_PER_MONTH[plan]` to the
171
- * band, because the add-on widens the one pool rather than opening a
172
- * second.
169
+ * The Aglyn AI tier (AGL-2896, AGL-3965) needs no knowledge here either
170
+ * way: the resolver has already added the purchased tier's credits
171
+ * (`AI_TIERS[tier].credits`) to the band, because a tier widens the one pool
172
+ * rather than opening a second.
173
173
  *
174
174
  * The same reading is what makes the guard survive a stubbed entitlements
175
175
  * module. A test double that answers 0 for every quota produces `null` here,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-credits.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 {\n isUncappedPlanComp,\n planLabelGrantingFeature,\n resolveOrgEntitlements,\n resolvePlanPricing,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { planQuotaOf } from '@aglyn/aglyn/plugin-manager/plugin-plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\n\n/**\n * Aglyn Assist credits: the unit assist is sold, metered and refused in.\n *\n * ## Why cost, and not messages\n *\n * Assist is measured per exchange as `estCostUsd` — the exchange priced at\n * the serving model's billed rates, computed where the tokens were counted. A\n * message allowance would throw that measurement away and price every action\n * the same. It cannot: a question is a few thousand tokens, while generating\n * a screen carries the node tree, the component catalog and the theme tokens\n * in, emits structured markup out, and iterates. The two differ by up to two\n * orders of magnitude, so one workspace's ten screen builds can outspend\n * another's thousand questions while both read as \"within allowance\".\n *\n * Metering in cost is the only unit under which those two workspaces draw\n * down their bands differently, which is the entire point.\n *\n * ## Why a credit rather than the dollar figure\n *\n * `estCostUsd` is a dollar figure off our own cost model, not a price.\n * Publishing it would put our model choice, our per-model rates and our\n * margin on a billing page, and it would move under customers every time a\n * model is swapped. A credit is a fixed quantity of that spend with a stable\n * public meaning, so the band on the plan card stays the same number when\n * the model behind it changes.\n *\n * Everything customer-facing counts credits. This module is the ONLY place\n * the two units meet.\n *\n * ## `estCostUsd` is the BILLED figure, not the bill we pay (AGL-3015)\n *\n * It is every exchange priced at the catalog's billed rates, which a model\n * may carry above what its provider charges. Credits are drawn from it, so\n * it is the right figure for a band, a wall, a cap and an invoice line. It\n * is the WRONG figure for a margin or a cost meter, which read the rollup's\n * provider figure through `assistProviderCostUsd` instead.\n */\n\n/**\n * What one credit is BILLED at, in USD.\n *\n * A tenth of a cent, chosen for two properties rather than for roundness:\n *\n * - **1,000 credits cost exactly $1.00**, so the per-1,000 retail rates on\n * `PLAN_PRICING.extraAssistCreditsUsdPer1k` read directly as multiples of\n * cost. A rate of $2.00 per 1,000 is cost x2 with no arithmetic in between,\n * which is what makes the 50% margin floor checkable by eye.\n * - **A single question is tens of credits, a generated screen is hundreds.**\n * At Sonnet list rates a short grounded answer runs around $0.013, so ~13\n * credits; a screen build runs into the hundreds. Both are whole numbers\n * with room to differ, which a cent-sized credit would have flattened to\n * \"1 vs 20\" and a dollar-sized one to \"0 vs 0\".\n *\n * ⚠️ This is a COST-MODEL constant, not a price and not a rate we publish.\n * It never appears on a customer surface; `assistCreditsFromUsd` is how a\n * measured dollar figure becomes something a customer may be shown.\n *\n * ⚠️ AND IT IS NOT WHAT A CREDIT COSTS US (AGL-3015). The dollar figure this\n * divides is priced at the model catalog's BILLED rates, and a model may be\n * billed above what the provider charges. A credit is therefore a tenth of a\n * cent of BILLED spend, and at most a tenth of a cent of provider spend. Read\n * against a retail rate it yields the floor of the line margin, never the\n * middle of it — which is the safe direction, and the direction every margin\n * assertion in this repo depends on. What a period actually cost is measured,\n * not derived: `assistProviderCostUsd` reads it off the rollup.\n */\nexport const ASSIST_CREDIT_COST_USD = 0.001\n\n/**\n * The field a monthly assist rollup, a signal row and a kind bucket keep\n * PROVIDER spend under, beside the `estCostUsd` a customer's credits are\n * drawn from (AGL-3015).\n *\n * Named here rather than in the meter that writes it because the readers are\n * spread across the plugin, the billing routes and the staff surfaces, and a\n * field name spelled out at six call sites is a field name that will be\n * spelled differently at the seventh.\n */\nexport const ASSIST_PROVIDER_COST_FIELD = 'providerCostUsd'\n\n/**\n * What a metered period ACTUALLY COST US, from the two dollar figures a\n * rollup carries.\n *\n * `billedUsd` is `estCostUsd`: every exchange priced at its model's billed\n * rates, and the figure credits are drawn from. `providerUsd` is\n * `ASSIST_PROVIDER_COST_FIELD`: the same exchanges priced at what the\n * provider charged. The second is the answer wherever it exists.\n *\n * ## Why a rollup may not carry one, and which way that errs\n *\n * The provider figure is written from the commit that split the two rates\n * onward. A period closed before it answers with the BILLED figure, which is\n * at or above what we paid — so a margin taken on history reads low and a\n * cost reads high, the direction a cost figure is allowed to be wrong in.\n * The one period that under-reads is the month in flight when the split\n * shipped, whose provider figure covers only the exchanges after it; that\n * month's error is bounded by the markup on a few days of a meter whose\n * largest measured organization is a fraction of a cent.\n */\nexport function assistProviderCostUsd(\n billedUsd: unknown,\n providerUsd: unknown,\n): number {\n const provider = Number(providerUsd)\n if (Number.isFinite(provider) && provider > 0) return provider\n const billed = Number(billedUsd)\n return Number.isFinite(billed) && billed > 0 ? billed : 0\n}\n\n/**\n * The minimum line margin every retail assist rate must clear.\n *\n * The same floor now applied to every retail rate in this codebase, and the\n * reason the assist ladder stops descending at cost x2 on Agency instead of\n * taking one more step the way the contacts ladder once did.\n */\nexport const ASSIST_CREDIT_MIN_MARGIN_PCT = 0.5\n\n/**\n * Credits -> our cost in USD. Non-finite or negative input is 0 credits, so a\n * corrupt band cannot become a budget of `NaN` that every comparison passes.\n */\nexport function assistUsdFromCredits(credits: number): number {\n const value = Number(credits)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.round(value * ASSIST_CREDIT_COST_USD * 1_000_000) / 1_000_000\n}\n\n/**\n * Measured USD -> credits, rounded UP.\n *\n * Up, because this converts spend the platform has ALREADY made into the\n * figure a customer is shown against their band. Rounding down would let a\n * long tail of sub-credit exchanges cost real money and draw nothing, which\n * is the one direction a meter must not err in.\n */\nexport function assistCreditsFromUsd(usd: number): number {\n const value = Number(usd)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.ceil(value / ASSIST_CREDIT_COST_USD)\n}\n\n/**\n * The org's monthly assist band in credits, or `null` when its plan sells no\n * assist band at all.\n *\n * ## `null` is not zero, and the difference is the whole safety property\n *\n * A band of 0 resolves to `null` — \"this org sells no band\" — and never to\n * a budget of `$0`. An org with no band still reaches the console\n * assistant's docs-grounded rung, which is bounded by the free daily message\n * cap and by the operator's spend backstop. Resolving zero as a budget would\n * refuse that rung outright: a whole assistant turned off by a pricing field\n * that was never about it.\n *\n * Since AGL-3203 no PLAN ROW bands at zero. Free carries the AGL-2925 taste\n * of `FREE_AI_TASTE_CREDITS_PER_MONTH` with no rate beside it, so it\n * resolves to a budget here and `assistBandRefuses` makes that budget a\n * wall; Starter carries 750 WITH a rate, so its budget is a line it meters\n * past rather than a wall. Both draw their docs-grounded chat on the same\n * band, which is fine — each is sized so a month of it costs well under a\n * dollar.\n *\n * So the zero case is now reached only by a per-org override that writes\n * one, and the reading above is what keeps that org's assistant running.\n *\n * The Aglyn AI add-on (AGL-2896) needs no knowledge here either way: the\n * resolver has already added `AI_ADDON_CREDITS_PER_MONTH[plan]` to the\n * band, because the add-on widens the one pool rather than opening a\n * second.\n *\n * The same reading is what makes the guard survive a stubbed entitlements\n * module. A test double that answers 0 for every quota produces `null` here,\n * which falls through to the operator backstop, rather than a budget of zero\n * that refuses everything while the assertions read green. A clamp that goes\n * green having refused every request has proved nothing, and 0 is exactly the\n * value a stub returns.\n *\n * ## Non-finite is also `null`, deliberately\n *\n * `Infinity` must never resolve to a budget. It is unrepresentable off-process\n * — `JSON.stringify(Infinity)` is `null` and reads back as 0 — so a band that\n * reached here as `Infinity` is a band that would read as zero somewhere\n * else. Falling through to the backstop is bounded and consistent; honouring\n * it would be an unbounded budget on one process and a refusal on the next.\n *\n * An UNCAPPED staff comp (AGL-3049) is where that `Infinity` comes from on\n * purpose, and `null` is right for it here too: there is no band to measure\n * against, be over, price or alert on. What `null` must NOT bring with it is\n * the backstop, whose repo default would cap the one workspace staff said\n * has no cap — so `assistBandRefuses` answers false for it, and the\n * reservation's ceiling (`assistMonthlyCeilingUsd` in the AI plugin) drops\n * the default and keeps only an operator's explicit figure.\n */\nexport function resolveAssistCreditBudget(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = planQuotaOf(resolveOrgEntitlements(org), 'assistCreditsPerMonth')\n if (!Number.isFinite(credits) || credits <= 0) return null\n return Math.floor(credits)\n}\n\n/** The org's monthly assist band in USD of provider spend, or `null`. */\nexport function resolveAssistBudgetUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = resolveAssistCreditBudget(org)\n return credits === null ? null : assistUsdFromCredits(credits)\n}\n\n/**\n * Credits spent past the band. A `null` band yields 0 — an org with no band\n * has nothing to be over, and reporting the whole month's spend as overage\n * there would invoice the free tier for its docs answers.\n */\nexport function assistCreditOverage(\n usedCredits: number,\n bandCredits: number | null,\n): number {\n const used = Number(usedCredits)\n if (!Number.isFinite(used) || used <= 0) return 0\n if (bandCredits === null) return 0\n const band = Number(bandCredits)\n if (!Number.isFinite(band) || band <= 0) return 0\n return Math.max(0, used - band)\n}\n\n/**\n * The per-1,000 rate the org's assist credits are sold at past its band, or\n * `null` when nothing is sold past it — the ONE reading of the rate\n * (AGL-2896). `priceAssistCreditOverage`, `assistBandRefuses`,\n * `assistRefusedByHardCap` and `assistHardCapRefusalText` all go through\n * it, so the gate, the invoice and the refusal sentence cannot quote three\n * different rates for one org.\n *\n * The plan's `extraAssistCreditsUsdPer1k` IS the answer — the whole answer,\n * since AGL-3203. Starter used to be the one exception: it banded at 0, so\n * the rate could not sit on its plan row without advertising a fee on a\n * quantity the plan never sold, and the add-on's band was priced from a\n * constant read here instead. Starter now includes 750 credits\n * unconditionally and carries $3.00 on its own row, so the exception and its\n * constant are gone and there is exactly one place the rate is written.\n *\n * Free and Enterprise are null with or without the add-on: Free sells no\n * add-on and its taste band is a wall by decision (AGL-2925), and\n * Enterprise's usage is in the contract.\n *\n * A STAFF COMP is null on every plan and with every add-on (AGL-3034),\n * because `resolvePlanPricing` — never `PLAN_PRICING` indexed directly —\n * sells nothing on a comp. That is what makes a comp's band a wall at the\n * gate and prices its overage to zero at the charge: the AI overage path\n * (AGL-3011) claims only what `assistMonthOverage` prices, so a comp never\n * becomes a Stripe invoice. The function is a single read for that reason;\n * a second source for the rate is how a comp gets quoted one.\n */\nexport function resolveAssistOverageRateUsdPer1k(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n return resolvePlanPricing(org).extraAssistCreditsUsdPer1k\n}\n\nexport interface AssistCreditOveragePrice {\n /** Credits past the plan's band, as handed in. */\n overageCredits: number\n /** Estimated charge at the plan's per-1,000 rate. */\n overageMonthlyUsd: number\n /** Per-1,000 rate; null when the plan prices no assist overage. */\n overageRateUsd: number | null\n}\n\n/**\n * Prices assist credits past a plan's band.\n *\n * ## No `allowed`, deliberately — and for the opposite reason to email\n *\n * `priceEmailSendOverage` omits an `allowed` field because email has two\n * gates and a transactional sender consulting the wrong one drops a password\n * reset. Assist omits it because it has exactly ONE gate and this is not it:\n * whether the band refuses is `assistBandRefuses`, applied inside\n * `reserveAssistMessage` before a token is spent. A second answer to \"may\n * this proceed\", computed after the fact from a rounded credit figure, would\n * be a second gate to drift from the first.\n *\n * ## Takes the overage, does not recompute it\n *\n * `assistCreditOverage` derives the excess once, from the measured meter and\n * the resolved band. Re-deriving it here would be a second overage model.\n *\n * A null rate yields zero structurally rather than by a check, which is what\n * keeps Free and Starter at zero on this axis.\n */\nexport function priceAssistCreditOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n overageCredits: number,\n): AssistCreditOveragePrice {\n const overageRateUsd = resolveAssistOverageRateUsdPer1k(org)\n const credits = Number(overageCredits)\n const over = Number.isFinite(credits) && credits > 0 ? credits : 0\n return {\n overageCredits: over,\n overageMonthlyUsd:\n overageRateUsd === null\n ? 0\n : Math.round((over / 1000) * overageRateUsd * 100) / 100,\n overageRateUsd,\n }\n}\n\n/**\n * The line margin a per-1,000-credit retail rate earns AT LEAST, as a\n * fraction.\n *\n * One expression, so the ladder and its floor cannot be checked against two\n * different definitions of margin. A null or non-positive rate has no margin\n * to report and answers `null` rather than 1 or 0 — \"this plan sells no\n * overage\" is not \"this plan sells overage at 100% margin\".\n *\n * A LOWER BOUND, not the realized figure (AGL-3015). The cost side is\n * `ASSIST_CREDIT_COST_USD`, which is what a credit BILLS at; a model billed\n * above its provider rate costs us less than that per credit, so the real\n * line margin is this or better and never worse. The floor stays checkable\n * by eye — a $2.00 rate is still cost x2 — and a retail rate that clears the\n * floor here clears it on every model mix. What a period realized is the\n * staff margin card's business, and it reads measured provider spend.\n */\nexport function assistCreditRateMarginPct(\n rateUsdPer1k: number | null,\n): number | null {\n if (rateUsdPer1k === null) return null\n const rate = Number(rateUsdPer1k)\n if (!Number.isFinite(rate) || rate <= 0) return null\n const costPer1k = ASSIST_CREDIT_COST_USD * 1000\n return (rate - costPer1k) / rate\n}\n\n/**\n * What a refused or in-progress workspace may be TOLD about its assist band.\n *\n * The reservation carries `costUsd` and `costLimitUsd` because that is what\n * it measured and what it compared against, and both are our provider bill.\n * Shipping the reservation itself to a browser publishes them. This is the\n * projection that crosses that boundary: credits only, no dollars, no model,\n * no rates.\n */\nexport interface PublicAssistCredits {\n /** Credits drawn this month, rounded up. */\n used: number\n /** The band, or `null` when no band applied to this request. */\n limit: number | null\n /** Credits left in the band, or `null` when there is no band. */\n remaining: number | null\n}\n\n/**\n * Projects a measured spend figure and a ceiling into the credit view.\n *\n * Both inputs are nullable because the reservation's are: `costUsd` is `null`\n * when nothing consulted the monthly document, and a ceiling is `null` when\n * none applied. Neither collapses to 0 here — \"nobody looked\" and \"spent\n * nothing\" have to stay distinguishable at the surface, exactly as they do on\n * the reservation.\n */\nexport function publicAssistCredits(\n costUsd: number | null,\n ceilingUsd: number | null,\n): PublicAssistCredits {\n const used = costUsd === null ? 0 : assistCreditsFromUsd(costUsd)\n if (ceilingUsd === null) return { used, limit: null, remaining: null }\n const limit = assistCreditsFromUsd(ceilingUsd)\n return { used, limit, remaining: Math.max(0, limit - used) }\n}\n\n/**\n * The console's name for the org's hard-cap switch (AGL-2653), quoted by the\n * refusal so the words on the 402 and the words beside the switch are one\n * string. Change it here and both move.\n */\nexport const ASSIST_HARD_CAP_CONTROL_LABEL = 'Stop AI assist at the included band'\n\n/** Where the switch lives, in the words the refusal uses. */\nexport const ASSIST_HARD_CAP_CONTROL_LOCATION = 'Billing → Usage'\n\n/**\n * Whether the org asked to be stopped at its assist band (AGL-2653).\n *\n * Strictly `=== true`: the map is absent on every org that has not touched\n * the switch, and absent is the selling default. A truthy string or a `1`\n * written by hand must not switch an assistant off at the band, so nothing\n * short of the boolean counts.\n */\nexport function resolveAssistHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n return org?.assistOverage?.hardCap === true\n}\n\n/**\n * Is the org's assist band a WALL, or a line past which credits are sold?\n *\n * Two things make it a wall, and only two:\n *\n * - **The org asked.** `assistOverage.hardCap` is the customer's own ceiling,\n * the `storageOverage.capUsd` of this meter.\n * - **There is nothing to sell past it at.** An org whose\n * `resolveAssistOverageRateUsdPer1k` is `null` has no rate to bill, so\n * credits past its band would be provider spend with no invoice line — the\n * silent free overage `plan-entitlements.spec.ts` forbids. Enterprise is\n * one case, its band being contractual; FREE is the other, its taste band\n * a wall by the AGL-2925 decision so that nothing about a Free workspace\n * can produce a charge.\n *\n * Everything else sells past the band by default, which is the 2026-09-07\n * decision this function encodes — and since AGL-3203 that includes Starter,\n * which carries 750 credits and a $3.00 rate like every paid tier above it.\n *\n * An org with NO band at all answers the same as Enterprise, harmlessly:\n * `resolveAssistBudgetUsd` is `null` for it and the reservation never\n * measures against a band. No plan ROW is in that state any more — Free and\n * Starter both were, at different times — so it is reached by a per-org\n * override that writes a zero.\n *\n * An UNCAPPED staff comp (AGL-3049) is neither: it has no band to be a wall,\n * and nothing is sold past a band it does not have. It answers false, ahead\n * of the switch — a workspace staff uncapped is not stopped by a band, and\n * the reservation reads that answer as \"no wall, no backstop default\".\n */\nexport function assistBandRefuses(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n if (isUncappedPlanComp(org)) return false\n if (resolveAssistHardCap(org)) return true\n return resolveAssistOverageRateUsdPer1k(org) === null\n}\n\n/**\n * Which ceiling refused a reservation, or `null` when it was admitted.\n *\n * - `messages`: the message cap (daily on the free rung, monthly entitled).\n * - `budget`: the operator's spend backstop.\n * - `band`: the plan's own band, as a wall — the switch, or a plan with\n * no rate to sell past it at.\n * - `cap`: the org's own dollar ceiling on overage (AGL-2898).\n *\n * The four that exist only on the Free taste (AGL-2925), each a precaution\n * against the one band that has no invoice behind it:\n *\n * - `account`: the ACCOUNT's 300 credits are spent across every free\n * workspace it owns — the org band alone would multiply by\n * the workspaces one person may hold.\n * - `requests`: the account's daily free request cap.\n * - `refusals`: the account's free generation is paused for the day after\n * too many `refusal` stops — a brief the model declined is a\n * brief that should not be retried thirty times.\n * - `platform`: the platform-wide daily ceiling on free spend; nobody's\n * fault, and \"try again tomorrow\" is the whole answer.\n *\n * Declared here, beside the helpers that read it, so the reservation, its\n * public projection and both doors name one union.\n */\nexport type AssistRefusedBy =\n | 'messages'\n | 'budget'\n | 'band'\n | 'cap'\n | 'account'\n | 'requests'\n | 'refusals'\n | 'platform'\n | null\n\n/**\n * The sentence a Free workspace is told when one of the taste's own\n * precautions refused it, or `null` when the refusal was something else.\n *\n * One string per rung, for both doors and the gate ladder, so a customer\n * reading the console panel and one reading the besigner get the same\n * words. Every sentence is customer-safe: it names what to do (upgrade,\n * wait) and never a figure from our cost model, a counter document, or\n * another workspace.\n */\nexport function assistFreeTasteRefusalText(\n refusedBy: AssistRefusedBy,\n): string | null {\n switch (refusedBy) {\n case 'account':\n return (\n 'Your free AI credits for this month are used across your ' +\n 'workspaces — upgrade any workspace to keep going.'\n )\n case 'requests':\n return (\n 'Free workspaces get a limited number of AI requests a day — ' +\n 'try again tomorrow, or upgrade to keep going.'\n )\n case 'refusals':\n return (\n 'The AI declined several requests today, so free AI generation is ' +\n 'paused until tomorrow. Try a different kind of brief then.'\n )\n case 'platform':\n return (\n 'Free AI generation is paused for the rest of today — try again ' +\n 'tomorrow. Paid workspaces are not affected.'\n )\n default:\n return null\n }\n}\n\n/**\n * The sentence a workspace whose plan does not carry `aiAssist` is told, at\n * the route and at every console door that checks the flag first.\n *\n * The plan it names is read from the plan table rather than written here.\n * Which tier first carries the flag is a pricing decision, and a name typed\n * into the sentence keeps naming the old tier after the table moves — telling\n * the reader to buy a plan above the one that would let them in. `undefined`\n * from the lookup means no tier carries the flag on its base row, and the\n * sentence then names no plan rather than a wrong one.\n */\nexport function assistNotOnPlanText(): string {\n const plan = planLabelGrantingFeature('aiAssist')\n return plan\n ? `AI assist starts on the ${plan} plan — see Billing to upgrade`\n : 'AI assist is not on this plan — see Billing to upgrade'\n}\n\n/**\n * Whether a refusal at the band was the org's OWN doing — the switch is on\n * AND turning it off would have let the exchange through. The refusal that\n * names the switch has to be the one the switch caused: on a plan with no\n * rate the band refuses whatever the switch says, and telling that customer\n * to turn it off would send them to a control that does nothing.\n */\nexport function assistRefusedByHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'band' &&\n resolveAssistHardCap(org) &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own hard cap refused it. ONE\n * string for both assist entrypoints, so the console panel and the besigner\n * cannot describe the same control in two ways. It names the switch by its\n * label, says where it lives, and quotes the plan's rate so the reader knows\n * what turning it off costs.\n */\nexport function assistHardCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const rate = resolveAssistOverageRateUsdPer1k(org)\n const rateClause =\n rate === null ? '' : ` at $${rate.toFixed(2)} per 1,000 credits`\n return (\n `This workspace used its assistant credits for the month, and ` +\n `\"${ASSIST_HARD_CAP_CONTROL_LABEL}\" is on. Turn it off under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going${rateClause}.`\n )\n}\n\nexport interface AssistMonthOverage extends AssistCreditOveragePrice {\n /** Credits drawn this month, rounded up from the measured spend. */\n usedCredits: number\n /** The band those credits were measured against, or `null` for no band. */\n bandCredits: number | null\n}\n\n/**\n * A month's assist overage from its measured spend — the ONE derivation the\n * invoice and every meter read (AGL-2653).\n *\n * Takes `estCostUsd` as the rollup reads it and composes the three steps\n * that already existed — credits from spend, overage past the band, price at\n * the plan's rate — so `report-usage` cannot assemble them in a different\n * order from a console readout and arrive at a different figure. The\n * rounding is the other overage terms' rounding: `priceAssistCreditOverage`\n * rounds to the cent, and the rollup turns dollars into cents with the same\n * `Math.round(usd * 100)` it applies to contacts, API and dataset storage.\n *\n * A non-finite or negative spend is zero credits, for the reason\n * `assistCreditsFromUsd` gives; a plan with no band or no rate prices zero\n * structurally, so Free, Starter and Enterprise bill nothing here without a\n * check that could be forgotten.\n */\nexport function assistMonthOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): AssistMonthOverage {\n const usedCredits = assistCreditsFromUsd(estCostUsd)\n const bandCredits = resolveAssistCreditBudget(org)\n return {\n usedCredits,\n bandCredits,\n ...priceAssistCreditOverage(\n org,\n assistCreditOverage(usedCredits, bandCredits),\n ),\n }\n}\n\n/** The console's name for the org's overage ceiling (AGL-2898). */\nexport const ASSIST_OVERAGE_CAP_CONTROL_LABEL =\n 'Stop AI when this month’s overage reaches'\n\n/**\n * Bounds on the ceiling a self-serve org may set. The floor is a dollar\n * because a ceiling of cents would refuse on the first exchange past the\n * band while reading as a limit; the roof is the storage cap's, so no\n * self-serve org writes itself an enterprise-sized commitment.\n */\nexport const ASSIST_OVERAGE_CAP_MIN_USD = 1\nexport const ASSIST_OVERAGE_CAP_MAX_USD = 100_000\n\n/**\n * The org's monthly ceiling on AI overage dollars, or `null` for none.\n *\n * Strictly a finite positive number. `null`, absent, a string, `NaN`,\n * `Infinity` and zero all read as no ceiling — the failure mode of the\n * alternative is a wall of `NaN` that every comparison passes, or a ceiling\n * of zero that refuses the first exchange past the band while the card\n * says nothing is set.\n */\nexport function resolveAssistOverageCapUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const raw = org?.assistOverage?.capUsd\n if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null\n return raw\n}\n\n/**\n * Has this month's overage reached the org's own ceiling (AGL-2898)?\n *\n * Composed from `assistMonthOverage`, the ONE derivation the invoice reads,\n * so the figure the ceiling stops at is the figure the invoice would have\n * billed. That composition is also what confines the ceiling to plans that\n * sell past their band: a plan with no rate prices its overage to zero, and\n * zero never reaches a positive ceiling — Free and Enterprise cannot get here\n * without a check that could be forgotten. `>=` rather than `>`, because an\n * org that asked to stop AT a figure is stopped when the figure is met.\n */\nexport function assistOverageCapReached(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): boolean {\n const cap = resolveAssistOverageCapUsd(org)\n if (cap === null) return false\n return assistMonthOverage(org, estCostUsd).overageMonthlyUsd >= cap\n}\n\n/**\n * Whether a refusal was the org's own ceiling. The reservation answers\n * `'cap'` only when `assistOverageCapReached` did, and that helper only\n * reaches a ceiling on a plan with a rate, so the two conditions restated\n * here are the same guarantee `assistRefusedByHardCap` gives the switch: the\n * control the sentence names is the control that refused.\n */\nexport function assistRefusedByOverageCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'cap' &&\n resolveAssistOverageCapUsd(org) !== null &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own overage ceiling refused it.\n * ONE string for both assist entrypoints, as `assistHardCapRefusalText` is\n * for the switch. It names the control by its label, quotes the figure the\n * org chose, and says where to raise or clear it.\n */\nexport function assistOverageCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const cap = resolveAssistOverageCapUsd(org)\n const figure = cap === null ? '' : ` $${cap.toFixed(2)}`\n return (\n `This month’s AI assist overage reached${figure}, the figure you set as ` +\n `\"${ASSIST_OVERAGE_CAP_CONTROL_LABEL}\". Raise or remove it under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going.`\n )\n}\n\n/**\n * The refusal sentence for whichever of the org's OWN controls refused, or\n * `null` when the refusal was nobody's control — the message cap, the\n * operator's backstop, or a plan that sells no overage. Both doors answer a\n * 402 with this text when it is a string and their usual 429 otherwise, so\n * the status and the sentence cannot disagree about whose decision it was.\n */\nexport function assistOwnControlRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): string | null {\n if (assistRefusedByHardCap(org, refusedBy)) return assistHardCapRefusalText(org)\n if (assistRefusedByOverageCap(org, refusedBy)) {\n return assistOverageCapRefusalText(org)\n }\n return null\n}\n"],"names":["isUncappedPlanComp","planLabelGrantingFeature","resolveOrgEntitlements","resolvePlanPricing","planQuotaOf","ASSIST_CREDIT_COST_USD","ASSIST_PROVIDER_COST_FIELD","assistProviderCostUsd","billedUsd","providerUsd","provider","Number","isFinite","billed","ASSIST_CREDIT_MIN_MARGIN_PCT","assistUsdFromCredits","credits","value","Math","round","assistCreditsFromUsd","usd","ceil","resolveAssistCreditBudget","org","floor","resolveAssistBudgetUsd","assistCreditOverage","usedCredits","bandCredits","used","band","max","resolveAssistOverageRateUsdPer1k","extraAssistCreditsUsdPer1k","priceAssistCreditOverage","overageCredits","overageRateUsd","over","overageMonthlyUsd","assistCreditRateMarginPct","rateUsdPer1k","rate","costPer1k","publicAssistCredits","costUsd","ceilingUsd","limit","remaining","ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_HARD_CAP_CONTROL_LOCATION","resolveAssistHardCap","assistOverage","hardCap","assistBandRefuses","assistFreeTasteRefusalText","refusedBy","assistNotOnPlanText","plan","assistRefusedByHardCap","assistHardCapRefusalText","rateClause","toFixed","assistMonthOverage","estCostUsd","ASSIST_OVERAGE_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_MIN_USD","ASSIST_OVERAGE_CAP_MAX_USD","resolveAssistOverageCapUsd","raw","capUsd","assistOverageCapReached","cap","assistRefusedByOverageCap","assistOverageCapRefusalText","figure","assistOwnControlRefusalText"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-credits.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 {\n isUncappedPlanComp,\n planLabelGrantingFeature,\n resolveOrgEntitlements,\n resolvePlanPricing,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { planQuotaOf } from '@aglyn/aglyn/plugin-manager/plugin-plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\n\n/**\n * Aglyn Assist credits: the unit assist is sold, metered and refused in.\n *\n * ## Why cost, and not messages\n *\n * Assist is measured per exchange as `estCostUsd` — the exchange priced at\n * the serving model's billed rates, computed where the tokens were counted. A\n * message allowance would throw that measurement away and price every action\n * the same. It cannot: a question is a few thousand tokens, while generating\n * a screen carries the node tree, the component catalog and the theme tokens\n * in, emits structured markup out, and iterates. The two differ by up to two\n * orders of magnitude, so one workspace's ten screen builds can outspend\n * another's thousand questions while both read as \"within allowance\".\n *\n * Metering in cost is the only unit under which those two workspaces draw\n * down their bands differently, which is the entire point.\n *\n * ## Why a credit rather than the dollar figure\n *\n * `estCostUsd` is a dollar figure off our own cost model, not a price.\n * Publishing it would put our model choice, our per-model rates and our\n * margin on a billing page, and it would move under customers every time a\n * model is swapped. A credit is a fixed quantity of that spend with a stable\n * public meaning, so the band on the plan card stays the same number when\n * the model behind it changes.\n *\n * Everything customer-facing counts credits. This module is the ONLY place\n * the two units meet.\n *\n * ## `estCostUsd` is the BILLED figure, not the bill we pay (AGL-3015)\n *\n * It is every exchange priced at the catalog's billed rates, which a model\n * may carry above what its provider charges. Credits are drawn from it, so\n * it is the right figure for a band, a wall, a cap and an invoice line. It\n * is the WRONG figure for a margin or a cost meter, which read the rollup's\n * provider figure through `assistProviderCostUsd` instead.\n */\n\n/**\n * What one credit is BILLED at, in USD.\n *\n * A tenth of a cent, chosen for two properties rather than for roundness:\n *\n * - **1,000 credits cost exactly $1.00**, so the per-1,000 retail rates on\n * `PLAN_PRICING.extraAssistCreditsUsdPer1k` read directly as multiples of\n * cost. A rate of $2.00 per 1,000 is cost x2 with no arithmetic in between,\n * which is what makes the 50% margin floor checkable by eye.\n * - **A single question is tens of credits, a generated screen is hundreds.**\n * At Sonnet list rates a short grounded answer runs around $0.013, so ~13\n * credits; a screen build runs into the hundreds. Both are whole numbers\n * with room to differ, which a cent-sized credit would have flattened to\n * \"1 vs 20\" and a dollar-sized one to \"0 vs 0\".\n *\n * ⚠️ This is a COST-MODEL constant, not a price and not a rate we publish.\n * It never appears on a customer surface; `assistCreditsFromUsd` is how a\n * measured dollar figure becomes something a customer may be shown.\n *\n * ⚠️ AND IT IS NOT WHAT A CREDIT COSTS US (AGL-3015). The dollar figure this\n * divides is priced at the model catalog's BILLED rates, and a model may be\n * billed above what the provider charges. A credit is therefore a tenth of a\n * cent of BILLED spend, and at most a tenth of a cent of provider spend. Read\n * against a retail rate it yields the floor of the line margin, never the\n * middle of it — which is the safe direction, and the direction every margin\n * assertion in this repo depends on. What a period actually cost is measured,\n * not derived: `assistProviderCostUsd` reads it off the rollup.\n */\nexport const ASSIST_CREDIT_COST_USD = 0.001\n\n/**\n * The field a monthly assist rollup, a signal row and a kind bucket keep\n * PROVIDER spend under, beside the `estCostUsd` a customer's credits are\n * drawn from (AGL-3015).\n *\n * Named here rather than in the meter that writes it because the readers are\n * spread across the plugin, the billing routes and the staff surfaces, and a\n * field name spelled out at six call sites is a field name that will be\n * spelled differently at the seventh.\n */\nexport const ASSIST_PROVIDER_COST_FIELD = 'providerCostUsd'\n\n/**\n * What a metered period ACTUALLY COST US, from the two dollar figures a\n * rollup carries.\n *\n * `billedUsd` is `estCostUsd`: every exchange priced at its model's billed\n * rates, and the figure credits are drawn from. `providerUsd` is\n * `ASSIST_PROVIDER_COST_FIELD`: the same exchanges priced at what the\n * provider charged. The second is the answer wherever it exists.\n *\n * ## Why a rollup may not carry one, and which way that errs\n *\n * The provider figure is written from the commit that split the two rates\n * onward. A period closed before it answers with the BILLED figure, which is\n * at or above what we paid — so a margin taken on history reads low and a\n * cost reads high, the direction a cost figure is allowed to be wrong in.\n * The one period that under-reads is the month in flight when the split\n * shipped, whose provider figure covers only the exchanges after it; that\n * month's error is bounded by the markup on a few days of a meter whose\n * largest measured organization is a fraction of a cent.\n */\nexport function assistProviderCostUsd(\n billedUsd: unknown,\n providerUsd: unknown,\n): number {\n const provider = Number(providerUsd)\n if (Number.isFinite(provider) && provider > 0) return provider\n const billed = Number(billedUsd)\n return Number.isFinite(billed) && billed > 0 ? billed : 0\n}\n\n/**\n * The minimum line margin every retail assist rate must clear.\n *\n * The same floor now applied to every retail rate in this codebase, and the\n * reason the assist ladder stops descending at cost x2 on Agency instead of\n * taking one more step the way the contacts ladder once did.\n */\nexport const ASSIST_CREDIT_MIN_MARGIN_PCT = 0.5\n\n/**\n * Credits -> our cost in USD. Non-finite or negative input is 0 credits, so a\n * corrupt band cannot become a budget of `NaN` that every comparison passes.\n */\nexport function assistUsdFromCredits(credits: number): number {\n const value = Number(credits)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.round(value * ASSIST_CREDIT_COST_USD * 1_000_000) / 1_000_000\n}\n\n/**\n * Measured USD -> credits, rounded UP.\n *\n * Up, because this converts spend the platform has ALREADY made into the\n * figure a customer is shown against their band. Rounding down would let a\n * long tail of sub-credit exchanges cost real money and draw nothing, which\n * is the one direction a meter must not err in.\n */\nexport function assistCreditsFromUsd(usd: number): number {\n const value = Number(usd)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.ceil(value / ASSIST_CREDIT_COST_USD)\n}\n\n/**\n * The org's monthly assist band in credits, or `null` when its plan sells no\n * assist band at all.\n *\n * ## `null` is not zero, and the difference is the whole safety property\n *\n * A band of 0 resolves to `null` — \"this org sells no band\" — and never to\n * a budget of `$0`. An org with no band still reaches the console\n * assistant's docs-grounded rung, which is bounded by the free daily message\n * cap and by the operator's spend backstop. Resolving zero as a budget would\n * refuse that rung outright: a whole assistant turned off by a pricing field\n * that was never about it.\n *\n * Since AGL-3203 no PLAN ROW bands at zero. Free carries the AGL-2925 taste\n * of `FREE_AI_TASTE_CREDITS_PER_MONTH` with no rate beside it, so it\n * resolves to a budget here and `assistBandRefuses` makes that budget a\n * wall; Starter carries 750 WITH a rate, so its budget is a line it meters\n * past rather than a wall. Both draw their docs-grounded chat on the same\n * band, which is fine — each is sized so a month of it costs well under a\n * dollar.\n *\n * So the zero case is now reached only by a per-org override that writes\n * one, and the reading above is what keeps that org's assistant running.\n *\n * The Aglyn AI tier (AGL-2896, AGL-3965) needs no knowledge here either\n * way: the resolver has already added the purchased tier's credits\n * (`AI_TIERS[tier].credits`) to the band, because a tier widens the one pool\n * rather than opening a second.\n *\n * The same reading is what makes the guard survive a stubbed entitlements\n * module. A test double that answers 0 for every quota produces `null` here,\n * which falls through to the operator backstop, rather than a budget of zero\n * that refuses everything while the assertions read green. A clamp that goes\n * green having refused every request has proved nothing, and 0 is exactly the\n * value a stub returns.\n *\n * ## Non-finite is also `null`, deliberately\n *\n * `Infinity` must never resolve to a budget. It is unrepresentable off-process\n * — `JSON.stringify(Infinity)` is `null` and reads back as 0 — so a band that\n * reached here as `Infinity` is a band that would read as zero somewhere\n * else. Falling through to the backstop is bounded and consistent; honouring\n * it would be an unbounded budget on one process and a refusal on the next.\n *\n * An UNCAPPED staff comp (AGL-3049) is where that `Infinity` comes from on\n * purpose, and `null` is right for it here too: there is no band to measure\n * against, be over, price or alert on. What `null` must NOT bring with it is\n * the backstop, whose repo default would cap the one workspace staff said\n * has no cap — so `assistBandRefuses` answers false for it, and the\n * reservation's ceiling (`assistMonthlyCeilingUsd` in the AI plugin) drops\n * the default and keeps only an operator's explicit figure.\n */\nexport function resolveAssistCreditBudget(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = planQuotaOf(resolveOrgEntitlements(org), 'assistCreditsPerMonth')\n if (!Number.isFinite(credits) || credits <= 0) return null\n return Math.floor(credits)\n}\n\n/** The org's monthly assist band in USD of provider spend, or `null`. */\nexport function resolveAssistBudgetUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = resolveAssistCreditBudget(org)\n return credits === null ? null : assistUsdFromCredits(credits)\n}\n\n/**\n * Credits spent past the band. A `null` band yields 0 — an org with no band\n * has nothing to be over, and reporting the whole month's spend as overage\n * there would invoice the free tier for its docs answers.\n */\nexport function assistCreditOverage(\n usedCredits: number,\n bandCredits: number | null,\n): number {\n const used = Number(usedCredits)\n if (!Number.isFinite(used) || used <= 0) return 0\n if (bandCredits === null) return 0\n const band = Number(bandCredits)\n if (!Number.isFinite(band) || band <= 0) return 0\n return Math.max(0, used - band)\n}\n\n/**\n * The per-1,000 rate the org's assist credits are sold at past its band, or\n * `null` when nothing is sold past it — the ONE reading of the rate\n * (AGL-2896). `priceAssistCreditOverage`, `assistBandRefuses`,\n * `assistRefusedByHardCap` and `assistHardCapRefusalText` all go through\n * it, so the gate, the invoice and the refusal sentence cannot quote three\n * different rates for one org.\n *\n * The plan's `extraAssistCreditsUsdPer1k` IS the answer — the whole answer,\n * since AGL-3203. Starter used to be the one exception: it banded at 0, so\n * the rate could not sit on its plan row without advertising a fee on a\n * quantity the plan never sold, and the add-on's band was priced from a\n * constant read here instead. Starter now includes 750 credits\n * unconditionally and carries $3.00 on its own row, so the exception and its\n * constant are gone and there is exactly one place the rate is written.\n *\n * Free and Enterprise are null with or without the add-on: Free sells no\n * add-on and its taste band is a wall by decision (AGL-2925), and\n * Enterprise's usage is in the contract.\n *\n * A STAFF COMP is null on every plan and with every add-on (AGL-3034),\n * because `resolvePlanPricing` — never `PLAN_PRICING` indexed directly —\n * sells nothing on a comp. That is what makes a comp's band a wall at the\n * gate and prices its overage to zero at the charge: the AI overage path\n * (AGL-3011) claims only what `assistMonthOverage` prices, so a comp never\n * becomes a Stripe invoice. The function is a single read for that reason;\n * a second source for the rate is how a comp gets quoted one.\n */\nexport function resolveAssistOverageRateUsdPer1k(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n return resolvePlanPricing(org).extraAssistCreditsUsdPer1k\n}\n\nexport interface AssistCreditOveragePrice {\n /** Credits past the plan's band, as handed in. */\n overageCredits: number\n /** Estimated charge at the plan's per-1,000 rate. */\n overageMonthlyUsd: number\n /** Per-1,000 rate; null when the plan prices no assist overage. */\n overageRateUsd: number | null\n}\n\n/**\n * Prices assist credits past a plan's band.\n *\n * ## No `allowed`, deliberately — and for the opposite reason to email\n *\n * `priceEmailSendOverage` omits an `allowed` field because email has two\n * gates and a transactional sender consulting the wrong one drops a password\n * reset. Assist omits it because it has exactly ONE gate and this is not it:\n * whether the band refuses is `assistBandRefuses`, applied inside\n * `reserveAssistMessage` before a token is spent. A second answer to \"may\n * this proceed\", computed after the fact from a rounded credit figure, would\n * be a second gate to drift from the first.\n *\n * ## Takes the overage, does not recompute it\n *\n * `assistCreditOverage` derives the excess once, from the measured meter and\n * the resolved band. Re-deriving it here would be a second overage model.\n *\n * A null rate yields zero structurally rather than by a check, which is what\n * keeps Free and Starter at zero on this axis.\n */\nexport function priceAssistCreditOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n overageCredits: number,\n): AssistCreditOveragePrice {\n const overageRateUsd = resolveAssistOverageRateUsdPer1k(org)\n const credits = Number(overageCredits)\n const over = Number.isFinite(credits) && credits > 0 ? credits : 0\n return {\n overageCredits: over,\n overageMonthlyUsd:\n overageRateUsd === null\n ? 0\n : Math.round((over / 1000) * overageRateUsd * 100) / 100,\n overageRateUsd,\n }\n}\n\n/**\n * The line margin a per-1,000-credit retail rate earns AT LEAST, as a\n * fraction.\n *\n * One expression, so the ladder and its floor cannot be checked against two\n * different definitions of margin. A null or non-positive rate has no margin\n * to report and answers `null` rather than 1 or 0 — \"this plan sells no\n * overage\" is not \"this plan sells overage at 100% margin\".\n *\n * A LOWER BOUND, not the realized figure (AGL-3015). The cost side is\n * `ASSIST_CREDIT_COST_USD`, which is what a credit BILLS at; a model billed\n * above its provider rate costs us less than that per credit, so the real\n * line margin is this or better and never worse. The floor stays checkable\n * by eye — a $2.00 rate is still cost x2 — and a retail rate that clears the\n * floor here clears it on every model mix. What a period realized is the\n * staff margin card's business, and it reads measured provider spend.\n */\nexport function assistCreditRateMarginPct(\n rateUsdPer1k: number | null,\n): number | null {\n if (rateUsdPer1k === null) return null\n const rate = Number(rateUsdPer1k)\n if (!Number.isFinite(rate) || rate <= 0) return null\n const costPer1k = ASSIST_CREDIT_COST_USD * 1000\n return (rate - costPer1k) / rate\n}\n\n/**\n * What a refused or in-progress workspace may be TOLD about its assist band.\n *\n * The reservation carries `costUsd` and `costLimitUsd` because that is what\n * it measured and what it compared against, and both are our provider bill.\n * Shipping the reservation itself to a browser publishes them. This is the\n * projection that crosses that boundary: credits only, no dollars, no model,\n * no rates.\n */\nexport interface PublicAssistCredits {\n /** Credits drawn this month, rounded up. */\n used: number\n /** The band, or `null` when no band applied to this request. */\n limit: number | null\n /** Credits left in the band, or `null` when there is no band. */\n remaining: number | null\n}\n\n/**\n * Projects a measured spend figure and a ceiling into the credit view.\n *\n * Both inputs are nullable because the reservation's are: `costUsd` is `null`\n * when nothing consulted the monthly document, and a ceiling is `null` when\n * none applied. Neither collapses to 0 here — \"nobody looked\" and \"spent\n * nothing\" have to stay distinguishable at the surface, exactly as they do on\n * the reservation.\n */\nexport function publicAssistCredits(\n costUsd: number | null,\n ceilingUsd: number | null,\n): PublicAssistCredits {\n const used = costUsd === null ? 0 : assistCreditsFromUsd(costUsd)\n if (ceilingUsd === null) return { used, limit: null, remaining: null }\n const limit = assistCreditsFromUsd(ceilingUsd)\n return { used, limit, remaining: Math.max(0, limit - used) }\n}\n\n/**\n * The console's name for the org's hard-cap switch (AGL-2653), quoted by the\n * refusal so the words on the 402 and the words beside the switch are one\n * string. Change it here and both move.\n */\nexport const ASSIST_HARD_CAP_CONTROL_LABEL = 'Stop AI assist at the included band'\n\n/** Where the switch lives, in the words the refusal uses. */\nexport const ASSIST_HARD_CAP_CONTROL_LOCATION = 'Billing → Usage'\n\n/**\n * Whether the org asked to be stopped at its assist band (AGL-2653).\n *\n * Strictly `=== true`: the map is absent on every org that has not touched\n * the switch, and absent is the selling default. A truthy string or a `1`\n * written by hand must not switch an assistant off at the band, so nothing\n * short of the boolean counts.\n */\nexport function resolveAssistHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n return org?.assistOverage?.hardCap === true\n}\n\n/**\n * Is the org's assist band a WALL, or a line past which credits are sold?\n *\n * Two things make it a wall, and only two:\n *\n * - **The org asked.** `assistOverage.hardCap` is the customer's own ceiling,\n * the `storageOverage.capUsd` of this meter.\n * - **There is nothing to sell past it at.** An org whose\n * `resolveAssistOverageRateUsdPer1k` is `null` has no rate to bill, so\n * credits past its band would be provider spend with no invoice line — the\n * silent free overage `plan-entitlements.spec.ts` forbids. Enterprise is\n * one case, its band being contractual; FREE is the other, its taste band\n * a wall by the AGL-2925 decision so that nothing about a Free workspace\n * can produce a charge.\n *\n * Everything else sells past the band by default, which is the 2026-09-07\n * decision this function encodes — and since AGL-3203 that includes Starter,\n * which carries 750 credits and a $3.00 rate like every paid tier above it.\n *\n * An org with NO band at all answers the same as Enterprise, harmlessly:\n * `resolveAssistBudgetUsd` is `null` for it and the reservation never\n * measures against a band. No plan ROW is in that state any more — Free and\n * Starter both were, at different times — so it is reached by a per-org\n * override that writes a zero.\n *\n * An UNCAPPED staff comp (AGL-3049) is neither: it has no band to be a wall,\n * and nothing is sold past a band it does not have. It answers false, ahead\n * of the switch — a workspace staff uncapped is not stopped by a band, and\n * the reservation reads that answer as \"no wall, no backstop default\".\n */\nexport function assistBandRefuses(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n if (isUncappedPlanComp(org)) return false\n if (resolveAssistHardCap(org)) return true\n return resolveAssistOverageRateUsdPer1k(org) === null\n}\n\n/**\n * Which ceiling refused a reservation, or `null` when it was admitted.\n *\n * - `messages`: the message cap (daily on the free rung, monthly entitled).\n * - `budget`: the operator's spend backstop.\n * - `band`: the plan's own band, as a wall — the switch, or a plan with\n * no rate to sell past it at.\n * - `cap`: the org's own dollar ceiling on overage (AGL-2898).\n *\n * The four that exist only on the Free taste (AGL-2925), each a precaution\n * against the one band that has no invoice behind it:\n *\n * - `account`: the ACCOUNT's 300 credits are spent across every free\n * workspace it owns — the org band alone would multiply by\n * the workspaces one person may hold.\n * - `requests`: the account's daily free request cap.\n * - `refusals`: the account's free generation is paused for the day after\n * too many `refusal` stops — a brief the model declined is a\n * brief that should not be retried thirty times.\n * - `platform`: the platform-wide daily ceiling on free spend; nobody's\n * fault, and \"try again tomorrow\" is the whole answer.\n *\n * Declared here, beside the helpers that read it, so the reservation, its\n * public projection and both doors name one union.\n */\nexport type AssistRefusedBy =\n | 'messages'\n | 'budget'\n | 'band'\n | 'cap'\n | 'account'\n | 'requests'\n | 'refusals'\n | 'platform'\n | null\n\n/**\n * The sentence a Free workspace is told when one of the taste's own\n * precautions refused it, or `null` when the refusal was something else.\n *\n * One string per rung, for both doors and the gate ladder, so a customer\n * reading the console panel and one reading the besigner get the same\n * words. Every sentence is customer-safe: it names what to do (upgrade,\n * wait) and never a figure from our cost model, a counter document, or\n * another workspace.\n */\nexport function assistFreeTasteRefusalText(\n refusedBy: AssistRefusedBy,\n): string | null {\n switch (refusedBy) {\n case 'account':\n return (\n 'Your free AI credits for this month are used across your ' +\n 'workspaces — upgrade any workspace to keep going.'\n )\n case 'requests':\n return (\n 'Free workspaces get a limited number of AI requests a day — ' +\n 'try again tomorrow, or upgrade to keep going.'\n )\n case 'refusals':\n return (\n 'The AI declined several requests today, so free AI generation is ' +\n 'paused until tomorrow. Try a different kind of brief then.'\n )\n case 'platform':\n return (\n 'Free AI generation is paused for the rest of today — try again ' +\n 'tomorrow. Paid workspaces are not affected.'\n )\n default:\n return null\n }\n}\n\n/**\n * The sentence a workspace whose plan does not carry `aiAssist` is told, at\n * the route and at every console door that checks the flag first.\n *\n * The plan it names is read from the plan table rather than written here.\n * Which tier first carries the flag is a pricing decision, and a name typed\n * into the sentence keeps naming the old tier after the table moves — telling\n * the reader to buy a plan above the one that would let them in. `undefined`\n * from the lookup means no tier carries the flag on its base row, and the\n * sentence then names no plan rather than a wrong one.\n */\nexport function assistNotOnPlanText(): string {\n const plan = planLabelGrantingFeature('aiAssist')\n return plan\n ? `AI assist starts on the ${plan} plan — see Billing to upgrade`\n : 'AI assist is not on this plan — see Billing to upgrade'\n}\n\n/**\n * Whether a refusal at the band was the org's OWN doing — the switch is on\n * AND turning it off would have let the exchange through. The refusal that\n * names the switch has to be the one the switch caused: on a plan with no\n * rate the band refuses whatever the switch says, and telling that customer\n * to turn it off would send them to a control that does nothing.\n */\nexport function assistRefusedByHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'band' &&\n resolveAssistHardCap(org) &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own hard cap refused it. ONE\n * string for both assist entrypoints, so the console panel and the besigner\n * cannot describe the same control in two ways. It names the switch by its\n * label, says where it lives, and quotes the plan's rate so the reader knows\n * what turning it off costs.\n */\nexport function assistHardCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const rate = resolveAssistOverageRateUsdPer1k(org)\n const rateClause =\n rate === null ? '' : ` at $${rate.toFixed(2)} per 1,000 credits`\n return (\n `This workspace used its assistant credits for the month, and ` +\n `\"${ASSIST_HARD_CAP_CONTROL_LABEL}\" is on. Turn it off under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going${rateClause}.`\n )\n}\n\nexport interface AssistMonthOverage extends AssistCreditOveragePrice {\n /** Credits drawn this month, rounded up from the measured spend. */\n usedCredits: number\n /** The band those credits were measured against, or `null` for no band. */\n bandCredits: number | null\n}\n\n/**\n * A month's assist overage from its measured spend — the ONE derivation the\n * invoice and every meter read (AGL-2653).\n *\n * Takes `estCostUsd` as the rollup reads it and composes the three steps\n * that already existed — credits from spend, overage past the band, price at\n * the plan's rate — so `report-usage` cannot assemble them in a different\n * order from a console readout and arrive at a different figure. The\n * rounding is the other overage terms' rounding: `priceAssistCreditOverage`\n * rounds to the cent, and the rollup turns dollars into cents with the same\n * `Math.round(usd * 100)` it applies to contacts, API and dataset storage.\n *\n * A non-finite or negative spend is zero credits, for the reason\n * `assistCreditsFromUsd` gives; a plan with no band or no rate prices zero\n * structurally, so Free, Starter and Enterprise bill nothing here without a\n * check that could be forgotten.\n */\nexport function assistMonthOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): AssistMonthOverage {\n const usedCredits = assistCreditsFromUsd(estCostUsd)\n const bandCredits = resolveAssistCreditBudget(org)\n return {\n usedCredits,\n bandCredits,\n ...priceAssistCreditOverage(\n org,\n assistCreditOverage(usedCredits, bandCredits),\n ),\n }\n}\n\n/** The console's name for the org's overage ceiling (AGL-2898). */\nexport const ASSIST_OVERAGE_CAP_CONTROL_LABEL =\n 'Stop AI when this month’s overage reaches'\n\n/**\n * Bounds on the ceiling a self-serve org may set. The floor is a dollar\n * because a ceiling of cents would refuse on the first exchange past the\n * band while reading as a limit; the roof is the storage cap's, so no\n * self-serve org writes itself an enterprise-sized commitment.\n */\nexport const ASSIST_OVERAGE_CAP_MIN_USD = 1\nexport const ASSIST_OVERAGE_CAP_MAX_USD = 100_000\n\n/**\n * The org's monthly ceiling on AI overage dollars, or `null` for none.\n *\n * Strictly a finite positive number. `null`, absent, a string, `NaN`,\n * `Infinity` and zero all read as no ceiling — the failure mode of the\n * alternative is a wall of `NaN` that every comparison passes, or a ceiling\n * of zero that refuses the first exchange past the band while the card\n * says nothing is set.\n */\nexport function resolveAssistOverageCapUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const raw = org?.assistOverage?.capUsd\n if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null\n return raw\n}\n\n/**\n * Has this month's overage reached the org's own ceiling (AGL-2898)?\n *\n * Composed from `assistMonthOverage`, the ONE derivation the invoice reads,\n * so the figure the ceiling stops at is the figure the invoice would have\n * billed. That composition is also what confines the ceiling to plans that\n * sell past their band: a plan with no rate prices its overage to zero, and\n * zero never reaches a positive ceiling — Free and Enterprise cannot get here\n * without a check that could be forgotten. `>=` rather than `>`, because an\n * org that asked to stop AT a figure is stopped when the figure is met.\n */\nexport function assistOverageCapReached(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): boolean {\n const cap = resolveAssistOverageCapUsd(org)\n if (cap === null) return false\n return assistMonthOverage(org, estCostUsd).overageMonthlyUsd >= cap\n}\n\n/**\n * Whether a refusal was the org's own ceiling. The reservation answers\n * `'cap'` only when `assistOverageCapReached` did, and that helper only\n * reaches a ceiling on a plan with a rate, so the two conditions restated\n * here are the same guarantee `assistRefusedByHardCap` gives the switch: the\n * control the sentence names is the control that refused.\n */\nexport function assistRefusedByOverageCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'cap' &&\n resolveAssistOverageCapUsd(org) !== null &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own overage ceiling refused it.\n * ONE string for both assist entrypoints, as `assistHardCapRefusalText` is\n * for the switch. It names the control by its label, quotes the figure the\n * org chose, and says where to raise or clear it.\n */\nexport function assistOverageCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const cap = resolveAssistOverageCapUsd(org)\n const figure = cap === null ? '' : ` $${cap.toFixed(2)}`\n return (\n `This month’s AI assist overage reached${figure}, the figure you set as ` +\n `\"${ASSIST_OVERAGE_CAP_CONTROL_LABEL}\". Raise or remove it under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going.`\n )\n}\n\n/**\n * The refusal sentence for whichever of the org's OWN controls refused, or\n * `null` when the refusal was nobody's control — the message cap, the\n * operator's backstop, or a plan that sells no overage. Both doors answer a\n * 402 with this text when it is a string and their usual 429 otherwise, so\n * the status and the sentence cannot disagree about whose decision it was.\n */\nexport function assistOwnControlRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): string | null {\n if (assistRefusedByHardCap(org, refusedBy)) return assistHardCapRefusalText(org)\n if (assistRefusedByOverageCap(org, refusedBy)) {\n return assistOverageCapRefusalText(org)\n }\n return null\n}\n"],"names":["isUncappedPlanComp","planLabelGrantingFeature","resolveOrgEntitlements","resolvePlanPricing","planQuotaOf","ASSIST_CREDIT_COST_USD","ASSIST_PROVIDER_COST_FIELD","assistProviderCostUsd","billedUsd","providerUsd","provider","Number","isFinite","billed","ASSIST_CREDIT_MIN_MARGIN_PCT","assistUsdFromCredits","credits","value","Math","round","assistCreditsFromUsd","usd","ceil","resolveAssistCreditBudget","org","floor","resolveAssistBudgetUsd","assistCreditOverage","usedCredits","bandCredits","used","band","max","resolveAssistOverageRateUsdPer1k","extraAssistCreditsUsdPer1k","priceAssistCreditOverage","overageCredits","overageRateUsd","over","overageMonthlyUsd","assistCreditRateMarginPct","rateUsdPer1k","rate","costPer1k","publicAssistCredits","costUsd","ceilingUsd","limit","remaining","ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_HARD_CAP_CONTROL_LOCATION","resolveAssistHardCap","assistOverage","hardCap","assistBandRefuses","assistFreeTasteRefusalText","refusedBy","assistNotOnPlanText","plan","assistRefusedByHardCap","assistHardCapRefusalText","rateClause","toFixed","assistMonthOverage","estCostUsd","ASSIST_OVERAGE_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_MIN_USD","ASSIST_OVERAGE_CAP_MAX_USD","resolveAssistOverageCapUsd","raw","capUsd","assistOverageCapReached","cap","assistRefusedByOverageCap","assistOverageCapRefusalText","figure","assistOwnControlRefusalText"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,wBAAwB,EACxBC,sBAAsB,EACtBC,kBAAkB,QACb,2CAA0C;AACjD,SAASC,WAAW,QAAQ,uDAAsD;AAGlF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD,OAAO,MAAMC,yBAAyB,MAAK;AAE3C;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BAA6B,kBAAiB;AAE3D;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,sBACdC,SAAkB,EAClBC,WAAoB;IAEpB,MAAMC,WAAWC,OAAOF;IACxB,IAAIE,OAAOC,QAAQ,CAACF,aAAaA,WAAW,GAAG,OAAOA;IACtD,MAAMG,SAASF,OAAOH;IACtB,OAAOG,OAAOC,QAAQ,CAACC,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAEA;;;;;;CAMC,GACD,OAAO,MAAMC,+BAA+B,IAAG;AAE/C;;;CAGC,GACD,OAAO,SAASC,qBAAqBC,OAAe;IAClD,MAAMC,QAAQN,OAAOK;IACrB,IAAI,CAACL,OAAOC,QAAQ,CAACK,UAAUA,SAAS,GAAG,OAAO;IAClD,OAAOC,KAAKC,KAAK,CAACF,QAAQZ,yBAAyB,WAAa;AAClE;AAEA;;;;;;;CAOC,GACD,OAAO,SAASe,qBAAqBC,GAAW;IAC9C,MAAMJ,QAAQN,OAAOU;IACrB,IAAI,CAACV,OAAOC,QAAQ,CAACK,UAAUA,SAAS,GAAG,OAAO;IAClD,OAAOC,KAAKI,IAAI,CAACL,QAAQZ;AAC3B;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmDC,GACD,OAAO,SAASkB,0BACdC,GAAgD;IAEhD,MAAMR,UAAUZ,YAAYF,uBAAuBsB,MAAM;IACzD,IAAI,CAACb,OAAOC,QAAQ,CAACI,YAAYA,WAAW,GAAG,OAAO;IACtD,OAAOE,KAAKO,KAAK,CAACT;AACpB;AAEA,uEAAuE,GACvE,OAAO,SAASU,uBACdF,GAAgD;IAEhD,MAAMR,UAAUO,0BAA0BC;IAC1C,OAAOR,YAAY,OAAO,OAAOD,qBAAqBC;AACxD;AAEA;;;;CAIC,GACD,OAAO,SAASW,oBACdC,WAAmB,EACnBC,WAA0B;IAE1B,MAAMC,OAAOnB,OAAOiB;IACpB,IAAI,CAACjB,OAAOC,QAAQ,CAACkB,SAASA,QAAQ,GAAG,OAAO;IAChD,IAAID,gBAAgB,MAAM,OAAO;IACjC,MAAME,OAAOpB,OAAOkB;IACpB,IAAI,CAAClB,OAAOC,QAAQ,CAACmB,SAASA,QAAQ,GAAG,OAAO;IAChD,OAAOb,KAAKc,GAAG,CAAC,GAAGF,OAAOC;AAC5B;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD,OAAO,SAASE,iCACdT,GAAgD;IAEhD,OAAOrB,mBAAmBqB,KAAKU,0BAA0B;AAC3D;AAWA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC,yBACdX,GAAgD,EAChDY,cAAsB;IAEtB,MAAMC,iBAAiBJ,iCAAiCT;IACxD,MAAMR,UAAUL,OAAOyB;IACvB,MAAME,OAAO3B,OAAOC,QAAQ,CAACI,YAAYA,UAAU,IAAIA,UAAU;IACjE,OAAO;QACLoB,gBAAgBE;QAChBC,mBACEF,mBAAmB,OACf,IACAnB,KAAKC,KAAK,CAAC,AAACmB,OAAO,OAAQD,iBAAiB,OAAO;QACzDA;IACF;AACF;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASG,0BACdC,YAA2B;IAE3B,IAAIA,iBAAiB,MAAM,OAAO;IAClC,MAAMC,OAAO/B,OAAO8B;IACpB,IAAI,CAAC9B,OAAOC,QAAQ,CAAC8B,SAASA,QAAQ,GAAG,OAAO;IAChD,MAAMC,YAAYtC,yBAAyB;IAC3C,OAAO,AAACqC,CAAAA,OAAOC,SAAQ,IAAKD;AAC9B;AAoBA;;;;;;;;CAQC,GACD,OAAO,SAASE,oBACdC,OAAsB,EACtBC,UAAyB;IAEzB,MAAMhB,OAAOe,YAAY,OAAO,IAAIzB,qBAAqByB;IACzD,IAAIC,eAAe,MAAM,OAAO;QAAEhB;QAAMiB,OAAO;QAAMC,WAAW;IAAK;IACrE,MAAMD,QAAQ3B,qBAAqB0B;IACnC,OAAO;QAAEhB;QAAMiB;QAAOC,WAAW9B,KAAKc,GAAG,CAAC,GAAGe,QAAQjB;IAAM;AAC7D;AAEA;;;;CAIC,GACD,OAAO,MAAMmB,gCAAgC,sCAAqC;AAElF,2DAA2D,GAC3D,OAAO,MAAMC,mCAAmC,kBAAiB;AAEjE;;;;;;;CAOC,GACD,OAAO,SAASC,qBACd3B,GAAgD;QAEzCA;IAAP,OAAOA,CAAAA,wBAAAA,qBAAAA,IAAK4B,aAAa,qBAAlB5B,mBAAoB6B,OAAO,MAAK;AACzC;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BC,GACD,OAAO,SAASC,kBACd9B,GAAgD;IAEhD,IAAIxB,mBAAmBwB,MAAM,OAAO;IACpC,IAAI2B,qBAAqB3B,MAAM,OAAO;IACtC,OAAOS,iCAAiCT,SAAS;AACnD;AAsCA;;;;;;;;;CASC,GACD,OAAO,SAAS+B,2BACdC,SAA0B;IAE1B,OAAQA;QACN,KAAK;YACH,OACE,8DACA;QAEJ,KAAK;YACH,OACE,iEACA;QAEJ,KAAK;YACH,OACE,sEACA;QAEJ,KAAK;YACH,OACE,oEACA;QAEJ;YACE,OAAO;IACX;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC;IACd,MAAMC,OAAOzD,yBAAyB;IACtC,OAAOyD,OACH,CAAC,wBAAwB,EAAEA,KAAK,8BAA8B,CAAC,GAC/D;AACN;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,uBACdnC,GAAgD,EAChDgC,SAA0B;IAE1B,OACEA,cAAc,UACdL,qBAAqB3B,QACrBS,iCAAiCT,SAAS;AAE9C;AAEA;;;;;;CAMC,GACD,OAAO,SAASoC,yBACdpC,GAAgD;IAEhD,MAAMkB,OAAOT,iCAAiCT;IAC9C,MAAMqC,aACJnB,SAAS,OAAO,KAAK,CAAC,KAAK,EAAEA,KAAKoB,OAAO,CAAC,GAAG,kBAAkB,CAAC;IAClE,OACE,CAAC,6DAA6D,CAAC,GAC/D,CAAC,CAAC,EAAEb,8BAA8B,2BAA2B,CAAC,GAC9D,GAAGC,iCAAiC,cAAc,EAAEW,WAAW,CAAC,CAAC;AAErE;AASA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASE,mBACdvC,GAAgD,EAChDwC,UAAkB;IAElB,MAAMpC,cAAcR,qBAAqB4C;IACzC,MAAMnC,cAAcN,0BAA0BC;IAC9C,OAAO;QACLI;QACAC;OACGM,yBACDX,KACAG,oBAAoBC,aAAaC;AAGvC;AAEA,iEAAiE,GACjE,OAAO,MAAMoC,mCACX,4CAA2C;AAE7C;;;;;CAKC,GACD,OAAO,MAAMC,6BAA6B,EAAC;AAC3C,OAAO,MAAMC,6BAA6B,OAAO;AAEjD;;;;;;;;CAQC,GACD,OAAO,SAASC,2BACd5C,GAAgD;QAEpCA;IAAZ,MAAM6C,MAAM7C,wBAAAA,qBAAAA,IAAK4B,aAAa,qBAAlB5B,mBAAoB8C,MAAM;IACtC,IAAI,OAAOD,QAAQ,YAAY,CAAC1D,OAAOC,QAAQ,CAACyD,QAAQA,OAAO,GAAG,OAAO;IACzE,OAAOA;AACT;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASE,wBACd/C,GAAgD,EAChDwC,UAAkB;IAElB,MAAMQ,MAAMJ,2BAA2B5C;IACvC,IAAIgD,QAAQ,MAAM,OAAO;IACzB,OAAOT,mBAAmBvC,KAAKwC,YAAYzB,iBAAiB,IAAIiC;AAClE;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,0BACdjD,GAAgD,EAChDgC,SAA0B;IAE1B,OACEA,cAAc,SACdY,2BAA2B5C,SAAS,QACpCS,iCAAiCT,SAAS;AAE9C;AAEA;;;;;CAKC,GACD,OAAO,SAASkD,4BACdlD,GAAgD;IAEhD,MAAMgD,MAAMJ,2BAA2B5C;IACvC,MAAMmD,SAASH,QAAQ,OAAO,KAAK,CAAC,EAAE,EAAEA,IAAIV,OAAO,CAAC,IAAI;IACxD,OACE,CAAC,sCAAsC,EAAEa,OAAO,wBAAwB,CAAC,GACzE,CAAC,CAAC,EAAEV,iCAAiC,4BAA4B,CAAC,GAClE,GAAGf,iCAAiC,eAAe,CAAC;AAExD;AAEA;;;;;;CAMC,GACD,OAAO,SAAS0B,4BACdpD,GAAgD,EAChDgC,SAA0B;IAE1B,IAAIG,uBAAuBnC,KAAKgC,YAAY,OAAOI,yBAAyBpC;IAC5E,IAAIiD,0BAA0BjD,KAAKgC,YAAY;QAC7C,OAAOkB,4BAA4BlD;IACrC;IACA,OAAO;AACT"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */ // Imported by the staff card (client) and by the App Route that serves it, so it
17
17
  // imports neither entry barrel (see `staff-org-ai.ts`).
18
+ import { AI_TIERS } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
19
  import { aiUsageMonthLabel } from "./ai-usage-wire.js";
19
20
  import { aiCreditsCell, aiOverageCell, assistCostCell, assistPoolSentence } from "./staff-org-usage-ai.js";
20
21
  /*
@@ -45,7 +46,7 @@ export const rate = (value)=>value == null ? '—' : `${Math.round(value * 100)}
45
46
  parts.push(`${credits(pool.planCredits)} plan band`);
46
47
  }
47
48
  if (pool.addonCredits > 0) {
48
- parts.push(`${credits(pool.addonCredits)} ${addonName} add-on`);
49
+ parts.push(`${credits(pool.addonCredits)} ${addonName} tier`);
49
50
  }
50
51
  return parts.join(' + ');
51
52
  }
@@ -157,8 +158,10 @@ export const rate = (value)=>value == null ? '—' : `${Math.round(value * 100)}
157
158
  month: data.month,
158
159
  addon: {
159
160
  on: addon.on,
160
- status: addon.on ? `${addonName} on` : `${addonName} off`,
161
- price: addon.priceUsd !== null ? `${usd(addon.priceUsd)}/mo on this plan` : 'Not sold on this plan',
161
+ // The tier held (AGL-3965): sold on its own, at one price on any paid
162
+ // plan, so the price is the tier's rather than "on this plan".
163
+ status: addon.on ? `${addonName}${addon.tier ? ` ${AI_TIERS[addon.tier].label}` : ''} on` : `${addonName} off`,
164
+ price: addon.priceUsd !== null ? `${usd(addon.priceUsd)}/mo` : 'No tier',
162
165
  since: addon.on ? addon.since : null,
163
166
  sinceNote: addon.on ? addonSinceNote(addon) : ''
164
167
  },