@aglyn/plugins-ai 1.0.0-beta.244 → 1.0.0-beta.246

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 (116) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.js +3 -1
  3. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  4. package/src/lib/components/ai-job-links.js +7 -0
  5. package/src/lib/components/ai-job-links.js.map +1 -1
  6. package/src/lib/components/ai-product-zones.d.ts +3 -1
  7. package/src/lib/components/ai-product-zones.js.map +1 -1
  8. package/src/lib/components/ai-products-hub-card.component.js +1 -1
  9. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  10. package/src/lib/components/ai-products-proposals.component.js +17 -9
  11. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  12. package/src/lib/components/ai-site-start-card.component.js +6 -4
  13. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  14. package/src/lib/components/ai-upsell-dialog.component.js +1 -1
  15. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
  16. package/src/lib/jobs/ai-build-capabilities.js +162 -2
  17. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  18. package/src/lib/jobs/ai-job-build-step.d.ts +7 -0
  19. package/src/lib/jobs/ai-job-build-step.js +85 -16
  20. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  21. package/src/lib/jobs/ai-job-duplicate-step.d.ts +123 -0
  22. package/src/lib/jobs/ai-job-duplicate-step.js +269 -0
  23. package/src/lib/jobs/ai-job-duplicate-step.js.map +1 -0
  24. package/src/lib/jobs/ai-job-layout-language.js +13 -0
  25. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  26. package/src/lib/jobs/ai-job-navigation-step.d.ts +157 -0
  27. package/src/lib/jobs/ai-job-navigation-step.js +439 -0
  28. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -0
  29. package/src/lib/jobs/ai-job-page-language.d.ts +2 -1
  30. package/src/lib/jobs/ai-job-page-language.js +21 -5
  31. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  32. package/src/lib/jobs/ai-job-page-sections.js +1 -1
  33. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  34. package/src/lib/jobs/ai-job-plan-step.d.ts +4 -2
  35. package/src/lib/jobs/ai-job-plan-step.js +16 -7
  36. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-products-step.d.ts +2 -2
  38. package/src/lib/jobs/ai-job-products-step.js +2 -2
  39. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-settings-step.d.ts +272 -0
  41. package/src/lib/jobs/ai-job-settings-step.js +695 -0
  42. package/src/lib/jobs/ai-job-settings-step.js.map +1 -0
  43. package/src/lib/jobs/ai-job-site-content.d.ts +20 -6
  44. package/src/lib/jobs/ai-job-site-content.js +28 -12
  45. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  46. package/src/lib/jobs/ai-job-site-datasets.d.ts +2 -0
  47. package/src/lib/jobs/ai-job-site-datasets.js +10 -3
  48. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  49. package/src/lib/jobs/ai-job-site-step.d.ts +3 -1
  50. package/src/lib/jobs/ai-job-site-step.js +70 -10
  51. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  52. package/src/lib/jobs/ai-site-publish.d.ts +62 -4
  53. package/src/lib/jobs/ai-site-publish.js +203 -56
  54. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  55. package/src/lib/layout-language/ai-layout-compiler.d.ts +7 -4
  56. package/src/lib/layout-language/ai-layout-compiler.js +26 -25
  57. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  58. package/src/lib/layout-language/ai-layout-frame-hollows.d.ts +41 -0
  59. package/src/lib/layout-language/ai-layout-frame-hollows.js +330 -0
  60. package/src/lib/layout-language/ai-layout-frame-hollows.js.map +1 -0
  61. package/src/lib/layout-language/ai-layout-frame.d.ts +18 -1
  62. package/src/lib/layout-language/ai-layout-frame.js +83 -13
  63. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  64. package/src/lib/layout-language/ai-layout-listings.d.ts +35 -0
  65. package/src/lib/layout-language/ai-layout-listings.js +34 -0
  66. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  67. package/src/lib/layout-language/ai-layout-tree.d.ts +2 -0
  68. package/src/lib/layout-language/ai-layout-tree.js +17 -0
  69. package/src/lib/layout-language/ai-layout-tree.js.map +1 -1
  70. package/src/lib/model/ai-build-job.d.ts +23 -0
  71. package/src/lib/model/ai-build-job.js +102 -13
  72. package/src/lib/model/ai-build-job.js.map +1 -1
  73. package/src/lib/model/ai-build-plan.d.ts +25 -0
  74. package/src/lib/model/ai-build-plan.js +45 -1
  75. package/src/lib/model/ai-build-plan.js.map +1 -1
  76. package/src/lib/model/ai-credit-estimate.d.ts +52 -0
  77. package/src/lib/model/ai-credit-estimate.js +51 -0
  78. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  79. package/src/lib/model/ai-job-activity.js +4 -1
  80. package/src/lib/model/ai-job-activity.js.map +1 -1
  81. package/src/lib/model/ai-jobs.types.d.ts +10 -2
  82. package/src/lib/model/ai-jobs.types.js +4 -1
  83. package/src/lib/model/ai-jobs.types.js.map +1 -1
  84. package/src/lib/model/ai-layout-menus.d.ts +104 -0
  85. package/src/lib/model/ai-layout-menus.js +518 -0
  86. package/src/lib/model/ai-layout-menus.js.map +1 -0
  87. package/src/lib/model/ai-products.d.ts +31 -1
  88. package/src/lib/model/ai-products.js +34 -1
  89. package/src/lib/model/ai-products.js.map +1 -1
  90. package/src/lib/model/ai-site-inventory.d.ts +15 -0
  91. package/src/lib/model/ai-site-inventory.js.map +1 -1
  92. package/src/lib/model/ai-site-job.d.ts +10 -0
  93. package/src/lib/model/ai-site-job.js +36 -9
  94. package/src/lib/model/ai-site-job.js.map +1 -1
  95. package/src/lib/model/ai-site-store-pages.js +1 -1
  96. package/src/lib/model/ai-site-store-pages.js.map +1 -1
  97. package/src/lib/runtime/ai-doctrine-validators.d.ts +53 -0
  98. package/src/lib/runtime/ai-doctrine-validators.js +166 -25
  99. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  100. package/src/lib/runtime/ai-doctrine.js +7 -2
  101. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  102. package/src/lib/runtime/ai-palette.generated.js +1 -1
  103. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  104. package/src/lib/runtime/ai-products-generation.d.ts +4 -2
  105. package/src/lib/runtime/ai-products-generation.js +12 -6
  106. package/src/lib/runtime/ai-products-generation.js.map +1 -1
  107. package/src/lib/runtime/site-inventory.d.ts +12 -0
  108. package/src/lib/runtime/site-inventory.js +29 -1
  109. package/src/lib/runtime/site-inventory.js.map +1 -1
  110. package/src/lib/server.js +12 -0
  111. package/src/lib/server.js.map +1 -1
  112. package/src/lib/testing/stand-in-dataset-index.js +37 -3
  113. package/src/lib/testing/stand-in-dataset-index.js.map +1 -1
  114. package/src/lib/tools/ai-products-tool.d.ts +3 -1
  115. package/src/lib/tools/ai-products-tool.js +15 -5
  116. package/src/lib/tools/ai-products-tool.js.map +1 -1
