@aglyn/plugins-ai 1.0.0-beta.264 → 1.0.0-beta.268

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 (124) hide show
  1. package/package.json +18 -18
  2. package/src/lib/assets/site-styles/beauty.webp +0 -0
  3. package/src/lib/assets/site-styles/blog.webp +0 -0
  4. package/src/lib/assets/site-styles/business.webp +0 -0
  5. package/src/lib/assets/site-styles/coming-soon.webp +0 -0
  6. package/src/lib/assets/site-styles/education.webp +0 -0
  7. package/src/lib/assets/site-styles/events.webp +0 -0
  8. package/src/lib/assets/site-styles/fitness.webp +0 -0
  9. package/src/lib/assets/site-styles/landing.webp +0 -0
  10. package/src/lib/assets/site-styles/music.webp +0 -0
  11. package/src/lib/assets/site-styles/nonprofit.webp +0 -0
  12. package/src/lib/assets/site-styles/personal.webp +0 -0
  13. package/src/lib/assets/site-styles/photography.webp +0 -0
  14. package/src/lib/assets/site-styles/portfolio.webp +0 -0
  15. package/src/lib/assets/site-styles/professional.webp +0 -0
  16. package/src/lib/assets/site-styles/realestate.webp +0 -0
  17. package/src/lib/assets/site-styles/restaurant.webp +0 -0
  18. package/src/lib/assets/site-styles/store.webp +0 -0
  19. package/src/lib/assets/site-styles/studio.webp +0 -0
  20. package/src/lib/assets/site-styles/trades.webp +0 -0
  21. package/src/lib/assets/site-styles/wellness.webp +0 -0
  22. package/src/lib/assets/site-styles/yoga.webp +0 -0
  23. package/src/lib/components/ai-job-site.d.ts +8 -1
  24. package/src/lib/components/ai-job-site.js +7 -0
  25. package/src/lib/components/ai-job-site.js.map +1 -1
  26. package/src/lib/components/ai-site-build-page.component.js +37 -0
  27. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  28. package/src/lib/components/ai-site-start-card.component.d.ts +12 -0
  29. package/src/lib/components/ai-site-start-card.component.js +279 -240
  30. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  31. package/src/lib/components/ai-site-start-steps.component.d.ts +114 -0
  32. package/src/lib/components/ai-site-start-steps.component.js +480 -0
  33. package/src/lib/components/ai-site-start-steps.component.js.map +1 -0
  34. package/src/lib/components/ai-site-start-tracking.d.ts +122 -0
  35. package/src/lib/components/ai-site-start-tracking.js +296 -0
  36. package/src/lib/components/ai-site-start-tracking.js.map +1 -0
  37. package/src/lib/components/ai-site-style-previews.d.ts +26 -0
  38. package/src/lib/components/ai-site-style-previews.js +115 -0
  39. package/src/lib/components/ai-site-style-previews.js.map +1 -0
  40. package/src/lib/components/assist-jobs-drawer.component.js +24 -2
  41. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  42. package/src/lib/insights/ai-insight-digest.d.ts +9 -0
  43. package/src/lib/insights/ai-insight-digest.js +35 -11
  44. package/src/lib/insights/ai-insight-digest.js.map +1 -1
  45. package/src/lib/jobs/ai-empty-site-sweep.d.ts +154 -0
  46. package/src/lib/jobs/ai-empty-site-sweep.js +346 -0
  47. package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -0
  48. package/src/lib/jobs/ai-job-page-language.d.ts +9 -0
  49. package/src/lib/jobs/ai-job-page-language.js +20 -2
  50. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  51. package/src/lib/jobs/ai-job-plan-step.d.ts +43 -8
  52. package/src/lib/jobs/ai-job-plan-step.js +99 -18
  53. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-site-datasets.d.ts +82 -1
  55. package/src/lib/jobs/ai-job-site-datasets.js +276 -14
  56. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  57. package/src/lib/jobs/ai-jobs-beat.d.ts +11 -3
  58. package/src/lib/jobs/ai-jobs-beat.js +23 -6
  59. package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
  60. package/src/lib/jobs/ai-jobs.js +46 -7
  61. package/src/lib/jobs/ai-jobs.js.map +1 -1
  62. package/src/lib/jobs/ai-layout-stock-photos.d.ts +143 -10
  63. package/src/lib/jobs/ai-layout-stock-photos.js +1046 -102
  64. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  65. package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.d.ts +58 -0
  66. package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.js +678 -0
  67. package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.js.map +1 -0
  68. package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.d.ts +33 -0
  69. package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.js +270 -0
  70. package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.js.map +1 -0
  71. package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.d.ts +33 -0
  72. package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.js +277 -0
  73. package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.js.map +1 -0
  74. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.d.ts +35 -0
  75. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js +379 -0
  76. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js.map +1 -0
  77. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.d.ts +31 -0
  78. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js +208 -0
  79. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js.map +1 -0
  80. package/src/lib/layout-language/ai-layout-compiler.js +49 -12
  81. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  82. package/src/lib/layout-language/ai-layout-design.d.ts +6 -3
  83. package/src/lib/layout-language/ai-layout-design.js +19 -13
  84. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  85. package/src/lib/layout-language/ai-layout-listings.d.ts +48 -0
  86. package/src/lib/layout-language/ai-layout-listings.js +91 -5
  87. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  88. package/src/lib/layout-language/ai-layout-pictures.d.ts +15 -0
  89. package/src/lib/layout-language/ai-layout-pictures.js +6 -2
  90. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  91. package/src/lib/model/ai-jobs.types.d.ts +20 -0
  92. package/src/lib/model/ai-jobs.types.js.map +1 -1
  93. package/src/lib/model/ai-site-home-arcs.d.ts +75 -0
  94. package/src/lib/model/ai-site-home-arcs.js +741 -0
  95. package/src/lib/model/ai-site-home-arcs.js.map +1 -0
  96. package/src/lib/model/ai-site-inventory.d.ts +18 -0
  97. package/src/lib/model/ai-site-inventory.js.map +1 -1
  98. package/src/lib/model/ai-site-job.d.ts +9 -6
  99. package/src/lib/model/ai-site-job.js +8 -5
  100. package/src/lib/model/ai-site-job.js.map +1 -1
  101. package/src/lib/model/ai-site-kinds.js +24 -24
  102. package/src/lib/model/ai-site-kinds.js.map +1 -1
  103. package/src/lib/model/ai-site-start-handoff.d.ts +49 -0
  104. package/src/lib/model/ai-site-start-handoff.js +88 -0
  105. package/src/lib/model/ai-site-start-handoff.js.map +1 -0
  106. package/src/lib/model/ai-site-start-steps.d.ts +67 -0
  107. package/src/lib/model/ai-site-start-steps.js +127 -0
  108. package/src/lib/model/ai-site-start-steps.js.map +1 -0
  109. package/src/lib/model/ai-site-start.js +4 -1
  110. package/src/lib/model/ai-site-start.js.map +1 -1
  111. package/src/lib/runtime/ai-doctrine-validators.d.ts +32 -0
  112. package/src/lib/runtime/ai-doctrine-validators.js +119 -2
  113. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  114. package/src/lib/runtime/ai-doctrine.d.ts +6 -1
  115. package/src/lib/runtime/ai-doctrine.js +9 -5
  116. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  117. package/src/lib/runtime/ai-palette.generated.js +5 -2
  118. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  119. package/src/lib/runtime/site-inventory.js +6 -0
  120. package/src/lib/runtime/site-inventory.js.map +1 -1
  121. package/src/lib/server/ai-insight-digest-route.js +2 -2
  122. package/src/lib/server/ai-insight-digest-route.js.map +1 -1
  123. package/src/lib/usage/assist-free-taste.js +3 -0
  124. package/src/lib/usage/assist-free-taste.js.map +1 -1
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginSiteEraseRequest, type PluginSiteEraseResult } from '@aglyn/aglyn/plugin-manager/plugin-site-erase';
18
+ import type { AiJob } from '../model/ai-jobs.types';
19
+ /**
20
+ * THE EMPTY SITE A GUIDED START LEFT IS REMOVED (AGL-3936).
21
+ *
22
+ * Since AGL-3859 a site is created only when its start is admitted: the
23
+ * starter, Skip, or "Plan my site". A site start cancelled or failed before
24
+ * it built anything leaves that site with no page, public, answering "This
25
+ * site is coming soon". The build page offers Start again, Try again and Use
26
+ * the starter, and a person who leaves without choosing used to leave the
27
+ * placeholder behind for good (kc-x-carder 2026-10-15, cancelled 13 s in;
28
+ * pflomo 2026-10-17, 35 s in).
29
+ *
30
+ * So when a site job settles `canceled` or `failed` with nothing built, a due
31
+ * marker is written, `aiEmptySiteSweeps/{hostId}`, an hour out. The AI jobs
32
+ * beat reads the markers that are due, a few a beat, and checks every
33
+ * condition again at that moment; only a site that is still truly empty is
34
+ * deleted, through `core.site-erase` — the console's own delete path, so the
35
+ * subdomain, the routing index, the org's site count, the members' rows and
36
+ * the cache all go as they do for a site its admin deletes. Anything a person
37
+ * did in the hour keeps the site and drops the marker. The starter is never
38
+ * written for them: the starter is written only when a person chooses it.
39
+ *
40
+ * The marker is server-only (the rules deny every client), holds ids and
41
+ * instants and nothing a person wrote, and is deleted once it is decided.
42
+ */
43
+ /** The due markers, top level, one per site, keyed by the site's id. */
44
+ export declare const AI_EMPTY_SITE_SWEEPS_COLLECTION = "aiEmptySiteSweeps";
45
+ /** How long a person has to come back and choose before the empty site goes. */
46
+ export declare const AI_EMPTY_SITE_GRACE_MS: number;
47
+ /** Markers one beat decides; the rest wait for the next minute's beat. */
48
+ export declare const AI_EMPTY_SITE_SWEEP_MAX = 3;
49
+ /** Wall clock one beat gives the removals before it runs any job's step. */
50
+ export declare const AI_EMPTY_SITE_SWEEP_BUDGET_MS = 20000;
51
+ /** A removal the eraser could not make is tried again this much later. */
52
+ export declare const AI_EMPTY_SITE_RETRY_MS: number;
53
+ /** Removals tried this many times without an answer are given up and logged. */
54
+ export declare const AI_EMPTY_SITE_MAX_ATTEMPTS = 5;
55
+ /** The workspace feed's words for it; the feed names the site beside them. */
56
+ export declare const AI_EMPTY_SITE_ACTIVITY = "Removed an empty site";
57
+ /** Why, on the staff audit row. */
58
+ export declare const AI_EMPTY_SITE_REASON = "ai-start-empty";
59
+ /**
60
+ * What a site holds that a person, or a start, put there. One document in
61
+ * any of them keeps the site: pages and emails (`screens`), layouts, forms,
62
+ * media, components, templates, content collections, redirects and plugin
63
+ * settings. `activity` and the visitors' `analytics` beacon are not on the
64
+ * list, and are read on their own terms below.
65
+ */
66
+ export declare const AI_EMPTY_SITE_CONTENT_COLLECTIONS: readonly ["screens", "layouts", "forms", "media", "components", "templates", "collections", "redirects", "pluginSettings"];
67
+ type Firestore = FirebaseFirestore.Firestore;
68
+ /** The due marker, as stored. */
69
+ export interface AiEmptySiteSweep {
70
+ hostId: string;
71
+ orgId: string;
72
+ jobId: string;
73
+ /** What the job ended as. */
74
+ status: 'canceled' | 'failed';
75
+ settledAt: Date;
76
+ dueAt: Date;
77
+ attempts: number;
78
+ }
79
+ /**
80
+ * Whether a job leaves the empty site this sweep is for: a site start that
81
+ * ended `canceled` or `failed` with nothing built — no output but the
82
+ * site's listing proposal, which rides out with the first unit and builds
83
+ * nothing (the rule `aiSiteStarterFallbackOffered` offers the starter by).
84
+ */
85
+ export declare function aiJobLeavesEmptySite(job: Pick<AiJob, 'kind' | 'status' | 'hostId' | 'outputs' | 'siteRemoved'>): boolean;
86
+ /**
87
+ * Writes the due marker for a job that just ended, when it leaves an empty
88
+ * site. Never throws: a marker that could not be written leaves the site as
89
+ * it was before this existed, and must not fail the write it follows.
90
+ */
91
+ export declare function scheduleAiEmptySiteSweep(firestore: Firestore, job: Pick<AiJob, '$id' | 'orgId' | 'kind' | 'status' | 'hostId' | 'outputs' | 'siteRemoved'>, now?: Date): Promise<boolean>;
92
+ /**
93
+ * Drops a site's marker: a job started, resumed or retried on the site, so
94
+ * it is not empty for long. Never throws; the beat's check at due time says
95
+ * the same thing if this write is lost.
96
+ */
97
+ export declare function clearAiEmptySiteSweep(firestore: Firestore, hostId: string | null | undefined): Promise<void>;
98
+ /** What the check at due time found. */
99
+ export type AiEmptySiteVerdict = {
100
+ kind: 'empty';
101
+ name: string | null;
102
+ }
103
+ /** The job settled again after the marker was written: wait a full grace from then. */
104
+ | {
105
+ kind: 'later';
106
+ dueAt: Date;
107
+ }
108
+ /** The site is kept; the marker is dropped. */
109
+ | {
110
+ kind: 'keep';
111
+ reason: string;
112
+ };
113
+ /**
114
+ * Every condition, read again as the site is now (AGL-3936). The site is
115
+ * removed only when all of them hold:
116
+ *
117
+ * - it exists, in the marker's workspace, with no custom domain, no sending
118
+ * domain, no page on its routing map, and no staff hold;
119
+ * - the job is still the one that ended, still `canceled` or `failed`, and
120
+ * still built nothing — not resumed, retried or finished since;
121
+ * - no other AI job names the site;
122
+ * - nothing in {@link AI_EMPTY_SITE_CONTENT_COLLECTIONS}: no page, email,
123
+ * layout, form or media file;
124
+ * - the site's own feed holds nothing but "Created the site" and the AI
125
+ * job's own `ai.job.*` entries: nobody edited it.
126
+ */
127
+ export declare function aiEmptySiteVerdict(firestore: Firestore, marker: Pick<AiEmptySiteSweep, 'hostId' | 'orgId' | 'jobId' | 'dueAt'>): Promise<AiEmptySiteVerdict>;
128
+ export interface AiEmptySiteSweepResult {
129
+ /** Markers that were due. */
130
+ due: number;
131
+ /** Sites removed. */
132
+ removed: number;
133
+ /** Markers dropped because the site was kept, or was already gone. */
134
+ kept: number;
135
+ /** Markers moved later: the job settled again, or the eraser did not answer. */
136
+ later: number;
137
+ }
138
+ export interface SweepAiEmptySitesOptions {
139
+ firestore: Firestore;
140
+ now?: () => number;
141
+ max?: number;
142
+ budgetMs?: number;
143
+ /** The site's removal; `core.site-erase` unless a spec replaces it. */
144
+ erase?: (request: PluginSiteEraseRequest) => Promise<PluginSiteEraseResult>;
145
+ }
146
+ /**
147
+ * One beat's removals (AGL-3936): the markers due now, oldest first, a few
148
+ * at a time and inside a few seconds of wall clock, each decided against the
149
+ * site as it is at that moment. The marker goes once it is decided; one the
150
+ * eraser could not answer is tried again later, and given up after
151
+ * {@link AI_EMPTY_SITE_MAX_ATTEMPTS}.
152
+ */
153
+ export declare function sweepAiEmptySites(options: SweepAiEmptySitesOptions): Promise<AiEmptySiteSweepResult>;
154
+ export {};
@@ -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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"}
@@ -161,6 +161,15 @@ export interface AiLayoutStartPage {
161
161
  * the model. Empty when the page has no words to show.
162
162
  */
163
163
  export declare function aiLayoutStartPageLines(start: AiLayoutStartPage | null | undefined): string[];
164
+ /**
165
+ * What a page is told about the site's form (AGL-3899). A page whose plan
166
+ * places the form, or a site whose form has no page of its own, is told the
167
+ * form; any other page is told where the form lives and that its call to
168
+ * action links there. Told "The site's form: …" on every page, two of the
169
+ * newest production homes drew the form on their own, beside the Contact
170
+ * page that held it.
171
+ */
172
+ export declare function aiLayoutFormLine(screen: Pick<AiBuildPlanScreen, 'sections'>, targets: AiLayoutTargets): string;
164
173
  /** The page's user turn: the page, the brief, the plan, its sections and where its links may go. */
165
174
  export declare function aiLayoutPagePrompt(input: {
166
175
  job: Pick<AiJob, 'brief' | 'inputs' | '$id'>;
@@ -286,6 +286,21 @@ export const AI_JOB_PAGE_LANGUAGE_BUDGET = aiJobStepBudget({
286
286
  shown
287
287
  ];
288
288
  }
289
+ /**
290
+ * What a page is told about the site's form (AGL-3899). A page whose plan
291
+ * places the form, or a site whose form has no page of its own, is told the
292
+ * form; any other page is told where the form lives and that its call to
293
+ * action links there. Told "The site's form: …" on every page, two of the
294
+ * newest production homes drew the form on their own, beside the Contact
295
+ * page that held it.
296
+ */ export function aiLayoutFormLine(screen, targets) {
297
+ if (!targets.forms.length) return 'The site has no saved form: place none.';
298
+ const ids = new Set(targets.forms.map((form)=>form.id));
299
+ const placed = screen.sections.some((section)=>section.uses.some((ref)=>ids.has(ref)));
300
+ const page = targets.formPageId ? targets.pages.find((entry)=>entry.id === targets.formPageId) : undefined;
301
+ if (placed || !page) return `The site's form: ${targets.forms.map((form)=>`${form.name} (${form.id})`).join(', ')}.`;
302
+ return `The site's form is on the ${page.label} page (page:${page.id}): place no form on this page, and make a call to action that asks visitors to get in touch a button to that page.`;
303
+ }
289
304
  /** The page's user turn: the page, the brief, the plan, its sections and where its links may go. */ export function aiLayoutPagePrompt(input) {
290
305
  const { job, plan, screen, targets } = input;
291
306
  const sections = screen.sections.map((section, index)=>{
@@ -312,8 +327,11 @@ export const AI_JOB_PAGE_LANGUAGE_BUDGET = aiJobStepBudget({
312
327
  return `${index + 1}. "${section.name}"${places}; the platform places the newsletter sign-up field here: write its heading and one line on what subscribers hear about, from the brief, and no form, button or offer the brief does not make`;
313
328
  }
314
329
  if ((listed == null ? void 0 : listed.listing.kind) === 'records') {
330
+ var _listed_filter;
315
331
  // One of the site's datasets (AGL-3616), repeated by the platform over its records.
316
- const names = listed.listing.records.length ? ` (${listed.listing.records.slice(0, 6).map((name)=>`“${name}”`).join(', ')})` : '';
332
+ // A section named for one group lists that group's records alone (AGL-3921).
333
+ const shown = ((_listed_filter = listed.filter) == null ? void 0 : _listed_filter.records.length) ? listed.filter.records : listed.listing.records;
334
+ const names = shown.length ? ` (${shown.slice(0, 6).map((name)=>`“${name}”`).join(', ')})` : '';
317
335
  return `${index + 1}. "${section.name}"${places}; the platform lists the records of the dataset “${listed.listing.name}”${names} here itself, one card each: write only its heading and a line about them, and no cards, list or images for them`;
318
336
  }
319
337
  if (listed) {
@@ -335,7 +353,7 @@ export const AI_JOB_PAGE_LANGUAGE_BUDGET = aiJobStepBudget({
335
353
  `Sections to design, in order:`,
336
354
  ...sections,
337
355
  pages.length ? `Other pages of this site: ${pages.join(', ')}.` : 'This site has no other page to link.',
338
- targets.forms.length ? `The site's form: ${targets.forms.map((form)=>`${form.name} (${form.id})`).join(', ')}.` : 'The site has no saved form: place none.',
356
+ aiLayoutFormLine(screen, targets),
339
357
  ...components.length ? [
340
358
  `Components this page may place: ${components.join('; ')}.`
341
359
  ] : [],