@aglyn/plugins-ai 1.0.0-beta.266 → 1.0.0-beta.269

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 (60) hide show
  1. package/package.json +18 -18
  2. package/src/lib/components/ai-job-site.d.ts +8 -1
  3. package/src/lib/components/ai-job-site.js +7 -0
  4. package/src/lib/components/ai-job-site.js.map +1 -1
  5. package/src/lib/components/ai-site-build-page.component.js +37 -0
  6. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  7. package/src/lib/components/ai-site-start-card.component.js +25 -6
  8. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  9. package/src/lib/components/ai-site-start-steps.component.d.ts +8 -2
  10. package/src/lib/components/ai-site-start-steps.component.js +55 -26
  11. package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
  12. package/src/lib/components/ai-site-start-tracking.d.ts +11 -3
  13. package/src/lib/components/ai-site-start-tracking.js +28 -9
  14. package/src/lib/components/ai-site-start-tracking.js.map +1 -1
  15. package/src/lib/components/assist-jobs-drawer.component.js +24 -2
  16. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  17. package/src/lib/jobs/ai-empty-site-sweep.d.ts +154 -0
  18. package/src/lib/jobs/ai-empty-site-sweep.js +346 -0
  19. package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -0
  20. package/src/lib/jobs/ai-job-page-language.d.ts +7 -1
  21. package/src/lib/jobs/ai-job-page-language.js +16 -3
  22. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  23. package/src/lib/jobs/ai-job-site-look.d.ts +2 -1
  24. package/src/lib/jobs/ai-job-site-look.js +6 -2
  25. package/src/lib/jobs/ai-job-site-look.js.map +1 -1
  26. package/src/lib/jobs/ai-job-site-step.js +11 -6
  27. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  28. package/src/lib/jobs/ai-jobs-beat.d.ts +11 -3
  29. package/src/lib/jobs/ai-jobs-beat.js +23 -6
  30. package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
  31. package/src/lib/jobs/ai-jobs.js +46 -7
  32. package/src/lib/jobs/ai-jobs.js.map +1 -1
  33. package/src/lib/jobs/ai-layout-stock-photos.d.ts +43 -0
  34. package/src/lib/jobs/ai-layout-stock-photos.js +735 -22
  35. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  36. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.d.ts +35 -0
  37. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js +379 -0
  38. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js.map +1 -0
  39. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.d.ts +35 -0
  40. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js +294 -0
  41. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js.map +1 -0
  42. package/src/lib/jobs/fixtures/ai-stock-photo-mexicano-recordings.d.ts +31 -0
  43. package/src/lib/jobs/fixtures/ai-stock-photo-mexicano-recordings.js +325 -0
  44. package/src/lib/jobs/fixtures/ai-stock-photo-mexicano-recordings.js.map +1 -0
  45. package/src/lib/layout-language/ai-layout-design.js +5 -0
  46. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  47. package/src/lib/layout-language/ai-layout-motion.d.ts +60 -7
  48. package/src/lib/layout-language/ai-layout-motion.js +231 -4
  49. package/src/lib/layout-language/ai-layout-motion.js.map +1 -1
  50. package/src/lib/layout-language/ai-layout-pictures.d.ts +8 -0
  51. package/src/lib/layout-language/ai-layout-pictures.js +68 -4
  52. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  53. package/src/lib/model/ai-jobs.types.d.ts +20 -0
  54. package/src/lib/model/ai-jobs.types.js.map +1 -1
  55. package/src/lib/model/ai-site-look.d.ts +10 -1
  56. package/src/lib/model/ai-site-look.js +14 -3
  57. package/src/lib/model/ai-site-look.js.map +1 -1
  58. package/src/lib/model/ai-site-start-handoff.d.ts +49 -0
  59. package/src/lib/model/ai-site-start-handoff.js +88 -0
  60. package/src/lib/model/ai-site-start-handoff.js.map +1 -0