@@ -0,0 +1,269 @@
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 { DUPLICABLE_RESOURCE_NOUNS } from "@aglyn/aglyn/app-utils/duplicate-resource";
18
+ import { hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
19
+ import { duplicateResource, recipeFor } from "@aglyn/tenant-data-admin/server/duplicate-resource";
20
+ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
21
+ import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
22
+ import { isAiPlanNewRef } from "../model/ai-build-plan.js";
23
+ import { parseAiDuplicateResourceInput } from "../tools/ai-duplicate-resource-tool.js";
24
+ import { registerAiJobAdmission } from "./ai-job-admission.js";
25
+ import { aiJobEditModel } from "./ai-job-edit-step.js";
26
+ import { aiLimitReview, aiUnspentOutcome } from "./ai-job-generation.js";
27
+ import { registerAiJobStep } from "./ai-jobs.js";
28
+ /**
29
+ * The `duplicate` step (AGL-3740): copying something the site already has —
30
+ * a page, a component, a layout, a template, a form, an email design or a
31
+ * workflow — as a first-class build operation, so "make another page like
32
+ * Services" copies instead of regenerating.
33
+ *
34
+ * NO MODEL. The copy is core's `duplicateResource`, the very function the
35
+ * console's Duplicate… row menus and `/api/hosts/resources` (`action:
36
+ * 'duplicate'`) call, and the one the `duplicate_resource` tool wraps: the
37
+ * same fields carried, the same uniqueness rules, the same plan counters met
38
+ * inside the transaction, the same `*.duplicated` activity row. So it costs
39
+ * no credits, and it can copy nothing the console could not.
40
+ *
41
+ * DRAFTS ONLY, by construction of the copy: a page copy has no address, a
42
+ * workflow copy no trigger, and nothing places a component, layout or form
43
+ * copy until a person does.
44
+ *
45
+ * ── Who may ──────────────────────────────────────────────────────────────
46
+ *
47
+ * The console door's rule: a member with a write role on the site (admin,
48
+ * editor or author), while no lockdown freezes the site, its org or the
49
+ * platform. `ai.generate` is the jobs door's own check. Asked at the door
50
+ * and again before the copy.
51
+ *
52
+ * ── Once per job ─────────────────────────────────────────────────────────
53
+ *
54
+ * The job's id is the copy's attempt key, so a pass asked again answers with
55
+ * the copy the first made rather than making a second.
56
+ */ export const AI_DUPLICATE_KIND = 'duplicate';
57
+ /**
58
+ * ASSUMED: the least time the step needs — the site, the org, the source's
59
+ * collection when it is named by name, and the one transaction that copies.
60
+ * No model, so it fits an inline door.
61
+ */ export const AI_JOB_DUPLICATE_STEP_MINIMUM_MS = 5000;
62
+ export const AI_DUPLICATE_NO_SITE_COPY = 'Open the site whose content should be copied.';
63
+ export const AI_DUPLICATE_ROLE_COPY = 'Copying on this site needs the admin, editor or author role on it.';
64
+ export const AI_DUPLICATE_LOCKED_COPY = 'This site is not accepting changes right now.';
65
+ export const AI_DUPLICATE_UNREADABLE_COPY = 'The request did not name something on this site to copy.';
66
+ export const AI_DUPLICATE_NOT_YET_COPY = 'Only something the site already has can be copied, not something this build is making.';
67
+ /** The output resource each duplicable kind is opened as. */ export const AI_DUPLICATE_OUTPUT_RESOURCES = {
68
+ screen: 'screen',
69
+ component: 'reusableComponent',
70
+ layout: 'layout',
71
+ template: 'template',
72
+ form: 'form',
73
+ emailDesign: 'emailScreen',
74
+ workflow: 'workflow'
75
+ };
76
+ /** The copy's attempt key: one copy per job, however often it is asked. */ export function aiDuplicateAttemptKey(job) {
77
+ return `ai-job:${job.$id}`.slice(0, 200);
78
+ }
79
+ /** What the plan's item asks, read; a refusal in words when it is unreadable. */ export function aiDuplicateRequestOf(inputs) {
80
+ var _parseAiDuplicateResourceInput;
81
+ const source = typeof (inputs == null ? void 0 : inputs['source']) === 'string' ? inputs['source'].trim() : '';
82
+ if (isAiPlanNewRef(source)) return AI_DUPLICATE_NOT_YET_COPY;
83
+ const name = typeof (inputs == null ? void 0 : inputs['name']) === 'string' ? inputs['name'] : null;
84
+ return (_parseAiDuplicateResourceInput = parseAiDuplicateResourceInput({
85
+ kind: inputs == null ? void 0 : inputs['kind'],
86
+ sourceId: source,
87
+ name
88
+ })) != null ? _parseAiDuplicateResourceInput : AI_DUPLICATE_UNREADABLE_COPY;
89
+ }
90
+ /** The rows of a kind's collection on a site. */ export async function readAiDuplicateSourceRows(firestore, hostId, kind) {
91
+ const recipe = recipeFor(kind);
92
+ if (!recipe) return [];
93
+ const snapshot = await firestore.collection('hosts').doc(hostId).collection(recipe.collection).select(recipe.nameField, 'kind', 'deletedAt').get();
94
+ return snapshot.docs.map((doc)=>{
95
+ var _doc_get;
96
+ return {
97
+ id: doc.id,
98
+ name: String((_doc_get = doc.get(recipe.nameField)) != null ? _doc_get : ''),
99
+ kind: doc.get('kind'),
100
+ deleted: doc.get('deletedAt') != null
101
+ };
102
+ });
103
+ }
104
+ /** Whether a row of a shared collection is of this kind: pages and email designs share `screens`. */ function rowIsKind(kind, row) {
105
+ if (kind === 'emailDesign') return row.kind === 'email';
106
+ if (kind === 'screen') return row.kind !== 'email';
107
+ return true;
108
+ }
109
+ /**
110
+ * The source's id: the row whose id it names, else the one row whose name it
111
+ * is (case aside) — so "copy the Welcome email" works where the inventory
112
+ * lists no email designs. Two rows of one name, or none, are refused in words.
113
+ */ export function aiDuplicateSourceId(kind, source, rows) {
114
+ const noun = DUPLICABLE_RESOURCE_NOUNS[kind];
115
+ const live = rows.filter((row)=>!row.deleted && rowIsKind(kind, row));
116
+ if (live.some((row)=>row.id === source)) return {
117
+ id: source
118
+ };
119
+ const wanted = source.trim().toLowerCase();
120
+ const named = live.filter((row)=>row.name.trim().toLowerCase() === wanted);
121
+ if (named.length === 1) return {
122
+ id: named[0].id
123
+ };
124
+ if (named.length > 1) return {
125
+ error: `This site has more than one ${noun} called “${source}”; name the one to copy.`
126
+ };
127
+ return {
128
+ error: `This site has no ${noun} “${source}” to copy.`
129
+ };
130
+ }
131
+ /** Why this member may not copy on this site now, or `null`. */ export async function aiDuplicateAccessRefusal(input, deps = {}) {
132
+ var _input_host_get, _deps_lockdown;
133
+ if (!input.host.exists) return 'Unknown site';
134
+ const roles = (_input_host_get = input.host.get('memberRoles')) != null ? _input_host_get : {};
135
+ if (!hostRoleCanWrite(roles[input.uid])) return AI_DUPLICATE_ROLE_COPY;
136
+ const locked = await ((_deps_lockdown = deps.lockdown) != null ? _deps_lockdown : getLockdownVerdict)({
137
+ staff: false,
138
+ uid: input.uid,
139
+ org: input.org,
140
+ host: input.host.data(),
141
+ intent: 'write'
142
+ });
143
+ return locked ? AI_DUPLICATE_LOCKED_COPY : null;
144
+ }
145
+ /** The copy's output note: what it is and what it waits for. */ export function aiDuplicateOutputNote(kind) {
146
+ const noun = DUPLICABLE_RESOURCE_NOUNS[kind];
147
+ return `A copy of the ${noun}, saved as a draft: nothing on the live site uses it until you do.`;
148
+ }
149
+ /** The request read and its source found, or a refusal in words. */ async function duplicatePlanOf(firestore, job, readRows) {
150
+ if (!job.hostId) return AI_DUPLICATE_NO_SITE_COPY;
151
+ const request = aiDuplicateRequestOf(job.inputs);
152
+ if (typeof request === 'string') return request;
153
+ const found = aiDuplicateSourceId(request.kind, request.sourceId, await readRows(firestore, job.hostId, request.kind));
154
+ if ('error' in found) return found.error;
155
+ return _extends({}, request, {
156
+ id: found.id
157
+ });
158
+ }
159
+ export function createAiJobDuplicateStep(deps = {}) {
160
+ var _deps_duplicate, _deps_readRows;
161
+ const duplicate = (_deps_duplicate = deps.duplicate) != null ? _deps_duplicate : duplicateResource;
162
+ const readRows = (_deps_readRows = deps.readRows) != null ? _deps_readRows : readAiDuplicateSourceRows;
163
+ return async ({ job, firestore, org, modelFor })=>{
164
+ var _ref, _data;
165
+ // No model is asked: the outcome records the edit step's at no cost.
166
+ const model = (_ref = modelFor == null ? void 0 : modelFor('job.edit')) != null ? _ref : aiJobEditModel();
167
+ const unspent = (failure)=>_extends({}, aiUnspentOutcome(model), {
168
+ failure
169
+ });
170
+ const hostId = job.hostId;
171
+ if (!hostId) return unspent(AI_DUPLICATE_NO_SITE_COPY);
172
+ const plan = await duplicatePlanOf(firestore, job, readRows);
173
+ if (typeof plan === 'string') return unspent(plan);
174
+ const orgData = org != null ? org : (_data = (await firestore.collection('orgs').doc(job.orgId).get()).data()) != null ? _data : null;
175
+ const host = await firestore.collection('hosts').doc(hostId).get();
176
+ const refused = await aiDuplicateAccessRefusal({
177
+ host,
178
+ uid: job.createdBy,
179
+ org: orgData
180
+ }, {
181
+ lockdown: deps.lockdown
182
+ });
183
+ if (refused) return aiUnspentOutcome(model, {
184
+ review: aiLimitReview(refused)
185
+ });
186
+ const result = await duplicate(plan.kind, {
187
+ orgId: job.orgId,
188
+ hostId,
189
+ sourceId: plan.id,
190
+ name: plan.name,
191
+ uid: job.createdBy,
192
+ email: null,
193
+ org: orgData,
194
+ attemptKey: aiDuplicateAttemptKey(job)
195
+ });
196
+ if (result.ok === false) {
197
+ // A plan counter or a feature the plan lacks is a limit to relay, not a fault.
198
+ return result.status === 403 ? aiUnspentOutcome(model, {
199
+ review: aiLimitReview(result.error)
200
+ }) : unspent(result.error);
201
+ }
202
+ const output = {
203
+ resource: AI_DUPLICATE_OUTPUT_RESOURCES[plan.kind],
204
+ id: result.id,
205
+ versionId: result.versionId,
206
+ hostId,
207
+ hostSubdomain: typeof host.get('subdomain') === 'string' && host.get('subdomain') || null,
208
+ label: result.name,
209
+ note: aiDuplicateOutputNote(plan.kind)
210
+ };
211
+ return aiUnspentOutcome(model, {
212
+ outputs: [
213
+ output
214
+ ]
215
+ });
216
+ };
217
+ }
218
+ export const runAiJobDuplicateStep = createAiJobDuplicateStep();
219
+ /**
220
+ * A copy is admitted on a site of the job's own org, for a member who may
221
+ * write its content now, of a source the site holds. Refused in those words
222
+ * before anything exists; what the plan's counters allow is the copy's own
223
+ * transaction to say.
224
+ */ export function createAiDuplicateJobAdmission(deps = {}) {
225
+ var _deps_ownerOf, _deps_readRows;
226
+ const ownerOf = (_deps_ownerOf = deps.ownerOf) != null ? _deps_ownerOf : resolveOrgIdForHost;
227
+ const readRows = (_deps_readRows = deps.readRows) != null ? _deps_readRows : readAiDuplicateSourceRows;
228
+ return async (context)=>{
229
+ var _context_uid;
230
+ if (!context.hostId) return {
231
+ status: 400,
232
+ error: AI_DUPLICATE_NO_SITE_COPY
233
+ };
234
+ const owner = await ownerOf(context.hostId);
235
+ if (!owner || owner !== context.orgId) return {
236
+ status: 404,
237
+ error: 'Unknown site'
238
+ };
239
+ const host = await context.firestore.collection('hosts').doc(context.hostId).get();
240
+ const refused = await aiDuplicateAccessRefusal({
241
+ host,
242
+ uid: (_context_uid = context.uid) != null ? _context_uid : '',
243
+ org: context.org
244
+ }, {
245
+ lockdown: deps.lockdown
246
+ });
247
+ if (refused) return {
248
+ status: 403,
249
+ error: refused
250
+ };
251
+ const plan = await duplicatePlanOf(context.firestore, {
252
+ hostId: context.hostId,
253
+ inputs: context.inputs
254
+ }, readRows);
255
+ return typeof plan === 'string' ? {
256
+ status: 400,
257
+ error: plan
258
+ } : null;
259
+ };
260
+ }
261
+ export const aiDuplicateJobAdmission = createAiDuplicateJobAdmission();
262
+ /** Registers the duplicate step and its door (AGL-3740). */ export function registerAiDuplicateJob() {
263
+ registerAiJobStep(AI_DUPLICATE_KIND, runAiJobDuplicateStep, {
264
+ minimumMs: AI_JOB_DUPLICATE_STEP_MINIMUM_MS
265
+ });
266
+ registerAiJobAdmission(AI_DUPLICATE_KIND, aiDuplicateJobAdmission);
267
+ }
268
+
269
+ //# sourceMappingURL=ai-job-duplicate-step.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-job-duplicate-step.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 DUPLICABLE_RESOURCE_NOUNS,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport { duplicateResource, recipeFor } from '@aglyn/tenant-data-admin/server/duplicate-resource'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport { isAiPlanNewRef } from '../model/ai-build-plan'\nimport type { AiJob, AiJobOutput, AiJobOutputResource } from '../model/ai-jobs.types'\nimport { parseAiDuplicateResourceInput, type AiDuplicateResourceInput } from '../tools/ai-duplicate-resource-tool'\nimport { registerAiJobAdmission, type AiJobAdmission, type AiJobAdmissionRefusal } from './ai-job-admission'\nimport { aiJobEditModel } from './ai-job-edit-step'\nimport { aiLimitReview, aiUnspentOutcome } from './ai-job-generation'\nimport type { AiJobStepOutcome, AiJobStepRunner } from './ai-job-text-step'\nimport { registerAiJobStep } from './ai-jobs'\n\n/**\n * The `duplicate` step (AGL-3740): copying something the site already has —\n * a page, a component, a layout, a template, a form, an email design or a\n * workflow — as a first-class build operation, so \"make another page like\n * Services\" copies instead of regenerating.\n *\n * NO MODEL. The copy is core's `duplicateResource`, the very function the\n * console's Duplicate… row menus and `/api/hosts/resources` (`action:\n * 'duplicate'`) call, and the one the `duplicate_resource` tool wraps: the\n * same fields carried, the same uniqueness rules, the same plan counters met\n * inside the transaction, the same `*.duplicated` activity row. So it costs\n * no credits, and it can copy nothing the console could not.\n *\n * DRAFTS ONLY, by construction of the copy: a page copy has no address, a\n * workflow copy no trigger, and nothing places a component, layout or form\n * copy until a person does.\n *\n * ── Who may ──────────────────────────────────────────────────────────────\n *\n * The console door's rule: a member with a write role on the site (admin,\n * editor or author), while no lockdown freezes the site, its org or the\n * platform. `ai.generate` is the jobs door's own check. Asked at the door\n * and again before the copy.\n *\n * ── Once per job ─────────────────────────────────────────────────────────\n *\n * The job's id is the copy's attempt key, so a pass asked again answers with\n * the copy the first made rather than making a second.\n */\n\nexport const AI_DUPLICATE_KIND = 'duplicate' as const\n\n/**\n * ASSUMED: the least time the step needs — the site, the org, the source's\n * collection when it is named by name, and the one transaction that copies.\n * No model, so it fits an inline door.\n */\nexport const AI_JOB_DUPLICATE_STEP_MINIMUM_MS = 5_000\n\nexport const AI_DUPLICATE_NO_SITE_COPY = 'Open the site whose content should be copied.'\nexport const AI_DUPLICATE_ROLE_COPY = 'Copying on this site needs the admin, editor or author role on it.'\nexport const AI_DUPLICATE_LOCKED_COPY = 'This site is not accepting changes right now.'\nexport const AI_DUPLICATE_UNREADABLE_COPY = 'The request did not name something on this site to copy.'\nexport const AI_DUPLICATE_NOT_YET_COPY = 'Only something the site already has can be copied, not something this build is making.'\n\n/** The output resource each duplicable kind is opened as. */\nexport const AI_DUPLICATE_OUTPUT_RESOURCES: Readonly<Record<DuplicableHostResourceKind, AiJobOutputResource>> = {\n screen: 'screen',\n component: 'reusableComponent',\n layout: 'layout',\n template: 'template',\n form: 'form',\n emailDesign: 'emailScreen',\n workflow: 'workflow',\n}\n\n/** The copy's attempt key: one copy per job, however often it is asked. */\nexport function aiDuplicateAttemptKey(job: Pick<AiJob, '$id'>): string {\n return `ai-job:${job.$id}`.slice(0, 200)\n}\n\n/** What the plan's item asks, read; a refusal in words when it is unreadable. */\nexport function aiDuplicateRequestOf(inputs: Readonly<Record<string, unknown>> | null | undefined): AiDuplicateResourceInput | string {\n const source = typeof inputs?.['source'] === 'string' ? inputs['source'].trim() : ''\n if (isAiPlanNewRef(source)) return AI_DUPLICATE_NOT_YET_COPY\n const name = typeof inputs?.['name'] === 'string' ? inputs['name'] : null\n return parseAiDuplicateResourceInput({ kind: inputs?.['kind'], sourceId: source, name }) ?? AI_DUPLICATE_UNREADABLE_COPY\n}\n\n/** One row of a kind's collection, as a source is looked up by. */\nexport interface AiDuplicateSourceRow {\n id: string\n name: string\n kind: unknown\n deleted: boolean\n}\n\n/** The rows of a kind's collection on a site. */\nexport async function readAiDuplicateSourceRows(\n firestore: Firestore,\n hostId: string,\n kind: DuplicableHostResourceKind,\n): Promise<AiDuplicateSourceRow[]> {\n const recipe = recipeFor(kind)\n if (!recipe) return []\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection(recipe.collection)\n .select(recipe.nameField, 'kind', 'deletedAt')\n .get()\n return snapshot.docs.map((doc) => ({\n id: doc.id,\n name: String(doc.get(recipe.nameField) ?? ''),\n kind: doc.get('kind'),\n deleted: doc.get('deletedAt') != null,\n }))\n}\n\n/** Whether a row of a shared collection is of this kind: pages and email designs share `screens`. */\nfunction rowIsKind(kind: DuplicableHostResourceKind, row: AiDuplicateSourceRow): boolean {\n if (kind === 'emailDesign') return row.kind === 'email'\n if (kind === 'screen') return row.kind !== 'email'\n return true\n}\n\n/**\n * The source's id: the row whose id it names, else the one row whose name it\n * is (case aside) — so \"copy the Welcome email\" works where the inventory\n * lists no email designs. Two rows of one name, or none, are refused in words.\n */\nexport function aiDuplicateSourceId(\n kind: DuplicableHostResourceKind,\n source: string,\n rows: readonly AiDuplicateSourceRow[],\n): { id: string } | { error: string } {\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const live = rows.filter((row) => !row.deleted && rowIsKind(kind, row))\n if (live.some((row) => row.id === source)) return { id: source }\n const wanted = source.trim().toLowerCase()\n const named = live.filter((row) => row.name.trim().toLowerCase() === wanted)\n if (named.length === 1) return { id: named[0].id }\n if (named.length > 1) return { error: `This site has more than one ${noun} called “${source}”; name the one to copy.` }\n return { error: `This site has no ${noun} “${source}” to copy.` }\n}\n\n/** Why this member may not copy on this site now, or `null`. */\nexport async function aiDuplicateAccessRefusal(\n input: { host: FirebaseFirestore.DocumentSnapshot; uid: string; org: object | null },\n deps: { lockdown?: typeof getLockdownVerdict } = {},\n): Promise<string | null> {\n if (!input.host.exists) return 'Unknown site'\n const roles = (input.host.get('memberRoles') ?? {}) as Record<string, unknown>\n if (!hostRoleCanWrite(roles[input.uid])) return AI_DUPLICATE_ROLE_COPY\n const locked = await (deps.lockdown ?? getLockdownVerdict)({\n staff: false,\n uid: input.uid,\n org: input.org as never,\n host: input.host.data() as never,\n intent: 'write',\n })\n return locked ? AI_DUPLICATE_LOCKED_COPY : null\n}\n\n/** The copy's output note: what it is and what it waits for. */\nexport function aiDuplicateOutputNote(kind: DuplicableHostResourceKind): string {\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n return `A copy of the ${noun}, saved as a draft: nothing on the live site uses it until you do.`\n}\n\nexport interface AiJobDuplicateStepDeps {\n duplicate?: typeof duplicateResource\n readRows?: typeof readAiDuplicateSourceRows\n lockdown?: typeof getLockdownVerdict\n}\n\n/** The request read and its source found, or a refusal in words. */\nasync function duplicatePlanOf(\n firestore: Firestore,\n job: Pick<AiJob, 'hostId' | 'inputs'>,\n readRows: typeof readAiDuplicateSourceRows,\n): Promise<(AiDuplicateResourceInput & { id: string }) | string> {\n if (!job.hostId) return AI_DUPLICATE_NO_SITE_COPY\n const request = aiDuplicateRequestOf(job.inputs)\n if (typeof request === 'string') return request\n const found = aiDuplicateSourceId(request.kind, request.sourceId, await readRows(firestore, job.hostId, request.kind))\n if ('error' in found) return found.error\n return { ...request, id: found.id }\n}\n\nexport function createAiJobDuplicateStep(deps: AiJobDuplicateStepDeps = {}): AiJobStepRunner {\n const duplicate = deps.duplicate ?? duplicateResource\n const readRows = deps.readRows ?? readAiDuplicateSourceRows\n return async ({ job, firestore, org, modelFor }): Promise<AiJobStepOutcome> => {\n // No model is asked: the outcome records the edit step's at no cost.\n const model = modelFor?.('job.edit') ?? aiJobEditModel()\n const unspent = (failure: string): AiJobStepOutcome => ({ ...aiUnspentOutcome(model), failure })\n const hostId = job.hostId\n if (!hostId) return unspent(AI_DUPLICATE_NO_SITE_COPY)\n const plan = await duplicatePlanOf(firestore, job, readRows)\n if (typeof plan === 'string') return unspent(plan)\n const orgData = (org ?? ((await firestore.collection('orgs').doc(job.orgId).get()).data() ?? null)) as Record<string, unknown> | null\n const host = await firestore.collection('hosts').doc(hostId).get()\n const refused = await aiDuplicateAccessRefusal({ host, uid: job.createdBy, org: orgData }, { lockdown: deps.lockdown })\n if (refused) return aiUnspentOutcome(model, { review: aiLimitReview(refused) })\n const result = await duplicate(plan.kind, {\n orgId: job.orgId,\n hostId,\n sourceId: plan.id,\n name: plan.name,\n uid: job.createdBy,\n email: null,\n org: orgData,\n attemptKey: aiDuplicateAttemptKey(job),\n })\n if (result.ok === false) {\n // A plan counter or a feature the plan lacks is a limit to relay, not a fault.\n return result.status === 403 ? aiUnspentOutcome(model, { review: aiLimitReview(result.error) }) : unspent(result.error)\n }\n const output: AiJobOutput = {\n resource: AI_DUPLICATE_OUTPUT_RESOURCES[plan.kind],\n id: result.id,\n versionId: result.versionId,\n hostId,\n hostSubdomain: (typeof host.get('subdomain') === 'string' && host.get('subdomain')) || null,\n label: result.name,\n note: aiDuplicateOutputNote(plan.kind),\n }\n return aiUnspentOutcome(model, { outputs: [output] })\n }\n}\n\nexport const runAiJobDuplicateStep = createAiJobDuplicateStep()\n\n// ── Admission ─────────────────────────────────────────────────────────────\n\nexport interface AiDuplicateJobAdmissionDeps {\n ownerOf?: typeof resolveOrgIdForHost\n readRows?: typeof readAiDuplicateSourceRows\n lockdown?: typeof getLockdownVerdict\n}\n\n/**\n * A copy is admitted on a site of the job's own org, for a member who may\n * write its content now, of a source the site holds. Refused in those words\n * before anything exists; what the plan's counters allow is the copy's own\n * transaction to say.\n */\nexport function createAiDuplicateJobAdmission(deps: AiDuplicateJobAdmissionDeps = {}): AiJobAdmission {\n const ownerOf = deps.ownerOf ?? resolveOrgIdForHost\n const readRows = deps.readRows ?? readAiDuplicateSourceRows\n return async (context): Promise<AiJobAdmissionRefusal | null> => {\n if (!context.hostId) return { status: 400, error: AI_DUPLICATE_NO_SITE_COPY }\n const owner = await ownerOf(context.hostId)\n if (!owner || owner !== context.orgId) return { status: 404, error: 'Unknown site' }\n const host = await context.firestore.collection('hosts').doc(context.hostId).get()\n const refused = await aiDuplicateAccessRefusal({ host, uid: context.uid ?? '', org: context.org }, { lockdown: deps.lockdown })\n if (refused) return { status: 403, error: refused }\n const plan = await duplicatePlanOf(context.firestore as Firestore, { hostId: context.hostId, inputs: context.inputs as Record<string, unknown> }, readRows)\n return typeof plan === 'string' ? { status: 400, error: plan } : null\n }\n}\n\nexport const aiDuplicateJobAdmission = createAiDuplicateJobAdmission()\n\n/** Registers the duplicate step and its door (AGL-3740). */\nexport function registerAiDuplicateJob(): void {\n registerAiJobStep(AI_DUPLICATE_KIND, runAiJobDuplicateStep, { minimumMs: AI_JOB_DUPLICATE_STEP_MINIMUM_MS })\n registerAiJobAdmission(AI_DUPLICATE_KIND, aiDuplicateJobAdmission)\n}\n"],"names":["DUPLICABLE_RESOURCE_NOUNS","hostRoleCanWrite","duplicateResource","recipeFor","getLockdownVerdict","resolveOrgIdForHost","isAiPlanNewRef","parseAiDuplicateResourceInput","registerAiJobAdmission","aiJobEditModel","aiLimitReview","aiUnspentOutcome","registerAiJobStep","AI_DUPLICATE_KIND","AI_JOB_DUPLICATE_STEP_MINIMUM_MS","AI_DUPLICATE_NO_SITE_COPY","AI_DUPLICATE_ROLE_COPY","AI_DUPLICATE_LOCKED_COPY","AI_DUPLICATE_UNREADABLE_COPY","AI_DUPLICATE_NOT_YET_COPY","AI_DUPLICATE_OUTPUT_RESOURCES","screen","component","layout","template","form","emailDesign","workflow","aiDuplicateAttemptKey","job","$id","slice","aiDuplicateRequestOf","inputs","source","trim","name","kind","sourceId","readAiDuplicateSourceRows","firestore","hostId","recipe","snapshot","collection","doc","select","nameField","get","docs","map","id","String","deleted","rowIsKind","row","aiDuplicateSourceId","rows","noun","live","filter","some","wanted","toLowerCase","named","length","error","aiDuplicateAccessRefusal","input","deps","host","exists","roles","uid","locked","lockdown","staff","org","data","intent","aiDuplicateOutputNote","duplicatePlanOf","readRows","request","found","createAiJobDuplicateStep","duplicate","modelFor","model","unspent","failure","plan","orgData","orgId","refused","createdBy","review","result","email","attemptKey","ok","status","output","resource","versionId","hostSubdomain","label","note","outputs","runAiJobDuplicateStep","createAiDuplicateJobAdmission","ownerOf","context","owner","aiDuplicateJobAdmission","registerAiDuplicateJob","minimumMs"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,QAEpB,4CAA2C;AAClD,SAASC,gBAAgB,QAAQ,uCAAsC;AACvE,SAASC,iBAAiB,EAAEC,SAAS,QAAQ,qDAAoD;AACjG,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,mBAAmB,QAAQ,gDAA+C;AAEnF,SAASC,cAAc,QAAQ,4BAAwB;AAEvD,SAASC,6BAA6B,QAAuC,yCAAqC;AAClH,SAASC,sBAAsB,QAAyD,wBAAoB;AAC5G,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,aAAa,EAAEC,gBAAgB,QAAQ,yBAAqB;AAErE,SAASC,iBAAiB,QAAQ,eAAW;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GAED,OAAO,MAAMC,oBAAoB,YAAoB;AAErD;;;;CAIC,GACD,OAAO,MAAMC,mCAAmC,KAAK;AAErD,OAAO,MAAMC,4BAA4B,gDAA+C;AACxF,OAAO,MAAMC,yBAAyB,qEAAoE;AAC1G,OAAO,MAAMC,2BAA2B,gDAA+C;AACvF,OAAO,MAAMC,+BAA+B,2DAA0D;AACtG,OAAO,MAAMC,4BAA4B,yFAAwF;AAEjI,2DAA2D,GAC3D,OAAO,MAAMC,gCAAmG;IAC9GC,QAAQ;IACRC,WAAW;IACXC,QAAQ;IACRC,UAAU;IACVC,MAAM;IACNC,aAAa;IACbC,UAAU;AACZ,EAAC;AAED,yEAAyE,GACzE,OAAO,SAASC,sBAAsBC,GAAuB;IAC3D,OAAO,CAAC,OAAO,EAAEA,IAAIC,GAAG,EAAE,CAACC,KAAK,CAAC,GAAG;AACtC;AAEA,+EAA+E,GAC/E,OAAO,SAASC,qBAAqBC,MAA4D;QAIxF1B;IAHP,MAAM2B,SAAS,QAAOD,0BAAAA,MAAQ,CAAC,SAAS,MAAK,WAAWA,MAAM,CAAC,SAAS,CAACE,IAAI,KAAK;IAClF,IAAI7B,eAAe4B,SAAS,OAAOf;IACnC,MAAMiB,OAAO,QAAOH,0BAAAA,MAAQ,CAAC,OAAO,MAAK,WAAWA,MAAM,CAAC,OAAO,GAAG;IACrE,QAAO1B,iCAAAA,8BAA8B;QAAE8B,IAAI,EAAEJ,0BAAAA,MAAQ,CAAC,OAAO;QAAEK,UAAUJ;QAAQE;IAAK,cAA/E7B,iCAAqFW;AAC9F;AAUA,+CAA+C,GAC/C,OAAO,eAAeqB,0BACpBC,SAAoB,EACpBC,MAAc,EACdJ,IAAgC;IAEhC,MAAMK,SAASvC,UAAUkC;IACzB,IAAI,CAACK,QAAQ,OAAO,EAAE;IACtB,MAAMC,WAAW,MAAMH,UACpBI,UAAU,CAAC,SACXC,GAAG,CAACJ,QACJG,UAAU,CAACF,OAAOE,UAAU,EAC5BE,MAAM,CAACJ,OAAOK,SAAS,EAAE,QAAQ,aACjCC,GAAG;IACN,OAAOL,SAASM,IAAI,CAACC,GAAG,CAAC,CAACL;YAEXA;eAFoB;YACjCM,IAAIN,IAAIM,EAAE;YACVf,MAAMgB,QAAOP,WAAAA,IAAIG,GAAG,CAACN,OAAOK,SAAS,aAAxBF,WAA6B;YAC1CR,MAAMQ,IAAIG,GAAG,CAAC;YACdK,SAASR,IAAIG,GAAG,CAAC,gBAAgB;QACnC;;AACF;AAEA,mGAAmG,GACnG,SAASM,UAAUjB,IAAgC,EAAEkB,GAAyB;IAC5E,IAAIlB,SAAS,eAAe,OAAOkB,IAAIlB,IAAI,KAAK;IAChD,IAAIA,SAAS,UAAU,OAAOkB,IAAIlB,IAAI,KAAK;IAC3C,OAAO;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASmB,oBACdnB,IAAgC,EAChCH,MAAc,EACduB,IAAqC;IAErC,MAAMC,OAAO1D,yBAAyB,CAACqC,KAAK;IAC5C,MAAMsB,OAAOF,KAAKG,MAAM,CAAC,CAACL,MAAQ,CAACA,IAAIF,OAAO,IAAIC,UAAUjB,MAAMkB;IAClE,IAAII,KAAKE,IAAI,CAAC,CAACN,MAAQA,IAAIJ,EAAE,KAAKjB,SAAS,OAAO;QAAEiB,IAAIjB;IAAO;IAC/D,MAAM4B,SAAS5B,OAAOC,IAAI,GAAG4B,WAAW;IACxC,MAAMC,QAAQL,KAAKC,MAAM,CAAC,CAACL,MAAQA,IAAInB,IAAI,CAACD,IAAI,GAAG4B,WAAW,OAAOD;IACrE,IAAIE,MAAMC,MAAM,KAAK,GAAG,OAAO;QAAEd,IAAIa,KAAK,CAAC,EAAE,CAACb,EAAE;IAAC;IACjD,IAAIa,MAAMC,MAAM,GAAG,GAAG,OAAO;QAAEC,OAAO,CAAC,4BAA4B,EAAER,KAAK,SAAS,EAAExB,OAAO,wBAAwB,CAAC;IAAC;IACtH,OAAO;QAAEgC,OAAO,CAAC,iBAAiB,EAAER,KAAK,EAAE,EAAExB,OAAO,UAAU,CAAC;IAAC;AAClE;AAEA,8DAA8D,GAC9D,OAAO,eAAeiC,yBACpBC,KAAoF,EACpFC,OAAiD,CAAC,CAAC;QAGpCD,iBAEOC;IAHtB,IAAI,CAACD,MAAME,IAAI,CAACC,MAAM,EAAE,OAAO;IAC/B,MAAMC,SAASJ,kBAAAA,MAAME,IAAI,CAACtB,GAAG,CAAC,0BAAfoB,kBAAiC,CAAC;IACjD,IAAI,CAACnE,iBAAiBuE,KAAK,CAACJ,MAAMK,GAAG,CAAC,GAAG,OAAOzD;IAChD,MAAM0D,SAAS,MAAM,EAACL,iBAAAA,KAAKM,QAAQ,YAAbN,iBAAiBjE,oBAAoB;QACzDwE,OAAO;QACPH,KAAKL,MAAMK,GAAG;QACdI,KAAKT,MAAMS,GAAG;QACdP,MAAMF,MAAME,IAAI,CAACQ,IAAI;QACrBC,QAAQ;IACV;IACA,OAAOL,SAASzD,2BAA2B;AAC7C;AAEA,8DAA8D,GAC9D,OAAO,SAAS+D,sBAAsB3C,IAAgC;IACpE,MAAMqB,OAAO1D,yBAAyB,CAACqC,KAAK;IAC5C,OAAO,CAAC,cAAc,EAAEqB,KAAK,kEAAkE,CAAC;AAClG;AAQA,kEAAkE,GAClE,eAAeuB,gBACbzC,SAAoB,EACpBX,GAAqC,EACrCqD,QAA0C;IAE1C,IAAI,CAACrD,IAAIY,MAAM,EAAE,OAAO1B;IACxB,MAAMoE,UAAUnD,qBAAqBH,IAAII,MAAM;IAC/C,IAAI,OAAOkD,YAAY,UAAU,OAAOA;IACxC,MAAMC,QAAQ5B,oBAAoB2B,QAAQ9C,IAAI,EAAE8C,QAAQ7C,QAAQ,EAAE,MAAM4C,SAAS1C,WAAWX,IAAIY,MAAM,EAAE0C,QAAQ9C,IAAI;IACpH,IAAI,WAAW+C,OAAO,OAAOA,MAAMlB,KAAK;IACxC,OAAO,aAAKiB;QAAShC,IAAIiC,MAAMjC,EAAE;;AACnC;AAEA,OAAO,SAASkC,yBAAyBhB,OAA+B,CAAC,CAAC;QACtDA,iBACDA;IADjB,MAAMiB,aAAYjB,kBAAAA,KAAKiB,SAAS,YAAdjB,kBAAkBnE;IACpC,MAAMgF,YAAWb,iBAAAA,KAAKa,QAAQ,YAAbb,iBAAiB9B;IAClC,OAAO,OAAO,EAAEV,GAAG,EAAEW,SAAS,EAAEqC,GAAG,EAAEU,QAAQ,EAAE;kBAQpB;QAPzB,qEAAqE;QACrE,MAAMC,gBAAQD,4BAAAA,SAAW,8BAAe9E;QACxC,MAAMgF,UAAU,CAACC,UAAuC,aAAK/E,iBAAiB6E;gBAAQE;;QACtF,MAAMjD,SAASZ,IAAIY,MAAM;QACzB,IAAI,CAACA,QAAQ,OAAOgD,QAAQ1E;QAC5B,MAAM4E,OAAO,MAAMV,gBAAgBzC,WAAWX,KAAKqD;QACnD,IAAI,OAAOS,SAAS,UAAU,OAAOF,QAAQE;QAC7C,MAAMC,UAAWf,cAAAA,OAAQ,QAAA,AAAC,CAAA,MAAMrC,UAAUI,UAAU,CAAC,QAAQC,GAAG,CAAChB,IAAIgE,KAAK,EAAE7C,GAAG,EAAC,EAAG8B,IAAI,cAA9D,QAAoE;QAC7F,MAAMR,OAAO,MAAM9B,UAAUI,UAAU,CAAC,SAASC,GAAG,CAACJ,QAAQO,GAAG;QAChE,MAAM8C,UAAU,MAAM3B,yBAAyB;YAAEG;YAAMG,KAAK5C,IAAIkE,SAAS;YAAElB,KAAKe;QAAQ,GAAG;YAAEjB,UAAUN,KAAKM,QAAQ;QAAC;QACrH,IAAImB,SAAS,OAAOnF,iBAAiB6E,OAAO;YAAEQ,QAAQtF,cAAcoF;QAAS;QAC7E,MAAMG,SAAS,MAAMX,UAAUK,KAAKtD,IAAI,EAAE;YACxCwD,OAAOhE,IAAIgE,KAAK;YAChBpD;YACAH,UAAUqD,KAAKxC,EAAE;YACjBf,MAAMuD,KAAKvD,IAAI;YACfqC,KAAK5C,IAAIkE,SAAS;YAClBG,OAAO;YACPrB,KAAKe;YACLO,YAAYvE,sBAAsBC;QACpC;QACA,IAAIoE,OAAOG,EAAE,KAAK,OAAO;YACvB,+EAA+E;YAC/E,OAAOH,OAAOI,MAAM,KAAK,MAAM1F,iBAAiB6E,OAAO;gBAAEQ,QAAQtF,cAAcuF,OAAO/B,KAAK;YAAE,KAAKuB,QAAQQ,OAAO/B,KAAK;QACxH;QACA,MAAMoC,SAAsB;YAC1BC,UAAUnF,6BAA6B,CAACuE,KAAKtD,IAAI,CAAC;YAClDc,IAAI8C,OAAO9C,EAAE;YACbqD,WAAWP,OAAOO,SAAS;YAC3B/D;YACAgE,eAAe,AAAC,OAAOnC,KAAKtB,GAAG,CAAC,iBAAiB,YAAYsB,KAAKtB,GAAG,CAAC,gBAAiB;YACvF0D,OAAOT,OAAO7D,IAAI;YAClBuE,MAAM3B,sBAAsBW,KAAKtD,IAAI;QACvC;QACA,OAAO1B,iBAAiB6E,OAAO;YAAEoB,SAAS;gBAACN;aAAO;QAAC;IACrD;AACF;AAEA,OAAO,MAAMO,wBAAwBxB,2BAA0B;AAU/D;;;;;CAKC,GACD,OAAO,SAASyB,8BAA8BzC,OAAoC,CAAC,CAAC;QAClEA,eACCA;IADjB,MAAM0C,WAAU1C,gBAAAA,KAAK0C,OAAO,YAAZ1C,gBAAgBhE;IAChC,MAAM6E,YAAWb,iBAAAA,KAAKa,QAAQ,YAAbb,iBAAiB9B;IAClC,OAAO,OAAOyE;YAKgDA;QAJ5D,IAAI,CAACA,QAAQvE,MAAM,EAAE,OAAO;YAAE4D,QAAQ;YAAKnC,OAAOnD;QAA0B;QAC5E,MAAMkG,QAAQ,MAAMF,QAAQC,QAAQvE,MAAM;QAC1C,IAAI,CAACwE,SAASA,UAAUD,QAAQnB,KAAK,EAAE,OAAO;YAAEQ,QAAQ;YAAKnC,OAAO;QAAe;QACnF,MAAMI,OAAO,MAAM0C,QAAQxE,SAAS,CAACI,UAAU,CAAC,SAASC,GAAG,CAACmE,QAAQvE,MAAM,EAAEO,GAAG;QAChF,MAAM8C,UAAU,MAAM3B,yBAAyB;YAAEG;YAAMG,GAAG,GAAEuC,eAAAA,QAAQvC,GAAG,YAAXuC,eAAe;YAAInC,KAAKmC,QAAQnC,GAAG;QAAC,GAAG;YAAEF,UAAUN,KAAKM,QAAQ;QAAC;QAC7H,IAAImB,SAAS,OAAO;YAAEO,QAAQ;YAAKnC,OAAO4B;QAAQ;QAClD,MAAMH,OAAO,MAAMV,gBAAgB+B,QAAQxE,SAAS,EAAe;YAAEC,QAAQuE,QAAQvE,MAAM;YAAER,QAAQ+E,QAAQ/E,MAAM;QAA4B,GAAGiD;QAClJ,OAAO,OAAOS,SAAS,WAAW;YAAEU,QAAQ;YAAKnC,OAAOyB;QAAK,IAAI;IACnE;AACF;AAEA,OAAO,MAAMuB,0BAA0BJ,gCAA+B;AAEtE,0DAA0D,GAC1D,OAAO,SAASK;IACdvG,kBAAkBC,mBAAmBgG,uBAAuB;QAAEO,WAAWtG;IAAiC;IAC1GN,uBAAuBK,mBAAmBqG;AAC5C"}
@@ -16,6 +16,7 @@
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { aiLayoutDesignChoices } from "../layout-language/ai-layout-design.js";
18
18
  import { aiCompileLayoutFrame } from "../layout-language/ai-layout-frame.js";
19
+ import { aiLayoutFrameHollows, aiLayoutFrameHollowsSaid, aiLayoutFrameIsHollow, aiLayoutRepairFrameHollows } from "../layout-language/ai-layout-frame-hollows.js";
19
20
  import { AI_LAYOUT_LANGUAGE_TEXT, aiReadLayoutFrame } from "../layout-language/ai-layout-language.js";
20
21
  import { aiLayoutCopyCheck } from "../layout-language/ai-layout-gaps.js";
21
22
  import { aiLayoutStoredTree } from "../layout-language/ai-layout-store.js";
@@ -191,12 +192,24 @@ import { AI_SITE_NAME_TOKEN } from "./ai-layout-site-facts.js";
191
192
  rootId: stored.rootId,
192
193
  nodes: copy.nodes
193
194
  }, 'layout'), 'layout', context).nodes;
