Setting the file. One moment. Migration Completion · Wix Replatform · wix/skills · Skills Docs 45.106lib/migration-completion.js
JavaScript·317 lines·18 KB
EXECUTION_LOG_FILE
}
=
require
(
'./execution-log.js'
);
8const { hashArtifact } = require('./artifact-freshness.js');
9const { STATUS_FILE, discoveredSetStatusFreshness, deriveFinalStatus, inputDigests, checkResolutionPrefix } = require('./discovered-set-status.js');
10const { ENTITY_QA_ATTESTATION_FILE, validateEntityQaAttestation } = require('./entity-qa-attestation.js');
11
12const COMPLETION_FILE = 'completion/migration-completion.json';
13const FRONTEND_COMPLETION_FILE = 'website/completion.json';
14const BACKEND_COMPLETION_FILE = 'execution/completion-report.json';
15const MAPPING_PLAN_FILE = 'mapping/mapping-plan.json';
16const QUICK_MODE_PLAN_FILE = 'quick-mode/plan.json';
17const COMPLETE_STATUSES = new Set(['complete', 'complete_with_warnings']);
18const BACKEND_COMPLETE_STATUSES = new Set([
19 'complete',
20 'complete_with_warnings',
21 'complete_with_recovered_records',
22 'complete_with_deferred_records',
23]);
24
25async function readJsonIfExists(filePath) {
26 try {
27 return JSON.parse(await fs.readFile(filePath, 'utf8'));
28 } catch (error) {
29 if (error && error.code === 'ENOENT') return null;
30 throw error;
31 }
32}
33
34// A parse failure on the entity-QA attestation (or on the independent mapped-entity
35// source used to judge its coverage) must resolve exactly like a missing file -- never a thrown
36// error that aborts finalization checking outright. This mirrors `orchestration-router.js`'s own
37// `readJson` helper; it is intentionally NOT `readJsonIfExists` above, whose re-throw-on-parse-
38// -failure behavior for every other artifact it already loads is unchanged.
39async function readJsonTolerant(filePath) {
40 try {
41 return JSON.parse(await fs.readFile(filePath, 'utf8'));
42 } catch {
43 return null;
44 }
45}
46
47// The independent coverage source `validateEntityQaAttestation` checks attestation coverage
48// against -- never the completion report's own (possibly incomplete) `entityCompleteness[]`.
49// Full mode: the entity rows of `mapping/mapping-plan.json`, read the same way
50// `website-handoff.js`'s `readEntityMappings` already does. Quick mode: the `entities` array of
51// `quick-mode/plan.json`, written at plan time before extraction or import ever runs.
52async function readMappedEntities(projectDir, managementImportMode) {
53 if (managementImportMode === 'quick') {
54 const quickPlan = await readJsonTolerant(path.join(projectDir, QUICK_MODE_PLAN_FILE));
55 return Array.isArray(quickPlan && quickPlan.entities) ? quickPlan.entities : [];
56 }
57 const mappingPlan = await readJsonTolerant(path.join(projectDir, MAPPING_PLAN_FILE));
58 return readEntityMappings(mappingPlan);
59}
60
61// This compatibility receipt test deliberately uses only the original receipt fields.
62// Later finalization requirements cannot prove that a legacy run really finished.
63async function classifyFinalizedRun(projectDir, { decisions = {}, receiptFile = COMPLETION_FILE } = {}) {
64 const { getDecisionValue, getEffectiveDeliveryMode } = require('./orchestration-decisions.js');
65 const receiptPath = path.join(projectDir, receiptFile);
66 try { await fs.access(receiptPath); } catch (error) { if (error.code === 'ENOENT') return { kind: 'none', errors: [] }; throw error; }
67 const [receipt, report] = await Promise.all([readJsonTolerant(receiptPath), readJsonTolerant(path.join(projectDir, BACKEND_COMPLETION_FILE))]);
68 if (receipt?.schemaVersion === 2) return { kind: 'current', errors: [] };
69 const quick = getDecisionValue(decisions, 'managementImportMode') === 'quick';
70 const reportIsObject = report && typeof report === 'object' && !Array.isArray(report);
71 if ((quick && (!reportIsObject || report.managementImportMode !== 'quick')) || (!quick && report?.managementImportMode === 'quick')) return { kind: 'unverified-v1', reason: 'completion_mode_mismatch', errors: ['completion_mode_mismatch'] };
72 const errors = [];
73 const object = receipt && typeof receipt === 'object' && !Array.isArray(receipt);
74 if (!object || receipt.schemaVersion !== 1) errors.push('schemaVersion');
75 if (!object || !BACKEND_COMPLETE_STATUSES.has(receipt.status)) errors.push('status');
76 if (typeof receipt?.finalizedAt !== 'string' || !Number.isFinite(Date.parse(receipt.finalizedAt))) errors.push('finalizedAt');
77 const mode = getEffectiveDeliveryMode(decisions);
78 if (!['management','management_and_website'].includes(mode) || receipt?.deliveryMode !== mode) errors.push('deliveryMode');
79 if (receipt?.backendCompletion !== BACKEND_COMPLETION_FILE) errors.push('backendCompletion');
80 if (mode === 'management_and_website') {
81 if (receipt?.frontendCompletion !== FRONTEND_COMPLETION_FILE || typeof receipt?.handoffFingerprint !== 'string' || !receipt.handoffFingerprint.trim()) errors.push('websiteBinding');
82 } else if (receipt?.frontendCompletion !== null || receipt?.handoffFingerprint !== null) errors.push('websiteBinding');
83 if (!reportIsObject || !BACKEND_COMPLETE_STATUSES.has(report.status) || receipt?.status !== report.status) errors.push('completionReport.status');
84 if (report?.lastWriteAt && (!Number.isFinite(Date.parse(report.lastWriteAt)) || Date.parse(receipt?.finalizedAt) <= Date.parse(report.lastWriteAt))) errors.push('completionReport.lastWriteAt');
85 if (errors.length) return { kind: 'unverified-v1', reason: quick ? 'quick_receipt_unverified' : 'finalized_receipt_unverified', errors: quick ? ['quick_receipt_unverified', ...errors] : errors };
86 return { kind: quick ? 'quick' : 'valid-v1', errors: [] };
87}
88
89function validateFrontendCompletion(receipt, handoff, { requireHandoff = true } = {}) {
90 if (!receipt || typeof receipt !== 'object') {
91 return { ok: false, reason: 'frontend_completion_missing' };
92 }
93 if (receipt.schemaVersion !== 1) {
94 return { ok: false, reason: 'frontend_completion_schema_invalid' };
95 }
96 if (!COMPLETE_STATUSES.has(receipt.status)) {
97 return { ok: false, reason: receipt.status === 'blocked' ? 'frontend_blocked' : 'frontend_completion_incomplete', receipt };
98 }
99 if (requireHandoff && (!handoff || !handoff.handoffFingerprint || receipt.handoffFingerprint !== handoff.handoffFingerprint)) {
100 return { ok: false, reason: 'frontend_completion_stale', receipt };
101 }
102 const gapAnalysis = receipt.gapAnalysis;
103 if (!gapAnalysis || gapAnalysis.screenshotReview !== 'complete'
104 || gapAnalysis.unresolvedCritical !== 0 || gapAnalysis.unresolvedHigh !== 0) {
105 return { ok: false, reason: 'frontend_gap_analysis_incomplete', receipt };
106 }
107 if (!receipt.release || !['released', 'not_applicable'].includes(receipt.release.status)) {
108 return { ok: false, reason: 'frontend_release_incomplete', receipt };
109 }
110 return { ok: true, receipt };
111}
112
113// "done" requires all four artifacts, and a requirement nothing checks is how three
114// of them came to have no writer. Both delivery modes now need the post-execution handoff and the
115// execution log -- `management` previously required no handoff at all, which is why a
116// backend-only run could finalize with no account of the migration anywhere in the project.
117function validateCompletionArtifacts({ postExecutionHandoff, executionLogPresent, backendCompletion }) {
118 if (!executionLogPresent) {
119 return { ok: false, reason: 'missing_execution_log' };
120 }
121 if (!postExecutionHandoff || typeof postExecutionHandoff !== 'object') {
122 return { ok: false, reason: 'missing_post_execution_handoff' };
123 }
124 const lastWriteAt = backendCompletion && backendCompletion.lastWriteAt;
125 if (!lastWriteAt) {
126 return { ok: false, reason: 'completion_report_missing_last_write' };
127 }
128 // The generation timestamp must be later than the run's own last write. A handoff that
129 // predates the import is exactly the artifact this check exists to stop.
130 if (!postExecutionHandoff.generatedAt || Date.parse(postExecutionHandoff.generatedAt) <= Date.parse(lastWriteAt)) {
131 return { ok: false, reason: 'post_execution_handoff_predates_last_write' };
132 }
133 return { ok: true };
134}
135
136function runEntityQaGate({ backendCompletion, entityQaAttestation, mappedEntities, reportDigest }) {
137 return validateEntityQaAttestation({
138 attestation: entityQaAttestation,
139 report: backendCompletion,
140 reportDigest,
141 mappedEntities,
142 });
143}
144
145function createMigrationCompletion({
146 deliveryMode,
147 backendCompletion,
148 frontendCompletion = null,
149 handoff = null,
150 postExecutionHandoff = null,
151 executionLogPresent = false,
152 entityQaAttestation = null,
153 mappedEntities = null,
154 reportDigest = null,
155 discoveredSetStatus = null, discoveredSetFreshness = null, discoveredSetDigest = null,
156}) {
157 if (!backendCompletion || typeof backendCompletion !== 'object' || !backendCompletion.status) {
158 throw new Error('backend completion report is required');
159 }
160 // A stopped run's report is never completion, whatever status a writer joined in.
161 if (backendCompletion.interruption) {
162 throw Object.assign(new Error('backend completion is interrupted; resume the import'), { code: 'backend_completion_interrupted' });
163 }
164 if (!BACKEND_COMPLETE_STATUSES.has(backendCompletion.status)) {
165 throw new Error(`backend completion is not successful: ${backendCompletion.status}`);
166 }
167 if (!['management', 'management_and_website'].includes(deliveryMode)) {
168 throw new Error('aggregate migration completion requires management or management_and_website deliveryMode');
169 }
170 const artifacts = validateCompletionArtifacts({ postExecutionHandoff, executionLogPresent, backendCompletion });
171 if (!artifacts.ok) throw new Error(`migration cannot complete: ${artifacts.reason}`);
172 const quick = backendCompletion.managementImportMode === 'quick';
173 if (!quick) {
174 if (!discoveredSetStatus) throw Object.assign(new Error('missing_discovered_set_status'), { code: 'missing_discovered_set_status' });
175 if (!discoveredSetFreshness?.ok) throw Object.assign(new Error('discovered_set_status_stale'), { code: 'discovered_set_status_stale', staleInputs: discoveredSetFreshness?.staleInputs || [] });
176 if (discoveredSetStatus.inputs?.completionReport !== reportDigest) throw Object.assign(new Error('discovered_set_status_stale'), { code: 'discovered_set_status_stale', staleInputs: ['completionReport'] });
177 if (!discoveredSetDigest || postExecutionHandoff?.discoveredSet?.sha256 !== discoveredSetDigest) throw Object.assign(new Error('handoff_discovered_set_stale'), { code: 'handoff_discovered_set_stale' });
178 }
179 const finalStatus = deriveFinalStatus(backendCompletion.status, quick ? null : discoveredSetStatus);
180 if (postExecutionHandoff?.status !== finalStatus) throw Object.assign(new Error('handoff_status_mismatch'), { code: 'handoff_status_mismatch' });
181 if (deliveryMode === 'management_and_website') {
182 const validation = validateFrontendCompletion(frontendCompletion, handoff);
183 if (!validation.ok) throw new Error(`website migration cannot complete: ${validation.reason}`);
184 }
185 // A run is not allowed to finish without a fresh, complete
186 // entity-QA attestation. Missing, stale or failed refuses the same as any other
187 // artifact gap above -- it is not a new kind of failure, just one more required artifact.
188 const entityQa = runEntityQaGate({ backendCompletion, entityQaAttestation, mappedEntities, reportDigest });
189 if (!entityQa.ok) throw new Error(`migration cannot complete: ${entityQa.reasons[0]}`);
190 return {
191 schemaVersion: 2,
192 status: finalStatus,
193 backendStatus: backendCompletion.status,
194 discoveredSet: quick ? null : { path: STATUS_FILE, sha256: discoveredSetDigest, openCount: discoveredSetStatus.openCount, inputs: discoveredSetStatus.inputs },
195 deliveryMode,
196 backendCompletion: BACKEND_COMPLETION_FILE,
197 frontendCompletion: deliveryMode === 'management_and_website' ? FRONTEND_COMPLETION_FILE : null,
198 handoffFingerprint: deliveryMode === 'management_and_website' ? handoff.handoffFingerprint : null,
199 postExecutionHandoff: POST_EXECUTION_HANDOFF_JSON,
200 executionLog: EXECUTION_LOG_FILE,
201 // `scope` says what was attested: import rows only. Not business setup, not launch
202 // readiness. A hollow attestation that copies the report can still pass; the gate proves
203 // freshness and coverage only.
204 entityQaAttestation: {
205 path: ENTITY_QA_ATTESTATION_FILE,
206 completionReportDigest: entityQaAttestation.completionReportDigest,
207 verifiedAt: entityQaAttestation.verifiedAt,
208 scope: 'import-entity-coverage',
209 },
210 finalizedAt: new Date().toISOString(),
211 };
212}
213
214async function loadMigrationCompletionInputs(projectDir) {
215 const [backendCompletion, frontendCompletion, handoff, completion, postExecutionHandoff, executionLogPresent, entityQaAttestation] = await Promise.all([
216 readJsonIfExists(path.join(projectDir, BACKEND_COMPLETION_FILE)),
217 readJsonIfExists(path.join(projectDir, FRONTEND_COMPLETION_FILE)),
218 readJsonIfExists(path.join(projectDir, 'website', 'handoff.json')),
219 readJsonIfExists(path.join(projectDir, COMPLETION_FILE)),
220 loadPostExecutionHandoff(projectDir),
221 executionLogExists(projectDir),
222 readJsonTolerant(path.join(projectDir, ENTITY_QA_ATTESTATION_FILE)),
223 ]);
224 const managementImportMode = backendCompletion && backendCompletion.managementImportMode === 'quick' ? 'quick' : 'full';
225 const mappedEntities = await readMappedEntities(projectDir, managementImportMode);
226 const reportDigest = hashArtifact(projectDir, BACKEND_COMPLETION_FILE);
227 const discoveredSetStatus = managementImportMode === 'quick' ? null : await readJsonTolerant(path.join(projectDir, STATUS_FILE));
228 const discoveredSetFreshness = discoveredSetStatus ? await discoveredSetStatusFreshness(projectDir, discoveredSetStatus) : null;
229 const discoveredSetDigest = discoveredSetStatus ? hashArtifact(projectDir, STATUS_FILE) : null;
230 const completionDiscoveredInputs = managementImportMode === 'quick' ? null : await inputDigests(projectDir);
231 const resolutionsPrefixValid = completion?.discoveredSet?.inputs?.resolutions ? await checkResolutionPrefix(projectDir, completion.discoveredSet.inputs.resolutions) : false;
232 return {
233 discoveredSetStatus, discoveredSetFreshness, discoveredSetDigest, completionDiscoveredInputs, resolutionsPrefixValid,
234 backendCompletion,
235 frontendCompletion,
236 handoff,
237 completion,
238 postExecutionHandoff,
239 executionLogPresent,
240 entityQaAttestation,
241 mappedEntities,
242 reportDigest,
243 };
244}
245
246function validateMigrationCompletion(completion, {
247 deliveryMode, backendCompletion, frontendCompletion, handoff, postExecutionHandoff, executionLogPresent,
248 entityQaAttestation, mappedEntities, reportDigest,
249 discoveredSetStatus = null, discoveredSetFreshness = null, discoveredSetDigest = null, completionDiscoveredInputs = null, resolutionsPrefixValid = false,
250}) {
251 // An existing finalization never outlives a stopped run that replaced its report.
252 if (backendCompletion?.interruption) return { ok: false, reason: 'backend_completion_interrupted' };
253 if (!completion || typeof completion !== 'object') return { ok: false, reason: 'migration_completion_missing' };
254 if (![1, 2].includes(completion.schemaVersion) || completion.deliveryMode !== deliveryMode) {
255 return { ok: false, reason: 'migration_completion_invalid' };
256 }
257 const quick = backendCompletion?.managementImportMode === 'quick';
258 if (!quick && completion.schemaVersion === 1) return { ok: false, reason: 'migration_completion_discovered_set_missing' };
259 if (!quick) {
260 if (!discoveredSetStatus || !completion.discoveredSet) return { ok: false, reason: 'missing_discovered_set_status' };
261 if (!resolutionsPrefixValid) return { ok: false, reason: 'discovered_set_resolutions_rewritten' };
262 if (!discoveredSetFreshness?.ok) return { ok: false, reason: 'discovered_set_status_stale', staleInputs: discoveredSetFreshness?.staleInputs || [] };
263 for (const key of ['completionReport','reconciliation','approvalsMapping']) {
264 if (!completionDiscoveredInputs || completion.discoveredSet.inputs?.[key] !== completionDiscoveredInputs[key]) return { ok: false, reason: 'discovered_set_status_stale', staleInputs: [key] };
265 }
266 }
267 // Only a verified append may lower the live open count while preserving the historical
268 // finalization. Without an append, derive status from the current report as usual.
269 const resolutionsAppended = !quick && completionDiscoveredInputs?.resolutions?.lines > completion.discoveredSet?.inputs?.resolutions?.lines;
270 if (resolutionsAppended && discoveredSetStatus.openCount > completion.discoveredSet.openCount) return { ok: false, reason: 'migration_completion_stale' };
271 const statusAtFinalization = resolutionsAppended ? { openCount: completion.discoveredSet.openCount } : discoveredSetStatus;
272 const expectedStatus = deriveFinalStatus(backendCompletion?.status, quick ? null : statusAtFinalization);
273 if (postExecutionHandoff?.status !== expectedStatus) return { ok: false, reason: 'handoff_status_mismatch' };
274 const artifacts = validateCompletionArtifacts({ postExecutionHandoff, executionLogPresent, backendCompletion });
275 if (!artifacts.ok) return artifacts;
276 if (!backendCompletion || !BACKEND_COMPLETE_STATUSES.has(backendCompletion.status) || completion.status !== expectedStatus) {
277 return { ok: false, reason: 'migration_completion_stale' };
278 }
279 if (deliveryMode === 'management_and_website') {
280 const frontend = validateFrontendCompletion(frontendCompletion, handoff);
281 if (!frontend.ok) return frontend;
282 if (completion.frontendCompletion !== FRONTEND_COMPLETION_FILE || completion.handoffFingerprint !== handoff.handoffFingerprint) {
283 return { ok: false, reason: 'migration_completion_stale' };
284 }
285 }
286 // The same existing gate call, handed the inputs it now also judges on -- not a
287 // new router branch. See `orchestration-router.js`'s `determineNextStep`.
288 const entityQa = runEntityQaGate({ backendCompletion, entityQaAttestation, mappedEntities, reportDigest });
289 if (!entityQa.ok) {
290 return {
291 ok: false,
292 reason: entityQa.reasons[0],
293 entityQa: { qaStatus: entityQa.qaStatus, entities: entityQa.entities, recheck: entityQa.recheck },
294 };
295 }
296 return { ok: true, completion };
297}
298
299module.exports = {
300 classifyFinalizedRun,
301 COMPLETION_FILE,
302 FRONTEND_COMPLETION_FILE,
303 BACKEND_COMPLETION_FILE,
304 MAPPING_PLAN_FILE,
305 QUICK_MODE_PLAN_FILE,
306 BACKEND_COMPLETE_STATUSES,
307 readJsonIfExists,
308 readJsonTolerant,
309 readMappedEntities,
310 POST_EXECUTION_HANDOFF_JSON,
311 EXECUTION_LOG_FILE,
312 validateFrontendCompletion,
313 validateCompletionArtifacts,
314 createMigrationCompletion,
315 loadMigrationCompletionInputs,
316 validateMigrationCompletion,
317};