@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.
- package/package.json +18 -18
- package/src/lib/assets/site-styles/beauty.webp +0 -0
- package/src/lib/assets/site-styles/blog.webp +0 -0
- package/src/lib/assets/site-styles/business.webp +0 -0
- package/src/lib/assets/site-styles/coming-soon.webp +0 -0
- package/src/lib/assets/site-styles/education.webp +0 -0
- package/src/lib/assets/site-styles/events.webp +0 -0
- package/src/lib/assets/site-styles/fitness.webp +0 -0
- package/src/lib/assets/site-styles/landing.webp +0 -0
- package/src/lib/assets/site-styles/music.webp +0 -0
- package/src/lib/assets/site-styles/nonprofit.webp +0 -0
- package/src/lib/assets/site-styles/personal.webp +0 -0
- package/src/lib/assets/site-styles/photography.webp +0 -0
- package/src/lib/assets/site-styles/portfolio.webp +0 -0
- package/src/lib/assets/site-styles/professional.webp +0 -0
- package/src/lib/assets/site-styles/realestate.webp +0 -0
- package/src/lib/assets/site-styles/restaurant.webp +0 -0
- package/src/lib/assets/site-styles/store.webp +0 -0
- package/src/lib/assets/site-styles/studio.webp +0 -0
- package/src/lib/assets/site-styles/trades.webp +0 -0
- package/src/lib/assets/site-styles/wellness.webp +0 -0
- package/src/lib/assets/site-styles/yoga.webp +0 -0
- package/src/lib/components/ai-job-site.d.ts +8 -1
- package/src/lib/components/ai-job-site.js +7 -0
- package/src/lib/components/ai-job-site.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.js +37 -0
- package/src/lib/components/ai-site-build-page.component.js.map +1 -1
- package/src/lib/components/ai-site-start-card.component.d.ts +12 -0
- package/src/lib/components/ai-site-start-card.component.js +279 -240
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-start-steps.component.d.ts +114 -0
- package/src/lib/components/ai-site-start-steps.component.js +480 -0
- package/src/lib/components/ai-site-start-steps.component.js.map +1 -0
- package/src/lib/components/ai-site-start-tracking.d.ts +122 -0
- package/src/lib/components/ai-site-start-tracking.js +296 -0
- package/src/lib/components/ai-site-start-tracking.js.map +1 -0
- package/src/lib/components/ai-site-style-previews.d.ts +26 -0
- package/src/lib/components/ai-site-style-previews.js +115 -0
- package/src/lib/components/ai-site-style-previews.js.map +1 -0
- package/src/lib/components/assist-jobs-drawer.component.js +24 -2
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/insights/ai-insight-digest.d.ts +9 -0
- package/src/lib/insights/ai-insight-digest.js +35 -11
- package/src/lib/insights/ai-insight-digest.js.map +1 -1
- package/src/lib/jobs/ai-empty-site-sweep.d.ts +154 -0
- package/src/lib/jobs/ai-empty-site-sweep.js +346 -0
- package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -0
- package/src/lib/jobs/ai-job-page-language.d.ts +9 -0
- package/src/lib/jobs/ai-job-page-language.js +20 -2
- package/src/lib/jobs/ai-job-page-language.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +43 -8
- package/src/lib/jobs/ai-job-plan-step.js +99 -18
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-datasets.d.ts +82 -1
- package/src/lib/jobs/ai-job-site-datasets.js +276 -14
- package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
- package/src/lib/jobs/ai-jobs-beat.d.ts +11 -3
- package/src/lib/jobs/ai-jobs-beat.js +23 -6
- package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
- package/src/lib/jobs/ai-jobs.js +46 -7
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-layout-stock-photos.d.ts +143 -10
- package/src/lib/jobs/ai-layout-stock-photos.js +1046 -102
- package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.d.ts +58 -0
- package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.js +678 -0
- package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.d.ts +33 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.js +270 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.d.ts +33 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.js +277 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.d.ts +35 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js +379 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.d.ts +31 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js +208 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js.map +1 -0
- package/src/lib/layout-language/ai-layout-compiler.js +49 -12
- package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
- package/src/lib/layout-language/ai-layout-design.d.ts +6 -3
- package/src/lib/layout-language/ai-layout-design.js +19 -13
- package/src/lib/layout-language/ai-layout-design.js.map +1 -1
- package/src/lib/layout-language/ai-layout-listings.d.ts +48 -0
- package/src/lib/layout-language/ai-layout-listings.js +91 -5
- package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
- package/src/lib/layout-language/ai-layout-pictures.d.ts +15 -0
- package/src/lib/layout-language/ai-layout-pictures.js +6 -2
- package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
- package/src/lib/model/ai-jobs.types.d.ts +20 -0
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-site-home-arcs.d.ts +75 -0
- package/src/lib/model/ai-site-home-arcs.js +741 -0
- package/src/lib/model/ai-site-home-arcs.js.map +1 -0
- package/src/lib/model/ai-site-inventory.d.ts +18 -0
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +9 -6
- package/src/lib/model/ai-site-job.js +8 -5
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-kinds.js +24 -24
- package/src/lib/model/ai-site-kinds.js.map +1 -1
- package/src/lib/model/ai-site-start-handoff.d.ts +49 -0
- package/src/lib/model/ai-site-start-handoff.js +88 -0
- package/src/lib/model/ai-site-start-handoff.js.map +1 -0
- package/src/lib/model/ai-site-start-steps.d.ts +67 -0
- package/src/lib/model/ai-site-start-steps.js +127 -0
- package/src/lib/model/ai-site-start-steps.js.map +1 -0
- package/src/lib/model/ai-site-start.js +4 -1
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +32 -0
- package/src/lib/runtime/ai-doctrine-validators.js +119 -2
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.d.ts +6 -1
- package/src/lib/runtime/ai-doctrine.js +9 -5
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-palette.generated.js +5 -2
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/site-inventory.js +6 -0
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-insight-digest-route.js +2 -2
- package/src/lib/server/ai-insight-digest-route.js.map +1 -1
- package/src/lib/usage/assist-free-taste.js +3 -0
- 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
|
-
|
|
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
|
-
|
|
356
|
+
aiLayoutFormLine(screen, targets),
|
|
339
357
|
...components.length ? [
|
|
340
358
|
`Components this page may place: ${components.join('; ')}.`
|
|
341
359
|
] : [],
|