195
+ // The copy check can take words out of the footer: no heading or column
196
+ // is left with nothing under it (AGL-3735), and none may remain.
197
+ aiLayoutRepairFrameHollows(nodes);
198
+ const hollow = aiLayoutFrameHollows(nodes);
194
199
  const report = validateAiDoctrineTree({
195
200
  rootId: stored.rootId,
196
201
  nodes
197
202
  }, 'layout', context);
198
203
  const violations = [
199
204
  ...report.violations,
205
+ ...aiLayoutFrameIsHollow(hollow) ? [
206
+ {
207
+ rule: null,
208
+ code: 'layout-frame-hollow',
209
+ message: 'Every footer heading needs something under it, and every footer column more than a heading.',
210
+ detail: aiLayoutFrameHollowsSaid(hollow).join('; ')
211
+ }
212
+ ] : [],
200
213
  ...copy.violations,
201
214
  ...input.extend({
202
215
  rootId: stored.rootId,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-job-layout-language.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 type { NodesMap } from '@aglyn/aglyn/types/nodes'\nimport { aiLayoutDesignChoices, type AiLayoutDesign } from '../layout-language/ai-layout-design'\nimport { aiCompileLayoutFrame } from '../layout-language/ai-layout-frame'\nimport {\n AI_LAYOUT_FRAME_TOOL,\n AI_LAYOUT_LANGUAGE_TEXT,\n aiReadLayoutFrame,\n} from '../layout-language/ai-layout-language'\nimport type {\n AiLayoutPage,\n AiLayoutTargets,\n} from '../layout-language/ai-layout-links'\nimport { aiLayoutCopyCheck } from '../layout-language/ai-layout-gaps'\nimport { aiLayoutStoredTree } from '../layout-language/ai-layout-store'\nimport { AI_GENERATION_MAX_ATTEMPTS } from '../runtime/ai-generation-bounds'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport {\n aiHomeScreenIds,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { AI_STEP_TIERS } from '../providers/catalog'\nimport type { AiGenerationCheck, AiValidatedTree } from '../runtime/ai-doctrine'\nimport {\n aiSettleMechanicalRules,\n aiSettleUnrelatedScreenLinks,\n validateAiDoctrineTree,\n type AiDoctrineNode,\n type AiDoctrineViolation,\n} from '../runtime/ai-doctrine-validators'\nimport type { AiLoadEstimate } from '../runtime/ai-palette'\nimport { aiSiteKindDesignLines } from '../model/ai-site-kinds'\nimport type { AiStoreFrameLinks } from '../model/ai-site-store-pages'\nimport type { AiSystemBlock } from '../runtime/ai-runtime'\nimport { aiJobStepBudget } from './ai-job-budget'\nimport { aiJobBriefLine } from './ai-job-generation'\nimport {\n AI_LAYOUT_SITE_PAGES_MAX,\n aiLayoutIsHomeSlug,\n aiLayoutSitePages,\n} from './ai-job-layout-site-pages'\nimport {\n AI_LAYOUT_FRAME_THINKING_TOKENS,\n aiLayoutPageTargets,\n} from './ai-job-page-language'\nimport { AI_SITE_NAME_TOKEN } from './ai-layout-site-facts'\n\n/**\n * A site's layout built in the compact layout language (AGL-3660): the layout\n * step's pass for a guided start's and a build's layout unit. The model\n * designs the header and the footer — their bands, the call to action, the\n * footer's columns and every word — in one short answer through\n * `submit_frame`, and the compiler writes the layout around what the site\n * owes it: its name as the brand, its pages as the navigation and the phone\n * menu, the Layout Slot, and the footer at the bottom of a short page\n * (`ai-layout-frame.ts`).\n */\n\n/**\n * The most a frame's answer asks for: two sections of a few blocks each (the\n * live runs of 2026-10-07 wrote 442 to 583), and the thinking before them.\n */\nexport const AI_JOB_LAYOUT_LANGUAGE_TOKENS = 1_200 + AI_LAYOUT_FRAME_THINKING_TOKENS\n\n/** A language layout pass's time, on the tier the layout step is served from. */\nexport const AI_JOB_LAYOUT_LANGUAGE_BUDGET = aiJobStepBudget({\n tier: AI_STEP_TIERS['job.layout'],\n maxTokens: AI_JOB_LAYOUT_LANGUAGE_TOKENS,\n})\n\n/** The generation kind a frame is asked under, which the doctrine scopes. */\nexport const AI_LAYOUT_FRAME_KIND = 'layout-frame'\n\n/** The language, as the frame door's cached instructions. */\nexport const AI_JOB_LAYOUT_LANGUAGE_INSTRUCTIONS: readonly AiSystemBlock[] = [\n {\n text: [\n AI_LAYOUT_LANGUAGE_TEXT,\n '',\n \"Now you design a site's header and footer, each one section of the language, answered with submit_frame.\",\n \"- header: the platform writes the site's name and its navigation itself. Choose its band and align (start, or center for a centered navigation), and give it at most one button: the call to action every page shows.\",\n '- footer: a section like any other, usually a row of two or three columns: a few words about the business, the details the brief gives, and a list of links (items with to). Put no contact detail, opening hours or address in it that the brief does not give, and no gap in square brackets for one.',\n ].join('\\n'),\n cacheBreakpoint: true,\n },\n]\n\n/** The site's name as the brand: the name the job was given, else the token the site's own name fills. */\nexport function aiLayoutSiteName(job: Pick<AiJob, 'inputs'>): string {\n const given = job.inputs?.['businessName']\n return typeof given === 'string' && given.trim()\n ? given.trim()\n : AI_SITE_NAME_TOKEN\n}\n\n/** The pages a frame's navigation links: the planned pages, else the site's own, in order. */\nexport function aiLayoutNavPages(\n job: Pick<AiJob, 'inputs'>,\n inventory: AiSiteInventory | null,\n): AiLayoutPage[] {\n // The header and the footer link the pages the job was told (AGL-3660),\n // else the site's own, the home page always and first, labelled Home. The\n // model draws neither list, so it cannot drop a page.\n const planned = aiLayoutSitePages(job.inputs)\n const own = (inventory?.screens ?? [])\n .filter((row) => !row.template)\n .map((row) => ({ id: row.id, label: row.name, slug: row.slug }))\n const siteHome = planned.some((page) => aiLayoutIsHomeSlug(page.slug))\n ? []\n : own.filter((page) => aiLayoutIsHomeSlug(page.slug)).slice(0, 1)\n const pages = [...siteHome, ...(planned.length ? planned : own)].filter((page) => page.label.trim())\n const home = pages.find((page) => aiLayoutIsHomeSlug(page.slug))\n return [\n ...(home ? [{ ...home, label: 'Home' }] : []),\n ...pages.filter((page) => page !== home && !aiLayoutIsHomeSlug(page.slug)),\n ].slice(0, AI_LAYOUT_SITE_PAGES_MAX)\n}\n\n/** The home page the brand links: the planned home, else the site's. */\nexport function aiLayoutHomeId(\n pages: readonly AiLayoutPage[],\n inventory: AiSiteInventory | null,\n): string | null {\n const planned = pages.find((page) => aiLayoutIsHomeSlug(page.slug))\n return planned?.id ?? aiHomeScreenIds(inventory)[0] ?? null\n}\n\n/** The frame's user turn: the site, its brief, its pages and its form. */\nexport function aiLayoutFramePrompt(input: {\n job: Pick<AiJob, 'brief' | '$id'> & Partial<Pick<AiJob, 'inputs'>>\n siteName: string\n pages: readonly AiLayoutPage[]\n targets: AiLayoutTargets\n}): string {\n const { job, pages, targets } = input\n return [\n `Site: ${input.siteName === AI_SITE_NAME_TOKEN ? 'the business the brief describes' : `\"${input.siteName}\"`}`,\n aiJobBriefLine(job),\n ...aiSiteKindDesignLines(job.inputs),\n pages.length\n ? `Its pages, which the navigation links: ${pages.map((page) => `${page.label} (page:${page.id})`).join(', ')}.`\n : 'It has no pages yet.',\n targets.forms.length || targets.formPageId\n ? 'A call to action that asks a visitor to get in touch goes to form.'\n : 'The site has no form yet.',\n // A storefront's header (AGL-3676): its cart, and an announcement line.\n ...(aiLayoutShopPageId(targets)\n ? [\n \"This is an online store: give the header no button, since the platform puts the cart beside the navigation. Give the header one note instead: a short announcement for the bar above it, under 60 characters, about the shop from the brief (a promise it makes, such as its shipping, or what is new); never invent an offer, a discount, a price or a shipping threshold the brief does not give.\",\n ]\n : []),\n `Design seed: ${job.$id.slice(-6)}.`,\n ].join('\\n')\n}\n\n/** A store's Shop page, by its id: the page its catalog's whole list is placed on (AGL-3676). */\nexport function aiLayoutShopPageId(targets: Pick<AiLayoutTargets, 'listings'>): string | null {\n const products = (targets.listings ?? []).find((listing) => listing.kind === 'products')\n return products?.placements.find((placement) => placement.role === 'index')?.screenId ?? null\n}\n\n/** A validated language layout. */\nexport interface AiLayoutFrameBuilt {\n rootId: string\n nodes: NodesMap\n load: AiLoadEstimate | null\n /** What the last answer's gaps took out of the frame (`ai-layout-gaps.ts`). */\n dropped: string[]\n}\n\n/** The check a frame answer is held to: read, compiled, stored and checked as a layout, with the layout door's own checks. */\nexport function aiLayoutFrameCheck(input: {\n /** The header arrangement the site's look chose (AGL-3660); the answer's otherwise. */\n headerAlign?: 'start' | 'center'\n siteName: string\n homeId: string | null\n pages: readonly AiLayoutPage[]\n targets: AiLayoutTargets\n extend: (tree: AiValidatedTree) => AiDoctrineViolation[]\n /** The site's design (`aiLayoutDesignOf`), whose pages' closing band the footer sits under. */\n design?: AiLayoutDesign | null\n /** A store's own pages, which the header and footer link by path (AGL-3676). */\n storeLinks?: AiStoreFrameLinks | null\n}): AiGenerationCheck<AiLayoutFrameBuilt> {\n let answers = 0\n return (answer) => {\n // The last answer a generation takes has its gaps taken out rather than asked about again.\n answers += 1\n const last = answers >= AI_GENERATION_MAX_ATTEMPTS\n const frame = aiReadLayoutFrame(answer)\n if (input.headerAlign && frame.header) frame.header.align = input.headerAlign\n const compiled = aiCompileLayoutFrame(\n frame,\n {\n siteName: input.siteName,\n homeId: input.homeId,\n navPages: input.pages,\n closesDark: input.design ? aiLayoutDesignChoices(input.design).coverClose : false,\n // A site that sells carries its cart in the header (AGL-3676).\n cart: (input.targets.listings ?? []).some((listing) => listing.kind === 'products' && listing.cart !== false),\n // And a store's header is a storefront's: no button repeating its navigation, an announcement bar (AGL-3676).\n shopId: aiLayoutShopPageId(input.targets),\n // And its account and policies, written after it (AGL-3676).\n storeLinks: input.storeLinks ?? null,\n },\n input.targets,\n )\n const context = {\n screenIds: [\n ...new Set([\n ...input.targets.pages.map((page) => page.id),\n ...input.pages.map((page) => page.id),\n ...(input.homeId ? [input.homeId] : []),\n ]),\n ],\n formIds: input.targets.forms.map((form) => form.id),\n homeScreenIds: input.targets.homeIds,\n // A footer's \"Get in touch\" goes to the form, wherever it is placed (AGL-3729).\n ...(input.targets.formPageId ? { formScreenIds: [input.targets.formPageId] } : {}),\n codeBuilt: true,\n }\n const stored = aiLayoutStoredTree(compiled.tree, 'layout', context)\n if (stored.ok === false) {\n console.error('ai layout frame: a compiled layout did not store', {\n error: stored.error,\n })\n return {\n value: null,\n violations: [\n {\n rule: null,\n code: 'layout-compile',\n message: 'This layout could not be built. Try again.',\n detail: stored.error,\n },\n ],\n }\n }\n // No gap and no internal reference in the words of a frame every published page shows.\n const navPages = [...input.targets.pages, ...input.pages]\n const copy = aiLayoutCopyCheck(\n stored.nodes,\n stored.rootId,\n {\n pages: navPages,\n ids: [...navPages.map((page) => page.id), ...input.targets.forms.map((form) => form.id)],\n },\n last,\n )\n const { dropped } = copy\n // An attribute a rule has one answer for is settled, not asked of the model again (AGL-3660).\n const nodes = aiSettleUnrelatedScreenLinks(\n aiSettleMechanicalRules(\n { rootId: stored.rootId, nodes: copy.nodes as unknown as Record<string, AiDoctrineNode> },\n 'layout',\n ),\n 'layout',\n context,\n ).nodes as unknown as typeof copy.nodes\n const report = validateAiDoctrineTree(\n { rootId: stored.rootId, nodes },\n 'layout',\n context,\n )\n const violations = [\n ...report.violations,\n ...copy.violations,\n ...input.extend({\n rootId: stored.rootId,\n nodes,\n repairs: [],\n sourceIds: {},\n score: report.score as AiValidatedTree['score'],\n load: report.load,\n }),\n ]\n return {\n value: violations.length\n ? null\n : { rootId: stored.rootId, nodes, load: report.load, dropped },\n violations,\n }\n }\n}\n\n/** What a frame's links resolve against: the site's pages and its form, as a page's do. */\nexport function aiLayoutFrameTargets(\n job: Pick<AiJob, 'brief' | 'inputs'>,\n inventory: AiSiteInventory | null,\n): AiLayoutTargets {\n return aiLayoutPageTargets({ job, inventory, own: [] })\n}\n"],"names":["aiLayoutDesignChoices","aiCompileLayoutFrame","AI_LAYOUT_LANGUAGE_TEXT","aiReadLayoutFrame","aiLayoutCopyCheck","aiLayoutStoredTree","AI_GENERATION_MAX_ATTEMPTS","aiHomeScreenIds","AI_STEP_TIERS","aiSettleMechanicalRules","aiSettleUnrelatedScreenLinks","validateAiDoctrineTree","aiSiteKindDesignLines","aiJobStepBudget","aiJobBriefLine","AI_LAYOUT_SITE_PAGES_MAX","aiLayoutIsHomeSlug","aiLayoutSitePages","AI_LAYOUT_FRAME_THINKING_TOKENS","aiLayoutPageTargets","AI_SITE_NAME_TOKEN","AI_JOB_LAYOUT_LANGUAGE_TOKENS","AI_JOB_LAYOUT_LANGUAGE_BUDGET","tier","maxTokens","AI_LAYOUT_FRAME_KIND","AI_JOB_LAYOUT_LANGUAGE_INSTRUCTIONS","text","join","cacheBreakpoint","aiLayoutSiteName","job","given","inputs","trim","aiLayoutNavPages","inventory","planned","own","screens","filter","row","template","map","id","label","name","slug","siteHome","some","page","slice","pages","length","home","find","aiLayoutHomeId","aiLayoutFramePrompt","input","targets","siteName","forms","formPageId","aiLayoutShopPageId","$id","products","listings","listing","kind","placements","placement","role","screenId","aiLayoutFrameCheck","answers","answer","last","frame","headerAlign","header","align","compiled","homeId","navPages","closesDark","design","coverClose","cart","shopId","storeLinks","context","screenIds","Set","formIds","form","homeScreenIds","homeIds","formScreenIds","codeBuilt","stored","tree","ok","console","error","value","violations","rule","code","message","detail","copy","nodes","rootId","ids","dropped","report","extend","repairs","sourceIds","score","load","aiLayoutFrameTargets"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-job-layout-language.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 type { NodesMap } from '@aglyn/aglyn/types/nodes'\nimport { aiLayoutDesignChoices, type AiLayoutDesign } from '../layout-language/ai-layout-design'\nimport { aiCompileLayoutFrame } from '../layout-language/ai-layout-frame'\nimport {\n aiLayoutFrameHollows,\n aiLayoutFrameHollowsSaid,\n aiLayoutFrameIsHollow,\n aiLayoutRepairFrameHollows,\n} from '../layout-language/ai-layout-frame-hollows'\nimport {\n AI_LAYOUT_FRAME_TOOL,\n AI_LAYOUT_LANGUAGE_TEXT,\n aiReadLayoutFrame,\n} from '../layout-language/ai-layout-language'\nimport type {\n AiLayoutPage,\n AiLayoutTargets,\n} from '../layout-language/ai-layout-links'\nimport { aiLayoutCopyCheck } from '../layout-language/ai-layout-gaps'\nimport { aiLayoutStoredTree } from '../layout-language/ai-layout-store'\nimport { AI_GENERATION_MAX_ATTEMPTS } from '../runtime/ai-generation-bounds'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport {\n aiHomeScreenIds,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { AI_STEP_TIERS } from '../providers/catalog'\nimport type { AiGenerationCheck, AiValidatedTree } from '../runtime/ai-doctrine'\nimport {\n aiSettleMechanicalRules,\n aiSettleUnrelatedScreenLinks,\n validateAiDoctrineTree,\n type AiDoctrineNode,\n type AiDoctrineViolation,\n} from '../runtime/ai-doctrine-validators'\nimport type { AiLoadEstimate } from '../runtime/ai-palette'\nimport { aiSiteKindDesignLines } from '../model/ai-site-kinds'\nimport type { AiStoreFrameLinks } from '../model/ai-site-store-pages'\nimport type { AiSystemBlock } from '../runtime/ai-runtime'\nimport { aiJobStepBudget } from './ai-job-budget'\nimport { aiJobBriefLine } from './ai-job-generation'\nimport {\n AI_LAYOUT_SITE_PAGES_MAX,\n aiLayoutIsHomeSlug,\n aiLayoutSitePages,\n} from './ai-job-layout-site-pages'\nimport {\n AI_LAYOUT_FRAME_THINKING_TOKENS,\n aiLayoutPageTargets,\n} from './ai-job-page-language'\nimport { AI_SITE_NAME_TOKEN } from './ai-layout-site-facts'\n\n/**\n * A site's layout built in the compact layout language (AGL-3660): the layout\n * step's pass for a guided start's and a build's layout unit. The model\n * designs the header and the footer — their bands, the call to action, the\n * footer's columns and every word — in one short answer through\n * `submit_frame`, and the compiler writes the layout around what the site\n * owes it: its name as the brand, its pages as the navigation and the phone\n * menu, the Layout Slot, and the footer at the bottom of a short page\n * (`ai-layout-frame.ts`).\n */\n\n/**\n * The most a frame's answer asks for: two sections of a few blocks each (the\n * live runs of 2026-10-07 wrote 442 to 583), and the thinking before them.\n */\nexport const AI_JOB_LAYOUT_LANGUAGE_TOKENS = 1_200 + AI_LAYOUT_FRAME_THINKING_TOKENS\n\n/** A language layout pass's time, on the tier the layout step is served from. */\nexport const AI_JOB_LAYOUT_LANGUAGE_BUDGET = aiJobStepBudget({\n tier: AI_STEP_TIERS['job.layout'],\n maxTokens: AI_JOB_LAYOUT_LANGUAGE_TOKENS,\n})\n\n/** The generation kind a frame is asked under, which the doctrine scopes. */\nexport const AI_LAYOUT_FRAME_KIND = 'layout-frame'\n\n/** The language, as the frame door's cached instructions. */\nexport const AI_JOB_LAYOUT_LANGUAGE_INSTRUCTIONS: readonly AiSystemBlock[] = [\n {\n text: [\n AI_LAYOUT_LANGUAGE_TEXT,\n '',\n \"Now you design a site's header and footer, each one section of the language, answered with submit_frame.\",\n \"- header: the platform writes the site's name and its navigation itself. Choose its band and align (start, or center for a centered navigation), and give it at most one button: the call to action every page shows.\",\n '- footer: a section like any other, usually a row of two or three columns: a few words about the business, the details the brief gives, and a list of links (items with to). Put no contact detail, opening hours or address in it that the brief does not give, and no gap in square brackets for one.',\n ].join('\\n'),\n cacheBreakpoint: true,\n },\n]\n\n/** The site's name as the brand: the name the job was given, else the token the site's own name fills. */\nexport function aiLayoutSiteName(job: Pick<AiJob, 'inputs'>): string {\n const given = job.inputs?.['businessName']\n return typeof given === 'string' && given.trim()\n ? given.trim()\n : AI_SITE_NAME_TOKEN\n}\n\n/** The pages a frame's navigation links: the planned pages, else the site's own, in order. */\nexport function aiLayoutNavPages(\n job: Pick<AiJob, 'inputs'>,\n inventory: AiSiteInventory | null,\n): AiLayoutPage[] {\n // The header and the footer link the pages the job was told (AGL-3660),\n // else the site's own, the home page always and first, labelled Home. The\n // model draws neither list, so it cannot drop a page.\n const planned = aiLayoutSitePages(job.inputs)\n const own = (inventory?.screens ?? [])\n .filter((row) => !row.template)\n .map((row) => ({ id: row.id, label: row.name, slug: row.slug }))\n const siteHome = planned.some((page) => aiLayoutIsHomeSlug(page.slug))\n ? []\n : own.filter((page) => aiLayoutIsHomeSlug(page.slug)).slice(0, 1)\n const pages = [...siteHome, ...(planned.length ? planned : own)].filter((page) => page.label.trim())\n const home = pages.find((page) => aiLayoutIsHomeSlug(page.slug))\n return [\n ...(home ? [{ ...home, label: 'Home' }] : []),\n ...pages.filter((page) => page !== home && !aiLayoutIsHomeSlug(page.slug)),\n ].slice(0, AI_LAYOUT_SITE_PAGES_MAX)\n}\n\n/** The home page the brand links: the planned home, else the site's. */\nexport function aiLayoutHomeId(\n pages: readonly AiLayoutPage[],\n inventory: AiSiteInventory | null,\n): string | null {\n const planned = pages.find((page) => aiLayoutIsHomeSlug(page.slug))\n return planned?.id ?? aiHomeScreenIds(inventory)[0] ?? null\n}\n\n/** The frame's user turn: the site, its brief, its pages and its form. */\nexport function aiLayoutFramePrompt(input: {\n job: Pick<AiJob, 'brief' | '$id'> & Partial<Pick<AiJob, 'inputs'>>\n siteName: string\n pages: readonly AiLayoutPage[]\n targets: AiLayoutTargets\n}): string {\n const { job, pages, targets } = input\n return [\n `Site: ${input.siteName === AI_SITE_NAME_TOKEN ? 'the business the brief describes' : `\"${input.siteName}\"`}`,\n aiJobBriefLine(job),\n ...aiSiteKindDesignLines(job.inputs),\n pages.length\n ? `Its pages, which the navigation links: ${pages.map((page) => `${page.label} (page:${page.id})`).join(', ')}.`\n : 'It has no pages yet.',\n targets.forms.length || targets.formPageId\n ? 'A call to action that asks a visitor to get in touch goes to form.'\n : 'The site has no form yet.',\n // A storefront's header (AGL-3676): its cart, and an announcement line.\n ...(aiLayoutShopPageId(targets)\n ? [\n \"This is an online store: give the header no button, since the platform puts the cart beside the navigation. Give the header one note instead: a short announcement for the bar above it, under 60 characters, about the shop from the brief (a promise it makes, such as its shipping, or what is new); never invent an offer, a discount, a price or a shipping threshold the brief does not give.\",\n ]\n : []),\n `Design seed: ${job.$id.slice(-6)}.`,\n ].join('\\n')\n}\n\n/** A store's Shop page, by its id: the page its catalog's whole list is placed on (AGL-3676). */\nexport function aiLayoutShopPageId(targets: Pick<AiLayoutTargets, 'listings'>): string | null {\n const products = (targets.listings ?? []).find((listing) => listing.kind === 'products')\n return products?.placements.find((placement) => placement.role === 'index')?.screenId ?? null\n}\n\n/** A validated language layout. */\nexport interface AiLayoutFrameBuilt {\n rootId: string\n nodes: NodesMap\n load: AiLoadEstimate | null\n /** What the last answer's gaps took out of the frame (`ai-layout-gaps.ts`). */\n dropped: string[]\n}\n\n/** The check a frame answer is held to: read, compiled, stored and checked as a layout, with the layout door's own checks. */\nexport function aiLayoutFrameCheck(input: {\n /** The header arrangement the site's look chose (AGL-3660); the answer's otherwise. */\n headerAlign?: 'start' | 'center'\n siteName: string\n homeId: string | null\n pages: readonly AiLayoutPage[]\n targets: AiLayoutTargets\n extend: (tree: AiValidatedTree) => AiDoctrineViolation[]\n /** The site's design (`aiLayoutDesignOf`), whose pages' closing band the footer sits under. */\n design?: AiLayoutDesign | null\n /** A store's own pages, which the header and footer link by path (AGL-3676). */\n storeLinks?: AiStoreFrameLinks | null\n}): AiGenerationCheck<AiLayoutFrameBuilt> {\n let answers = 0\n return (answer) => {\n // The last answer a generation takes has its gaps taken out rather than asked about again.\n answers += 1\n const last = answers >= AI_GENERATION_MAX_ATTEMPTS\n const frame = aiReadLayoutFrame(answer)\n if (input.headerAlign && frame.header) frame.header.align = input.headerAlign\n const compiled = aiCompileLayoutFrame(\n frame,\n {\n siteName: input.siteName,\n homeId: input.homeId,\n navPages: input.pages,\n closesDark: input.design ? aiLayoutDesignChoices(input.design).coverClose : false,\n // A site that sells carries its cart in the header (AGL-3676).\n cart: (input.targets.listings ?? []).some((listing) => listing.kind === 'products' && listing.cart !== false),\n // And a store's header is a storefront's: no button repeating its navigation, an announcement bar (AGL-3676).\n shopId: aiLayoutShopPageId(input.targets),\n // And its account and policies, written after it (AGL-3676).\n storeLinks: input.storeLinks ?? null,\n },\n input.targets,\n )\n const context = {\n screenIds: [\n ...new Set([\n ...input.targets.pages.map((page) => page.id),\n ...input.pages.map((page) => page.id),\n ...(input.homeId ? [input.homeId] : []),\n ]),\n ],\n formIds: input.targets.forms.map((form) => form.id),\n homeScreenIds: input.targets.homeIds,\n // A footer's \"Get in touch\" goes to the form, wherever it is placed (AGL-3729).\n ...(input.targets.formPageId ? { formScreenIds: [input.targets.formPageId] } : {}),\n codeBuilt: true,\n }\n const stored = aiLayoutStoredTree(compiled.tree, 'layout', context)\n if (stored.ok === false) {\n console.error('ai layout frame: a compiled layout did not store', {\n error: stored.error,\n })\n return {\n value: null,\n violations: [\n {\n rule: null,\n code: 'layout-compile',\n message: 'This layout could not be built. Try again.',\n detail: stored.error,\n },\n ],\n }\n }\n // No gap and no internal reference in the words of a frame every published page shows.\n const navPages = [...input.targets.pages, ...input.pages]\n const copy = aiLayoutCopyCheck(\n stored.nodes,\n stored.rootId,\n {\n pages: navPages,\n ids: [...navPages.map((page) => page.id), ...input.targets.forms.map((form) => form.id)],\n },\n last,\n )\n const { dropped } = copy\n // An attribute a rule has one answer for is settled, not asked of the model again (AGL-3660).\n const nodes = aiSettleUnrelatedScreenLinks(\n aiSettleMechanicalRules(\n { rootId: stored.rootId, nodes: copy.nodes as unknown as Record<string, AiDoctrineNode> },\n 'layout',\n ),\n 'layout',\n context,\n ).nodes as unknown as typeof copy.nodes\n // The copy check can take words out of the footer: no heading or column\n // is left with nothing under it (AGL-3735), and none may remain.\n aiLayoutRepairFrameHollows(nodes as unknown as Record<string, unknown>)\n const hollow = aiLayoutFrameHollows(nodes as unknown as Record<string, unknown>)\n const report = validateAiDoctrineTree(\n { rootId: stored.rootId, nodes },\n 'layout',\n context,\n )\n const violations = [\n ...report.violations,\n ...(aiLayoutFrameIsHollow(hollow)\n ? [\n {\n rule: null,\n code: 'layout-frame-hollow',\n message: 'Every footer heading needs something under it, and every footer column more than a heading.',\n detail: aiLayoutFrameHollowsSaid(hollow).join('; '),\n },\n ]\n : []),\n ...copy.violations,\n ...input.extend({\n rootId: stored.rootId,\n nodes,\n repairs: [],\n sourceIds: {},\n score: report.score as AiValidatedTree['score'],\n load: report.load,\n }),\n ]\n return {\n value: violations.length\n ? null\n : { rootId: stored.rootId, nodes, load: report.load, dropped },\n violations,\n }\n }\n}\n\n/** What a frame's links resolve against: the site's pages and its form, as a page's do. */\nexport function aiLayoutFrameTargets(\n job: Pick<AiJob, 'brief' | 'inputs'>,\n inventory: AiSiteInventory | null,\n): AiLayoutTargets {\n return aiLayoutPageTargets({ job, inventory, own: [] })\n}\n"],"names":["aiLayoutDesignChoices","aiCompileLayoutFrame","aiLayoutFrameHollows","aiLayoutFrameHollowsSaid","aiLayoutFrameIsHollow","aiLayoutRepairFrameHollows","AI_LAYOUT_LANGUAGE_TEXT","aiReadLayoutFrame","aiLayoutCopyCheck","aiLayoutStoredTree","AI_GENERATION_MAX_ATTEMPTS","aiHomeScreenIds","AI_STEP_TIERS","aiSettleMechanicalRules","aiSettleUnrelatedScreenLinks","validateAiDoctrineTree","aiSiteKindDesignLines","aiJobStepBudget","aiJobBriefLine","AI_LAYOUT_SITE_PAGES_MAX","aiLayoutIsHomeSlug","aiLayoutSitePages","AI_LAYOUT_FRAME_THINKING_TOKENS","aiLayoutPageTargets","AI_SITE_NAME_TOKEN","AI_JOB_LAYOUT_LANGUAGE_TOKENS","AI_JOB_LAYOUT_LANGUAGE_BUDGET","tier","maxTokens","AI_LAYOUT_FRAME_KIND","AI_JOB_LAYOUT_LANGUAGE_INSTRUCTIONS","text","join","cacheBreakpoint","aiLayoutSiteName","job","given","inputs","trim","aiLayoutNavPages","inventory","planned","own","screens","filter","row","template","map","id","label","name","slug","siteHome","some","page","slice","pages","length","home","find","aiLayoutHomeId","aiLayoutFramePrompt","input","targets","siteName","forms","formPageId","aiLayoutShopPageId","$id","products","listings","listing","kind","placements","placement","role","screenId","aiLayoutFrameCheck","answers","answer","last","frame","headerAlign","header","align","compiled","homeId","navPages","closesDark","design","coverClose","cart","shopId","storeLinks","context","screenIds","Set","formIds","form","homeScreenIds","homeIds","formScreenIds","codeBuilt","stored","tree","ok","console","error","value","violations","rule","code","message","detail","copy","nodes","rootId","ids","dropped","hollow","report","extend","repairs","sourceIds","score","load","aiLayoutFrameTargets"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,qBAAqB,QAA6B,yCAAqC;AAChG,SAASC,oBAAoB,QAAQ,wCAAoC;AACzE,SACEC,oBAAoB,EACpBC,wBAAwB,EACxBC,qBAAqB,EACrBC,0BAA0B,QACrB,gDAA4C;AACnD,SAEEC,uBAAuB,EACvBC,iBAAiB,QACZ,2CAAuC;AAK9C,SAASC,iBAAiB,QAAQ,uCAAmC;AACrE,SAASC,kBAAkB,QAAQ,wCAAoC;AACvE,SAASC,0BAA0B,QAAQ,qCAAiC;AAE5E,SACEC,eAAe,QAEV,gCAA4B;AACnC,SAASC,aAAa,QAAQ,0BAAsB;AAEpD,SACEC,uBAAuB,EACvBC,4BAA4B,EAC5BC,sBAAsB,QAGjB,uCAAmC;AAE1C,SAASC,qBAAqB,QAAQ,4BAAwB;AAG9D,SAASC,eAAe,QAAQ,qBAAiB;AACjD,SAASC,cAAc,QAAQ,yBAAqB;AACpD,SACEC,wBAAwB,EACxBC,kBAAkB,EAClBC,iBAAiB,QACZ,gCAA4B;AACnC,SACEC,+BAA+B,EAC/BC,mBAAmB,QACd,4BAAwB;AAC/B,SAASC,kBAAkB,QAAQ,4BAAwB;AAE3D;;;;;;;;;CASC,GAED;;;CAGC,GACD,OAAO,MAAMC,gCAAgC,OAAQH,gCAA+B;AAEpF,+EAA+E,GAC/E,OAAO,MAAMI,gCAAgCT,gBAAgB;IAC3DU,MAAMf,aAAa,CAAC,aAAa;IACjCgB,WAAWH;AACb,GAAE;AAEF,2EAA2E,GAC3E,OAAO,MAAMI,uBAAuB,eAAc;AAElD,2DAA2D,GAC3D,OAAO,MAAMC,sCAAgE;IAC3E;QACEC,MAAM;YACJzB;YACA;YACA;YACA;YACA;SACD,CAAC0B,IAAI,CAAC;QACPC,iBAAiB;IACnB;CACD,CAAA;AAED,wGAAwG,GACxG,OAAO,SAASC,iBAAiBC,GAA0B;QAC3CA;IAAd,MAAMC,SAAQD,cAAAA,IAAIE,MAAM,qBAAVF,WAAY,CAAC,eAAe;IAC1C,OAAO,OAAOC,UAAU,YAAYA,MAAME,IAAI,KAC1CF,MAAME,IAAI,KACVd;AACN;AAEA,4FAA4F,GAC5F,OAAO,SAASe,iBACdJ,GAA0B,EAC1BK,SAAiC;;IAEjC,wEAAwE;IACxE,0EAA0E;IAC1E,sDAAsD;IACtD,MAAMC,UAAUpB,kBAAkBc,IAAIE,MAAM;IAC5C,MAAMK,MAAM,SAACF,6BAAAA,UAAWG,OAAO,mBAAI,EAAE,EAClCC,MAAM,CAAC,CAACC,MAAQ,CAACA,IAAIC,QAAQ,EAC7BC,GAAG,CAAC,CAACF,MAAS,CAAA;YAAEG,IAAIH,IAAIG,EAAE;YAAEC,OAAOJ,IAAIK,IAAI;YAAEC,MAAMN,IAAIM,IAAI;QAAC,CAAA;IAC/D,MAAMC,WAAWX,QAAQY,IAAI,CAAC,CAACC,OAASlC,mBAAmBkC,KAAKH,IAAI,KAChE,EAAE,GACFT,IAAIE,MAAM,CAAC,CAACU,OAASlC,mBAAmBkC,KAAKH,IAAI,GAAGI,KAAK,CAAC,GAAG;IACjE,MAAMC,QAAQ;WAAIJ;WAAcX,QAAQgB,MAAM,GAAGhB,UAAUC;KAAK,CAACE,MAAM,CAAC,CAACU,OAASA,KAAKL,KAAK,CAACX,IAAI;IACjG,MAAMoB,OAAOF,MAAMG,IAAI,CAAC,CAACL,OAASlC,mBAAmBkC,KAAKH,IAAI;IAC9D,OAAO;WACDO,OAAO;YAAC,aAAKA;gBAAMT,OAAO;;SAAS,GAAG,EAAE;WACzCO,MAAMZ,MAAM,CAAC,CAACU,OAASA,SAASI,QAAQ,CAACtC,mBAAmBkC,KAAKH,IAAI;KACzE,CAACI,KAAK,CAAC,GAAGpC;AACb;AAEA,sEAAsE,GACtE,OAAO,SAASyC,eACdJ,KAA8B,EAC9BhB,SAAiC;QAG1BC;IADP,MAAMA,UAAUe,MAAMG,IAAI,CAAC,CAACL,OAASlC,mBAAmBkC,KAAKH,IAAI;IACjE,QAAOV,gBAAAA,2BAAAA,QAASO,EAAE,oBAAIrC,gBAAgB6B,UAAU,CAAC,EAAE,YAA5CC,OAAgD;AACzD;AAEA,wEAAwE,GACxE,OAAO,SAASoB,oBAAoBC,KAKnC;IACC,MAAM,EAAE3B,GAAG,EAAEqB,KAAK,EAAEO,OAAO,EAAE,GAAGD;IAChC,OAAO;QACL,CAAC,MAAM,EAAEA,MAAME,QAAQ,KAAKxC,qBAAqB,qCAAqC,CAAC,CAAC,EAAEsC,MAAME,QAAQ,CAAC,CAAC,CAAC,EAAE;QAC7G9C,eAAeiB;WACZnB,sBAAsBmB,IAAIE,MAAM;QACnCmB,MAAMC,MAAM,GACR,CAAC,uCAAuC,EAAED,MAAMT,GAAG,CAAC,CAACO,OAAS,GAAGA,KAAKL,KAAK,CAAC,OAAO,EAAEK,KAAKN,EAAE,CAAC,CAAC,CAAC,EAAEhB,IAAI,CAAC,MAAM,CAAC,CAAC,GAC9G;QACJ+B,QAAQE,KAAK,CAACR,MAAM,IAAIM,QAAQG,UAAU,GACtC,uEACA;QACJ,wEAAwE;WACpEC,mBAAmBJ,WACnB;YACE;SACD,GACD,EAAE;QACN,CAAC,aAAa,EAAE5B,IAAIiC,GAAG,CAACb,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;KACrC,CAACvB,IAAI,CAAC;AACT;AAEA,+FAA+F,GAC/F,OAAO,SAASmC,mBAAmBJ,OAA0C;QACzDA;QACXM;IADP,MAAMA,WAAW,EAACN,oBAAAA,QAAQO,QAAQ,YAAhBP,oBAAoB,EAAE,EAAEJ,IAAI,CAAC,CAACY,UAAYA,QAAQC,IAAI,KAAK;IAC7E,eAAOH,6BAAAA,4BAAAA,SAAUI,UAAU,CAACd,IAAI,CAAC,CAACe,YAAcA,UAAUC,IAAI,KAAK,6BAA5DN,0BAAsEO,QAAQ,mBAAI;AAC3F;AAWA,4HAA4H,GAC5H,OAAO,SAASC,mBAAmBf,KAYlC;IACC,IAAIgB,UAAU;IACd,OAAO,CAACC;YAcKjB,yBAIKA;QAjBhB,2FAA2F;QAC3FgB,WAAW;QACX,MAAME,OAAOF,WAAWpE;QACxB,MAAMuE,QAAQ1E,kBAAkBwE;QAChC,IAAIjB,MAAMoB,WAAW,IAAID,MAAME,MAAM,EAAEF,MAAME,MAAM,CAACC,KAAK,GAAGtB,MAAMoB,WAAW;QAC7E,MAAMG,WAAWpF,qBACfgF,OACA;YACEjB,UAAUF,MAAME,QAAQ;YACxBsB,QAAQxB,MAAMwB,MAAM;YACpBC,UAAUzB,MAAMN,KAAK;YACrBgC,YAAY1B,MAAM2B,MAAM,GAAGzF,sBAAsB8D,MAAM2B,MAAM,EAAEC,UAAU,GAAG;YAC5E,+DAA+D;YAC/DC,MAAM,EAAC7B,0BAAAA,MAAMC,OAAO,CAACO,QAAQ,YAAtBR,0BAA0B,EAAE,EAAET,IAAI,CAAC,CAACkB,UAAYA,QAAQC,IAAI,KAAK,cAAcD,QAAQoB,IAAI,KAAK;YACvG,8GAA8G;YAC9GC,QAAQzB,mBAAmBL,MAAMC,OAAO;YACxC,6DAA6D;YAC7D8B,UAAU,GAAE/B,oBAAAA,MAAM+B,UAAU,YAAhB/B,oBAAoB;QAClC,GACAA,MAAMC,OAAO;QAEf,MAAM+B,UAAU;YACdC,WAAW;mBACN,IAAIC,IAAI;uBACNlC,MAAMC,OAAO,CAACP,KAAK,CAACT,GAAG,CAAC,CAACO,OAASA,KAAKN,EAAE;uBACzCc,MAAMN,KAAK,CAACT,GAAG,CAAC,CAACO,OAASA,KAAKN,EAAE;uBAChCc,MAAMwB,MAAM,GAAG;wBAACxB,MAAMwB,MAAM;qBAAC,GAAG,EAAE;iBACvC;aACF;YACDW,SAASnC,MAAMC,OAAO,CAACE,KAAK,CAAClB,GAAG,CAAC,CAACmD,OAASA,KAAKlD,EAAE;YAClDmD,eAAerC,MAAMC,OAAO,CAACqC,OAAO;WAEhCtC,MAAMC,OAAO,CAACG,UAAU,GAAG;YAAEmC,eAAe;gBAACvC,MAAMC,OAAO,CAACG,UAAU;aAAC;QAAC,IAAI,CAAC;YAChFoC,WAAW;;QAEb,MAAMC,SAAS9F,mBAAmB4E,SAASmB,IAAI,EAAE,UAAUV;QAC3D,IAAIS,OAAOE,EAAE,KAAK,OAAO;YACvBC,QAAQC,KAAK,CAAC,oDAAoD;gBAChEA,OAAOJ,OAAOI,KAAK;YACrB;YACA,OAAO;gBACLC,OAAO;gBACPC,YAAY;oBACV;wBACEC,MAAM;wBACNC,MAAM;wBACNC,SAAS;wBACTC,QAAQV,OAAOI,KAAK;oBACtB;iBACD;YACH;QACF;QACA,uFAAuF;QACvF,MAAMpB,WAAW;eAAIzB,MAAMC,OAAO,CAACP,KAAK;eAAKM,MAAMN,KAAK;SAAC;QACzD,MAAM0D,OAAO1G,kBACX+F,OAAOY,KAAK,EACZZ,OAAOa,MAAM,EACb;YACE5D,OAAO+B;YACP8B,KAAK;mBAAI9B,SAASxC,GAAG,CAAC,CAACO,OAASA,KAAKN,EAAE;mBAAMc,MAAMC,OAAO,CAACE,KAAK,CAAClB,GAAG,CAAC,CAACmD,OAASA,KAAKlD,EAAE;aAAE;QAC1F,GACAgC;QAEF,MAAM,EAAEsC,OAAO,EAAE,GAAGJ;QACpB,8FAA8F;QAC9F,MAAMC,QAAQrG,6BACZD,wBACE;YAAEuG,QAAQb,OAAOa,MAAM;YAAED,OAAOD,KAAKC,KAAK;QAA8C,GACxF,WAEF,UACArB,SACAqB,KAAK;QACP,wEAAwE;QACxE,iEAAiE;QACjE9G,2BAA2B8G;QAC3B,MAAMI,SAASrH,qBAAqBiH;QACpC,MAAMK,SAASzG,uBACb;YAAEqG,QAAQb,OAAOa,MAAM;YAAED;QAAM,GAC/B,UACArB;QAEF,MAAMe,aAAa;eACdW,OAAOX,UAAU;eAChBzG,sBAAsBmH,UACtB;gBACE;oBACET,MAAM;oBACNC,MAAM;oBACNC,SAAS;oBACTC,QAAQ9G,yBAAyBoH,QAAQvF,IAAI,CAAC;gBAChD;aACD,GACD,EAAE;eACHkF,KAAKL,UAAU;eACf/C,MAAM2D,MAAM,CAAC;gBACdL,QAAQb,OAAOa,MAAM;gBACrBD;gBACAO,SAAS,EAAE;gBACXC,WAAW,CAAC;gBACZC,OAAOJ,OAAOI,KAAK;gBACnBC,MAAML,OAAOK,IAAI;YACnB;SACD;QACD,OAAO;YACLjB,OAAOC,WAAWpD,MAAM,GACpB,OACA;gBAAE2D,QAAQb,OAAOa,MAAM;gBAAED;gBAAOU,MAAML,OAAOK,IAAI;gBAAEP;YAAQ;YAC/DT;QACF;IACF;AACF;AAEA,yFAAyF,GACzF,OAAO,SAASiB,qBACd3F,GAAoC,EACpCK,SAAiC;IAEjC,OAAOjB,oBAAoB;QAAEY;QAAKK;QAAWE,KAAK,EAAE;IAAC;AACvD"}
@@ -0,0 +1,157 @@
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 { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown';
18
+ import { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations';
19
+ import type { Firestore } from 'firebase-admin/firestore';
20
+ import { type AiBuildMade } from '../model/ai-build-job';
21
+ import { type AiMenuChanges, type AiMenuName, type AiMenuTarget } from '../model/ai-layout-menus';
22
+ import { type AiJobAdmission } from './ai-job-admission';
23
+ import { readAiEditTarget } from './ai-job-edit-step';
24
+ import type { AiJobStepRunner } from './ai-job-text-step';
25
+ /**
26
+ * The `navigation` step (AGL-3728): a change to a site's header or footer
27
+ * menu — add, take out, rename, relink or reorder its items — as a
28
+ * first-class build operation, so Assist's one plan card can say "add About
29
+ * and Pricing to the menu, and take Blog out" and have it done.
30
+ *
31
+ * NO MODEL. The planner already wrote the change as the item's arguments, in
32
+ * words (`aiMenuChangesOf`); this applies it to the layout's own Besigner
33
+ * elements in code (`aiLayoutWithMenuChanges`), so it costs no credits and
34
+ * cannot invent a link: every destination is one of the site's pages, a page
35
+ * this build made, a path on the site or an https address.
36
+ *
37
+ * ── Where it lands ───────────────────────────────────────────────────────
38
+ *
39
+ * As the `edit` step's change to a layout does: a NEW version beside the
40
+ * layout's current one, which visitors keep seeing until a person publishes
41
+ * the new one. A layout is always served, so without version history there is
42
+ * nowhere for it to land and it is refused as a plan limit, charged nothing.
43
+ * The version is named by the job's id, so a pass asked again finds what it
44
+ * wrote.
45
+ *
46
+ * ── Who may ──────────────────────────────────────────────────────────────
47
+ *
48
+ * The editor's own rule for a layout's versions (`canWriteHostContent` and
49
+ * `hostWritesFrozen` in the Firestore rules): a member with a write role on
50
+ * the site (admin, editor or author), while no lockdown freezes the site, its
51
+ * org or the platform. Asked at the door, again before the write, and the
52
+ * role once more inside the transaction that writes.
53
+ *
54
+ * ── Audit ────────────────────────────────────────────────────────────────
55
+ *
56
+ * The layout it wrote is the job's output, which the job machine records as
57
+ * an `ai.job.output` activity row on the site, as every other operation's is.
58
+ */
59
+ export declare const AI_NAVIGATION_KIND: "navigation";
60
+ /**
61
+ * ASSUMED: the least time the step needs — the site's pages, the layout and
62
+ * its version, the org, and the one transaction that writes. No model, so it
63
+ * fits an inline door.
64
+ */
65
+ export declare const AI_JOB_NAVIGATION_STEP_MINIMUM_MS = 5000;
66
+ export declare const AI_NAVIGATION_NO_SITE_COPY = "Open the site whose menu should change.";
67
+ export declare const AI_NAVIGATION_ROLE_COPY = "Changing the site\u2019s menus needs the admin, editor or author role on this site.";
68
+ export declare const AI_NAVIGATION_LOCKED_COPY = "This site is not accepting changes right now.";
69
+ export declare const AI_NAVIGATION_NO_LAYOUT_COPY = "This site has no layout with a header or footer to change. Make one in Layouts first.";
70
+ export declare const AI_NAVIGATION_NOTHING_COPY = "The request named no menu change this could make.";
71
+ /** The name the new version carries in the layout's versions. */
72
+ export declare const AI_NAVIGATION_VERSION_NAME = "Menu changes";
73
+ /** The site's pages a menu may link, by id, with the layout each renders inside. */
74
+ export interface AiNavigationSitePage {
75
+ id: string;
76
+ title: string;
77
+ slug: string;
78
+ template: boolean;
79
+ layoutId: string | null;
80
+ }
81
+ /** The site's pages: not deleted, not an email design. */
82
+ export declare function readAiNavigationSitePages(firestore: Firestore, hostId: string): Promise<AiNavigationSitePage[]>;
83
+ /**
84
+ * What a destination in an item's words links (AGL-3728): a page this build
85
+ * made (`new:<title>`), one of the site's pages by id (`page:<id>` or the bare
86
+ * id), a path on the site, or an https address; `null` for anything else.
87
+ */
88
+ export declare function aiNavigationTarget(destination: string, context: {
89
+ pages: readonly Pick<AiNavigationSitePage, 'id' | 'template'>[];
90
+ made: AiBuildMade;
91
+ }): AiMenuTarget | null;
92
+ /** The layout an item changes: the one it names, else the one the site's pages render inside. */
93
+ export declare function aiNavigationLayoutOf(named: string, context: {
94
+ pages: readonly AiNavigationSitePage[];
95
+ made: AiBuildMade;
96
+ }): string | null;
97
+ /** Why this member may not change this site's menus now, or `null`. */
98
+ export declare function aiNavigationAccessRefusal(input: {
99
+ host: FirebaseFirestore.DocumentSnapshot;
100
+ uid: string;
101
+ org: object | null;
102
+ }, deps?: {
103
+ lockdown?: typeof getLockdownVerdict;
104
+ }): Promise<string | null>;
105
+ /** The new version's output note: what was done, what was not, and that it waits to be published. */
106
+ export declare function aiNavigationOutputNote(done: readonly string[], notDone: readonly string[]): string;
107
+ export type AiNavigationWrite = {
108
+ ok: true;
109
+ versionId: string;
110
+ done: string[];
111
+ notDone: string[];
112
+ } | {
113
+ ok: false;
114
+ error: string;
115
+ };
116
+ /**
117
+ * The change written, in one transaction that reads the layout as it is
118
+ * stored NOW: it still exists, its version is still there, the member still
119
+ * holds a write role, and the plan still keeps version history. The change is
120
+ * applied again to that version's nodes, so a save made since is kept.
121
+ */
122
+ export declare function writeAiNavigation(firestore: Firestore, input: {
123
+ hostId: string;
124
+ layoutId: string;
125
+ sourceVersionId: string;
126
+ versionId: string;
127
+ menus: readonly AiMenuName[];
128
+ changes: AiMenuChanges;
129
+ uid: string;
130
+ aiJobId: string;
131
+ versioning: boolean;
132
+ now: Date;
133
+ }): Promise<AiNavigationWrite>;
134
+ export interface AiJobNavigationStepDeps {
135
+ readTarget?: typeof readAiEditTarget;
136
+ readPages?: typeof readAiNavigationSitePages;
137
+ write?: typeof writeAiNavigation;
138
+ lockdown?: typeof getLockdownVerdict;
139
+ }
140
+ export declare function createAiJobNavigationStep(deps?: AiJobNavigationStepDeps): AiJobStepRunner;
141
+ export declare const runAiJobNavigationStep: AiJobStepRunner;
142
+ export interface AiNavigationJobAdmissionDeps {
143
+ ownerOf?: typeof resolveOrgIdForHost;
144
+ readTarget?: typeof readAiEditTarget;
145
+ readPages?: typeof readAiNavigationSitePages;
146
+ lockdown?: typeof getLockdownVerdict;
147
+ }
148
+ /**
149
+ * A navigation change is admitted on a site of the job's own org, for a
150
+ * member who may write its content now, where the site has a layout and the
151
+ * change has somewhere to land that no visitor sees on this plan. Refused in
152
+ * those words before anything exists.
153
+ */
154
+ export declare function createAiNavigationJobAdmission(deps?: AiNavigationJobAdmissionDeps): AiJobAdmission;
155
+ export declare const aiNavigationJobAdmission: AiJobAdmission;
156
+ /** Registers the navigation step and its door (AGL-3728). */
157
+ export declare function registerAiNavigationJob(): void;