@aglyn/plugins-ai 1.0.0-beta.228 → 1.0.0-beta.230

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 (274) hide show
  1. package/README.md +3 -3
  2. package/package.json +17 -17
  3. package/src/lib/activity/ai-activity-actions.js +7 -1
  4. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  5. package/src/lib/components/ai-automation-zones.d.ts +38 -0
  6. package/src/lib/components/ai-automation-zones.js +5 -1
  7. package/src/lib/components/ai-automation-zones.js.map +1 -1
  8. package/src/lib/components/ai-brief-dialog.component.d.ts +20 -2
  9. package/src/lib/components/ai-brief-dialog.component.js +87 -8
  10. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  11. package/src/lib/components/ai-campaign-create.component.d.ts +54 -0
  12. package/src/lib/components/ai-campaign-create.component.js +270 -0
  13. package/src/lib/components/ai-campaign-create.component.js.map +1 -0
  14. package/src/lib/components/ai-describe-automation.component.d.ts +8 -3
  15. package/src/lib/components/ai-describe-automation.component.js +15 -6
  16. package/src/lib/components/ai-describe-automation.component.js.map +1 -1
  17. package/src/lib/components/ai-describe-button.component.d.ts +20 -8
  18. package/src/lib/components/ai-describe-button.component.js +19 -36
  19. package/src/lib/components/ai-describe-button.component.js.map +1 -1
  20. package/src/lib/components/ai-describe-email.component.d.ts +31 -0
  21. package/src/lib/components/ai-describe-email.component.js +22 -0
  22. package/src/lib/components/ai-describe-email.component.js.map +1 -0
  23. package/src/lib/components/ai-describe-org-automation.component.d.ts +36 -0
  24. package/src/lib/components/ai-describe-org-automation.component.js +238 -0
  25. package/src/lib/components/ai-describe-org-automation.component.js.map +1 -0
  26. package/src/lib/components/ai-experiment-cards.component.d.ts +9 -1
  27. package/src/lib/components/ai-experiment-cards.component.js +104 -18
  28. package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
  29. package/src/lib/components/ai-explain-automation.component.js +1 -5
  30. package/src/lib/components/ai-explain-automation.component.js.map +1 -1
  31. package/src/lib/components/ai-funnel-zones.component.d.ts +61 -0
  32. package/src/lib/components/ai-funnel-zones.component.js +178 -0
  33. package/src/lib/components/ai-funnel-zones.component.js.map +1 -0
  34. package/src/lib/components/ai-insight-card.component.d.ts +42 -0
  35. package/src/lib/components/ai-insight-card.component.js +137 -0
  36. package/src/lib/components/ai-insight-card.component.js.map +1 -0
  37. package/src/lib/components/ai-insight-dialog.component.d.ts +6 -0
  38. package/src/lib/components/ai-insight-dialog.component.js +9 -3
  39. package/src/lib/components/ai-insight-dialog.component.js.map +1 -1
  40. package/src/lib/components/ai-job-plan.component.d.ts +7 -2
  41. package/src/lib/components/ai-job-plan.component.js +35 -5
  42. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  43. package/src/lib/components/ai-job-requests.d.ts +17 -1
  44. package/src/lib/components/ai-job-requests.js +39 -3
  45. package/src/lib/components/ai-job-requests.js.map +1 -1
  46. package/src/lib/components/ai-jobs-list-page.component.js +12 -1
  47. package/src/lib/components/ai-jobs-list-page.component.js.map +1 -1
  48. package/src/lib/components/ai-logic-zones.d.ts +64 -0
  49. package/src/lib/components/ai-logic-zones.js +18 -0
  50. package/src/lib/components/ai-logic-zones.js.map +1 -0
  51. package/src/lib/components/ai-logic.component.d.ts +77 -0
  52. package/src/lib/components/ai-logic.component.js +553 -0
  53. package/src/lib/components/ai-logic.component.js.map +1 -0
  54. package/src/lib/components/ai-marketing-insight.component.d.ts +30 -0
  55. package/src/lib/components/ai-marketing-insight.component.js +80 -0
  56. package/src/lib/components/ai-marketing-insight.component.js.map +1 -0
  57. package/src/lib/components/ai-media-create.component.d.ts +55 -0
  58. package/src/lib/components/ai-media-create.component.js +433 -0
  59. package/src/lib/components/ai-media-create.component.js.map +1 -0
  60. package/src/lib/components/ai-overlay-cards.component.d.ts +83 -0
  61. package/src/lib/components/ai-overlay-cards.component.js +505 -0
  62. package/src/lib/components/ai-overlay-cards.component.js.map +1 -0
  63. package/src/lib/components/ai-product-zones.d.ts +5 -0
  64. package/src/lib/components/ai-product-zones.js +1 -1
  65. package/src/lib/components/ai-product-zones.js.map +1 -1
  66. package/src/lib/components/ai-products-create-button.component.d.ts +42 -0
  67. package/src/lib/components/ai-products-create-button.component.js +67 -0
  68. package/src/lib/components/ai-products-create-button.component.js.map +1 -0
  69. package/src/lib/components/ai-products-hub-card.component.js +100 -81
  70. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  71. package/src/lib/components/ai-revise-automation.component.d.ts +16 -0
  72. package/src/lib/components/ai-revise-automation.component.js +207 -0
  73. package/src/lib/components/ai-revise-automation.component.js.map +1 -0
  74. package/src/lib/components/ai-site-build-page.component.d.ts +1 -1
  75. package/src/lib/components/ai-site-build-page.component.js +177 -20
  76. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  77. package/src/lib/components/ai-site-start-card.component.d.ts +10 -1
  78. package/src/lib/components/ai-site-start-card.component.js +7 -4
  79. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  80. package/src/lib/components/ai-upsell-dialog.component.d.ts +53 -0
  81. package/src/lib/components/ai-upsell-dialog.component.js +177 -0
  82. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -0
  83. package/src/lib/components/assist-build-card.component.d.ts +42 -0
  84. package/src/lib/components/assist-build-card.component.js +183 -0
  85. package/src/lib/components/assist-build-card.component.js.map +1 -0
  86. package/src/lib/components/assist-edit-canvas.d.ts +2 -0
  87. package/src/lib/components/assist-edit-canvas.js +56 -1
  88. package/src/lib/components/assist-edit-canvas.js.map +1 -1
  89. package/src/lib/components/assist-jobs-drawer.component.d.ts +11 -0
  90. package/src/lib/components/assist-jobs-drawer.component.js +85 -4
  91. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  92. package/src/lib/components/assist-panel.component.js +51 -2
  93. package/src/lib/components/assist-panel.component.js.map +1 -1
  94. package/src/lib/components/use-ai-job-run.d.ts +3 -14
  95. package/src/lib/components/use-ai-job-run.js +0 -50
  96. package/src/lib/components/use-ai-job-run.js.map +1 -1
  97. package/src/lib/jobs/ai-build-capabilities.d.ts +58 -0
  98. package/src/lib/jobs/ai-build-capabilities.js +256 -0
  99. package/src/lib/jobs/ai-build-capabilities.js.map +1 -0
  100. package/src/lib/jobs/ai-build-unit-outcome.d.ts +52 -0
  101. package/src/lib/jobs/ai-build-unit-outcome.js +138 -0
  102. package/src/lib/jobs/ai-build-unit-outcome.js.map +1 -0
  103. package/src/lib/jobs/ai-job-build-step.d.ts +144 -0
  104. package/src/lib/jobs/ai-job-build-step.js +657 -0
  105. package/src/lib/jobs/ai-job-build-step.js.map +1 -0
  106. package/src/lib/jobs/ai-job-draft-ids.js +9 -1
  107. package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
  108. package/src/lib/jobs/ai-job-drafts.d.ts +14 -4
  109. package/src/lib/jobs/ai-job-drafts.js +25 -4
  110. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  111. package/src/lib/jobs/ai-job-layout-site-pages.d.ts +70 -0
  112. package/src/lib/jobs/ai-job-layout-site-pages.js +216 -0
  113. package/src/lib/jobs/ai-job-layout-site-pages.js.map +1 -0
  114. package/src/lib/jobs/ai-job-layout-step.d.ts +2 -2
  115. package/src/lib/jobs/ai-job-layout-step.js +19 -1
  116. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  117. package/src/lib/jobs/ai-job-logic-step.d.ts +107 -0
  118. package/src/lib/jobs/ai-job-logic-step.js +395 -0
  119. package/src/lib/jobs/ai-job-logic-step.js.map +1 -0
  120. package/src/lib/jobs/ai-job-overlay-copy.d.ts +70 -0
  121. package/src/lib/jobs/ai-job-overlay-copy.js +178 -0
  122. package/src/lib/jobs/ai-job-overlay-copy.js.map +1 -0
  123. package/src/lib/jobs/ai-job-page-step.js +6 -6
  124. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  125. package/src/lib/jobs/ai-job-plan-step.d.ts +26 -1
  126. package/src/lib/jobs/ai-job-plan-step.js +79 -11
  127. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  128. package/src/lib/jobs/ai-job-site-step.d.ts +15 -1
  129. package/src/lib/jobs/ai-job-site-step.js +207 -58
  130. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  131. package/src/lib/jobs/ai-job-text-step.d.ts +22 -1
  132. package/src/lib/jobs/ai-job-text-step.js +12 -0
  133. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  134. package/src/lib/jobs/ai-job-workflow-step.d.ts +54 -4
  135. package/src/lib/jobs/ai-job-workflow-step.js +318 -13
  136. package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
  137. package/src/lib/jobs/ai-jobs.d.ts +90 -2
  138. package/src/lib/jobs/ai-jobs.js +495 -61
  139. package/src/lib/jobs/ai-jobs.js.map +1 -1
  140. package/src/lib/jobs/ai-logic-records.d.ts +40 -0
  141. package/src/lib/jobs/ai-logic-records.js +55 -0
  142. package/src/lib/jobs/ai-logic-records.js.map +1 -0
  143. package/src/lib/jobs/ai-site-publish.d.ts +7 -0
  144. package/src/lib/jobs/ai-site-publish.js +9 -0
  145. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  146. package/src/lib/jobs/ai-workflow-records.d.ts +4 -0
  147. package/src/lib/jobs/ai-workflow-records.js +40 -1
  148. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  149. package/src/lib/model/ai-automation-draft.d.ts +18 -0
  150. package/src/lib/model/ai-automation-draft.js +86 -0
  151. package/src/lib/model/ai-automation-draft.js.map +1 -1
  152. package/src/lib/model/ai-build-job.d.ts +201 -0
  153. package/src/lib/model/ai-build-job.js +471 -0
  154. package/src/lib/model/ai-build-job.js.map +1 -0
  155. package/src/lib/model/ai-build-plan.d.ts +61 -0
  156. package/src/lib/model/ai-build-plan.js +154 -9
  157. package/src/lib/model/ai-build-plan.js.map +1 -1
  158. package/src/lib/model/ai-build-progress.d.ts +56 -0
  159. package/src/lib/model/ai-build-progress.js +128 -0
  160. package/src/lib/model/ai-build-progress.js.map +1 -0
  161. package/src/lib/model/ai-insight.d.ts +20 -10
  162. package/src/lib/model/ai-insight.js +72 -9
  163. package/src/lib/model/ai-insight.js.map +1 -1
  164. package/src/lib/model/ai-job-activity.js +3 -1
  165. package/src/lib/model/ai-job-activity.js.map +1 -1
  166. package/src/lib/model/ai-job-failure-copy.d.ts +20 -1
  167. package/src/lib/model/ai-job-failure-copy.js +28 -1
  168. package/src/lib/model/ai-job-failure-copy.js.map +1 -1
  169. package/src/lib/model/ai-job-notice.d.ts +3 -0
  170. package/src/lib/model/ai-job-notice.js +11 -2
  171. package/src/lib/model/ai-job-notice.js.map +1 -1
  172. package/src/lib/model/ai-jobs.types.d.ts +87 -2
  173. package/src/lib/model/ai-jobs.types.js +3 -1
  174. package/src/lib/model/ai-jobs.types.js.map +1 -1
  175. package/src/lib/model/ai-logic-job.d.ts +108 -0
  176. package/src/lib/model/ai-logic-job.js +327 -0
  177. package/src/lib/model/ai-logic-job.js.map +1 -0
  178. package/src/lib/model/ai-media-credits.d.ts +52 -0
  179. package/src/lib/model/ai-media-credits.js +61 -0
  180. package/src/lib/model/ai-media-credits.js.map +1 -0
  181. package/src/lib/model/ai-overlay-copy.d.ts +114 -0
  182. package/src/lib/model/ai-overlay-copy.js +175 -0
  183. package/src/lib/model/ai-overlay-copy.js.map +1 -0
  184. package/src/lib/model/ai-site-build-progress.d.ts +28 -3
  185. package/src/lib/model/ai-site-build-progress.js +231 -17
  186. package/src/lib/model/ai-site-build-progress.js.map +1 -1
  187. package/src/lib/model/ai-site-start-seo.d.ts +16 -0
  188. package/src/lib/model/ai-site-start-seo.js +54 -4
  189. package/src/lib/model/ai-site-start-seo.js.map +1 -1
  190. package/src/lib/model/ai-site-start.d.ts +14 -0
  191. package/src/lib/model/ai-site-start.js +43 -0
  192. package/src/lib/model/ai-site-start.js.map +1 -1
  193. package/src/lib/model/ai-svg.d.ts +69 -0
  194. package/src/lib/model/ai-svg.js +265 -0
  195. package/src/lib/model/ai-svg.js.map +1 -0
  196. package/src/lib/model/ai-usage-by-user.d.ts +3 -2
  197. package/src/lib/model/ai-usage-by-user.js.map +1 -1
  198. package/src/lib/model/ai-workflow-job.d.ts +92 -4
  199. package/src/lib/model/ai-workflow-job.js +124 -7
  200. package/src/lib/model/ai-workflow-job.js.map +1 -1
  201. package/src/lib/model/assist-build.d.ts +58 -0
  202. package/src/lib/model/assist-build.js +52 -0
  203. package/src/lib/model/assist-build.js.map +1 -0
  204. package/src/lib/model/assist-edit.d.ts +48 -3
  205. package/src/lib/model/assist-edit.js +48 -4
  206. package/src/lib/model/assist-edit.js.map +1 -1
  207. package/src/lib/plugin-config.js +2 -0
  208. package/src/lib/plugin-config.js.map +1 -1
  209. package/src/lib/plugin.js +240 -11
  210. package/src/lib/plugin.js.map +1 -1
  211. package/src/lib/providers/catalog.d.ts +81 -4
  212. package/src/lib/providers/catalog.js +80 -9
  213. package/src/lib/providers/catalog.js.map +1 -1
  214. package/src/lib/providers/image-contract.d.ts +116 -0
  215. package/src/lib/providers/image-contract.js +74 -0
  216. package/src/lib/providers/image-contract.js.map +1 -0
  217. package/src/lib/providers/model-choice.js +11 -0
  218. package/src/lib/providers/model-choice.js.map +1 -1
  219. package/src/lib/providers/routing.js +18 -0
  220. package/src/lib/providers/routing.js.map +1 -1
  221. package/src/lib/providers/vertex-image.d.ts +121 -0
  222. package/src/lib/providers/vertex-image.js +312 -0
  223. package/src/lib/providers/vertex-image.js.map +1 -0
  224. package/src/lib/runtime/ai-doctrine-validators.d.ts +19 -0
  225. package/src/lib/runtime/ai-doctrine-validators.js +89 -0
  226. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  227. package/src/lib/runtime/ai-doctrine.d.ts +6 -0
  228. package/src/lib/runtime/ai-doctrine.js +16 -6
  229. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  230. package/src/lib/runtime/ai-eval-live.js +4 -0
  231. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  232. package/src/lib/runtime/ai-eval.d.ts +15 -1
  233. package/src/lib/runtime/ai-eval.js +52 -1
  234. package/src/lib/runtime/ai-eval.js.map +1 -1
  235. package/src/lib/runtime/experiment-variant-copy.d.ts +34 -0
  236. package/src/lib/runtime/experiment-variant-copy.js +102 -0
  237. package/src/lib/runtime/experiment-variant-copy.js.map +1 -0
  238. package/src/lib/server/ai-experiment-versions.d.ts +51 -0
  239. package/src/lib/server/ai-experiment-versions.js +270 -0
  240. package/src/lib/server/ai-experiment-versions.js.map +1 -0
  241. package/src/lib/server/ai-jobs-resume.js +74 -4
  242. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  243. package/src/lib/server/ai-media-image.d.ts +66 -0
  244. package/src/lib/server/ai-media-image.js +521 -0
  245. package/src/lib/server/ai-media-image.js.map +1 -0
  246. package/src/lib/server/ai-media-svg.d.ts +91 -0
  247. package/src/lib/server/ai-media-svg.js +186 -0
  248. package/src/lib/server/ai-media-svg.js.map +1 -0
  249. package/src/lib/server/assist-build.d.ts +54 -0
  250. package/src/lib/server/assist-build.js +121 -0
  251. package/src/lib/server/assist-build.js.map +1 -0
  252. package/src/lib/server/assist-chat.js +108 -12
  253. package/src/lib/server/assist-chat.js.map +1 -1
  254. package/src/lib/server/assist-edit.js +213 -3
  255. package/src/lib/server/assist-edit.js.map +1 -1
  256. package/src/lib/server/assist-view-context.js +242 -4
  257. package/src/lib/server/assist-view-context.js.map +1 -1
  258. package/src/lib/server.js +26 -0
  259. package/src/lib/server.js.map +1 -1
  260. package/src/lib/tools/ai-logic-tool.d.ts +27 -0
  261. package/src/lib/tools/ai-logic-tool.js +207 -0
  262. package/src/lib/tools/ai-logic-tool.js.map +1 -0
  263. package/src/lib/tools/ai-overlay-tool.d.ts +35 -0
  264. package/src/lib/tools/ai-overlay-tool.js +107 -0
  265. package/src/lib/tools/ai-overlay-tool.js.map +1 -0
  266. package/src/lib/tools/ai-workflow-tool.d.ts +11 -0
  267. package/src/lib/tools/ai-workflow-tool.js +31 -0
  268. package/src/lib/tools/ai-workflow-tool.js.map +1 -1
  269. package/src/lib/usage/assist-job-refund.d.ts +31 -3
  270. package/src/lib/usage/assist-job-refund.js +46 -10
  271. package/src/lib/usage/assist-job-refund.js.map +1 -1
  272. package/src/lib/usage/assist-usage.d.ts +5 -0
  273. package/src/lib/usage/assist-usage.js +2 -0
  274. package/src/lib/usage/assist-usage.js.map +1 -1
