@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.
- package/CHANGELOG.md +31 -0
- package/dist/ai/governance.d.ts +6 -0
- package/dist/ai/governance.d.ts.map +1 -1
- package/dist/ai/governance.js +19 -0
- package/dist/ai/governance.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +2 -0
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/routes/redirects.d.ts.map +1 -1
- package/dist/api/routes/redirects.js +89 -93
- package/dist/api/routes/redirects.js.map +1 -1
- package/dist/api/routes/seo-autopilot.d.ts +12 -0
- package/dist/api/routes/seo-autopilot.d.ts.map +1 -0
- package/dist/api/routes/seo-autopilot.js +257 -0
- package/dist/api/routes/seo-autopilot.js.map +1 -0
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +195 -360
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/db/model-types.d.ts +26 -0
- package/dist/db/model-types.d.ts.map +1 -1
- package/dist/generated/browser.d.ts +8 -0
- package/dist/generated/client.d.ts +8 -0
- package/dist/generated/internal/class.d.ts +11 -0
- package/dist/generated/internal/class.js +4 -4
- package/dist/generated/internal/prismaNamespace.d.ts +120 -1
- package/dist/generated/internal/prismaNamespace.js +42 -0
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +44 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +42 -0
- package/dist/generated/models/SeoAutoApply.d.ts +1495 -0
- package/dist/generated/models/SeoAutoApply.js +1 -0
- package/dist/generated/models.d.ts +1 -0
- package/dist/redirects/recovery.d.ts +15 -9
- package/dist/redirects/recovery.d.ts.map +1 -1
- package/dist/redirects/recovery.js +15 -61
- package/dist/redirects/recovery.js.map +1 -1
- package/dist/redirects/suggestion-accept.d.ts +29 -0
- package/dist/redirects/suggestion-accept.d.ts.map +1 -0
- package/dist/redirects/suggestion-accept.js +108 -0
- package/dist/redirects/suggestion-accept.js.map +1 -0
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js +0 -2
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/autopilot-gate.d.ts +101 -0
- package/dist/seo/autopilot-gate.d.ts.map +1 -0
- package/dist/seo/autopilot-gate.js +272 -0
- package/dist/seo/autopilot-gate.js.map +1 -0
- package/dist/seo/autopilot-ledger.d.ts +74 -0
- package/dist/seo/autopilot-ledger.d.ts.map +1 -0
- package/dist/seo/autopilot-ledger.js +118 -0
- package/dist/seo/autopilot-ledger.js.map +1 -0
- package/dist/seo/autopilot-outcome.d.ts +93 -0
- package/dist/seo/autopilot-outcome.d.ts.map +1 -0
- package/dist/seo/autopilot-outcome.js +206 -0
- package/dist/seo/autopilot-outcome.js.map +1 -0
- package/dist/seo/autopilot-policy.d.ts +169 -0
- package/dist/seo/autopilot-policy.d.ts.map +1 -0
- package/dist/seo/autopilot-policy.js +267 -0
- package/dist/seo/autopilot-policy.js.map +1 -0
- package/dist/seo/autopilot-revert.d.ts +40 -0
- package/dist/seo/autopilot-revert.d.ts.map +1 -0
- package/dist/seo/autopilot-revert.js +155 -0
- package/dist/seo/autopilot-revert.js.map +1 -0
- package/dist/seo/autopilot.d.ts +91 -0
- package/dist/seo/autopilot.d.ts.map +1 -0
- package/dist/seo/autopilot.js +1060 -0
- package/dist/seo/autopilot.js.map +1 -0
- package/dist/seo/config-store.d.ts +35 -2
- package/dist/seo/config-store.d.ts.map +1 -1
- package/dist/seo/config-store.js +58 -1
- package/dist/seo/config-store.js.map +1 -1
- package/dist/seo/deep-equal.d.ts +12 -0
- package/dist/seo/deep-equal.d.ts.map +1 -0
- package/dist/seo/deep-equal.js +42 -0
- package/dist/seo/deep-equal.js.map +1 -0
- package/dist/seo/digest.d.ts +27 -0
- package/dist/seo/digest.d.ts.map +1 -1
- package/dist/seo/digest.js +77 -1
- package/dist/seo/digest.js.map +1 -1
- package/dist/seo/index.d.ts +20 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +12 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/issue-fix-ai.d.ts +40 -1
- package/dist/seo/issue-fix-ai.d.ts.map +1 -1
- package/dist/seo/issue-fix-ai.js +53 -1
- package/dist/seo/issue-fix-ai.js.map +1 -1
- package/dist/seo/issue-fix-apply.d.ts +104 -0
- package/dist/seo/issue-fix-apply.d.ts.map +1 -0
- package/dist/seo/issue-fix-apply.js +406 -0
- package/dist/seo/issue-fix-apply.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +9 -0
- package/dist/seo/issue-fix.d.ts.map +1 -1
- package/dist/seo/issue-fix.js +1 -0
- package/dist/seo/issue-fix.js.map +1 -1
- package/dist/seo/proposals-bulk-apply.d.ts +52 -0
- package/dist/seo/proposals-bulk-apply.d.ts.map +1 -0
- package/dist/seo/proposals-bulk-apply.js +183 -0
- package/dist/seo/proposals-bulk-apply.js.map +1 -0
- package/dist/seo/proposals-generate.d.ts +45 -0
- package/dist/seo/proposals-generate.d.ts.map +1 -0
- package/dist/seo/proposals-generate.js +144 -0
- package/dist/seo/proposals-generate.js.map +1 -0
- package/dist/seo/proposals.d.ts +125 -0
- package/dist/seo/proposals.d.ts.map +1 -0
- package/dist/seo/proposals.js +270 -0
- package/dist/seo/proposals.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/0017_seo_auto_applies/migration.sql +35 -0
- 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
|