@prenta/core 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/ai/governance.d.ts +6 -0
  3. package/dist/ai/governance.d.ts.map +1 -1
  4. package/dist/ai/governance.js +19 -0
  5. package/dist/ai/governance.js.map +1 -1
  6. package/dist/api/handlers.d.ts.map +1 -1
  7. package/dist/api/handlers.js +2 -0
  8. package/dist/api/handlers.js.map +1 -1
  9. package/dist/api/routes/redirects.d.ts.map +1 -1
  10. package/dist/api/routes/redirects.js +89 -93
  11. package/dist/api/routes/redirects.js.map +1 -1
  12. package/dist/api/routes/seo-autopilot.d.ts +12 -0
  13. package/dist/api/routes/seo-autopilot.d.ts.map +1 -0
  14. package/dist/api/routes/seo-autopilot.js +257 -0
  15. package/dist/api/routes/seo-autopilot.js.map +1 -0
  16. package/dist/api/routes/seo.d.ts.map +1 -1
  17. package/dist/api/routes/seo.js +195 -360
  18. package/dist/api/routes/seo.js.map +1 -1
  19. package/dist/db/model-types.d.ts +26 -0
  20. package/dist/db/model-types.d.ts.map +1 -1
  21. package/dist/generated/browser.d.ts +8 -0
  22. package/dist/generated/client.d.ts +8 -0
  23. package/dist/generated/internal/class.d.ts +11 -0
  24. package/dist/generated/internal/class.js +4 -4
  25. package/dist/generated/internal/prismaNamespace.d.ts +120 -1
  26. package/dist/generated/internal/prismaNamespace.js +42 -0
  27. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +44 -0
  28. package/dist/generated/internal/prismaNamespaceBrowser.js +42 -0
  29. package/dist/generated/models/SeoAutoApply.d.ts +1495 -0
  30. package/dist/generated/models/SeoAutoApply.js +1 -0
  31. package/dist/generated/models.d.ts +1 -0
  32. package/dist/redirects/recovery.d.ts +15 -9
  33. package/dist/redirects/recovery.d.ts.map +1 -1
  34. package/dist/redirects/recovery.js +15 -61
  35. package/dist/redirects/recovery.js.map +1 -1
  36. package/dist/redirects/suggestion-accept.d.ts +29 -0
  37. package/dist/redirects/suggestion-accept.d.ts.map +1 -0
  38. package/dist/redirects/suggestion-accept.js +108 -0
  39. package/dist/redirects/suggestion-accept.js.map +1 -0
  40. package/dist/seo/audit-runner.d.ts.map +1 -1
  41. package/dist/seo/audit-runner.js +0 -2
  42. package/dist/seo/audit-runner.js.map +1 -1
  43. package/dist/seo/autopilot-gate.d.ts +101 -0
  44. package/dist/seo/autopilot-gate.d.ts.map +1 -0
  45. package/dist/seo/autopilot-gate.js +272 -0
  46. package/dist/seo/autopilot-gate.js.map +1 -0
  47. package/dist/seo/autopilot-ledger.d.ts +74 -0
  48. package/dist/seo/autopilot-ledger.d.ts.map +1 -0
  49. package/dist/seo/autopilot-ledger.js +118 -0
  50. package/dist/seo/autopilot-ledger.js.map +1 -0
  51. package/dist/seo/autopilot-outcome.d.ts +93 -0
  52. package/dist/seo/autopilot-outcome.d.ts.map +1 -0
  53. package/dist/seo/autopilot-outcome.js +206 -0
  54. package/dist/seo/autopilot-outcome.js.map +1 -0
  55. package/dist/seo/autopilot-policy.d.ts +169 -0
  56. package/dist/seo/autopilot-policy.d.ts.map +1 -0
  57. package/dist/seo/autopilot-policy.js +267 -0
  58. package/dist/seo/autopilot-policy.js.map +1 -0
  59. package/dist/seo/autopilot-revert.d.ts +40 -0
  60. package/dist/seo/autopilot-revert.d.ts.map +1 -0
  61. package/dist/seo/autopilot-revert.js +155 -0
  62. package/dist/seo/autopilot-revert.js.map +1 -0
  63. package/dist/seo/autopilot.d.ts +91 -0
  64. package/dist/seo/autopilot.d.ts.map +1 -0
  65. package/dist/seo/autopilot.js +1060 -0
  66. package/dist/seo/autopilot.js.map +1 -0
  67. package/dist/seo/config-store.d.ts +35 -2
  68. package/dist/seo/config-store.d.ts.map +1 -1
  69. package/dist/seo/config-store.js +58 -1
  70. package/dist/seo/config-store.js.map +1 -1
  71. package/dist/seo/deep-equal.d.ts +12 -0
  72. package/dist/seo/deep-equal.d.ts.map +1 -0
  73. package/dist/seo/deep-equal.js +42 -0
  74. package/dist/seo/deep-equal.js.map +1 -0
  75. package/dist/seo/digest.d.ts +27 -0
  76. package/dist/seo/digest.d.ts.map +1 -1
  77. package/dist/seo/digest.js +77 -1
  78. package/dist/seo/digest.js.map +1 -1
  79. package/dist/seo/index.d.ts +20 -2
  80. package/dist/seo/index.d.ts.map +1 -1
  81. package/dist/seo/index.js +12 -1
  82. package/dist/seo/index.js.map +1 -1
  83. package/dist/seo/issue-fix-ai.d.ts +40 -1
  84. package/dist/seo/issue-fix-ai.d.ts.map +1 -1
  85. package/dist/seo/issue-fix-ai.js +53 -1
  86. package/dist/seo/issue-fix-ai.js.map +1 -1
  87. package/dist/seo/issue-fix-apply.d.ts +104 -0
  88. package/dist/seo/issue-fix-apply.d.ts.map +1 -0
  89. package/dist/seo/issue-fix-apply.js +406 -0
  90. package/dist/seo/issue-fix-apply.js.map +1 -0
  91. package/dist/seo/issue-fix.d.ts +9 -0
  92. package/dist/seo/issue-fix.d.ts.map +1 -1
  93. package/dist/seo/issue-fix.js +1 -0
  94. package/dist/seo/issue-fix.js.map +1 -1
  95. package/dist/seo/proposals-bulk-apply.d.ts +52 -0
  96. package/dist/seo/proposals-bulk-apply.d.ts.map +1 -0
  97. package/dist/seo/proposals-bulk-apply.js +183 -0
  98. package/dist/seo/proposals-bulk-apply.js.map +1 -0
  99. package/dist/seo/proposals-generate.d.ts +45 -0
  100. package/dist/seo/proposals-generate.d.ts.map +1 -0
  101. package/dist/seo/proposals-generate.js +144 -0
  102. package/dist/seo/proposals-generate.js.map +1 -0
  103. package/dist/seo/proposals.d.ts +125 -0
  104. package/dist/seo/proposals.d.ts.map +1 -0
  105. package/dist/seo/proposals.js +270 -0
  106. package/dist/seo/proposals.js.map +1 -0
  107. package/package.json +1 -1
  108. package/prisma/migrations/0017_seo_auto_applies/migration.sql +35 -0
  109. package/prisma/schema.prisma +43 -0