@@ -0,0 +1,102 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { AI_TEXT_LIMITS } from "./ai-palette.js";
18
+ /**
19
+ * One A/B variant's copy, put into a COPY of a page's node map (AGL-3603),
20
+ * which the caller stores as a new unpublished version for the variant to
21
+ * pin. Nothing here reads or writes a document, and the map it is handed is
22
+ * never changed.
23
+ *
24
+ * The copy lands in the region the test varies: the whole page's content for
25
+ * a page test, the one section's subtree for a section test. Within it the
26
+ * first heading takes the variant's headline and the first plain text after
27
+ * it takes the body — the same two places the copy under test was read from
28
+ * when a person pasted "the headline and copy as they stand". Rich text is
29
+ * never rewritten, structure is never changed, and a region with no plain
30
+ * heading or text to take the copy says so rather than guessing.
31
+ */ /** The text elements a variant's copy may be written into, as the SEO fixes read them. */ const TEXT_COMPONENTS = new Set([
32
+ 'muiTypography',
33
+ 'muiInlineText',
34
+ 'muiListItemText'
35
+ ]);
36
+ const HEADING = /^h[1-6]$/;
37
+ const isRecord = (value)=>typeof value === 'object' && value !== null && !Array.isArray(value);
38
+ /** A text element's own plain text, or `null` for one a variant may not rewrite. */ function plainText(node) {
39
+ if (!node || !TEXT_COMPONENTS.has(String(node['componentId']))) return null;
40
+ const props = isRecord(node.props) ? node.props : {};
41
+ if (typeof props['html'] === 'string' && props['html'].trim()) return null;
42
+ return typeof props['children'] === 'string' ? props['children'] : null;
43
+ }
44
+ function isHeading(node) {
45
+ var _props_component, _props_variant;
46
+ const props = isRecord(node.props) ? node.props : {};
47
+ return HEADING.test(String((_props_component = props['component']) != null ? _props_component : '')) || HEADING.test(String((_props_variant = props['variant']) != null ? _props_variant : ''));
48
+ }
49
+ /** The region's elements in document order, the region's root first. */ function walk(nodes, rootId) {
50
+ const order = [];
51
+ const seen = new Set();
52
+ const visit = (id)=>{
53
+ if (seen.has(id) || !nodes[id]) return;
54
+ seen.add(id);
55
+ order.push(id);
56
+ const children = Array.isArray(nodes[id].nodes) ? nodes[id].nodes : [];
57
+ for (const child of children)if (typeof child === 'string') visit(child);
58
+ };
59
+ visit(rootId);
60
+ return order;
61
+ }
62
+ const line = (value, limit)=>value.replace(/\s+/g, ' ').trim().slice(0, limit).trim();
63
+ /** Put one variant's copy into a copy of `nodes`, inside the region rooted at `regionRootId`. */ export function applyAiExperimentVariantCopy(nodes, copy, regionRootId) {
64
+ const result = JSON.parse(JSON.stringify(nodes));
65
+ if (!regionRootId || !result[regionRootId]) {
66
+ return {
67
+ nodes: result,
68
+ changed: [],
69
+ reason: 'The part of the page under test is no longer there.'
70
+ };
71
+ }
72
+ const order = walk(result, regionRootId);
73
+ const headline = line(copy.headline, AI_TEXT_LIMITS.headline);
74
+ const body = copy.body.replace(/\r\n?/g, '\n').trim().slice(0, AI_TEXT_LIMITS.body).trim();
75
+ const changed = [];
76
+ const headingAt = order.findIndex((id)=>plainText(result[id]) !== null && isHeading(result[id]));
77
+ if (headline && headingAt >= 0) {
78
+ var _node_props;
79
+ const node = result[order[headingAt]];
80
+ node.props = _extends({}, (_node_props = node.props) != null ? _node_props : {}, {
81
+ children: headline
82
+ });
83
+ changed.push('headline');
84
+ }
85
+ // The body is the first plain text after the heading, or in the region when it has none.
86
+ const bodyAt = order.findIndex((id, index)=>index > headingAt && plainText(result[id]) !== null && !isHeading(result[id]));
87
+ if (body && bodyAt >= 0) {
88
+ var _node_props1;
89
+ const node = result[order[bodyAt]];
90
+ node.props = _extends({}, (_node_props1 = node.props) != null ? _node_props1 : {}, {
91
+ children: body
92
+ });
93
+ changed.push('body');
94
+ }
95
+ return {
96
+ nodes: result,
97
+ changed,
98
+ reason: changed.length ? null : 'This part of the page has no plain heading or text for the variant’s copy.'
99
+ };
100
+ }
101
+
102
+ //# sourceMappingURL=experiment-variant-copy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/experiment-variant-copy.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 { AI_TEXT_LIMITS } from './ai-palette'\n\n/**\n * One A/B variant's copy, put into a COPY of a page's node map (AGL-3603),\n * which the caller stores as a new unpublished version for the variant to\n * pin. Nothing here reads or writes a document, and the map it is handed is\n * never changed.\n *\n * The copy lands in the region the test varies: the whole page's content for\n * a page test, the one section's subtree for a section test. Within it the\n * first heading takes the variant's headline and the first plain text after\n * it takes the body — the same two places the copy under test was read from\n * when a person pasted \"the headline and copy as they stand\". Rich text is\n * never rewritten, structure is never changed, and a region with no plain\n * heading or text to take the copy says so rather than guessing.\n */\n\n/** The text elements a variant's copy may be written into, as the SEO fixes read them. */\nconst TEXT_COMPONENTS: ReadonlySet<string> = new Set(['muiTypography', 'muiInlineText', 'muiListItemText'])\n\nconst HEADING = /^h[1-6]$/\n\ntype StoredNode = Record<string, unknown> & { props?: Record<string, unknown>; nodes?: unknown }\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\nexport interface AiExperimentVariantCopy {\n headline: string\n body: string\n}\n\nexport interface AiExperimentVariantCopyResult {\n nodes: Record<string, StoredNode>\n /** Which of the two the copy changed. */\n changed: Array<'headline' | 'body'>\n /** Why nothing changed, when nothing did; customer-safe. */\n reason: string | null\n}\n\n/** A text element's own plain text, or `null` for one a variant may not rewrite. */\nfunction plainText(node: StoredNode | undefined): string | null {\n if (!node || !TEXT_COMPONENTS.has(String(node['componentId']))) return null\n const props = isRecord(node.props) ? node.props : {}\n if (typeof props['html'] === 'string' && props['html'].trim()) return null\n return typeof props['children'] === 'string' ? props['children'] : null\n}\n\nfunction isHeading(node: StoredNode): boolean {\n const props = isRecord(node.props) ? node.props : {}\n return HEADING.test(String(props['component'] ?? '')) || HEADING.test(String(props['variant'] ?? ''))\n}\n\n/** The region's elements in document order, the region's root first. */\nfunction walk(nodes: Record<string, StoredNode>, rootId: string): string[] {\n const order: string[] = []\n const seen = new Set<string>()\n const visit = (id: string) => {\n if (seen.has(id) || !nodes[id]) return\n seen.add(id)\n order.push(id)\n const children = Array.isArray(nodes[id].nodes) ? (nodes[id].nodes as unknown[]) : []\n for (const child of children) if (typeof child === 'string') visit(child)\n }\n visit(rootId)\n return order\n}\n\nconst line = (value: string, limit: number) => value.replace(/\\s+/g, ' ').trim().slice(0, limit).trim()\n\n/** Put one variant's copy into a copy of `nodes`, inside the region rooted at `regionRootId`. */\nexport function applyAiExperimentVariantCopy(\n nodes: Record<string, unknown>,\n copy: AiExperimentVariantCopy,\n regionRootId: string | null,\n): AiExperimentVariantCopyResult {\n const result = JSON.parse(JSON.stringify(nodes)) as Record<string, StoredNode>\n if (!regionRootId || !result[regionRootId]) {\n return { nodes: result, changed: [], reason: 'The part of the page under test is no longer there.' }\n }\n const order = walk(result, regionRootId)\n const headline = line(copy.headline, AI_TEXT_LIMITS.headline)\n const body = copy.body.replace(/\\r\\n?/g, '\\n').trim().slice(0, AI_TEXT_LIMITS.body).trim()\n const changed: AiExperimentVariantCopyResult['changed'] = []\n\n const headingAt = order.findIndex((id) => plainText(result[id]) !== null && isHeading(result[id]))\n if (headline && headingAt >= 0) {\n const node = result[order[headingAt]]\n node.props = { ...(node.props ?? {}), children: headline }\n changed.push('headline')\n }\n // The body is the first plain text after the heading, or in the region when it has none.\n const bodyAt = order.findIndex(\n (id, index) => index > headingAt && plainText(result[id]) !== null && !isHeading(result[id]),\n )\n if (body && bodyAt >= 0) {\n const node = result[order[bodyAt]]\n node.props = { ...(node.props ?? {}), children: body }\n changed.push('body')\n }\n return {\n nodes: result,\n changed,\n reason: changed.length ? null : 'This part of the page has no plain heading or text for the variant’s copy.',\n }\n}\n"],"names":["AI_TEXT_LIMITS","TEXT_COMPONENTS","Set","HEADING","isRecord","value","Array","isArray","plainText","node","has","String","props","trim","isHeading","test","walk","nodes","rootId","order","seen","visit","id","add","push","children","child","line","limit","replace","slice","applyAiExperimentVariantCopy","copy","regionRootId","result","JSON","parse","stringify","changed","reason","headline","body","headingAt","findIndex","bodyAt","index","length"],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { AiJob } from '../model/ai-jobs.types';
18
+ import { type AiExperimentVariantsProposalView } from '../model/ai-experiment-proposal';
19
+ /** The id a job's variant version is stored under. */
20
+ export declare function aiExperimentVersionId(jobId: string, index: number): string;
21
+ /** What a version made from a variant is named in the versions list. */
22
+ export declare function aiExperimentVersionName(name: string, index: number): string;
23
+ export interface AiExperimentVersionsInput {
24
+ hostId: string;
25
+ screenId: string;
26
+ /** A section test's element; `null` for a page test. */
27
+ nodeId: string | null;
28
+ job: Pick<AiJob, '$id'>;
29
+ proposal: AiExperimentVariantsProposalView;
30
+ uid: string;
31
+ now?: Date;
32
+ }
33
+ export interface AiExperimentVersionsResult {
34
+ /** One per variant past the control, in order. */
35
+ versions: Array<{
36
+ index: number;
37
+ name: string;
38
+ versionId: string;
39
+ changed: string[];
40
+ }>;
41
+ skipped: Array<{
42
+ index: number;
43
+ reason: string;
44
+ }>;
45
+ /** Why no version could be made at all, customer-safe; `null` when one could. */
46
+ refusal: string | null;
47
+ }
48
+ /** Make the versions. Exported for the spec, which drives it against a Firestore double. */
49
+ export declare function createAiExperimentVersions(firestore: FirebaseFirestore.Firestore, input: AiExperimentVersionsInput): Promise<AiExperimentVersionsResult>;
50
+ export declare function POST(request: Request): Promise<Response>;
51
+ export declare const dynamic = "force-dynamic";
@@ -0,0 +1,270 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ // lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts
18
+ // The gate carries the org verdict; the handler adds the site's own below,
19
+ // with the host document in hand.
20
+ import { checkEntitlement, encodeStoredNodes, hostRoleCanWrite } from "@aglyn/aglyn/server";
21
+ import { pageContentRootId } from "@aglyn/aglyn/app-utils/page-markdown";
22
+ import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
23
+ import { lockdownRefusal } from "@aglyn/tenant-data-admin/server/lockdown";
24
+ import { getAiJob, writeAiJobAudit } from "../jobs/ai-jobs.js";
25
+ import { readAiExperimentVariants } from "../model/ai-experiment-proposal.js";
26
+ import { applyAiExperimentVariantCopy } from "../runtime/experiment-variant-copy.js";
27
+ import { AI_SEO_APPLY_VERSIONING_COPY } from "./ai-seo-apply.js";
28
+ import { aiJobsGate } from "./ai-jobs-gate.js";
29
+ /**
30
+ * A page's or a section's A/B variants as draft versions (AGL-3603):
31
+ * `POST /api/ai/experiments/versions { orgId, hostId, jobId, screenId, nodeId? }`.
32
+ *
33
+ * An `experiment` job proposes the variants' copy. For an email that copy IS
34
+ * the variant, and the editor holds it. For a page or a section the variant is
35
+ * a version of the page, which the editor only pins — so this door makes one
36
+ * per proposed variant past the first: the page's PUBLISHED version is
37
+ * copied, the variant's headline and body are put into the region the test
38
+ * varies (`runtime/experiment-variant-copy.ts`), and the copy is stored as a
39
+ * new unpublished version named for the variant.
40
+ *
41
+ * ## Nothing goes live
42
+ *
43
+ * The published version, the screen's `versionId` and the experiment document
44
+ * are never touched. The A/B testing editor pins each new version to its
45
+ * variant as an unsaved change; the person reviews the versions, saves the
46
+ * test and starts it. The first proposed variant is the copy as it stands, so
47
+ * it is the control and pins the published version, as the editor's default
48
+ * already does.
49
+ *
50
+ * ## The rungs
51
+ *
52
+ * The jobs read gate (membership, the release flag, `aiGenerative`, the org
53
+ * lockdown); then the site — the job's and the org's — a role that writes the
54
+ * site's content, the site's own lockdown verdict, and the `versioning`
55
+ * entitlement, because a page that already has a version gains retained
56
+ * history here, which is what that entitlement sells. Nothing spends a token.
57
+ *
58
+ * Idempotent: each version's id is derived from the job and the variant, so
59
+ * a second press finds the versions the first made.
60
+ */ const ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/;
61
+ /** The id a job's variant version is stored under. */ export function aiExperimentVersionId(jobId, index) {
62
+ return `ab-${jobId}-${index}`;
63
+ }
64
+ /** What a version made from a variant is named in the versions list. */ export function aiExperimentVersionName(name, index) {
65
+ const label = name.replace(/\s+/g, ' ').trim() || `Variant ${index + 1}`;
66
+ return `A/B: ${label}`.slice(0, 80);
67
+ }
68
+ const str = (value)=>typeof value === 'string' ? value.trim() : '';
69
+ /** Make the versions. Exported for the spec, which drives it against a Firestore double. */ export async function createAiExperimentVersions(firestore, input) {
70
+ var _input_now, _input_nodeId;
71
+ const now = (_input_now = input.now) != null ? _input_now : new Date();
72
+ const screenRef = firestore.collection('hosts').doc(input.hostId).collection('screens').doc(input.screenId);
73
+ const screen = await screenRef.get();
74
+ const empty = (refusal)=>({
75
+ versions: [],
76
+ skipped: [],
77
+ refusal
78
+ });
79
+ if (!screen.exists || screen.get('deletedAt')) return empty('The page under test no longer exists.');
80
+ const publishedId = str(screen.get('versionId'));
81
+ const source = publishedId ? await screenRef.collection('versions').doc(publishedId).get() : null;
82
+ const sourceData = (source == null ? void 0 : source.exists) ? source.data() : null;
83
+ const nodes = sourceData ? decodeStoredNodes(sourceData['nodes']) : null;
84
+ if (!sourceData || !nodes) return empty('The page has no published version to start from.');
85
+ const rootId = typeof sourceData['rootId'] === 'string' ? sourceData['rootId'] : undefined;
86
+ const regionRootId = (_input_nodeId = input.nodeId) != null ? _input_nodeId : pageContentRootId(nodes, rootId);
87
+ const versions = [];
88
+ const skipped = [];
89
+ for (const [index, variant] of input.proposal.variants.entries()){
90
+ // The first variant is the copy as it stands: the control, on the published version.
91
+ if (index === 0) continue;
92
+ const versionId = aiExperimentVersionId(input.job.$id, index);
93
+ const name = aiExperimentVersionName(variant.name, index);
94
+ const versionRef = screenRef.collection('versions').doc(versionId);
95
+ const earlier = await versionRef.get();
96
+ if (earlier.exists && !earlier.get('deletedAt')) {
97
+ versions.push({
98
+ index,
99
+ name: str(earlier.get('displayName')) || name,
100
+ versionId,
101
+ changed: []
102
+ });
103
+ continue;
104
+ }
105
+ const result = applyAiExperimentVariantCopy(nodes, {
106
+ headline: variant.headline,
107
+ body: variant.body
108
+ }, regionRootId);
109
+ if (!result.changed.length) {
110
+ var _result_reason;
111
+ skipped.push({
112
+ index,
113
+ reason: (_result_reason = result.reason) != null ? _result_reason : 'Nothing on the page took the variant’s copy.'
114
+ });
115
+ continue;
116
+ }
117
+ // Compressed at rest, as every write of a version's nodes is.
118
+ const packed = encodeStoredNodes(result.nodes);
119
+ await versionRef.set(_extends({}, sourceData, {
120
+ nodes: packed ? Buffer.from(packed) : result.nodes,
121
+ displayName: name,
122
+ createdAt: now,
123
+ updatedAt: now,
124
+ createdBy: input.uid
125
+ }));
126
+ versions.push({
127
+ index,
128
+ name,
129
+ versionId,
130
+ changed: result.changed
131
+ });
132
+ }
133
+ return {
134
+ versions,
135
+ skipped,
136
+ refusal: null
137
+ };
138
+ }
139
+ export async function POST(request) {
140
+ var _body_orgId, _host_memberRoles, _job_outputs;
141
+ var _find;
142
+ let body;
143
+ try {
144
+ var _ref;
145
+ body = (_ref = await request.json()) != null ? _ref : {};
146
+ } catch (unused) {
147
+ body = {};
148
+ }
149
+ const gate = await aiJobsGate(request, String((_body_orgId = body['orgId']) != null ? _body_orgId : ''));
150
+ if (gate instanceof Response) return gate;
151
+ const hostId = str(body['hostId']);
152
+ const jobId = str(body['jobId']);
153
+ const screenId = str(body['screenId']);
154
+ const nodeId = str(body['nodeId']) || null;
155
+ if (!ID_CHARS.test(hostId) || !ID_CHARS.test(jobId) || !ID_CHARS.test(screenId)) {
156
+ return Response.json({
157
+ error: 'Name the site, the page under test and the variants to make'
158
+ }, {
159
+ status: 400
160
+ });
161
+ }
162
+ if (nodeId !== null && !ID_CHARS.test(nodeId)) {
163
+ return Response.json({
164
+ error: 'That is not a section of the page'
165
+ }, {
166
+ status: 400
167
+ });
168
+ }
169
+ const { firestore, orgId, uid, staff, org, decoded } = gate;
170
+ const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
171
+ const host = hostSnapshot.exists ? hostSnapshot.data() : null;
172
+ if (!host || host['orgId'] !== orgId) {
173
+ return Response.json({
174
+ error: 'Unknown site'
175
+ }, {
176
+ status: 404
177
+ });
178
+ }
179
+ const role = ((_host_memberRoles = host['memberRoles']) != null ? _host_memberRoles : {})[uid];
180
+ if (!staff && !hostRoleCanWrite(role)) {
181
+ return Response.json({
182
+ error: 'Editing requires the editor role'
183
+ }, {
184
+ status: 403
185
+ });
186
+ }
187
+ const locked = await lockdownRefusal({
188
+ request,
189
+ staff,
190
+ uid,
191
+ org: org,
192
+ host: host
193
+ });
194
+ if (locked) return locked;
195
+ if (!checkEntitlement(org, 'versioning')) {
196
+ return Response.json({
197
+ error: AI_SEO_APPLY_VERSIONING_COPY
198
+ }, {
199
+ status: 403
200
+ });
201
+ }
202
+ const job = await getAiJob(firestore, orgId, jobId);
203
+ if (!job || job.kind !== 'experiment' || job.hostId !== hostId) {
204
+ return Response.json({
205
+ error: 'Unknown variants'
206
+ }, {
207
+ status: 404
208
+ });
209
+ }
210
+ if (job.status !== 'done') {
211
+ return Response.json({
212
+ error: 'The variants are still being written'
213
+ }, {
214
+ status: 409
215
+ });
216
+ }
217
+ const proposal = readAiExperimentVariants((_find = ((_job_outputs = job.outputs) != null ? _job_outputs : []).find((output)=>output.resource === 'experiment')) == null ? void 0 : _find.proposal);
218
+ if (!proposal || proposal.target === 'email') {
219
+ return Response.json({
220
+ error: 'These variants are not a page’s or a section’s'
221
+ }, {
222
+ status: 409
223
+ });
224
+ }
225
+ if (proposal.target === 'section' && !nodeId) {
226
+ return Response.json({
227
+ error: 'Pick the section under test first'
228
+ }, {
229
+ status: 400
230
+ });
231
+ }
232
+ const result = await createAiExperimentVersions(firestore, {
233
+ hostId,
234
+ screenId,
235
+ nodeId: proposal.target === 'section' ? nodeId : null,
236
+ job,
237
+ proposal,
238
+ uid
239
+ });
240
+ if (result.refusal) return Response.json({
241
+ error: result.refusal
242
+ }, {
243
+ status: 409
244
+ });
245
+ const made = result.versions.filter((entry)=>entry.changed.length);
246
+ if (made.length) {
247
+ var _decoded_email;
248
+ await writeAiJobAudit(firestore, {
249
+ action: 'ai.job.apply',
250
+ actorUid: uid,
251
+ actorEmail: (_decoded_email = decoded.email) != null ? _decoded_email : null,
252
+ orgId,
253
+ jobId,
254
+ after: {
255
+ hostId,
256
+ screenId,
257
+ versions: made.map((entry)=>entry.versionId)
258
+ }
259
+ });
260
+ }
261
+ return Response.json(result, {
262
+ status: 200,
263
+ headers: {
264
+ 'Cache-Control': 'no-store'
265
+ }
266
+ });
267
+ }
268
+ export const dynamic = 'force-dynamic';
269
+
270
+ //# sourceMappingURL=ai-experiment-versions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-experiment-versions.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/server/ai-jobs-gate.ts\n// The gate carries the org verdict; the handler adds the site's own below,\n// with the host document in hand.\n\nimport {\n checkEntitlement,\n encodeStoredNodes,\n hostRoleCanWrite,\n} from '@aglyn/aglyn/server'\nimport { pageContentRootId } from '@aglyn/aglyn/app-utils/page-markdown'\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { getAiJob, writeAiJobAudit } from '../jobs/ai-jobs'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport {\n readAiExperimentVariants,\n type AiExperimentVariantsProposalView,\n} from '../model/ai-experiment-proposal'\nimport { applyAiExperimentVariantCopy } from '../runtime/experiment-variant-copy'\nimport { AI_SEO_APPLY_VERSIONING_COPY } from './ai-seo-apply'\nimport { aiJobsGate } from './ai-jobs-gate'\n\n/**\n * A page's or a section's A/B variants as draft versions (AGL-3603):\n * `POST /api/ai/experiments/versions { orgId, hostId, jobId, screenId, nodeId? }`.\n *\n * An `experiment` job proposes the variants' copy. For an email that copy IS\n * the variant, and the editor holds it. For a page or a section the variant is\n * a version of the page, which the editor only pins — so this door makes one\n * per proposed variant past the first: the page's PUBLISHED version is\n * copied, the variant's headline and body are put into the region the test\n * varies (`runtime/experiment-variant-copy.ts`), and the copy is stored as a\n * new unpublished version named for the variant.\n *\n * ## Nothing goes live\n *\n * The published version, the screen's `versionId` and the experiment document\n * are never touched. The A/B testing editor pins each new version to its\n * variant as an unsaved change; the person reviews the versions, saves the\n * test and starts it. The first proposed variant is the copy as it stands, so\n * it is the control and pins the published version, as the editor's default\n * already does.\n *\n * ## The rungs\n *\n * The jobs read gate (membership, the release flag, `aiGenerative`, the org\n * lockdown); then the site — the job's and the org's — a role that writes the\n * site's content, the site's own lockdown verdict, and the `versioning`\n * entitlement, because a page that already has a version gains retained\n * history here, which is what that entitlement sells. Nothing spends a token.\n *\n * Idempotent: each version's id is derived from the job and the variant, so\n * a second press finds the versions the first made.\n */\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The id a job's variant version is stored under. */\nexport function aiExperimentVersionId(jobId: string, index: number): string {\n return `ab-${jobId}-${index}`\n}\n\n/** What a version made from a variant is named in the versions list. */\nexport function aiExperimentVersionName(name: string, index: number): string {\n const label = name.replace(/\\s+/g, ' ').trim() || `Variant ${index + 1}`\n return `A/B: ${label}`.slice(0, 80)\n}\n\nexport interface AiExperimentVersionsInput {\n hostId: string\n screenId: string\n /** A section test's element; `null` for a page test. */\n nodeId: string | null\n job: Pick<AiJob, '$id'>\n proposal: AiExperimentVariantsProposalView\n uid: string\n now?: Date\n}\n\nexport interface AiExperimentVersionsResult {\n /** One per variant past the control, in order. */\n versions: Array<{ index: number; name: string; versionId: string; changed: string[] }>\n skipped: Array<{ index: number; reason: string }>\n /** Why no version could be made at all, customer-safe; `null` when one could. */\n refusal: string | null\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/** Make the versions. Exported for the spec, which drives it against a Firestore double. */\nexport async function createAiExperimentVersions(\n firestore: FirebaseFirestore.Firestore,\n input: AiExperimentVersionsInput,\n): Promise<AiExperimentVersionsResult> {\n const now = input.now ?? new Date()\n const screenRef = firestore.collection('hosts').doc(input.hostId).collection('screens').doc(input.screenId)\n const screen = await screenRef.get()\n const empty = (refusal: string): AiExperimentVersionsResult => ({ versions: [], skipped: [], refusal })\n if (!screen.exists || screen.get('deletedAt')) return empty('The page under test no longer exists.')\n const publishedId = str(screen.get('versionId'))\n const source = publishedId ? await screenRef.collection('versions').doc(publishedId).get() : null\n const sourceData = source?.exists ? (source.data() as Record<string, unknown>) : null\n const nodes = sourceData ? decodeStoredNodes<Record<string, unknown>>(sourceData['nodes']) : null\n if (!sourceData || !nodes) return empty('The page has no published version to start from.')\n const rootId = typeof sourceData['rootId'] === 'string' ? sourceData['rootId'] : undefined\n const regionRootId = input.nodeId ?? pageContentRootId(nodes as never, rootId)\n\n const versions: AiExperimentVersionsResult['versions'] = []\n const skipped: AiExperimentVersionsResult['skipped'] = []\n for (const [index, variant] of input.proposal.variants.entries()) {\n // The first variant is the copy as it stands: the control, on the published version.\n if (index === 0) continue\n const versionId = aiExperimentVersionId(input.job.$id, index)\n const name = aiExperimentVersionName(variant.name, index)\n const versionRef = screenRef.collection('versions').doc(versionId)\n const earlier = await versionRef.get()\n if (earlier.exists && !earlier.get('deletedAt')) {\n versions.push({ index, name: str(earlier.get('displayName')) || name, versionId, changed: [] })\n continue\n }\n const result = applyAiExperimentVariantCopy(\n nodes,\n { headline: variant.headline, body: variant.body },\n regionRootId,\n )\n if (!result.changed.length) {\n skipped.push({ index, reason: result.reason ?? 'Nothing on the page took the variant’s copy.' })\n continue\n }\n // Compressed at rest, as every write of a version's nodes is.\n const packed = encodeStoredNodes(result.nodes)\n await versionRef.set({\n ...sourceData,\n nodes: packed ? Buffer.from(packed) : result.nodes,\n displayName: name,\n createdAt: now,\n updatedAt: now,\n createdBy: input.uid,\n })\n versions.push({ index, name, versionId, changed: result.changed })\n }\n return { versions, skipped, refusal: null }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let body: Record<string, unknown>\n try {\n body = ((await request.json()) ?? {}) as Record<string, unknown>\n } catch {\n body = {}\n }\n const gate = await aiJobsGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const hostId = str(body['hostId'])\n const jobId = str(body['jobId'])\n const screenId = str(body['screenId'])\n const nodeId = str(body['nodeId']) || null\n if (!ID_CHARS.test(hostId) || !ID_CHARS.test(jobId) || !ID_CHARS.test(screenId)) {\n return Response.json({ error: 'Name the site, the page under test and the variants to make' }, { status: 400 })\n }\n if (nodeId !== null && !ID_CHARS.test(nodeId)) {\n return Response.json({ error: 'That is not a section of the page' }, { status: 400 })\n }\n const { firestore, orgId, uid, staff, org, decoded } = gate\n\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n const host = hostSnapshot.exists ? (hostSnapshot.data() as Record<string, unknown>) : null\n if (!host || host['orgId'] !== orgId) {\n return Response.json({ error: 'Unknown site' }, { status: 404 })\n }\n const role = ((host['memberRoles'] ?? {}) as Record<string, unknown>)[uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return Response.json({ error: 'Editing requires the editor role' }, { status: 403 })\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid,\n org: org as Record<string, unknown>,\n host: host as never,\n })\n if (locked) return locked\n if (!checkEntitlement(org, 'versioning')) {\n return Response.json({ error: AI_SEO_APPLY_VERSIONING_COPY }, { status: 403 })\n }\n\n const job = await getAiJob(firestore, orgId, jobId)\n if (!job || job.kind !== 'experiment' || job.hostId !== hostId) {\n return Response.json({ error: 'Unknown variants' }, { status: 404 })\n }\n if (job.status !== 'done') {\n return Response.json({ error: 'The variants are still being written' }, { status: 409 })\n }\n const proposal = readAiExperimentVariants(\n (job.outputs ?? []).find((output) => output.resource === 'experiment')?.proposal,\n )\n if (!proposal || proposal.target === 'email') {\n return Response.json({ error: 'These variants are not a page’s or a section’s' }, { status: 409 })\n }\n if (proposal.target === 'section' && !nodeId) {\n return Response.json({ error: 'Pick the section under test first' }, { status: 400 })\n }\n\n const result = await createAiExperimentVersions(firestore, {\n hostId,\n screenId,\n nodeId: proposal.target === 'section' ? nodeId : null,\n job,\n proposal,\n uid,\n })\n if (result.refusal) return Response.json({ error: result.refusal }, { status: 409 })\n const made = result.versions.filter((entry) => entry.changed.length)\n if (made.length) {\n await writeAiJobAudit(firestore, {\n action: 'ai.job.apply',\n actorUid: uid,\n actorEmail: decoded.email ?? null,\n orgId,\n jobId,\n after: { hostId, screenId, versions: made.map((entry) => entry.versionId) },\n })\n }\n return Response.json(result, { status: 200, headers: { 'Cache-Control': 'no-store' } })\n}\n\nexport const dynamic = 'force-dynamic'\n"],"names":["checkEntitlement","encodeStoredNodes","hostRoleCanWrite","pageContentRootId","decodeStoredNodes","lockdownRefusal","getAiJob","writeAiJobAudit","readAiExperimentVariants","applyAiExperimentVariantCopy","AI_SEO_APPLY_VERSIONING_COPY","aiJobsGate","ID_CHARS","aiExperimentVersionId","jobId","index","aiExperimentVersionName","name","label","replace","trim","slice","str","value","createAiExperimentVersions","firestore","input","now","Date","screenRef","collection","doc","hostId","screenId","screen","get","empty","refusal","versions","skipped","exists","publishedId","source","sourceData","data","nodes","rootId","undefined","regionRootId","nodeId","variant","proposal","variants","entries","versionId","job","$id","versionRef","earlier","push","changed","result","headline","body","length","reason","packed","set","Buffer","from","displayName","createdAt","updatedAt","createdBy","uid","POST","request","host","json","gate","String","Response","test","error","status","orgId","staff","org","decoded","hostSnapshot","role","locked","kind","outputs","find","output","resource","target","made","filter","entry","action","actorUid","actorEmail","email","after","map","headers","dynamic"],"mappings":"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"}
@@ -17,7 +17,9 @@
17
17
  // The POST climbs `aiGateLadder`, whose lockdown rung is the verdict.
