@aglyn/plugins-ai 1.0.0-beta.266 → 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/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.js +25 -6
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-start-steps.component.d.ts +8 -2
- package/src/lib/components/ai-site-start-steps.component.js +55 -26
- package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
- package/src/lib/components/ai-site-start-tracking.d.ts +11 -3
- package/src/lib/components/ai-site-start-tracking.js +28 -9
- package/src/lib/components/ai-site-start-tracking.js.map +1 -1
- 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/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-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 +35 -0
- package/src/lib/jobs/ai-layout-stock-photos.js +321 -19
- package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
- 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-design.js +5 -0
- package/src/lib/layout-language/ai-layout-design.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-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
|
@@ -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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"}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import { type SweepAiJobsResult } from './ai-jobs';
|
|
18
|
+
import { type AiEmptySiteSweepResult } from './ai-empty-site-sweep';
|
|
18
19
|
/**
|
|
19
20
|
* Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).
|
|
20
21
|
*
|
|
@@ -69,10 +70,17 @@ export type AiJobsBeatResult = {
|
|
|
69
70
|
held: true;
|
|
70
71
|
} | ({
|
|
71
72
|
held: false;
|
|
73
|
+
emptySites: AiEmptySiteSweepResult | null;
|
|
72
74
|
} & SweepAiJobsResult);
|
|
73
75
|
/**
|
|
74
|
-
* One beat: the switch, then
|
|
75
|
-
*
|
|
76
|
-
*
|
|
76
|
+
* One beat: the switch, then the empty sites a guided start left that are
|
|
77
|
+
* due for removal (AGL-3936), then one sweep under its own lease owner. The
|
|
78
|
+
* owner is fresh per beat and fixed for the whole sweep, so two beats
|
|
79
|
+
* overlapping inside a lease never read each other's lease as their own.
|
|
80
|
+
*
|
|
81
|
+
* The removals come first, a few a beat inside
|
|
82
|
+
* `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the
|
|
83
|
+
* sweep's budget, so the route's arithmetic inside `maxDuration` holds. A
|
|
84
|
+
* removal that fails never holds up a job's step.
|
|
77
85
|
*/
|
|
78
86
|
export declare function runAiJobsBeat(): Promise<AiJobsBeatResult>;
|
|
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { randomUUID } from "crypto";
|
|
18
18
|
import { featureLockdownRefusal, firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
19
19
|
import { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs } from "./ai-jobs.js";
|
|
20
|
+
import { sweepAiEmptySites } from "./ai-empty-site-sweep.js";
|
|
20
21
|
/**
|
|
21
22
|
* Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).
|
|
22
23
|
*
|
|
@@ -64,9 +65,15 @@ import { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs } from "./ai-jobs.js";
|
|
|
64
65
|
* with the sweep's function time, so the plugin dispatcher never serves it.
|
|
65
66
|
*/ export const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat';
|
|
66
67
|
/**
|
|
67
|
-
* One beat: the switch, then
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* One beat: the switch, then the empty sites a guided start left that are
|
|
69
|
+
* due for removal (AGL-3936), then one sweep under its own lease owner. The
|
|
70
|
+
* owner is fresh per beat and fixed for the whole sweep, so two beats
|
|
71
|
+
* overlapping inside a lease never read each other's lease as their own.
|
|
72
|
+
*
|
|
73
|
+
* The removals come first, a few a beat inside
|
|
74
|
+
* `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the
|
|
75
|
+
* sweep's budget, so the route's arithmetic inside `maxDuration` holds. A
|
|
76
|
+
* removal that fails never holds up a job's step.
|
|
70
77
|
*/ export async function runAiJobsBeat() {
|
|
71
78
|
const locked = await featureLockdownRefusal({
|
|
72
79
|
feature: 'ai-generate'
|
|
@@ -77,16 +84,26 @@ import { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs } from "./ai-jobs.js";
|
|
|
77
84
|
held: true
|
|
78
85
|
};
|
|
79
86
|
}
|
|
87
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
88
|
+
const startedAt = Date.now();
|
|
89
|
+
const emptySites = await sweepAiEmptySites({
|
|
90
|
+
firestore
|
|
91
|
+
}).catch((error)=>{
|
|
92
|
+
console.error('ai jobs beat: the empty site sweep failed', error);
|
|
93
|
+
return null;
|
|
94
|
+
});
|
|
95
|
+
if (emptySites == null ? void 0 : emptySites.removed) console.info(`ai jobs: ${emptySites.removed} empty site(s) removed`);
|
|
80
96
|
const result = await sweepAiJobs({
|
|
81
|
-
firestore
|
|
97
|
+
firestore,
|
|
82
98
|
owner: `beat:${randomUUID()}`,
|
|
83
|
-
budgetMs: AI_JOB_SWEEP_BUDGET_MS
|
|
99
|
+
budgetMs: Math.max(0, AI_JOB_SWEEP_BUDGET_MS - (Date.now() - startedAt))
|
|
84
100
|
});
|
|
85
101
|
if (result.due) {
|
|
86
102
|
console.info(`ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` + `${result.paused} held by a workspace's AI pause, ` + `${result.remaining} left for the next beat` + (result.budgetExhausted ? ' (budget exhausted)' : ''));
|
|
87
103
|
}
|
|
88
104
|
return _extends({
|
|
89
|
-
held: false
|
|
105
|
+
held: false,
|
|
106
|
+
emptySites
|
|
90
107
|
}, result);
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-beat.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 { randomUUID } from 'crypto'\nimport { featureLockdownRefusal, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs, type SweepAiJobsResult } from './ai-jobs'\n\n/**\n * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).\n *\n * A job's first step runs inline in the console route when it can; every\n * step after it, every step too long for an inline door, and any step a\n * budget cut short, runs here. The sweep bounds itself at\n * `AI_JOB_SWEEP_BUDGET_MS` of wall clock and hands the rest to the next beat\n * — the ordering of the queue is the cursor, so nothing is stored between\n * beats and nothing can be skipped.\n *\n * ## Why the console runs it\n *\n * A step calls the AI provider, and the provider's key is held by the\n * console alone: the tenant app serves every published site, and a\n * credential it holds is one request-handling bug away from a visitor. So\n * the beat is a console route (`server/ai-jobs-beat-route.ts`, registered\n * by the console surface), driven every minute by its own Cloud Scheduler\n * job in `cloud/functions` on the console's cron secret — the reason the\n * sending-domain sweep is a console route and not a job on the tenant's\n * platform beat. A beat must resolve to the minute: a step handed back by\n * a budget, or a page pass waiting on the next, should resume on the next\n * minute, not the next quarter hour.\n *\n * ## The kill switch\n *\n * `ai-generate` on the staff lockdown page is the lever for a provider\n * incident or a spend runaway on the generative doors, and this beat is the\n * one generative caller with no request to refuse. So it asks the same\n * feature verdict a door would — the platform scope and then the feature\n * document — and does nothing while either is locked. A job it leaves\n * queued stays queued: its lease is not taken, so nothing expires and\n * nothing is lost, and the first beat after the lock lifts picks it up.\n *\n * The same switch paused for ONE workspace (AGL-3037) holds that workspace's\n * jobs and no other's: `runAiJobStep` asks the verdict with the job's org\n * before it claims a step, and leaves a paused workspace's job queued.\n */\n\n/** The beat's row in `SCHEDULED_JOBS`, and the mark it leaves for `/api/health/crons`. */\nexport const AI_JOBS_BEAT_CRON_ID = 'ai-jobs-beat'\n\n/**\n * The console path the beat is registered at: the one path this plugin\n * registers outside its own prefixes. `/api/admin/` is where the console's\n * scheduled sweeps live, and the console's edge lets a request past its bot\n * challenge there when it carries the cron secret's header — a route\n * anywhere else would need a firewall rule of its own before a scheduler\n * could reach it. The console app serves this exact path from a named route\n * with the sweep's function time, so the plugin dispatcher never serves it.\n */\nexport const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat'\n\n/** What one beat did: nothing while `ai-generate` is locked, else the sweep's account. */\nexport type AiJobsBeatResult =\n | { held: true }\n | ({ held: false } & SweepAiJobsResult)\n\n/**\n * One beat: the switch, then one sweep under its own lease owner. The
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-beat.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 { randomUUID } from 'crypto'\nimport { featureLockdownRefusal, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs, type SweepAiJobsResult } from './ai-jobs'\nimport { sweepAiEmptySites, type AiEmptySiteSweepResult } from './ai-empty-site-sweep'\n\n/**\n * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).\n *\n * A job's first step runs inline in the console route when it can; every\n * step after it, every step too long for an inline door, and any step a\n * budget cut short, runs here. The sweep bounds itself at\n * `AI_JOB_SWEEP_BUDGET_MS` of wall clock and hands the rest to the next beat\n * — the ordering of the queue is the cursor, so nothing is stored between\n * beats and nothing can be skipped.\n *\n * ## Why the console runs it\n *\n * A step calls the AI provider, and the provider's key is held by the\n * console alone: the tenant app serves every published site, and a\n * credential it holds is one request-handling bug away from a visitor. So\n * the beat is a console route (`server/ai-jobs-beat-route.ts`, registered\n * by the console surface), driven every minute by its own Cloud Scheduler\n * job in `cloud/functions` on the console's cron secret — the reason the\n * sending-domain sweep is a console route and not a job on the tenant's\n * platform beat. A beat must resolve to the minute: a step handed back by\n * a budget, or a page pass waiting on the next, should resume on the next\n * minute, not the next quarter hour.\n *\n * ## The kill switch\n *\n * `ai-generate` on the staff lockdown page is the lever for a provider\n * incident or a spend runaway on the generative doors, and this beat is the\n * one generative caller with no request to refuse. So it asks the same\n * feature verdict a door would — the platform scope and then the feature\n * document — and does nothing while either is locked. A job it leaves\n * queued stays queued: its lease is not taken, so nothing expires and\n * nothing is lost, and the first beat after the lock lifts picks it up.\n *\n * The same switch paused for ONE workspace (AGL-3037) holds that workspace's\n * jobs and no other's: `runAiJobStep` asks the verdict with the job's org\n * before it claims a step, and leaves a paused workspace's job queued.\n */\n\n/** The beat's row in `SCHEDULED_JOBS`, and the mark it leaves for `/api/health/crons`. */\nexport const AI_JOBS_BEAT_CRON_ID = 'ai-jobs-beat'\n\n/**\n * The console path the beat is registered at: the one path this plugin\n * registers outside its own prefixes. `/api/admin/` is where the console's\n * scheduled sweeps live, and the console's edge lets a request past its bot\n * challenge there when it carries the cron secret's header — a route\n * anywhere else would need a firewall rule of its own before a scheduler\n * could reach it. The console app serves this exact path from a named route\n * with the sweep's function time, so the plugin dispatcher never serves it.\n */\nexport const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat'\n\n/** What one beat did: nothing while `ai-generate` is locked, else the sweep's account. */\nexport type AiJobsBeatResult =\n | { held: true }\n | ({ held: false; emptySites: AiEmptySiteSweepResult | null } & SweepAiJobsResult)\n\n/**\n * One beat: the switch, then the empty sites a guided start left that are\n * due for removal (AGL-3936), then one sweep under its own lease owner. The\n * owner is fresh per beat and fixed for the whole sweep, so two beats\n * overlapping inside a lease never read each other's lease as their own.\n *\n * The removals come first, a few a beat inside\n * `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the\n * sweep's budget, so the route's arithmetic inside `maxDuration` holds. A\n * removal that fails never holds up a job's step.\n */\nexport async function runAiJobsBeat(): Promise<AiJobsBeatResult> {\n const locked = await featureLockdownRefusal({ feature: 'ai-generate' })\n if (locked) {\n console.warn('ai jobs beat held: ai-generate is locked')\n return { held: true }\n }\n const firestore = firebaseAdmin.app().firestore()\n const startedAt = Date.now()\n const emptySites = await sweepAiEmptySites({ firestore }).catch((error: unknown) => {\n console.error('ai jobs beat: the empty site sweep failed', error)\n return null\n })\n if (emptySites?.removed) console.info(`ai jobs: ${emptySites.removed} empty site(s) removed`)\n const result = await sweepAiJobs({\n firestore,\n owner: `beat:${randomUUID()}`,\n budgetMs: Math.max(0, AI_JOB_SWEEP_BUDGET_MS - (Date.now() - startedAt)),\n })\n if (result.due) {\n console.info(\n `ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` +\n `${result.paused} held by a workspace's AI pause, ` +\n `${result.remaining} left for the next beat` +\n (result.budgetExhausted ? ' (budget exhausted)' : ''),\n )\n }\n return { held: false, emptySites, ...result }\n}\n"],"names":["randomUUID","featureLockdownRefusal","firebaseAdmin","AI_JOB_SWEEP_BUDGET_MS","sweepAiJobs","sweepAiEmptySites","AI_JOBS_BEAT_CRON_ID","AI_JOBS_BEAT_PATH","runAiJobsBeat","locked","feature","console","warn","held","firestore","app","startedAt","Date","now","emptySites","catch","error","removed","info","result","owner","budgetMs","Math","max","due","ran","skipped","paused","remaining","budgetExhausted"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,sBAAsB,EAAEC,aAAa,QAAQ,2BAA0B;AAChF,SAASC,sBAAsB,EAAEC,WAAW,QAAgC,eAAW;AACvF,SAASC,iBAAiB,QAAqC,2BAAuB;AAEtF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,wFAAwF,GACxF,OAAO,MAAMC,uBAAuB,eAAc;AAElD;;;;;;;;CAQC,GACD,OAAO,MAAMC,oBAAoB,qBAAoB;AAOrD;;;;;;;;;;CAUC,GACD,OAAO,eAAeC;IACpB,MAAMC,SAAS,MAAMR,uBAAuB;QAAES,SAAS;IAAc;IACrE,IAAID,QAAQ;QACVE,QAAQC,IAAI,CAAC;QACb,OAAO;YAAEC,MAAM;QAAK;IACtB;IACA,MAAMC,YAAYZ,cAAca,GAAG,GAAGD,SAAS;IAC/C,MAAME,YAAYC,KAAKC,GAAG;IAC1B,MAAMC,aAAa,MAAMd,kBAAkB;QAAES;IAAU,GAAGM,KAAK,CAAC,CAACC;QAC/DV,QAAQU,KAAK,CAAC,6CAA6CA;QAC3D,OAAO;IACT;IACA,IAAIF,8BAAAA,WAAYG,OAAO,EAAEX,QAAQY,IAAI,CAAC,CAAC,SAAS,EAAEJ,WAAWG,OAAO,CAAC,sBAAsB,CAAC;IAC5F,MAAME,SAAS,MAAMpB,YAAY;QAC/BU;QACAW,OAAO,CAAC,KAAK,EAAEzB,cAAc;QAC7B0B,UAAUC,KAAKC,GAAG,CAAC,GAAGzB,yBAA0Bc,CAAAA,KAAKC,GAAG,KAAKF,SAAQ;IACvE;IACA,IAAIQ,OAAOK,GAAG,EAAE;QACdlB,QAAQY,IAAI,CACV,CAAC,SAAS,EAAEC,OAAOM,GAAG,CAAC,cAAc,EAAEN,OAAOO,OAAO,CAAC,UAAU,CAAC,GAC/D,GAAGP,OAAOQ,MAAM,CAAC,iCAAiC,CAAC,GACnD,GAAGR,OAAOS,SAAS,CAAC,uBAAuB,CAAC,GAC3CT,CAAAA,OAAOU,eAAe,GAAG,wBAAwB,EAAC;IAEzD;IACA,OAAO;QAAErB,MAAM;QAAOM;OAAeK;AACvC"}
|