@@ -0,0 +1,346 @@
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 { erasePluginSite } from "@aglyn/aglyn/plugin-manager/plugin-site-erase";
18
+ /**
19
+ * THE EMPTY SITE A GUIDED START LEFT IS REMOVED (AGL-3936).
20
+ *
21
+ * Since AGL-3859 a site is created only when its start is admitted: the
22
+ * starter, Skip, or "Plan my site". A site start cancelled or failed before
23
+ * it built anything leaves that site with no page, public, answering "This
24
+ * site is coming soon". The build page offers Start again, Try again and Use
25
+ * the starter, and a person who leaves without choosing used to leave the
26
+ * placeholder behind for good (kc-x-carder 2026-10-15, cancelled 13 s in;
27
+ * pflomo 2026-10-17, 35 s in).
28
+ *
29
+ * So when a site job settles `canceled` or `failed` with nothing built, a due
30
+ * marker is written, `aiEmptySiteSweeps/{hostId}`, an hour out. The AI jobs
31
+ * beat reads the markers that are due, a few a beat, and checks every
32
+ * condition again at that moment; only a site that is still truly empty is
33
+ * deleted, through `core.site-erase` — the console's own delete path, so the
34
+ * subdomain, the routing index, the org's site count, the members' rows and
35
+ * the cache all go as they do for a site its admin deletes. Anything a person
36
+ * did in the hour keeps the site and drops the marker. The starter is never
37
+ * written for them: the starter is written only when a person chooses it.
38
+ *
39
+ * The marker is server-only (the rules deny every client), holds ids and
40
+ * instants and nothing a person wrote, and is deleted once it is decided.
41
+ */ /** The due markers, top level, one per site, keyed by the site's id. */ export const AI_EMPTY_SITE_SWEEPS_COLLECTION = 'aiEmptySiteSweeps';
42
+ /** How long a person has to come back and choose before the empty site goes. */ export const AI_EMPTY_SITE_GRACE_MS = 60 * 60000;
43
+ /** Markers one beat decides; the rest wait for the next minute's beat. */ export const AI_EMPTY_SITE_SWEEP_MAX = 3;
44
+ /** Wall clock one beat gives the removals before it runs any job's step. */ export const AI_EMPTY_SITE_SWEEP_BUDGET_MS = 20000;
45
+ /** A removal the eraser could not make is tried again this much later. */ export const AI_EMPTY_SITE_RETRY_MS = 15 * 60000;
46
+ /** Removals tried this many times without an answer are given up and logged. */ export const AI_EMPTY_SITE_MAX_ATTEMPTS = 5;
47
+ /** The workspace feed's words for it; the feed names the site beside them. */ export const AI_EMPTY_SITE_ACTIVITY = 'Removed an empty site';
48
+ /** Why, on the staff audit row. */ export const AI_EMPTY_SITE_REASON = 'ai-start-empty';
49
+ /** The org's jobs, as `AI_JOBS_COLLECTION` in `ai-jobs.ts` names them (that module imports this one). */ const JOBS_COLLECTION = 'aiJobs';
50
+ /**
51
+ * What a site holds that a person, or a start, put there. One document in
52
+ * any of them keeps the site: pages and emails (`screens`), layouts, forms,
53
+ * media, components, templates, content collections, redirects and plugin
54
+ * settings. `activity` and the visitors' `analytics` beacon are not on the
55
+ * list, and are read on their own terms below.
56
+ */ export const AI_EMPTY_SITE_CONTENT_COLLECTIONS = [
57
+ 'screens',
58
+ 'layouts',
59
+ 'forms',
60
+ 'media',
61
+ 'components',
62
+ 'templates',
63
+ 'collections',
64
+ 'redirects',
65
+ 'pluginSettings'
66
+ ];
67
+ /** The site's own feed entries that are not a person editing it. */ const UNEDITED_ACTIVITY = new Set([
68
+ 'Created the site'
69
+ ]);
70
+ /** More feed entries than this and the site was worked on. */ const ACTIVITY_SCAN = 20;
71
+ function toMillis(value) {
72
+ if (value === null || value === undefined) return null;
73
+ if (value instanceof Date) return value.getTime();
74
+ if (typeof value === 'number') return value;
75
+ if (typeof value === 'string') {
76
+ const parsed = Date.parse(value);
77
+ return Number.isFinite(parsed) ? parsed : null;
78
+ }
79
+ return typeof value.toMillis === 'function' ? value.toMillis() : null;
80
+ }
81
+ /**
82
+ * Whether a job leaves the empty site this sweep is for: a site start that
83
+ * ended `canceled` or `failed` with nothing built — no output but the
84
+ * site's listing proposal, which rides out with the first unit and builds
85
+ * nothing (the rule `aiSiteStarterFallbackOffered` offers the starter by).
86
+ */ export function aiJobLeavesEmptySite(job) {
87
+ var _job_outputs;
88
+ if (job.kind !== 'site' || !job.hostId || job.siteRemoved) return false;
89
+ if (job.status !== 'canceled' && job.status !== 'failed') return false;
90
+ return ((_job_outputs = job.outputs) != null ? _job_outputs : []).every((output)=>output.resource === 'seo');
91
+ }
92
+ /**
93
+ * Writes the due marker for a job that just ended, when it leaves an empty
94
+ * site. Never throws: a marker that could not be written leaves the site as
95
+ * it was before this existed, and must not fail the write it follows.
96
+ */ export async function scheduleAiEmptySiteSweep(firestore, job, now = new Date()) {
97
+ if (!aiJobLeavesEmptySite(job) || !job.hostId) return false;
98
+ const marker = {
99
+ hostId: job.hostId,
100
+ orgId: job.orgId,
101
+ jobId: job.$id,
102
+ status: job.status,
103
+ settledAt: now,
104
+ dueAt: new Date(now.getTime() + AI_EMPTY_SITE_GRACE_MS),
105
+ attempts: 0
106
+ };
107
+ try {
108
+ await firestore.collection(AI_EMPTY_SITE_SWEEPS_COLLECTION).doc(job.hostId).set(marker);
109
+ return true;
110
+ } catch (error) {
111
+ console.warn('ai empty site: the removal could not be scheduled', {
112
+ orgId: job.orgId,
113
+ jobId: job.$id,
114
+ hostId: job.hostId,
115
+ error: error instanceof Error ? error.message : String(error)
116
+ });
117
+ return false;
118
+ }
119
+ }
120
+ /**
121
+ * Drops a site's marker: a job started, resumed or retried on the site, so
122
+ * it is not empty for long. Never throws; the beat's check at due time says
123
+ * the same thing if this write is lost.
124
+ */ export async function clearAiEmptySiteSweep(firestore, hostId) {
125
+ if (!hostId) return;
126
+ try {
127
+ await firestore.collection(AI_EMPTY_SITE_SWEEPS_COLLECTION).doc(hostId).delete();
128
+ } catch (unused) {
129
+ // A marker left behind is decided at its due time, against the site as it is then.
130
+ }
131
+ }
132
+ /**
133
+ * Every condition, read again as the site is now (AGL-3936). The site is
134
+ * removed only when all of them hold:
135
+ *
136
+ * - it exists, in the marker's workspace, with no custom domain, no sending
137
+ * domain, no page on its routing map, and no staff hold;
138
+ * - the job is still the one that ended, still `canceled` or `failed`, and
139
+ * still built nothing — not resumed, retried or finished since;
140
+ * - no other AI job names the site;
141
+ * - nothing in {@link AI_EMPTY_SITE_CONTENT_COLLECTIONS}: no page, email,
142
+ * layout, form or media file;
143
+ * - the site's own feed holds nothing but "Created the site" and the AI
144
+ * job's own `ai.job.*` entries: nobody edited it.
145
+ */ export async function aiEmptySiteVerdict(firestore, marker) {
146
+ const hostRef = firestore.collection('hosts').doc(marker.hostId);
147
+ const host = await hostRef.get();
148
+ if (!host.exists) return {
149
+ kind: 'keep',
150
+ reason: 'site-gone'
151
+ };
152
+ if (host.get('orgId') !== marker.orgId) return {
153
+ kind: 'keep',
154
+ reason: 'site-moved'
155
+ };
156
+ const text = (field)=>{
157
+ var _host_get;
158
+ return String((_host_get = host.get(field)) != null ? _host_get : '').trim();
159
+ };
160
+ if (text('cname') || host.get('hasCustomDomain') === true) return {
161
+ kind: 'keep',
162
+ reason: 'custom-domain'
163
+ };
164
+ if (text('sendingLabel')) return {
165
+ kind: 'keep',
166
+ reason: 'sending-domain'
167
+ };
168
+ if (host.get('suspended') === true || host.get('suspendedAt')) return {
169
+ kind: 'keep',
170
+ reason: 'staff-hold'
171
+ };
172
+ const routes = host.get('screens');
173
+ if (routes && typeof routes === 'object' && Object.keys(routes).length > 0) {
174
+ return {
175
+ kind: 'keep',
176
+ reason: 'routed-pages'
177
+ };
178
+ }
179
+ const jobSnapshot = await firestore.collection('orgs').doc(marker.orgId).collection(JOBS_COLLECTION).doc(marker.jobId).get();
180
+ if (!jobSnapshot.exists) return {
181
+ kind: 'keep',
182
+ reason: 'job-gone'
183
+ };
184
+ const job = _extends({}, jobSnapshot.data(), {
185
+ $id: jobSnapshot.id
186
+ });
187
+ if (job.hostId !== marker.hostId || !aiJobLeavesEmptySite(job)) return {
188
+ kind: 'keep',
189
+ reason: 'job-moved-on'
190
+ };
191
+ const settledMs = toMillis(job.updatedAt);
192
+ const markedMs = marker.dueAt.getTime() - AI_EMPTY_SITE_GRACE_MS;
193
+ if (settledMs !== null && settledMs > markedMs + 1000) {
194
+ return {
195
+ kind: 'later',
196
+ dueAt: new Date(settledMs + AI_EMPTY_SITE_GRACE_MS)
197
+ };
198
+ }
199
+ const [others, ...contents] = await Promise.all([
200
+ firestore.collection('orgs').doc(marker.orgId).collection(JOBS_COLLECTION).where('hostId', '==', marker.hostId).limit(2).get(),
201
+ ...AI_EMPTY_SITE_CONTENT_COLLECTIONS.map((name)=>hostRef.collection(name).limit(1).get())
202
+ ]);
203
+ if (others.docs.some((doc)=>doc.id !== marker.jobId)) return {
204
+ kind: 'keep',
205
+ reason: 'another-job'
206
+ };
207
+ const held = AI_EMPTY_SITE_CONTENT_COLLECTIONS.find((_, index)=>!contents[index].empty);
208
+ if (held) return {
209
+ kind: 'keep',
210
+ reason: `has-${held}`
211
+ };
212
+ const activity = await hostRef.collection('activity').limit(ACTIVITY_SCAN + 1).get();
213
+ if (activity.size > ACTIVITY_SCAN) return {
214
+ kind: 'keep',
215
+ reason: 'edited'
216
+ };
217
+ const edited = activity.docs.some((doc)=>{
218
+ var _doc_get;
219
+ const action = String((_doc_get = doc.get('action')) != null ? _doc_get : '');
220
+ return !UNEDITED_ACTIVITY.has(action) && !action.startsWith('ai.job.');
221
+ });
222
+ if (edited) return {
223
+ kind: 'keep',
224
+ reason: 'edited'
225
+ };
226
+ const name = host.get('displayName');
227
+ return {
228
+ kind: 'empty',
229
+ name: typeof name === 'string' && name ? name : null
230
+ };
231
+ }
232
+ /**
233
+ * One beat's removals (AGL-3936): the markers due now, oldest first, a few
234
+ * at a time and inside a few seconds of wall clock, each decided against the
235
+ * site as it is at that moment. The marker goes once it is decided; one the
236
+ * eraser could not answer is tried again later, and given up after
237
+ * {@link AI_EMPTY_SITE_MAX_ATTEMPTS}.
238
+ */ export async function sweepAiEmptySites(options) {
239
+ var _options_now, _options_budgetMs, _options_erase, _options_max;
240
+ const now = (_options_now = options.now) != null ? _options_now : Date.now;
241
+ const startedAt = now();
242
+ const budgetMs = (_options_budgetMs = options.budgetMs) != null ? _options_budgetMs : AI_EMPTY_SITE_SWEEP_BUDGET_MS;
243
+ const erase = (_options_erase = options.erase) != null ? _options_erase : erasePluginSite;
244
+ const markers = options.firestore.collection(AI_EMPTY_SITE_SWEEPS_COLLECTION);
245
+ const due = await markers.where('dueAt', '<=', new Date(startedAt)).orderBy('dueAt', 'asc').limit((_options_max = options.max) != null ? _options_max : AI_EMPTY_SITE_SWEEP_MAX).get();
246
+ const result = {
247
+ due: due.size,
248
+ removed: 0,
249
+ kept: 0,
250
+ later: 0
251
+ };
252
+ for (const doc of due.docs){
253
+ var _stored_hostId, _stored_orgId, _stored_jobId, _toMillis, _stored_attempts;
254
+ if (now() - startedAt >= budgetMs) break;
255
+ const stored = doc.data();
256
+ const marker = {
257
+ hostId: String((_stored_hostId = stored.hostId) != null ? _stored_hostId : doc.id),
258
+ orgId: String((_stored_orgId = stored.orgId) != null ? _stored_orgId : ''),
259
+ jobId: String((_stored_jobId = stored.jobId) != null ? _stored_jobId : ''),
260
+ dueAt: new Date((_toMillis = toMillis(stored.dueAt)) != null ? _toMillis : startedAt),
261
+ attempts: Number((_stored_attempts = stored.attempts) != null ? _stored_attempts : 0)
262
+ };
263
+ try {
264
+ if (!marker.orgId || !marker.jobId) {
265
+ await doc.ref.delete();
266
+ result.kept += 1;
267
+ continue;
268
+ }
269
+ const verdict = await aiEmptySiteVerdict(options.firestore, marker);
270
+ if (verdict.kind === 'keep') {
271
+ await doc.ref.delete();
272
+ result.kept += 1;
273
+ console.info('ai empty site kept', _extends({}, marker, {
274
+ reason: verdict.reason
275
+ }));
276
+ continue;
277
+ }
278
+ if (verdict.kind === 'later') {
279
+ await doc.ref.set({
280
+ dueAt: verdict.dueAt
281
+ }, {
282
+ merge: true
283
+ });
284
+ result.later += 1;
285
+ continue;
286
+ }
287
+ const erased = await erase({
288
+ hostId: marker.hostId,
289
+ orgId: marker.orgId,
290
+ activity: AI_EMPTY_SITE_ACTIVITY,
291
+ reason: AI_EMPTY_SITE_REASON
292
+ });
293
+ if (erased.erased) {
294
+ // Said on the job only once the site is gone, so its pages can say so.
295
+ await options.firestore.collection('orgs').doc(marker.orgId).collection(JOBS_COLLECTION).doc(marker.jobId).set({
296
+ siteRemoved: {
297
+ at: new Date(now()),
298
+ name: verdict.name
299
+ }
300
+ }, {
301
+ merge: true
302
+ });
303
+ await doc.ref.delete();
304
+ result.removed += 1;
305
+ console.info('ai empty site removed', marker);
306
+ continue;
307
+ }
308
+ const refused = 'reason' in erased ? erased.reason : 'unavailable';
309
+ if (refused !== 'unavailable') {
310
+ await doc.ref.delete();
311
+ result.kept += 1;
312
+ console.info('ai empty site kept', _extends({}, marker, {
313
+ reason: refused
314
+ }));
315
+ continue;
316
+ }
317
+ await retryLater(doc.ref, marker, now());
318
+ result.later += 1;
319
+ } catch (error) {
320
+ console.error('ai empty site sweep failed', _extends({}, marker, {
321
+ error
322
+ }));
323
+ await retryLater(doc.ref, marker, now()).catch(()=>undefined);
324
+ result.later += 1;
325
+ }
326
+ }
327
+ return result;
328
+ }
329
+ /** Moves a marker the eraser could not answer later, or gives it up. */ async function retryLater(ref, marker, nowMs) {
330
+ const attempts = marker.attempts + 1;
331
+ if (attempts >= AI_EMPTY_SITE_MAX_ATTEMPTS) {
332
+ console.error('ai empty site: removal given up', _extends({}, marker, {
333
+ attempts
334
+ }));
335
+ await ref.delete();
336
+ return;
337
+ }
338
+ await ref.set({
339
+ attempts,
340
+ dueAt: new Date(nowMs + AI_EMPTY_SITE_RETRY_MS)
341
+ }, {
342
+ merge: true
343
+ });
344
+ }
345
+
346
+ //# sourceMappingURL=ai-empty-site-sweep.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-empty-site-sweep.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 erasePluginSite,\n type PluginSiteEraseRequest,\n type PluginSiteEraseResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-site-erase'\nimport type { AiJob } from '../model/ai-jobs.types'\n\n/**\n * THE EMPTY SITE A GUIDED START LEFT IS REMOVED (AGL-3936).\n *\n * Since AGL-3859 a site is created only when its start is admitted: the\n * starter, Skip, or \"Plan my site\". A site start cancelled or failed before\n * it built anything leaves that site with no page, public, answering \"This\n * site is coming soon\". The build page offers Start again, Try again and Use\n * the starter, and a person who leaves without choosing used to leave the\n * placeholder behind for good (kc-x-carder 2026-10-15, cancelled 13 s in;\n * pflomo 2026-10-17, 35 s in).\n *\n * So when a site job settles `canceled` or `failed` with nothing built, a due\n * marker is written, `aiEmptySiteSweeps/{hostId}`, an hour out. The AI jobs\n * beat reads the markers that are due, a few a beat, and checks every\n * condition again at that moment; only a site that is still truly empty is\n * deleted, through `core.site-erase` — the console's own delete path, so the\n * subdomain, the routing index, the org's site count, the members' rows and\n * the cache all go as they do for a site its admin deletes. Anything a person\n * did in the hour keeps the site and drops the marker. The starter is never\n * written for them: the starter is written only when a person chooses it.\n *\n * The marker is server-only (the rules deny every client), holds ids and\n * instants and nothing a person wrote, and is deleted once it is decided.\n */\n\n/** The due markers, top level, one per site, keyed by the site's id. */\nexport const AI_EMPTY_SITE_SWEEPS_COLLECTION = 'aiEmptySiteSweeps'\n\n/** How long a person has to come back and choose before the empty site goes. */\nexport const AI_EMPTY_SITE_GRACE_MS = 60 * 60_000\n\n/** Markers one beat decides; the rest wait for the next minute's beat. */\nexport const AI_EMPTY_SITE_SWEEP_MAX = 3\n\n/** Wall clock one beat gives the removals before it runs any job's step. */\nexport const AI_EMPTY_SITE_SWEEP_BUDGET_MS = 20_000\n\n/** A removal the eraser could not make is tried again this much later. */\nexport const AI_EMPTY_SITE_RETRY_MS = 15 * 60_000\n\n/** Removals tried this many times without an answer are given up and logged. */\nexport const AI_EMPTY_SITE_MAX_ATTEMPTS = 5\n\n/** The workspace feed's words for it; the feed names the site beside them. */\nexport const AI_EMPTY_SITE_ACTIVITY = 'Removed an empty site'\n\n/** Why, on the staff audit row. */\nexport const AI_EMPTY_SITE_REASON = 'ai-start-empty'\n\n/** The org's jobs, as `AI_JOBS_COLLECTION` in `ai-jobs.ts` names them (that module imports this one). */\nconst JOBS_COLLECTION = 'aiJobs'\n\n/**\n * What a site holds that a person, or a start, put there. One document in\n * any of them keeps the site: pages and emails (`screens`), layouts, forms,\n * media, components, templates, content collections, redirects and plugin\n * settings. `activity` and the visitors' `analytics` beacon are not on the\n * list, and are read on their own terms below.\n */\nexport const AI_EMPTY_SITE_CONTENT_COLLECTIONS = [\n 'screens',\n 'layouts',\n 'forms',\n 'media',\n 'components',\n 'templates',\n 'collections',\n 'redirects',\n 'pluginSettings',\n] as const\n\n/** The site's own feed entries that are not a person editing it. */\nconst UNEDITED_ACTIVITY = new Set(['Created the site'])\n\n/** More feed entries than this and the site was worked on. */\nconst ACTIVITY_SCAN = 20\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Instant = Date | { toMillis(): number } | number | string | null | undefined\n\nfunction toMillis(value: Instant): number | null {\n if (value === null || value === undefined) return null\n if (value instanceof Date) return value.getTime()\n if (typeof value === 'number') return value\n if (typeof value === 'string') {\n const parsed = Date.parse(value)\n return Number.isFinite(parsed) ? parsed : null\n }\n return typeof value.toMillis === 'function' ? value.toMillis() : null\n}\n\n/** The due marker, as stored. */\nexport interface AiEmptySiteSweep {\n hostId: string\n orgId: string\n jobId: string\n /** What the job ended as. */\n status: 'canceled' | 'failed'\n settledAt: Date\n dueAt: Date\n attempts: number\n}\n\n/**\n * Whether a job leaves the empty site this sweep is for: a site start that\n * ended `canceled` or `failed` with nothing built — no output but the\n * site's listing proposal, which rides out with the first unit and builds\n * nothing (the rule `aiSiteStarterFallbackOffered` offers the starter by).\n */\nexport function aiJobLeavesEmptySite(\n job: Pick<AiJob, 'kind' | 'status' | 'hostId' | 'outputs' | 'siteRemoved'>,\n): boolean {\n if (job.kind !== 'site' || !job.hostId || job.siteRemoved) return false\n if (job.status !== 'canceled' && job.status !== 'failed') return false\n return (job.outputs ?? []).every((output) => output.resource === 'seo')\n}\n\n/**\n * Writes the due marker for a job that just ended, when it leaves an empty\n * site. Never throws: a marker that could not be written leaves the site as\n * it was before this existed, and must not fail the write it follows.\n */\nexport async function scheduleAiEmptySiteSweep(\n firestore: Firestore,\n job: Pick<AiJob, '$id' | 'orgId' | 'kind' | 'status' | 'hostId' | 'outputs' | 'siteRemoved'>,\n now = new Date(),\n): Promise<boolean> {\n if (!aiJobLeavesEmptySite(job) || !job.hostId) return false\n const marker: AiEmptySiteSweep = {\n hostId: job.hostId,\n orgId: job.orgId,\n jobId: job.$id,\n status: job.status as AiEmptySiteSweep['status'],\n settledAt: now,\n dueAt: new Date(now.getTime() + AI_EMPTY_SITE_GRACE_MS),\n attempts: 0,\n }\n try {\n await firestore.collection(AI_EMPTY_SITE_SWEEPS_COLLECTION).doc(job.hostId).set(marker)\n return true\n } catch (error) {\n console.warn('ai empty site: the removal could not be scheduled', {\n orgId: job.orgId,\n jobId: job.$id,\n hostId: job.hostId,\n error: error instanceof Error ? error.message : String(error),\n })\n return false\n }\n}\n\n/**\n * Drops a site's marker: a job started, resumed or retried on the site, so\n * it is not empty for long. Never throws; the beat's check at due time says\n * the same thing if this write is lost.\n */\nexport async function clearAiEmptySiteSweep(firestore: Firestore, hostId: string | null | undefined): Promise<void> {\n if (!hostId) return\n try {\n await firestore.collection(AI_EMPTY_SITE_SWEEPS_COLLECTION).doc(hostId).delete()\n } catch {\n // A marker left behind is decided at its due time, against the site as it is then.\n }\n}\n\n/** What the check at due time found. */\nexport type AiEmptySiteVerdict =\n | { kind: 'empty'; name: string | null }\n /** The job settled again after the marker was written: wait a full grace from then. */\n | { kind: 'later'; dueAt: Date }\n /** The site is kept; the marker is dropped. */\n | { kind: 'keep'; reason: string }\n\n/**\n * Every condition, read again as the site is now (AGL-3936). The site is\n * removed only when all of them hold:\n *\n * - it exists, in the marker's workspace, with no custom domain, no sending\n * domain, no page on its routing map, and no staff hold;\n * - the job is still the one that ended, still `canceled` or `failed`, and\n * still built nothing — not resumed, retried or finished since;\n * - no other AI job names the site;\n * - nothing in {@link AI_EMPTY_SITE_CONTENT_COLLECTIONS}: no page, email,\n * layout, form or media file;\n * - the site's own feed holds nothing but \"Created the site\" and the AI\n * job's own `ai.job.*` entries: nobody edited it.\n */\nexport async function aiEmptySiteVerdict(\n firestore: Firestore,\n marker: Pick<AiEmptySiteSweep, 'hostId' | 'orgId' | 'jobId' | 'dueAt'>,\n): Promise<AiEmptySiteVerdict> {\n const hostRef = firestore.collection('hosts').doc(marker.hostId)\n const host = await hostRef.get()\n if (!host.exists) return { kind: 'keep', reason: 'site-gone' }\n if (host.get('orgId') !== marker.orgId) return { kind: 'keep', reason: 'site-moved' }\n const text = (field: string) => String(host.get(field) ?? '').trim()\n if (text('cname') || host.get('hasCustomDomain') === true) return { kind: 'keep', reason: 'custom-domain' }\n if (text('sendingLabel')) return { kind: 'keep', reason: 'sending-domain' }\n if (host.get('suspended') === true || host.get('suspendedAt')) return { kind: 'keep', reason: 'staff-hold' }\n const routes = host.get('screens')\n if (routes && typeof routes === 'object' && Object.keys(routes as object).length > 0) {\n return { kind: 'keep', reason: 'routed-pages' }\n }\n\n const jobSnapshot = await firestore\n .collection('orgs')\n .doc(marker.orgId)\n .collection(JOBS_COLLECTION)\n .doc(marker.jobId)\n .get()\n if (!jobSnapshot.exists) return { kind: 'keep', reason: 'job-gone' }\n const job = { ...(jobSnapshot.data() as Omit<AiJob, '$id'>), $id: jobSnapshot.id } as AiJob\n if (job.hostId !== marker.hostId || !aiJobLeavesEmptySite(job)) return { kind: 'keep', reason: 'job-moved-on' }\n const settledMs = toMillis(job.updatedAt as Instant)\n const markedMs = marker.dueAt.getTime() - AI_EMPTY_SITE_GRACE_MS\n if (settledMs !== null && settledMs > markedMs + 1_000) {\n return { kind: 'later', dueAt: new Date(settledMs + AI_EMPTY_SITE_GRACE_MS) }\n }\n\n const [others, ...contents] = await Promise.all([\n firestore\n .collection('orgs')\n .doc(marker.orgId)\n .collection(JOBS_COLLECTION)\n .where('hostId', '==', marker.hostId)\n .limit(2)\n .get(),\n ...AI_EMPTY_SITE_CONTENT_COLLECTIONS.map((name) => hostRef.collection(name).limit(1).get()),\n ])\n if (others.docs.some((doc) => doc.id !== marker.jobId)) return { kind: 'keep', reason: 'another-job' }\n const held = AI_EMPTY_SITE_CONTENT_COLLECTIONS.find((_, index) => !contents[index].empty)\n if (held) return { kind: 'keep', reason: `has-${held}` }\n\n const activity = await hostRef.collection('activity').limit(ACTIVITY_SCAN + 1).get()\n if (activity.size > ACTIVITY_SCAN) return { kind: 'keep', reason: 'edited' }\n const edited = activity.docs.some((doc) => {\n const action = String(doc.get('action') ?? '')\n return !UNEDITED_ACTIVITY.has(action) && !action.startsWith('ai.job.')\n })\n if (edited) return { kind: 'keep', reason: 'edited' }\n\n const name = host.get('displayName')\n return { kind: 'empty', name: typeof name === 'string' && name ? name : null }\n}\n\nexport interface AiEmptySiteSweepResult {\n /** Markers that were due. */\n due: number\n /** Sites removed. */\n removed: number\n /** Markers dropped because the site was kept, or was already gone. */\n kept: number\n /** Markers moved later: the job settled again, or the eraser did not answer. */\n later: number\n}\n\nexport interface SweepAiEmptySitesOptions {\n firestore: Firestore\n now?: () => number\n max?: number\n budgetMs?: number\n /** The site's removal; `core.site-erase` unless a spec replaces it. */\n erase?: (request: PluginSiteEraseRequest) => Promise<PluginSiteEraseResult>\n}\n\n/**\n * One beat's removals (AGL-3936): the markers due now, oldest first, a few\n * at a time and inside a few seconds of wall clock, each decided against the\n * site as it is at that moment. The marker goes once it is decided; one the\n * eraser could not answer is tried again later, and given up after\n * {@link AI_EMPTY_SITE_MAX_ATTEMPTS}.\n */\nexport async function sweepAiEmptySites(options: SweepAiEmptySitesOptions): Promise<AiEmptySiteSweepResult> {\n const now = options.now ?? Date.now\n const startedAt = now()\n const budgetMs = options.budgetMs ?? AI_EMPTY_SITE_SWEEP_BUDGET_MS\n const erase = options.erase ?? erasePluginSite\n const markers = options.firestore.collection(AI_EMPTY_SITE_SWEEPS_COLLECTION)\n const due = await markers\n .where('dueAt', '<=', new Date(startedAt))\n .orderBy('dueAt', 'asc')\n .limit(options.max ?? AI_EMPTY_SITE_SWEEP_MAX)\n .get()\n const result: AiEmptySiteSweepResult = { due: due.size, removed: 0, kept: 0, later: 0 }\n\n for (const doc of due.docs) {\n if (now() - startedAt >= budgetMs) break\n const stored = doc.data() as Partial<AiEmptySiteSweep> & { dueAt?: Instant; settledAt?: Instant }\n const marker = {\n hostId: String(stored.hostId ?? doc.id),\n orgId: String(stored.orgId ?? ''),\n jobId: String(stored.jobId ?? ''),\n dueAt: new Date(toMillis(stored.dueAt) ?? startedAt),\n attempts: Number(stored.attempts ?? 0),\n }\n try {\n if (!marker.orgId || !marker.jobId) {\n await doc.ref.delete()\n result.kept += 1\n continue\n }\n const verdict = await aiEmptySiteVerdict(options.firestore, marker)\n if (verdict.kind === 'keep') {\n await doc.ref.delete()\n result.kept += 1\n console.info('ai empty site kept', { ...marker, reason: verdict.reason })\n continue\n }\n if (verdict.kind === 'later') {\n await doc.ref.set({ dueAt: verdict.dueAt }, { merge: true })\n result.later += 1\n continue\n }\n const erased = await erase({\n hostId: marker.hostId,\n orgId: marker.orgId,\n activity: AI_EMPTY_SITE_ACTIVITY,\n reason: AI_EMPTY_SITE_REASON,\n })\n if (erased.erased) {\n // Said on the job only once the site is gone, so its pages can say so.\n await options.firestore\n .collection('orgs')\n .doc(marker.orgId)\n .collection(JOBS_COLLECTION)\n .doc(marker.jobId)\n .set({ siteRemoved: { at: new Date(now()), name: verdict.name } }, { merge: true })\n await doc.ref.delete()\n result.removed += 1\n console.info('ai empty site removed', marker)\n continue\n }\n const refused = 'reason' in erased ? erased.reason : 'unavailable'\n if (refused !== 'unavailable') {\n await doc.ref.delete()\n result.kept += 1\n console.info('ai empty site kept', { ...marker, reason: refused })\n continue\n }\n await retryLater(doc.ref, marker, now())\n result.later += 1\n } catch (error) {\n console.error('ai empty site sweep failed', { ...marker, error })\n await retryLater(doc.ref, marker, now()).catch(() => undefined)\n result.later += 1\n }\n }\n return result\n}\n\n/** Moves a marker the eraser could not answer later, or gives it up. */\nasync function retryLater(\n ref: FirebaseFirestore.DocumentReference,\n marker: { hostId: string; orgId: string; jobId: string; attempts: number },\n nowMs: number,\n): Promise<void> {\n const attempts = marker.attempts + 1\n if (attempts >= AI_EMPTY_SITE_MAX_ATTEMPTS) {\n console.error('ai empty site: removal given up', { ...marker, attempts })\n await ref.delete()\n return\n }\n await ref.set({ attempts, dueAt: new Date(nowMs + AI_EMPTY_SITE_RETRY_MS) }, { merge: true })\n}\n"],"names":["erasePluginSite","AI_EMPTY_SITE_SWEEPS_COLLECTION","AI_EMPTY_SITE_GRACE_MS","AI_EMPTY_SITE_SWEEP_MAX","AI_EMPTY_SITE_SWEEP_BUDGET_MS","AI_EMPTY_SITE_RETRY_MS","AI_EMPTY_SITE_MAX_ATTEMPTS","AI_EMPTY_SITE_ACTIVITY","AI_EMPTY_SITE_REASON","JOBS_COLLECTION","AI_EMPTY_SITE_CONTENT_COLLECTIONS","UNEDITED_ACTIVITY","Set","ACTIVITY_SCAN","toMillis","value","undefined","Date","getTime","parsed","parse","Number","isFinite","aiJobLeavesEmptySite","job","kind","hostId","siteRemoved","status","outputs","every","output","resource","scheduleAiEmptySiteSweep","firestore","now","marker","orgId","jobId","$id","settledAt","dueAt","attempts","collection","doc","set","error","console","warn","Error","message","String","clearAiEmptySiteSweep","delete","aiEmptySiteVerdict","hostRef","host","get","exists","reason","text","field","trim","routes","Object","keys","length","jobSnapshot","data","id","settledMs","updatedAt","markedMs","others","contents","Promise","all","where","limit","map","name","docs","some","held","find","_","index","empty","activity","size","edited","action","has","startsWith","sweepAiEmptySites","options","startedAt","budgetMs","erase","markers","due","orderBy","max","result","removed","kept","later","stored","ref","verdict","info","merge","erased","at","refused","retryLater","catch","nowMs"],"mappings":"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"}
@@ -18,6 +18,7 @@ import type { NodesMap } from '@aglyn/aglyn/types/nodes';
18
18
  import { type AiBuildPlanScreen } from '../model/ai-build-plan';