18
18
  import { randomUUID } from "crypto";
19
19
  import { aiJobAdmissionRefusal, aiJobSiteRefusal } from "../jobs/ai-job-admission.js";
20
- import { AI_JOB_INLINE_BUDGET_MS, aiJobNextStepMinimumMs, aiJobSummary, getAiJob, resumeAiJob, runAiJobStep, writeAiJobAudit } from "../jobs/ai-jobs.js";
20
+ import { AI_JOB_INLINE_BUDGET_MS, aiJobNextStepMinimumMs, aiJobSummary, getAiJob, aiBuildRetryable, resumeAiJob, retryAiBuildJob, runAiJobStep, writeAiJobAudit } from "../jobs/ai-jobs.js";
21
+ import { aiBuildFreeBalanceRefusal, aiBuildFreeTaste } from "../jobs/ai-job-build-step.js";
22
+ import { aiBuildCreditEstimate, aiBuildRetryLedger, aiBuildUnits, aiLedgerUnits } from "../model/ai-build-job.js";
21
23
  import { aiGateLadder } from "../runtime/ai-gate.js";
22
24
  import { releaseAssistMessage } from "../usage/assist-usage.js";
23
25
  /**
@@ -83,6 +85,31 @@ export async function POST(request, context) {
83
85
  status: 400
84
86
  });
85
87
  }
88
+ // Try again on a build (AGL-3616): its failed items and what they left
89
+ // unbuilt, never what succeeded, and never its plan again.
90
+ const retryFailedItems = (payload == null ? void 0 : payload['retry']) === 'failed-items';
91
+ if (retryFailedItems) {
92
+ const refusal = await aiBuildRetryRefusal(gate, existing).catch((error)=>{
93
+ console.error('ai build retry admission failed', {
94
+ orgId: gate.orgId,
95
+ jobId,
96
+ error
97
+ });
98
+ return {
99
+ status: 500,
100
+ error: 'The AI job could not be resumed'
101
+ };
102
+ });
103
+ if (refusal) {
104
+ await release();
105
+ return Response.json({
106
+ error: refusal.error,
107
+ job: aiJobSummary(existing, new Date())
108
+ }, {
109
+ status: refusal.status
110
+ });
111
+ }
112
+ }
86
113
  // A confirmed plan runs the step that writes, and an allowance free when the
87
114
  // job was created may be used by now (AGL-2909): the kind is asked again
88
115
  // before anything runs, and a refusal leaves the job waiting for review.
@@ -129,9 +156,11 @@ export async function POST(request, context) {
129
156
  }
130
157
  }
131
158
  const now = new Date();
132
- const { job, changed } = await resumeAiJob(gate.firestore, gate.orgId, jobId, {
159
+ const { job, changed } = retryFailedItems ? await retryAiBuildJob(gate.firestore, gate.orgId, jobId, now) : await resumeAiJob(gate.firestore, gate.orgId, jobId, {
133
160
  uid: gate.uid
134
- }, now);
161
+ }, now, {
162
+ publishConfirmed: (payload == null ? void 0 : payload['publish']) === true
163
+ });
135
164
  if (!changed) {
136
165
  await release();
137
166
  return Response.json({
@@ -149,7 +178,7 @@ export async function POST(request, context) {
149
178
  jobId,
150
179
  after: {
151
180
  status: job.status,
152
- reason: (_ref1 = (_existing_review = existing.review) == null ? void 0 : _existing_review.reason) != null ? _ref1 : null,
181
+ reason: retryFailedItems ? 'retry-failed-items' : (_ref1 = (_existing_review = existing.review) == null ? void 0 : _existing_review.reason) != null ? _ref1 : null,
153
182
  kind: existing.kind
154
183
  }
155
184
  });
@@ -203,6 +232,47 @@ export async function POST(request, context) {
203
232
  status: 200
204
233
  });
205
234
  }
235
+ /**
236
+ * Why a build's Try again cannot run (AGL-3616), or `null`: it is not a
237
+ * finished build, the site switched AI off, or — on the Free taste — the
238
+ * month's Free credits do not cover what the retried items are estimated at.
239
+ */ async function aiBuildRetryRefusal(gate, job) {
240
+ var _job_hostId;
241
+ var _job_plan, _job_items;
242
+ const plan = ((_job_plan = job.plan) == null ? void 0 : _job_plan.status) === 'confirmed' ? job.plan : null;
243
+ if (!aiBuildRetryable(job) || !plan || !((_job_items = job.items) == null ? void 0 : _job_items.length)) {
244
+ return {
245
+ status: 409,
246
+ error: 'Only a finished build can try its failed items again'
247
+ };
248
+ }
249
+ const site = await aiJobSiteRefusal({
250
+ firestore: gate.firestore,
251
+ org: gate.org,
252
+ hostId: (_job_hostId = job.hostId) != null ? _job_hostId : null
253
+ });
254
+ if (site) return site;
255
+ const { retried } = aiBuildRetryLedger(job.items, job.kind === 'site' ? aiLedgerUnits(job.items) : aiBuildUnits(plan));
256
+ if (!retried.length) return {
257
+ status: 409,
258
+ error: 'Nothing in this build is left to try again'
259
+ };
260
+ // A site's retried parts are its own pages and creations, held by the
261
+ // reservation each pass takes; a build's are estimated against the Free taste.
262
+ if (job.kind === 'site' || !aiBuildFreeTaste(gate.org)) return null;
263
+ const refusal = await aiBuildFreeBalanceRefusal({
264
+ firestore: gate.firestore,
265
+ org: gate.org,
266
+ estimate: aiBuildCreditEstimate(plan, {
267
+ slots: new Set(retried)
268
+ }),
269
+ now: new Date()
270
+ });
271
+ return refusal ? {
272
+ status: 403,
273
+ error: refusal
274
+ } : null;
275
+ }
206
276
  export const dynamic = 'force-dynamic';
207
277
  export const maxDuration = 60;
208
278