@@ -0,0 +1,1060 @@
1
+ import { updateDocumentSeoFields } from '../actions.js';
2
+ import { getAIConfig } from '../ai/config-store.js';
3
+ import { getActiveBrandVoice } from '../ai/brand-voice.js';
4
+ import { ensureBrandVoiceDocument } from '../ai/brand-voice-document.js';
5
+ import { parseStructuredProfile } from '../ai/brand-voice-structured.js';
6
+ import { getFeatureGovernance } from '../ai/governance.js';
7
+ import { ADMIN_ROLES } from '../api/route-helpers.js';
8
+ import { getPrentaConfig } from '../config/runtime.js';
9
+ import { assertPlanFeature } from '../plan/features.js';
10
+ import { acceptRedirectSuggestion, } from '../redirects/suggestion-accept.js';
11
+ import { logEvent } from '../security/audit.js';
12
+ import { AUTOPILOT_METADATA_TYPES, isAutopilotIssueType, } from './autopilot-policy.js';
13
+ import { buildDuplicateIndex, evaluateAutopilotGate, featureKeyFor, } from './autopilot-gate.js';
14
+ import { countAutoAppliesToday, loadCooldownKeys, recordAutoApply, } from './autopilot-ledger.js';
15
+ import { runOutcomeWatch } from './autopilot-outcome.js';
16
+ import { applySeoOverrides, getSeoOverrides, patchAutopilotPolicy, patchSeoModuleSettings, resolveSeoModuleSettings, } from './config-store.js';
17
+ import { deepEqualUnordered } from './deep-equal.js';
18
+ import { applyIssueFix, loadIssueFixContext } from './issue-fix-apply.js';
19
+ import { buildFixFingerprint, suggestDeterministicFix } from './issue-fix.js';
20
+ import { pendingFixFromMetadata, stickyFingerprintsFromMetadata } from './issue-fix-ai.js';
21
+ import { generateSeoProposals } from './proposals-generate.js';
22
+ import { ISSUE_FIX_FEATURE_KEY, REDIRECT_FEATURE_KEY, listSeoProposals, liveFingerprintFor, loadOpenFixableIssuesWithDocs, } from './proposals.js';
23
+ import { resolveRenderedMetaTitle } from './title-templates.js';
24
+ /** Whole-tick wall clock, inside the 30 s function ceiling. */
25
+ export const AUTOPILOT_BUDGET_MS = 20_000;
26
+ /**
27
+ * Grace added to the run budget before a held run lease is considered abandoned
28
+ * (a tick that died without reaching its `finally`). Below this age a second
29
+ * tick halts `already_running`; at or above it the lease is taken over.
30
+ */
31
+ export const AUTOPILOT_LEASE_STALE_MARGIN_MS = 10_000;
32
+ /** Cap on the AI generate phase. */
33
+ export const AUTOPILOT_GENERATE_BUDGET_MS = 10_000;
34
+ /** Wall clock that must remain for the apply phase after generate. */
35
+ export const AUTOPILOT_APPLY_RESERVE_MS = 5_000;
36
+ /**
37
+ * Budget for the outcome watch, passed to `runOutcomeWatch` as its `deadline`:
38
+ * it stops judging rows once reached (the rest stay `pending` for the next
39
+ * tick). The in-flight row may still overrun slightly — that is logged and
40
+ * shrinks the generate window; the apply reserve holds.
41
+ */
42
+ export const AUTOPILOT_OUTCOME_BUDGET_MS = 3_000;
43
+ /** Max issue contexts loaded per tick while scanning for deterministic-tier fixes. */
44
+ export const AUTOPILOT_DETERMINISTIC_SCAN_MAX = 50;
45
+ /**
46
+ * The deterministic scan stops loading issue contexts this long before the
47
+ * run deadline so the gate context build and at least one apply still fit.
48
+ */
49
+ export const AUTOPILOT_SCAN_DEADLINE_MARGIN_MS = 1_000;
50
+ /** Look-back for the `Version` row an apply produced (best effort). */
51
+ const VERSION_LOOKBACK_MS = 60_000;
52
+ const PUBLISHED_INDEX_MAX = 5_000;
53
+ const REDIRECT_INDEX_MAX = 10_000;
54
+ /**
55
+ * Patch keys `updateDocumentSeoFields` is known to transform on write
56
+ * (`syncSchemaTypeFields` mirrors `structuredDataType` ↔ `schemaType`;
57
+ * `applyStructuredDataFieldsToData` stamps them). The written-equals-gated
58
+ * check skips these; every other key must land byte-for-byte. Deliberately
59
+ * NOT exempt: `focusKeyphrase` — `normalizeSeoFieldAliases` folds it into
60
+ * `focusKeyword`, so a patch carrying it fails closed (`gate_mismatch`) rather
61
+ * than skipping the check; a fix type that needs the alias must map it to
62
+ * `focusKeyword` in both the `before` snapshot and the comparison.
63
+ */
64
+ const GATE_MISMATCH_EXEMPT_KEYS = new Set([
65
+ 'structuredData',
66
+ 'structuredDataType',
67
+ 'schemaType',
68
+ ]);
69
+ function asRecord(v) {
70
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
71
+ }
72
+ function sameValue(a, b) {
73
+ if (a === b)
74
+ return true;
75
+ if (a == null && b == null)
76
+ return true;
77
+ return JSON.stringify(a) === JSON.stringify(b);
78
+ }
79
+ function bump(map, key) {
80
+ map[key] = (map[key] ?? 0) + 1;
81
+ }
82
+ function bumpGate(gated, reason) {
83
+ gated[reason] = (gated[reason] ?? 0) + 1;
84
+ }
85
+ /** Ledger `path`: pathname only, no origin / query / hash, no trailing slash, root kept. */
86
+ export function normalizeAutopilotPath(url) {
87
+ if (!url)
88
+ return null;
89
+ let pathname;
90
+ try {
91
+ pathname = new URL(url, 'https://placeholder.invalid').pathname;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ let path = pathname.replace(/\/+$/, '') || '/';
97
+ // `new URL` percent-encodes; the GSC sync (`normalizeGscPageUrl`) stores the
98
+ // decoded form, and the outcome watch joins on it. Keep the encoded form only
99
+ // when decoding fails (malformed escape).
100
+ try {
101
+ path = decodeURIComponent(path);
102
+ }
103
+ catch {
104
+ /* keep encoded */
105
+ }
106
+ return path;
107
+ }
108
+ const defaultRestoreFields = (db, collection, entityId, fields, actor) => {
109
+ const ctx = { userId: actor.userId, role: actor.role, db };
110
+ return updateDocumentSeoFields(collection, entityId, fields, ctx);
111
+ };
112
+ /**
113
+ * Deterministic-tier candidates: open fixable issues of the opted-in metadata
114
+ * types with no LIVE cache whose deterministic suggestion is auto-fixable.
115
+ * These never appear in the inbox (the generator skips them), so the run
116
+ * discovers them directly. A stale cache counts as none — the inbox row it
117
+ * backs is gated `stale`, so without this the issue would be fixed by neither
118
+ * tier. Bounded by AUTOPILOT_DETERMINISTIC_SCAN_MAX loads and by the run
119
+ * deadline (minus AUTOPILOT_SCAN_DEADLINE_MARGIN_MS).
120
+ */
121
+ async function defaultDeterministicCandidates(db, types, opts) {
122
+ const { issues, docsById } = await loadOpenFixableIssuesWithDocs(db);
123
+ const out = [];
124
+ const scanDeadline = opts.deadline - AUTOPILOT_SCAN_DEADLINE_MARGIN_MS;
125
+ let scanned = 0;
126
+ for (const issue of issues) {
127
+ if (scanned >= AUTOPILOT_DETERMINISTIC_SCAN_MAX)
128
+ break;
129
+ if (opts.now() >= scanDeadline)
130
+ break;
131
+ if (issue.fixable !== true || !types.has(String(issue.type)))
132
+ continue;
133
+ if (typeof issue.entityId !== 'string' || !docsById.has(issue.entityId))
134
+ continue;
135
+ const cache = pendingFixFromMetadata(issue.metadata);
136
+ if (cache && liveFingerprintFor(issue, cache, docsById) === cache.fingerprint)
137
+ continue;
138
+ scanned++;
139
+ const loaded = await loadIssueFixContext(db, issue, { overrides: opts.overrides });
140
+ if (!loaded.ok)
141
+ continue;
142
+ const suggestion = suggestDeterministicFix({
143
+ type: String(issue.type),
144
+ title: String(issue.title ?? issue.type),
145
+ recommendation: typeof issue.recommendation === 'string' ? issue.recommendation : null,
146
+ entityType: typeof issue.entityType === 'string' ? issue.entityType : null,
147
+ entityId: issue.entityId,
148
+ }, {
149
+ entity: loaded.ctx.entity,
150
+ siteSeo: loaded.ctx.siteSeo,
151
+ updatedAt: loaded.ctx.updatedAt,
152
+ metadata: asRecord(issue.metadata),
153
+ });
154
+ if (!suggestion.autoFixable || Object.keys(suggestion.patch).length === 0)
155
+ continue;
156
+ out.push({
157
+ id: `issue:${String(issue.id)}`,
158
+ kind: 'issue-fix',
159
+ title: String(issue.title ?? issue.type),
160
+ entity: {
161
+ type: typeof issue.entityType === 'string' ? issue.entityType : null,
162
+ id: issue.entityId,
163
+ title: docsById.get(issue.entityId)?.title ?? null,
164
+ url: typeof issue.url === 'string' ? issue.url : null,
165
+ },
166
+ changes: suggestion.changes,
167
+ justification: suggestion.justification,
168
+ source: 'deterministic',
169
+ confidence: null,
170
+ brandAlignment: null,
171
+ generatedAt: null,
172
+ fingerprint: buildFixFingerprint(issue.entityId, String(issue.type), loaded.ctx.updatedAt),
173
+ stale: false,
174
+ issueType: String(issue.type),
175
+ severity: issue.severity === 'critical' || issue.severity === 'warning' || issue.severity === 'info'
176
+ ? issue.severity
177
+ : null,
178
+ issueId: String(issue.id),
179
+ suggestionId: null,
180
+ patch: suggestion.patch,
181
+ dismissedFingerprint: stickyFingerprintsFromMetadata(issue.metadata).dismissed,
182
+ });
183
+ }
184
+ return out;
185
+ }
186
+ function toCandidate(p) {
187
+ const typeKey = p.kind === 'redirect' ? 'redirect' : p.issueType;
188
+ if (!isAutopilotIssueType(typeKey))
189
+ return null;
190
+ return {
191
+ ...p,
192
+ featureKey: featureKeyFor(p.kind),
193
+ issueTypeKey: typeKey,
194
+ patch: 'patch' in p ? p.patch : null,
195
+ dismissedFingerprint: 'dismissedFingerprint' in p ? p.dismissedFingerprint : undefined,
196
+ };
197
+ }
198
+ /**
199
+ * Inbox proposals do not carry the issue's sticky dismissal marker (the
200
+ * proposal shape is the public inbox contract), so it is read once here for
201
+ * every issue-fix candidate that does not already have it. A lookup failure
202
+ * propagates: an unverifiable "not dismissed" is not a green light.
203
+ */
204
+ async function attachDismissalMarkers(db, candidates) {
205
+ const pending = candidates.filter((c) => c.kind === 'issue-fix' && c.issueId && c.dismissedFingerprint === undefined);
206
+ if (pending.length === 0)
207
+ return;
208
+ const ids = [...new Set(pending.map((c) => c.issueId))];
209
+ const rows = await db.seoIssue.findMany({
210
+ where: { id: { in: ids } },
211
+ select: { id: true, metadata: true },
212
+ });
213
+ const byId = new Map(rows.map((r) => [String(r.id), r.metadata]));
214
+ for (const c of pending) {
215
+ c.dismissedFingerprint = byId.has(c.issueId)
216
+ ? stickyFingerprintsFromMetadata(byId.get(c.issueId)).dismissed
217
+ : null;
218
+ }
219
+ }
220
+ /** Deterministic first, then AI fixes by brand alignment (nulls last), then redirects by confidence. */
221
+ function orderCandidates(list) {
222
+ const tier = (c) => c.source === 'deterministic' ? 0 : c.kind === 'issue-fix' ? 1 : 2;
223
+ return [...list].sort((a, b) => {
224
+ const t = tier(a) - tier(b);
225
+ if (t !== 0)
226
+ return t;
227
+ if (a.kind === 'issue-fix' && b.kind === 'issue-fix') {
228
+ return (b.brandAlignment ?? -1) - (a.brandAlignment ?? -1);
229
+ }
230
+ return (b.confidence ?? 0) - (a.confidence ?? 0);
231
+ });
232
+ }
233
+ function dedupeById(list) {
234
+ const seen = new Set();
235
+ return list.filter((c) => {
236
+ if (seen.has(c.id))
237
+ return false;
238
+ seen.add(c.id);
239
+ return true;
240
+ });
241
+ }
242
+ async function buildGateContext(db, policy, candidates, now, overrides) {
243
+ const governance = {};
244
+ for (const key of [ISSUE_FIX_FEATURE_KEY, REDIRECT_FEATURE_KEY]) {
245
+ governance[key] = await getFeatureGovernance(db, key);
246
+ }
247
+ const featureCandidates = {};
248
+ for (const c of candidates) {
249
+ if (policy.types[c.issueTypeKey] === true)
250
+ bump(featureCandidates, c.featureKey);
251
+ }
252
+ const [appliedToday, cooldown] = await Promise.all([
253
+ countAutoAppliesToday(db, now),
254
+ loadCooldownKeys(db, now),
255
+ ]);
256
+ let brandProfile = null;
257
+ try {
258
+ const active = getActiveBrandVoice(await getAIConfig(db));
259
+ if (active)
260
+ brandProfile = parseStructuredProfile(ensureBrandVoiceDocument(active));
261
+ }
262
+ catch (err) {
263
+ console.warn('[seo/autopilot] brand profile unavailable; brand gate skipped', err);
264
+ }
265
+ // The gate compares `changes[].after` — the RENDERED title (page title through the site
266
+ // template) — so every published document is indexed the same way, falling back to the
267
+ // document title when no meta title is stored (those pages collide too).
268
+ const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
269
+ const published = await db.document.findMany({
270
+ where: { status: 'PUBLISHED', deletedAt: null },
271
+ select: { id: true, title: true, data: true },
272
+ take: PUBLISHED_INDEX_MAX,
273
+ });
274
+ const duplicates = buildDuplicateIndex(published.map((d) => {
275
+ const data = asRecord(d.data);
276
+ return {
277
+ entityId: d.id,
278
+ metaTitle: resolveRenderedMetaTitle({
279
+ metaTitlePage: typeof data.metaTitle === 'string' ? data.metaTitle : null,
280
+ documentTitle: d.title,
281
+ site: siteSeo,
282
+ }),
283
+ metaDescription: data.metaDescription,
284
+ };
285
+ }));
286
+ const redirects = await db.redirect.findMany({
287
+ select: { source: true },
288
+ take: REDIRECT_INDEX_MAX,
289
+ });
290
+ return {
291
+ policy,
292
+ governance,
293
+ featureCandidates,
294
+ featureApplied: {},
295
+ appliedThisRun: 0,
296
+ appliedToday,
297
+ cooldownIssueIds: cooldown.issueIds,
298
+ cooldownEntityKeys: cooldown.entityKeys,
299
+ brandProfile,
300
+ duplicates,
301
+ existingRedirectSources: new Set(redirects.map((r) => r.source)),
302
+ planDenied: assertPlanFeature('seo.inlineApply') !== null,
303
+ };
304
+ }
305
+ /**
306
+ * The data-layer patch an issue-fix candidate will write. Deterministic
307
+ * candidates carry it; inbox (AI) candidates keep it in the issue's cache,
308
+ * which must still be the proposal the gate scored: same fingerprint AND the
309
+ * same `changes` / `brandAlignment`. The fingerprint alone is not enough —
310
+ * `suggest-fix` regenerates the cache under an unchanged fingerprint, so a
311
+ * regeneration between listing and apply would otherwise write text the
312
+ * brand / banned-term / duplicate / validator gates never saw. Any mismatch
313
+ * is `stale` (nothing written); the proposal is re-listed on the next tick.
314
+ */
315
+ async function resolvePatch(db, c) {
316
+ if (c.patch)
317
+ return c.patch;
318
+ const issue = await db.seoIssue.findUnique({ where: { id: c.issueId } });
319
+ const cache = pendingFixFromMetadata(issue?.metadata);
320
+ if (!cache || (c.fingerprint && cache.fingerprint !== c.fingerprint))
321
+ return null;
322
+ if (!deepEqualUnordered(cache.changes, c.changes))
323
+ return null;
324
+ if (cache.brandAlignment !== c.brandAlignment)
325
+ return null;
326
+ return cache.patch;
327
+ }
328
+ async function readDocument(db, id) {
329
+ const doc = await db.document.findFirst({
330
+ where: { id, deletedAt: null },
331
+ select: { id: true, collection: true, data: true },
332
+ });
333
+ return doc ? { id: doc.id, collection: doc.collection, data: asRecord(doc.data) } : null;
334
+ }
335
+ async function findApplyVersionId(db, documentId, actorId, nowMs) {
336
+ try {
337
+ const version = await db.version.findFirst({
338
+ where: {
339
+ documentId,
340
+ changedById: actorId,
341
+ createdAt: { gte: new Date(nowMs - VERSION_LOOKBACK_MS) },
342
+ },
343
+ orderBy: { createdAt: 'desc' },
344
+ select: { id: true },
345
+ });
346
+ return version?.id ?? null;
347
+ }
348
+ catch {
349
+ return null;
350
+ }
351
+ }
352
+ async function audit(event, userId, details) {
353
+ try {
354
+ await logEvent({ event, userId, details });
355
+ }
356
+ catch {
357
+ /* audit-log failures never block */
358
+ }
359
+ }
360
+ function countApplied(state, c) {
361
+ state.ctx.appliedThisRun++;
362
+ state.ctx.appliedToday++;
363
+ bump(state.ctx.featureApplied, c.featureKey);
364
+ }
365
+ /**
366
+ * Hard `maxPerDay`: the gate's `appliedToday` was read once when the context was
367
+ * built, so a tick overlapping this one (the run lease closes most of that
368
+ * window, not all of it) could push the day past the cap. Re-count the ledger
369
+ * immediately before every write; when the day is already full, gate the
370
+ * candidate `cap_per_day` and tell the loop nothing later can pass. Returns
371
+ * whether the write may proceed. A count that throws propagates — an
372
+ * unverifiable cap is not a green light.
373
+ */
374
+ async function dayCapAllowsWrite(state) {
375
+ const fresh = await countAutoAppliesToday(state.db, state.startedAt);
376
+ // Adopt the fresher figure so the gate's own cap check stays honest too.
377
+ if (fresh > state.ctx.appliedToday)
378
+ state.ctx.appliedToday = fresh;
379
+ if (fresh < state.policy.maxPerDay)
380
+ return true;
381
+ bumpGate(state.summary.gated, 'cap_per_day');
382
+ state.dayCapReached = true;
383
+ return false;
384
+ }
385
+ /**
386
+ * Ledger a write that has already landed. The ledger is the only audit and
387
+ * undo path for an unattended write, so a failed insert is not tolerated: the
388
+ * write is undone (best effort), the failure is audited as
389
+ * `seo_autopilot_ledger_failed` so it is attributable to the issue / redirect,
390
+ * and the run halts with `ledger_write_failed` so nothing else is written
391
+ * under a broken ledger. Returns whether the row was recorded.
392
+ */
393
+ async function recordOrHalt(state, input, undo) {
394
+ try {
395
+ await recordAutoApply(state.db, input);
396
+ return true;
397
+ }
398
+ catch (err) {
399
+ const ref = { kind: input.kind, issueId: input.issueId, redirectId: input.redirectId };
400
+ console.warn('[seo/autopilot] ledger write failed after apply; undoing and halting', ref, err);
401
+ await audit('seo_autopilot_ledger_failed', state.actor.userId, ref);
402
+ if (undo) {
403
+ try {
404
+ await undo();
405
+ }
406
+ catch (undoErr) {
407
+ console.warn('[seo/autopilot] undo after ledger failure failed', undoErr);
408
+ }
409
+ }
410
+ state.summary.halted = true;
411
+ state.summary.haltedReason = 'ledger_write_failed';
412
+ return false;
413
+ }
414
+ }
415
+ async function applyRedirect(state, c, gate) {
416
+ const { db, actor, summary } = state;
417
+ if (!(await dayCapAllowsWrite(state)))
418
+ return;
419
+ let res;
420
+ try {
421
+ res = await state.deps.acceptRedirect(db, {
422
+ suggestionId: c.suggestionId,
423
+ actor: { userId: actor.userId },
424
+ });
425
+ }
426
+ catch (err) {
427
+ // Includes a unique violation on `Redirect.source` and probe/network errors: one bad
428
+ // suggestion must not take the phase down.
429
+ console.warn('[seo/autopilot] acceptRedirectSuggestion threw', { suggestionId: c.suggestionId }, err);
430
+ bump(summary.applyOutcomes, 'error');
431
+ return;
432
+ }
433
+ switch (res.status) {
434
+ case 'accepted': {
435
+ const recorded = await recordOrHalt(state, {
436
+ kind: 'redirect',
437
+ issueId: null,
438
+ issueType: 'redirect',
439
+ entityId: null,
440
+ collection: null,
441
+ path: null,
442
+ redirectId: res.redirectId,
443
+ before: null,
444
+ after: {
445
+ source: c.entity.url,
446
+ destination: c.changes[0]?.after ?? null,
447
+ suggestionId: c.suggestionId,
448
+ },
449
+ gate,
450
+ status: 'applied',
451
+ appliedById: actor.userId,
452
+ }, async () => {
453
+ await db.redirect.delete({ where: { id: res.redirectId } });
454
+ await db.redirectSuggestion.update({
455
+ where: { id: c.suggestionId },
456
+ data: { status: 'open' },
457
+ });
458
+ });
459
+ if (!recorded)
460
+ return;
461
+ // Same-run containment: a second suggestion for this source must now gate
462
+ // `redirect_manual_exists` instead of hitting the unique index.
463
+ if (c.entity.url)
464
+ state.ctx.existingRedirectSources.add(c.entity.url);
465
+ summary.applied++;
466
+ countApplied(state, c);
467
+ return;
468
+ }
469
+ case 'not-found':
470
+ case 'invalid':
471
+ case 'still-live':
472
+ bump(summary.applyOutcomes, res.status);
473
+ return;
474
+ default: {
475
+ const exhaustive = res;
476
+ return exhaustive;
477
+ }
478
+ }
479
+ }
480
+ /**
481
+ * Put an issue back to `open` after its fix was undone. `applyIssueFix` marks
482
+ * the issue resolved as part of a successful write, so every path that restores
483
+ * `before` afterwards (gate mismatch, unreadable re-read, ledger failure, throw
484
+ * after write) must reopen it or the audit shows a resolved issue whose problem
485
+ * is live again. Idempotent for issues that were never resolved. Best effort —
486
+ * the audit may have removed the row since.
487
+ */
488
+ async function reopenIssue(db, issueId, cause) {
489
+ try {
490
+ await db.seoIssue.update({
491
+ where: { id: issueId },
492
+ data: { status: 'open', resolvedAt: null, resolvedById: null },
493
+ });
494
+ }
495
+ catch (err) {
496
+ console.warn(`[seo/autopilot] issue reopen after ${cause} failed`, { issueId }, err);
497
+ }
498
+ }
499
+ /**
500
+ * Verify failed (or the apply threw after writing, or the landed value is not
501
+ * the gated one): put `before` back, reopen the issue, and ledger the attempt as
502
+ * `verify_failed`. A restore that throws is logged and recorded with
503
+ * `revertReason: null` — the row says what was written, the revert did not
504
+ * happen — and never aborts the run. The restore has already run, so a failed
505
+ * ledger insert here has nothing to undo; it still halts the run (`recordOrHalt`).
506
+ */
507
+ async function restoreAndRecord(state, c, doc, base, before) {
508
+ const { db, actor, summary } = state;
509
+ let restored = false;
510
+ try {
511
+ await state.deps.restoreFields(db, doc.collection, doc.id, before, actor);
512
+ restored = true;
513
+ if (c.issueId)
514
+ await reopenIssue(db, c.issueId, 'restore');
515
+ }
516
+ catch (err) {
517
+ console.warn('[seo/autopilot] restore after verify failure failed', { issueId: c.issueId, fields: Object.keys(before) }, err);
518
+ }
519
+ await recordOrHalt(state, {
520
+ ...base,
521
+ status: 'verify_failed',
522
+ revertReason: restored ? 'verify_failed' : null,
523
+ revertedAt: restored ? new Date(state.now()) : null,
524
+ revertedById: restored ? actor.userId : null,
525
+ }, null);
526
+ summary.verifyFailed++;
527
+ countApplied(state, c);
528
+ state.verifyFailures++;
529
+ if (!summary.halted && state.verifyFailures >= state.policy.verifyFailuresPerRun) {
530
+ summary.halted = true;
531
+ summary.haltedReason = 'verify_failures';
532
+ }
533
+ }
534
+ async function applyIssueFixCandidate(state, c, gate) {
535
+ const { db, actor, summary } = state;
536
+ if (!c.entity.id) {
537
+ bump(summary.applyOutcomes, 'not-found');
538
+ return;
539
+ }
540
+ const doc = await readDocument(db, c.entity.id);
541
+ if (!doc) {
542
+ bump(summary.applyOutcomes, 'not-found');
543
+ return;
544
+ }
545
+ const patch = await resolvePatch(db, c);
546
+ if (!patch) {
547
+ bump(summary.applyOutcomes, 'stale');
548
+ return;
549
+ }
550
+ const keys = Object.keys(patch);
551
+ if (keys.length === 0) {
552
+ bump(summary.applyOutcomes, 'not-fixable');
553
+ return;
554
+ }
555
+ // Snapshot BEFORE the write — applyIssueFix does not return the diff on verify-failed.
556
+ const before = {};
557
+ for (const key of keys)
558
+ before[key] = doc.data[key] ?? null;
559
+ const afterFromPatch = {};
560
+ for (const key of keys)
561
+ afterFromPatch[key] = patch[key] ?? null;
562
+ const base = {
563
+ kind: 'issue-fix',
564
+ issueId: c.issueId,
565
+ issueType: c.issueTypeKey,
566
+ entityId: doc.id,
567
+ collection: doc.collection,
568
+ path: normalizeAutopilotPath(c.entity.url),
569
+ redirectId: null,
570
+ before,
571
+ after: afterFromPatch,
572
+ gate,
573
+ appliedById: actor.userId,
574
+ };
575
+ /** Live values for the patch keys after the apply; `null` when unreadable. */
576
+ const liveAfter = async () => {
577
+ const fresh = await readDocument(db, doc.id).catch(() => null);
578
+ if (!fresh)
579
+ return null;
580
+ const out = {};
581
+ for (const key of keys)
582
+ out[key] = fresh.data[key] ?? null;
583
+ return out;
584
+ };
585
+ if (!(await dayCapAllowsWrite(state)))
586
+ return;
587
+ let res;
588
+ try {
589
+ res = await state.deps.applyFix(db, {
590
+ issueId: c.issueId,
591
+ fingerprint: c.fingerprint ?? undefined,
592
+ actor,
593
+ // The gate evaluated the cached text for AI-tier candidates; make applyIssueFix write
594
+ // that rather than a recomputed deterministic value. Deterministic-tier candidates
595
+ // were gated on their own recompute, so they keep the default order.
596
+ preferCached: c.source === 'ai',
597
+ });
598
+ }
599
+ catch (err) {
600
+ console.warn('[seo/autopilot] applyIssueFix threw', { issueId: c.issueId }, err);
601
+ // Unknown failure: find out whether the document was written before treating it as a no-op.
602
+ const live = await liveAfter();
603
+ const wrote = live !== null && keys.some((key) => !sameValue(live[key], before[key]));
604
+ if (wrote) {
605
+ await restoreAndRecord(state, c, doc, { ...base, after: live }, before);
606
+ }
607
+ else {
608
+ bump(summary.applyOutcomes, 'error');
609
+ }
610
+ return;
611
+ }
612
+ switch (res.status) {
613
+ case 'applied': {
614
+ const live = await liveAfter();
615
+ // Written value must equal gated value. `applyIssueFix` verified against ITS suggestion;
616
+ // this verifies against the patch the gate saw. Any drift is a verify failure — and so
617
+ // is not being able to read the document back: an unverifiable write is not kept.
618
+ if (!live) {
619
+ console.warn('[seo/autopilot] document unreadable after apply; restoring', {
620
+ issueId: c.issueId,
621
+ entityId: doc.id,
622
+ });
623
+ bump(summary.applyOutcomes, 'after_unreadable');
624
+ await restoreAndRecord(state, c, doc, base, before);
625
+ return;
626
+ }
627
+ const drifted = keys.filter((key) => !GATE_MISMATCH_EXEMPT_KEYS.has(key) && !sameValue(live[key], afterFromPatch[key]));
628
+ if (drifted.length > 0) {
629
+ console.warn('[seo/autopilot] written value differs from gated patch; restoring', {
630
+ issueId: c.issueId,
631
+ fields: drifted,
632
+ });
633
+ bump(summary.applyOutcomes, 'gate_mismatch');
634
+ await restoreAndRecord(state, c, doc, { ...base, after: live }, before);
635
+ return;
636
+ }
637
+ const recorded = await recordOrHalt(state, {
638
+ ...base,
639
+ after: live,
640
+ status: 'applied',
641
+ versionId: await findApplyVersionId(db, doc.id, actor.userId, state.now()),
642
+ }, async () => {
643
+ await state.deps.restoreFields(db, doc.collection, doc.id, before, actor);
644
+ await reopenIssue(db, c.issueId, 'ledger failure');
645
+ });
646
+ if (!recorded)
647
+ return;
648
+ summary.applied++;
649
+ countApplied(state, c);
650
+ return;
651
+ }
652
+ case 'verify-failed':
653
+ await restoreAndRecord(state, c, doc, base, before);
654
+ return;
655
+ case 'stale':
656
+ case 'plan-required':
657
+ case 'not-found':
658
+ case 'not-allowed':
659
+ case 'not-fixable':
660
+ bump(summary.applyOutcomes, res.status);
661
+ return;
662
+ default: {
663
+ const exhaustive = res;
664
+ return exhaustive;
665
+ }
666
+ }
667
+ }
668
+ async function applyCandidate(state, c) {
669
+ const verdict = evaluateAutopilotGate(c, state.ctx);
670
+ if (!verdict.pass) {
671
+ bumpGate(state.summary.gated, verdict.reason);
672
+ return;
673
+ }
674
+ const gate = {
675
+ source: c.source,
676
+ brandAlignment: c.brandAlignment,
677
+ confidence: c.confidence,
678
+ checks: verdict.checks,
679
+ };
680
+ switch (c.kind) {
681
+ case 'redirect':
682
+ if (!c.suggestionId) {
683
+ bump(state.summary.applyOutcomes, 'not-found');
684
+ return;
685
+ }
686
+ await applyRedirect(state, { ...c, suggestionId: c.suggestionId }, gate);
687
+ return;
688
+ case 'issue-fix':
689
+ if (!c.issueId) {
690
+ bump(state.summary.applyOutcomes, 'not-found');
691
+ return;
692
+ }
693
+ await applyIssueFixCandidate(state, { ...c, issueId: c.issueId }, gate);
694
+ return;
695
+ default: {
696
+ const exhaustive = c.kind;
697
+ return exhaustive;
698
+ }
699
+ }
700
+ }
701
+ function isActiveAdmin(owner) {
702
+ return owner !== null && owner.isActive === true && ADMIN_ROLES.has(owner.role);
703
+ }
704
+ /**
705
+ * The resolved owner came from the legacy 404 lever's `updatedBy` (or there was
706
+ * none) rather than from a stored `autopilot.enabledById`. Such an owner is
707
+ * advisory: crons rewrite `updatedBy` with `'system'`, and the admin who flipped
708
+ * the lever may have left since. Only true while the lever is on and nothing
709
+ * explicit has been stored — one PUT/resume stores a real owner and ends it.
710
+ */
711
+ function isLegacyOwnerAdvisory(overrides, moduleSettings) {
712
+ const stored = asRecord(asRecord(overrides?.module).autopilot);
713
+ return typeof stored.enabledById !== 'string' && moduleSettings.redirectRecoveryAutoApply === true;
714
+ }
715
+ /**
716
+ * Pick the most recently active (then newest) active admin, persist it as the
717
+ * explicit `enabledById` (sparse write — nothing else materialises), and audit
718
+ * `seo_autopilot_owner_migrated`. Returns `null` — leaving the caller on the
719
+ * `owner_unavailable` path — when no admin exists or the write fails; an owner
720
+ * that could not be recorded must not run the tick.
721
+ */
722
+ async function migrateLegacyOwner(db, previous) {
723
+ let admin;
724
+ try {
725
+ admin = await db.user.findFirst({
726
+ where: { isActive: true, role: { in: [...ADMIN_ROLES] } },
727
+ orderBy: [{ lastActiveAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }],
728
+ select: { id: true, role: true, isActive: true },
729
+ });
730
+ }
731
+ catch (err) {
732
+ console.warn('[seo/autopilot] legacy owner migration: admin lookup failed', err);
733
+ return null;
734
+ }
735
+ if (!isActiveAdmin(admin))
736
+ return null;
737
+ try {
738
+ await patchAutopilotPolicy(db, { enabledById: admin.id }, admin.id);
739
+ }
740
+ catch (err) {
741
+ console.warn('[seo/autopilot] legacy owner migration: failed to persist owner', err);
742
+ return null;
743
+ }
744
+ await audit('seo_autopilot_owner_migrated', admin.id, {
745
+ enabledById: admin.id,
746
+ previous,
747
+ source: 'legacy_redirect_recovery_lever',
748
+ });
749
+ return admin;
750
+ }
751
+ /**
752
+ * The user the pause is written as. The owner row (even inactive / demoted)
753
+ * is preferred; otherwise any active admin — the settings document's
754
+ * `updatedById` is a foreign key, so an unverified id would fail the write.
755
+ */
756
+ async function resolvePauseWriter(db, owner) {
757
+ if (owner)
758
+ return owner.id;
759
+ try {
760
+ const admin = await db.user.findFirst({
761
+ where: { isActive: true, role: { in: [...ADMIN_ROLES] } },
762
+ select: { id: true },
763
+ });
764
+ return admin?.id ?? null;
765
+ }
766
+ catch {
767
+ return null;
768
+ }
769
+ }
770
+ export async function runSeoAutopilot(db, opts = {}) {
771
+ const now = opts.now ?? Date.now;
772
+ const startedMs = now();
773
+ const startedAt = new Date(startedMs);
774
+ const budgetMs = opts.budgetMs ?? AUTOPILOT_BUDGET_MS;
775
+ const deps = opts.deps ?? {};
776
+ const generate = deps.generate ?? generateSeoProposals;
777
+ const listProposals = deps.listProposals ?? listSeoProposals;
778
+ const deterministicCandidates = deps.deterministicCandidates ?? defaultDeterministicCandidates;
779
+ const outcomeWatch = deps.outcomeWatch ?? runOutcomeWatch;
780
+ const applyDeps = {
781
+ applyFix: deps.applyFix ?? applyIssueFix,
782
+ acceptRedirect: deps.acceptRedirect ?? acceptRedirectSuggestion,
783
+ restoreFields: deps.restoreFields ?? defaultRestoreFields,
784
+ };
785
+ const elapsed = () => now() - startedMs;
786
+ const summary = {
787
+ startedAt: startedAt.toISOString(),
788
+ durationMs: 0,
789
+ applied: 0,
790
+ verifyFailed: 0,
791
+ reverted: 0,
792
+ gated: {},
793
+ applyOutcomes: {},
794
+ generated: { generated: 0, skipped: 0, failed: 0, haltedReason: null },
795
+ halted: false,
796
+ haltedReason: null,
797
+ };
798
+ // 1. Preflight
799
+ const overrides = await getSeoOverrides(db);
800
+ const moduleSettings = resolveSeoModuleSettings(overrides);
801
+ const policy = moduleSettings.autopilot;
802
+ if (!policy.enabled) {
803
+ summary.halted = true;
804
+ summary.haltedReason = 'disabled';
805
+ summary.durationMs = elapsed();
806
+ return summary;
807
+ }
808
+ if (policy.pausedAt) {
809
+ summary.halted = true;
810
+ summary.haltedReason = 'paused';
811
+ summary.durationMs = elapsed();
812
+ return summary;
813
+ }
814
+ let owner = policy.enabledById
815
+ ? await db.user.findUnique({
816
+ where: { id: policy.enabledById },
817
+ select: { id: true, role: true, isActive: true },
818
+ })
819
+ : null;
820
+ if (!isActiveAdmin(owner) && isLegacyOwnerAdvisory(overrides, moduleSettings)) {
821
+ // Compatibility: the tenant enabled the old 404 lever and never stored an
822
+ // owner. Adopt an active admin instead of pausing on the first tick.
823
+ owner = (await migrateLegacyOwner(db, policy.enabledById)) ?? owner;
824
+ }
825
+ if (!isActiveAdmin(owner)) {
826
+ summary.halted = true;
827
+ summary.haltedReason = 'owner_unavailable';
828
+ const writer = await resolvePauseWriter(db, owner);
829
+ if (writer) {
830
+ try {
831
+ summary.durationMs = elapsed();
832
+ // Sparse policy write: only the pause keys, merged onto the stored block.
833
+ await patchAutopilotPolicy(db, { pausedAt: startedAt.toISOString(), pausedReason: 'owner_unavailable' }, writer);
834
+ await patchSeoModuleSettings(db, { autopilotLastRunAt: startedAt.toISOString(), autopilotLastRun: summary }, writer);
835
+ }
836
+ catch (err) {
837
+ console.warn('[seo/autopilot] failed to persist owner_unavailable pause', err);
838
+ }
839
+ await audit('seo_autopilot_paused', writer, {
840
+ reason: 'owner_unavailable',
841
+ enabledById: policy.enabledById,
842
+ });
843
+ }
844
+ else {
845
+ console.warn('[seo/autopilot] owner unavailable and no active admin to write the pause');
846
+ }
847
+ summary.durationMs = elapsed();
848
+ return summary;
849
+ }
850
+ const actor = { userId: owner.id, role: owner.role };
851
+ // Run lease. Not atomic (read-then-write on the settings document), but it
852
+ // closes the realistic overlap — admin "Run now" landing on the cron tick, or
853
+ // two admins clicking within the budget — to a window of milliseconds, and the
854
+ // per-write day-count check bounds whatever slips through. A lease older than
855
+ // budget + margin belongs to a tick that died before its `finally`; take it over.
856
+ if (isLeaseHeld(moduleSettings.autopilotRunStartedAt, startedMs, budgetMs)) {
857
+ summary.halted = true;
858
+ summary.haltedReason = 'already_running';
859
+ summary.durationMs = elapsed();
860
+ return summary;
861
+ }
862
+ await patchSeoModuleSettings(db, { autopilotRunStartedAt: summary.startedAt }, actor.userId);
863
+ try {
864
+ await runPhases(db, { policy, actor, summary, overrides, startedMs, startedAt, budgetMs, now }, { generate, listProposals, deterministicCandidates, outcomeWatch, applyDeps });
865
+ }
866
+ catch (err) {
867
+ // Nothing in the phases is expected to throw (each contains its own failures);
868
+ // an escape is a bug, but the lease and last-run record must still be correct.
869
+ // The record carries a code only: the summary is served to every authenticated
870
+ // role and provider / Prisma messages can name hosts and tables.
871
+ console.warn('[seo/autopilot] run failed', err);
872
+ summary.halted = true;
873
+ summary.haltedReason = summary.haltedReason ?? 'run_failed';
874
+ throw err;
875
+ }
876
+ finally {
877
+ // 5. Summary + release the lease in the same write. Nothing here may throw:
878
+ // a stuck lease would block every tick until the stale margin expires.
879
+ try {
880
+ summary.durationMs = elapsed();
881
+ }
882
+ catch {
883
+ /* keep the last measured duration */
884
+ }
885
+ try {
886
+ await patchSeoModuleSettings(db, {
887
+ autopilotLastRunAt: startedAt.toISOString(),
888
+ autopilotLastRun: summary,
889
+ autopilotRunStartedAt: null,
890
+ }, actor.userId);
891
+ }
892
+ catch (err) {
893
+ console.warn('[seo/autopilot] failed to persist run summary', err);
894
+ }
895
+ }
896
+ await audit('seo_autopilot_run', actor.userId, { ...summary });
897
+ return summary;
898
+ }
899
+ /** A lease younger than this run's budget plus the stale margin belongs to a live tick. */
900
+ function isLeaseHeld(lease, nowMs, budgetMs) {
901
+ if (!lease)
902
+ return false;
903
+ const startedMs = Date.parse(lease);
904
+ if (!Number.isFinite(startedMs))
905
+ return false;
906
+ return nowMs - startedMs < budgetMs + AUTOPILOT_LEASE_STALE_MARGIN_MS;
907
+ }
908
+ /** Phases 2–4 (outcome watch, generate, apply). Runs under the lease; mutates `summary`. */
909
+ async function runPhases(db, input, deps) {
910
+ const { policy, actor, summary, overrides, startedMs, startedAt, budgetMs, now } = input;
911
+ const { generate, listProposals, deterministicCandidates, outcomeWatch, applyDeps } = deps;
912
+ const elapsed = () => now() - startedMs;
913
+ let workingPolicy = policy;
914
+ // 2. Outcome watch + breakers (bounded by AUTOPILOT_OUTCOME_BUDGET_MS)
915
+ const watchStartedMs = now();
916
+ // Pauses are persisted the moment the watch decides them (sparse write that
917
+ // merges onto the stored map), so a revert that throws, a watch that escapes,
918
+ // or a killed process cannot lose a breaker. `persisted` lets the post-watch
919
+ // sweep below write only what the callback failed to.
920
+ const decidedPauses = {};
921
+ const persisted = new Set();
922
+ const onPause = async (type, reason) => {
923
+ decidedPauses[type] = reason;
924
+ await patchAutopilotPolicy(db, { pausedTypes: { [type]: reason } }, actor.userId);
925
+ persisted.add(type);
926
+ };
927
+ try {
928
+ const watch = await outcomeWatch(db, workingPolicy, {
929
+ now: startedAt,
930
+ actor,
931
+ clock: now,
932
+ deadline: watchStartedMs + AUTOPILOT_OUTCOME_BUDGET_MS,
933
+ onPause,
934
+ });
935
+ summary.reverted = watch.reverted;
936
+ if (watch.revertErrors > 0) {
937
+ summary.applyOutcomes.outcome_revert_error =
938
+ (summary.applyOutcomes.outcome_revert_error ?? 0) + watch.revertErrors;
939
+ }
940
+ Object.assign(decidedPauses, watch.pausedTypes);
941
+ }
942
+ catch (err) {
943
+ console.warn('[seo/autopilot] outcome watch failed', err);
944
+ bump(summary.applyOutcomes, 'outcome_watch_error');
945
+ }
946
+ if (Object.keys(decidedPauses).length > 0) {
947
+ workingPolicy = {
948
+ ...workingPolicy,
949
+ pausedTypes: { ...workingPolicy.pausedTypes, ...decidedPauses },
950
+ };
951
+ const unpersisted = Object.fromEntries(Object.entries(decidedPauses).filter(([t]) => !persisted.has(t)));
952
+ if (Object.keys(unpersisted).length > 0) {
953
+ try {
954
+ await patchAutopilotPolicy(db, { pausedTypes: unpersisted }, actor.userId);
955
+ }
956
+ catch (err) {
957
+ console.warn('[seo/autopilot] failed to persist outcome pauses', err);
958
+ bump(summary.applyOutcomes, 'outcome_watch_error');
959
+ }
960
+ }
961
+ }
962
+ const watchMs = now() - watchStartedMs;
963
+ if (watchMs > AUTOPILOT_OUTCOME_BUDGET_MS) {
964
+ console.warn('[seo/autopilot] outcome watch exceeded its budget', {
965
+ ms: watchMs,
966
+ budgetMs: AUTOPILOT_OUTCOME_BUDGET_MS,
967
+ });
968
+ }
969
+ // 3. Generate (AI) — only for opted-in, unpaused metadata types
970
+ const enabledMetadata = new Set(AUTOPILOT_METADATA_TYPES.filter((t) => workingPolicy.types[t] === true && !(t in workingPolicy.pausedTypes)));
971
+ if (enabledMetadata.size > 0) {
972
+ const generateBudget = Math.min(AUTOPILOT_GENERATE_BUDGET_MS, budgetMs - elapsed() - AUTOPILOT_APPLY_RESERVE_MS);
973
+ if (generateBudget > 0) {
974
+ try {
975
+ const g = await generate(db, { userId: actor.userId }, { concurrency: 2, budgetMs: generateBudget, onlyTypes: enabledMetadata, now });
976
+ summary.generated = {
977
+ generated: g.generated,
978
+ skipped: g.skipped,
979
+ failed: g.failed,
980
+ haltedReason: g.haltedReason,
981
+ };
982
+ }
983
+ catch (err) {
984
+ console.warn('[seo/autopilot] generation failed', err);
985
+ summary.generated.haltedReason = 'generate_failed';
986
+ }
987
+ }
988
+ else {
989
+ summary.generated.haltedReason = 'budget_exhausted';
990
+ }
991
+ }
992
+ // 4. Apply
993
+ if (elapsed() >= budgetMs) {
994
+ summary.halted = true;
995
+ summary.haltedReason = 'budget_exhausted';
996
+ }
997
+ else {
998
+ try {
999
+ const payload = await listProposals(db);
1000
+ let deterministic = [];
1001
+ if (enabledMetadata.size > 0) {
1002
+ try {
1003
+ deterministic = await deterministicCandidates(db, enabledMetadata, {
1004
+ now,
1005
+ deadline: startedMs + budgetMs,
1006
+ overrides,
1007
+ });
1008
+ }
1009
+ catch (err) {
1010
+ console.warn('[seo/autopilot] deterministic scan failed; inbox proposals only', err);
1011
+ }
1012
+ }
1013
+ const candidates = orderCandidates(dedupeById([...deterministic, ...payload.proposals]
1014
+ .map(toCandidate)
1015
+ .filter((c) => c !== null)));
1016
+ await attachDismissalMarkers(db, candidates);
1017
+ // Discovery (inbox + scan) and the gate context (published-document index, redirect
1018
+ // index, caps) are the expensive preamble. If the scan already consumed the reserve,
1019
+ // do not pay for the gate context; if the gate context consumed it, say so rather than
1020
+ // entering the loop only to exit on its first check.
1021
+ const ctx = elapsed() < budgetMs
1022
+ ? await buildGateContext(db, workingPolicy, candidates, startedAt, overrides)
1023
+ : null;
1024
+ if (!ctx || elapsed() >= budgetMs) {
1025
+ summary.halted = true;
1026
+ summary.haltedReason = 'budget_exhausted';
1027
+ }
1028
+ else {
1029
+ const state = {
1030
+ db,
1031
+ policy: workingPolicy,
1032
+ actor,
1033
+ summary,
1034
+ ctx,
1035
+ verifyFailures: 0,
1036
+ now,
1037
+ startedAt,
1038
+ dayCapReached: false,
1039
+ deps: applyDeps,
1040
+ };
1041
+ for (const c of candidates) {
1042
+ if (summary.halted || state.dayCapReached)
1043
+ break;
1044
+ if (elapsed() >= budgetMs) {
1045
+ summary.halted = true;
1046
+ summary.haltedReason = 'budget_exhausted';
1047
+ break;
1048
+ }
1049
+ await applyCandidate(state, c);
1050
+ }
1051
+ }
1052
+ }
1053
+ catch (err) {
1054
+ console.warn('[seo/autopilot] apply phase failed', err);
1055
+ summary.halted = true;
1056
+ summary.haltedReason = summary.haltedReason ?? 'apply_failed';
1057
+ }
1058
+ }
1059
+ }
1060
+ //# sourceMappingURL=autopilot.js.map