19
19
  import type { AiJob, AiJobPlan } from '../model/ai-jobs.types';
20
20
  import { type AiSiteInventory } from '../model/ai-site-inventory';
21
+ import { type AiSiteMotionStyle } from '../layout-language/ai-layout-motion';
21
22
  import { type AiLayoutListing } from '../layout-language/ai-layout-listings';
22
23
  import { type AiLayoutSection, type AiLayoutSettlement } from '../layout-language/ai-layout-language';
23
24
  import type { AiLayoutTargets } from '../layout-language/ai-layout-links';
@@ -192,11 +193,15 @@ export declare function aiLayoutDesignOf(job: Pick<AiJob, '$id' | 'inputs'>, slu
192
193
  /**
193
194
  * The site a page's entrance motion is drawn for (AGL-3838): its design where
194
195
  * the job names a kind of site, else the job's own seed, which moves in the
195
- * standard mood — so every page of one site moves alike.
196
+ * standard mood — so every page of one site moves alike. The motion style its
197
+ * look chose rides along (AGL-3941); a job whose look chose none — one from
198
+ * before the look did, a replay of one, a job with no look — moves in its
199
+ * kind's mood.
196
200
  */
197
201
  export declare function aiLayoutMotionSiteOf(job: Pick<AiJob, '$id' | 'inputs'>, slug: string): {
198
202
  kind?: string;
199
203
  seed: number;
204
+ motion?: AiSiteMotionStyle;
200
205
  };
201
206
  /** A validated language page, stored as the page keeps it. */
202
207
  export interface AiLayoutPageBuilt {
@@ -229,6 +234,7 @@ export interface AiLayoutPageCheckInput {
229
234
  motion?: {
230
235
  kind?: string;
231
236
  seed: number;
237
+ motion?: AiSiteMotionStyle;
232
238
  };
233
239
  /** Whether copy may promise what is coming (a coming-soon site, AGL-3887); absent, it may not. */
234
240
  promisesAllowed?: boolean;
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { isAiPlanNewRef } from "../model/ai-build-plan.js";
19
19
  import { aiHomeScreenIds } from "../model/ai-site-inventory.js";
20
20
  import { AI_LAYOUT_POST_ADDRESS_TOKENS, aiCompileLayoutPage } from "../layout-language/ai-layout-compiler.js";
21
- import { aiWithSiteMotion } from "../layout-language/ai-layout-motion.js";
21
+ import { aiSiteMotionStyle, aiWithSiteMotion } from "../layout-language/ai-layout-motion.js";
22
22
  import { aiLayoutListingAt, aiLayoutListingsOf, aiLayoutRecordHref } from "../layout-language/ai-layout-listings.js";
23
23
  import { AI_LAYOUT_LANGUAGE_TEXT, AI_LAYOUT_PAGE_TOOL, aiReadLayoutPage } from "../layout-language/ai-layout-language.js";
24
24
  import { aiLayoutCopyCheck, aiLayoutWithoutSections } from "../layout-language/ai-layout-gaps.js";
@@ -382,12 +382,25 @@ export const AI_JOB_PAGE_LANGUAGE_BUDGET = aiJobStepBudget({
382
382
  /**
383
383
  * The site a page's entrance motion is drawn for (AGL-3838): its design where
384
384
  * the job names a kind of site, else the job's own seed, which moves in the
385
- * standard mood — so every page of one site moves alike.
385
+ * standard mood — so every page of one site moves alike. The motion style its
386
+ * look chose rides along (AGL-3941); a job whose look chose none — one from
387
+ * before the look did, a replay of one, a job with no look — moves in its
388
+ * kind's mood.
386
389
  */ export function aiLayoutMotionSiteOf(job, slug) {
387
390
  var _aiLayoutDesignOf;
388
- return (_aiLayoutDesignOf = aiLayoutDesignOf(job, slug)) != null ? _aiLayoutDesignOf : {
391
+ var _job_inputs;
392
+ const style = (_job_inputs = job.inputs) == null ? void 0 : _job_inputs['siteStyle'];
393
+ const motion = aiSiteMotionStyle(style == null ? void 0 : style['motion']);
394
+ const { kind, seed } = (_aiLayoutDesignOf = aiLayoutDesignOf(job, slug)) != null ? _aiLayoutDesignOf : {
389
395
  seed: aiSiteSeed(aiOriginJobId(job))
390
396
  };
397
+ return _extends({}, kind ? {
398
+ kind
399
+ } : {}, {
400
+ seed
401
+ }, motion ? {
402
+ motion
403
+ } : {});
391
404
  }
392
405
  /**
393
406
  * The follow-up turn for the sections a page's answer left out (AGL-3660):