Setting the file. One moment.
Read Back Approval · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 45.130
Position 130 of 182
Type JavaScript
Size 17 KB
Lines 290 lib/ read-back-approval.js
JavaScript · 290 lines · 17 KB
}
=
require
(
'./artifact-freshness'
);
8 const {
9 requireOperatorIdentity
10 } = require ( './operator-identity' );
11 const {
12 loadArtifacts ,
13 updateApprovalObject
14 } = require ( './orchestration-state' );
15 async function gatedEntities ( projectDir ) {
16 let files;
17 try {
18 files = await fs. readdir (path. join (projectDir, 'mapping/entity-decisions' ));
19 } catch (e) {
20 if (e.code === 'ENOENT' ) return [];
21 throw e;
22 }
23 const plan = await state. readJson (projectDir, 'mapping/mapping-plan.json' , {});
24 const exact = plan.fieldContractVersion === 1 ? require ( './mapping-field-contract' ) : null ;
25 const rows = exact ? exact. entityRows (plan) : [];
26 const resolved = exact && new Set (plan.executionScope?.resolvedEntityRefs || []);
27 const result = [];
28 const candidates = exact
29 ? Object. keys (contract. DOMAINS ). map ( entity => ({ entity, name: contract. decisionFileName (projectDir, entity) }))
30 : files. filter ( name => name. endsWith ( '.json' ) && Object. hasOwn (contract. DOMAINS , name. slice ( 0 , - 5 )))
31 . map ( name => ({ entity: name. slice ( 0 , - 5 ), name }));
32 for ( const { entity , name } of candidates) if (files. includes (name)) {
33 const d = await state. readJson (projectDir, `mapping/entity-decisions/${ name }` );
34 const row = exact && rows. find ( candidate => exact. entityKeyMatches (candidate, entity));
35 // A deferred or excluded exact-mapping entity is a cutover gap, not a read-back
36 // gate input. Its inventory is intentionally absent; only resolved writers need
37 // inventory validation and acknowledgement.
38 if (d.readBackContract && ( ! exact || row && resolved. has (exact. entityRef (row)))) result. push (entity);
39 }
40 return result. sort ();
41 }
42 async function applyApproval ( projectDir , request , answer , {
43 identity = requireOperatorIdentity
44 } = {}) {
45 const type = request.effect?.approvalType;
46 const status = {
47 approved: 'approved' ,
48 approve: 'approved' ,
49 declined: 'rejected' ,
50 reject: 'rejected'
51 }[answer.choice];
52 if ( ! [ 'mapping' , 'execution' ]. includes (type) || ! status) throw new Error ( 'Unsupported approval choice/type' );
53 return state. locked (projectDir, async () => {
54 const artifacts = await loadArtifacts (projectDir);
55 const exact = require ( './mapping-omission-approval' );
56 const plan = type === 'mapping' ? await state. readJson (projectDir, 'mapping/mapping-plan.json' ,{}) : {};
57 let entities = type === 'mapping' ? await gatedEntities (projectDir) : [];
58 if (plan.fieldContractVersion === 1 ) entities = entities. filter ( name => require ( './mapping-field-contract' ). entityRows (plan). some ( row => require ( './mapping-field-contract' ). entityKeyMatches (row,name) && plan.executionScope?.resolvedEntityRefs?. includes ( require ( './mapping-field-contract' ). entityRef (row))));
59 const omissionRows = plan.fieldContractVersion === 1 ? exact. candidates (plan) : [];
60 let hasOmissions = omissionRows. length > 0 ;
61 if (status === 'approved' && omissionRows. length && ! require ( './mapping-field-contract' ). compareMappingProjections (projectDir,plan).ok) throw new Error ( 'omission projections must match canonical authority' );
62 const bindingDigests = {};
63 if (status === 'approved' ) for ( const entity of entities) {
64 Object. assign (bindingDigests, contract. bindings (projectDir, entity));
65 const decision = await state. readJson (projectDir, `mapping/entity-decisions/${ contract . decisionFileName ( projectDir , entity ) }` );
66 const omissions = (decision.fieldDispositions || []). filter ( d => d.status === 'notMigrated' );
67 const canonicalRow = plan.fieldContractVersion === 1 ? require ( './mapping-field-contract' ). entityRows (plan). find ( row => require ( './mapping-field-contract' ). entityKeyMatches (row,entity)) : null ;
68 hasOmissions ||= canonicalRow ? exact. omissions (canonicalRow). length > 0 : omissions. length > 0 ;
69 const gate = await state. load (projectDir);
70 const history = [ ... gate.observedLosses, ... Object. values (gate.runs). flatMap ( r => Object. values (r.entities || {}). flatMap ( e => [ ... (e.attemptedPaths || []), ... (e.settlements || []). filter ( x => x.status !== 'passed' )]))];
71 for ( const d of omissions) if (history. some ( h => ( ! h.entity || h.entity === entity) && [ ... (h.sourcePaths || []), h.writePath, h.intendedTargetPath]. includes (d.path || d.sourcePath || d.targetPath))) throw new Error ( 'Attempted or observed loss cannot become approved omission' );
72 }
73 if (status === 'approved' && hasOmissions && exact. mode (artifacts.decisions) === 'one_click' ) throw new Error ( 'one-click approvals cannot authorize field omissions' );
74 if (status === 'approved' ) for ( const row of omissionRows) {exact. validateEvidence (projectDir,row);Object. assign (bindingDigests,exact. bindings (projectDir,row));}
75 for ( const [ p , h ] of Object. entries (bindingDigests)) if ( ! request.artifactRefs?. some ( r => r.path === p && String (r.sha256). replace ( / ^ sha256:/ , '' ) === h)) throw new Error ( `Missing or stale read-back approval reference: ${ p }` );
76 const artifactRefs = await require ( './user-actions' ). approvalArtifactRefs (projectDir, { approvalType: type, status, decidedBy: 'user' , artifactRefs: request.artifactRefs, requireRequestRef: true });
77 // Freshness is checked again under the state lock, not just by the caller's earlier check.
78 if ( ! (type === 'mapping' && status === 'rejected' ) && require ( './user-actions' ). staleArtifactRefs (projectDir, request). length ) throw new Error ( 'user_action_stale: approval inputs changed' );
79 const approvedBy = status === 'approved' && (entities. length || omissionRows. length ) ? identity () : null ;
80 updateApprovalObject (artifacts.approvals, type, status, {
81 artifactRefs,
82 notes: request.title,
83 decidedBy: 'user'
84 });
85 if (approvedBy) Object. assign (artifacts.approvals[type], {
86 approvedBy,
87 requestId: request.requestId,
88 bindingDigests
89 });
90 if (status === 'approved' && hasOmissions) {
91 if ( ! require ( './operator-identity' ). GUID . test (approvedBy?.userId || '' ) || ! approvedBy.displayName?. trim ()) throw new Error ( 'identified operator required for omission approval' );
92 const omissionApprovalContext = omissionRows. length ? exact. context (projectDir) : {automationMode: 'interactive' };
93 const fieldDispositionDigests = exact. dispositionDigests (plan);
94 Object. assign (artifacts.approvals[type],{omissionApprovalContext,fieldDispositionDigests});
95 // answerUserAction writes this same request after the effect. Persist context here
96 // too; execution still requires the final answered record, including after a crash.
97 Object. assign (request,{omissionApprovalContext,fieldDispositionDigests});
98 await state. atomic (projectDir,path. relative (projectDir, require ( './user-actions' ). requestPath (projectDir,request.requestId)),request);
99 }
100 await state. atomic (projectDir, 'orchestration/approvals.json' , artifacts.approvals);
101 return artifacts.approvals[type];
102 });
103 }
104 function omissionDigest ( omissions ) {
105 return contract. digest (omissions. map (({
106 path,
107 kind,
108 recordCount,
109 approvedBy
110 }) => ({
111 path,
112 kind,
113 recordCount,
114 approvedBy
115 })));
116 }
117 async function acknowledge ( projectDir , request , answer , {
118 identity = requireOperatorIdentity
119 } = {}) {
120 if (request.effect?.kind !== 'read-back-omission-ack' || request.kind !== 'manual_action' || request.response?.mode !== 'none' || answer.acknowledged !== true ) throw new Error ( 'Invalid omission acknowledgement' );
121 const {
122 runId ,
123 entity ,
124 omissionSetDigest
125 } = request.effect;
126 contract. entityKey (entity);
127 return state. update (projectDir, async gate => {
128 const omissions = gate.runs[runId]?.entities?.[entity]?.approvedOmissions;
129 if ( ! omissions || omissionDigest (omissions) !== omissionSetDigest || omissions. some ( o => o.merchantAcknowledgement?.requestId !== request.requestId)) throw new Error ( 'Omission acknowledgement binding changed' );
130 const pending = omissions. filter ( o => o.merchantAcknowledgement.status !== 'acknowledged' );
131 if ( ! pending. length ) return ;
132 // The listed evidence must still be exactly the gate's omission set, affected records included.
133 const evidenceRef = (request.artifactRefs || []). find ( r => r?.path === evidencePath (entity, request.requestId));
134 if (evidenceRef) {
135 let evidence = null ;
136 try { evidence = await state. readJson (projectDir, evidenceRef.path); } catch { evidence = null ; }
137 if (evidence?.requestId !== request.requestId || evidence.omissionSetDigest !== omissionSetDigest || ! Array. isArray (evidence.omissions)
138 || contract. digest (evidence.omissions) !== contract. digest ( evidenceOmissions (omissions))) throw new Error ( 'Omission acknowledgement binding changed' );
139 }
140 if ( require ( './user-actions' ). staleArtifactRefs (projectDir, request). length ) throw new Error ( 'Omission acknowledgement artifact binding changed' );
141 const recordedBy = identity ();
142 for ( const o of pending) o.merchantAcknowledgement = {
143 status: 'acknowledged' ,
144 requestId: request.requestId,
145 answeredAt: new Date (). toISOString (),
146 recordedBy,
147 ... (answer.note ? {
148 note: answer.note
149 } : {})
150 };
151 });
152 }
153 // Called at gate load and report generation to heal effect-before-answer crash/restore windows.
154 async function recoverAcknowledgements ( projectDir , options = {}) {
155 const {
156 listUserActions ,
157 readUserAction
158 } = require ( './user-actions' );
159 const rows = await listUserActions (projectDir, {
160 status: 'answered'
161 });
162 for ( const row of rows) {
163 const r = await readUserAction (projectDir, row.requestId);
164 if (r.effect?.kind === 'read-back-omission-ack' ) await acknowledge (projectDir, r, r.answer, options);
165 }
166 }
167 // The complete approved set is evidence, not prompt text: hundreds of field locations cannot fit the
168 // user-action prompt cap. Each request writes its own list once, pinned by sha256 in artifactRefs, so
169 // an answer after the list changed is refused. Acknowledging records that the merchant was told; the
170 // omissions stay approved omissions (open gaps), never resolved by the answer.
171 const PROMPT_MAX = 4000 ; // user-actions' prompt cap
172 const evidencePath = ( entity , requestId ) => `execution/review/read-back-omissions-${ entity }-${ requestId }.json` ;
173 const evidenceOmissions = omissions => omissions. map (({ merchantAcknowledgement , ... o }) => o);
174 const countText = o => o.recordCount == null ? 'unknown count' : `${ o . recordCount } records` ;
175 function acknowledgementPrompt ( entity , omissions , file , digest ) {
176 const inline = `Tell the merchant these fields were not migrated before launch: ${ omissions . map ( o => `${ o . path } (${ countText ( o ) })` ). join ( ', ' ) }` ;
177 if (inline. length <= PROMPT_MAX ) return inline;
178 const head = `Tell the merchant these ${ omissions . length } approved ${ entity } field locations were not migrated before launch. Acknowledging records that the merchant was told; it does not resolve the gaps, which stay open until fixed or dismissed at Cutover. The complete list (location, record count and affected source records for each) is ${ file } (sha256 pinned in this request; omission-set digest ${ digest . slice ( 0 , 12 ) }). First locations: ` ;
179 let listed = '' , shown = 0 ;
180 for ( const o of omissions) {
181 const next = `${ shown ? ', ' : ''}${ o . path } (${ countText ( o ) })` ;
182 if (head. length + listed. length + next. length + 40 > PROMPT_MAX ) break ;
183 listed += next; shown ++ ;
184 }
185 return `${ head }${ listed }${ shown < omissions . length ? `, and ${ omissions . length - shown } more in the file.` : '.'}` ;
186 }
187 async function buildAcknowledgement ( projectDir , runId , entity , item , requestId ) {
188 const omissions = item.approvedOmissions,
189 omissionSetDigest = omissionDigest (omissions),
190 file = evidencePath (entity, requestId);
191 await state. atomic (projectDir, file, { schemaVersion: 1 , requestId, runId, entity, omissionSetDigest, count: omissions. length , omissions: evidenceOmissions (omissions) });
192 return {
193 requestId,
194 kind: 'manual_action' ,
195 stage: 'completion' ,
196 title: `Acknowledge omitted ${ entity } fields` ,
197 prompt: acknowledgementPrompt (entity, omissions, file, omissionSetDigest),
198 response: {
199 mode: 'none'
200 },
201 artifactRefs: [ ... Object. entries (item.bindingDigests || contract. bindings (projectDir, entity)). map (([ p , sha256 ]) => ({
202 path: p,
203 sha256
204 })), { path: file, mediaType: 'application/json' , sha256: hashArtifact (projectDir, file) }],
205 effect: {
206 kind: 'read-back-omission-ack' ,
207 runId,
208 entity,
209 omissionSetDigest
210 }
211 };
212 }
213 function bindAcknowledgement ( item , req ) {
214 item.pendingAcknowledgementRequest = req;
215 for ( const o of item.approvedOmissions) o.merchantAcknowledgement = { status: 'pending' , requestId: req.requestId };
216 }
217 async function ensureAcknowledgementRequest ( projectDir , req ) {
218 const actions = require ( './user-actions' );
219 try { return await actions. readUserAction (projectDir, req.requestId); }
220 catch (e) { if (e.code !== 'ENOENT' ) throw e; }
221 return actions. createUserAction (projectDir, req);
222 }
223 async function createAcknowledgement ( projectDir , runId , entity ) {
224 return state. locked (projectDir, async () => {
225 const gate = await state. load (projectDir), item = gate.runs[runId]?.entities?.[entity];
226 if ( ! item?.approvedOmissions?. length || item.approvedOmissions. every ( o => o.merchantAcknowledgement?.status === 'acknowledged' )) return ;
227 const previous = item.pendingAcknowledgementRequest;
228 if (previous) try {
229 await require ( './user-actions' ). readUserAction (projectDir, previous.requestId);
230 return ;
231 } catch (e) { if (e.code !== 'ENOENT' ) throw e; }
232 // A request not yet shown keeps the identity already checkpointed on its omissions.
233 const requestId = previous?.requestId || item.approvedOmissions[ 0 ].merchantAcknowledgement?.requestId || `uar_${ require ( 'node:crypto' ). randomUUID () }` ;
234 const req = await buildAcknowledgement (projectDir, runId, entity, item, requestId);
235 bindAcknowledgement (item, req);
236 await state. atomic (projectDir, state. FILE , gate);
237 return ensureAcknowledgementRequest (projectDir, req);
238 });
239 }
240
241 // Only the producer can move request-ID bindings and regenerate its per-request evidence.
242 // Checkpoint before publishing the replacement; retries finish that same request after a crash.
243 async function recoverStaleAcknowledgement ( projectDir , requestId ) {
244 return state. locked (projectDir, async () => {
245 const actions = require ( './user-actions' );
246 const old = await actions. readUserAction (projectDir, requestId);
247 if (old.effect?.kind !== 'read-back-omission-ack' ) throw new Error ( 'Not an omission acknowledgement' );
248 if (old.status === 'superseded' && old.supersededBy) return actions. readUserAction (projectDir, old.supersededBy);
249 if (old.status !== 'pending' || ! actions. staleArtifactRefs (projectDir, old). length ) throw new Error ( 'Recovery requires a stale pending omission acknowledgement' );
250 const { runId , entity , omissionSetDigest } = old.effect;
251 const gate = await state. load (projectDir), item = gate.runs[runId]?.entities?.[entity];
252 const resuming = item?.acknowledgementRecovery?.fromRequestId === requestId;
253 const boundId = resuming ? item.acknowledgementRecovery.toRequestId : requestId;
254 if ( ! item?.approvedOmissions?. length || omissionDigest (item.approvedOmissions) !== omissionSetDigest
255 || item.pendingAcknowledgementRequest?.requestId !== boundId
256 || item.approvedOmissions. some ( o => ! (resuming ? [ 'pending' , 'acknowledged' ] : [ 'pending' ]). includes (o.merchantAcknowledgement?.status) || o.merchantAcknowledgement.requestId !== boundId)) {
257 throw new Error ( 'Omission acknowledgement binding changed; restore the producing read-back gate before recovery' );
258 }
259 // Evidence can be rebuilt from the gate, but changed mapping/import inputs need their
260 // producing stage's validation. Never refresh those hashes to bless outdated approvals.
261 const refs = [ ... old.artifactRefs. filter ( r => r.path !== evidencePath (entity, requestId)),
262 ... Object. entries (item.bindingDigests || {}). map (([ path , sha256 ]) => ({ path, sha256 }))];
263 if (actions. staleArtifactRefs (projectDir, { artifactRefs: refs }). length ) {
264 throw new Error ( 'Read-back inputs changed; revalidate the producing mapping/import stage before acknowledgement recovery' );
265 }
266 let req = item.pendingAcknowledgementRequest;
267 if ( ! resuming) {
268 req = await buildAcknowledgement (projectDir, runId, entity, item, `uar_${ require ( 'node:crypto' ). randomUUID () }` );
269 bindAcknowledgement (item, req);
270 item.acknowledgementRecovery = { fromRequestId: requestId, toRequestId: req.requestId };
271 await state. atomic (projectDir, state. FILE , gate);
272 }
273 const replacement = await ensureAcknowledgementRequest (projectDir, req);
274 const answered = replacement.status === 'answered' && replacement.answer?.acknowledged === true
275 && item.approvedOmissions. every ( o => o.merchantAcknowledgement.status === 'acknowledged' );
276 if ( ! answered && (replacement.status !== 'pending' || actions. staleArtifactRefs (projectDir, replacement). length )) throw new Error ( 'Recovery replacement must remain fresh and pending' );
277 await actions. supersedeUserAction (projectDir, requestId, { replacementId: replacement.requestId });
278 return replacement;
279 });
280 }
281 module . exports = {
282 applyApproval,
283 gatedEntities,
284 acknowledge,
285 recoverAcknowledgements,
286 createAcknowledgement,
287 recoverStaleAcknowledgement,
288 omissionDigest,
289 evidencePath
290 };