Setting the file. One moment.
User Actions · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 182
async function readUserAction
— line 182
This file
Number 45.163
Position 163 of 182
Type JavaScript
Size 19 KB
Lines 366 lib/ user-actions.js
JavaScript · 366 lines · 19 KB
const
{
hashArtifact
}
=
require
(
'./artifact-freshness.js'
);
9 const { isValidSiteTypeEffect , isValidEditorWebsiteEffect } = require ( './destination-choice.js' );
10
11 const SCHEMA_VERSION = 1 ;
12 const KINDS = new Set ([ 'question' , 'review' , 'approval' , 'manual_action' ]);
13 const RESPONSE_MODES = new Set ([ 'choice' , 'text' , 'none' ]);
14
15 function userActionsDir ( projectDir ) {
16 return path. join (projectDir, 'orchestration' , 'user-actions' );
17 }
18
19 function requestPath ( projectDir , requestId ) {
20 return path. join ( userActionsDir (projectDir), `${ requestId }.json` );
21 }
22
23 function newRequestId () {
24 return `uar_${ crypto . randomUUID () }` ;
25 }
26
27 async function writeJsonAtomic ( filePath , value ) {
28 await fs. mkdir (path. dirname (filePath), { recursive: true });
29 const tempPath = `${ filePath }.${ process . pid }.${ Date . now () }.tmp` ;
30 await fs. writeFile (tempPath, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
31 await fs. rename (tempPath, filePath);
32 }
33
34 function assertSafeText ( value , label , { max = 4000 , required = true } = {}) {
35 if ((value === undefined || value === null || value === '' ) && ! required) return null ;
36 if ( typeof value !== 'string' || ! value. trim ()) throw new Error ( `${ label } must be non-empty text` );
37 if (value. length > max) throw new Error ( `${ label } exceeds ${ max } characters` );
38 if ( / \x00 / . test (value)) throw new Error ( `${ label } contains a null byte` );
39 return value;
40 }
41
42 function normalizeArtifact ( projectDir , ref ) {
43 if ( ! ref || typeof ref !== 'object' ) throw new Error ( 'artifactRef must be an object' );
44 const relativePath = assertSafeText (ref.path, 'artifactRef.path' , { max: 512 });
45 const resolved = path. resolve (projectDir, relativePath);
46 const root = `${ path . resolve ( projectDir ) }${ path . sep }` ;
47 if ( ! resolved. startsWith (root)) throw new Error ( 'artifactRef.path must remain inside project' );
48 if (relativePath. split ( / [ \\ /] / ). includes ( '..' )) throw new Error ( 'artifactRef.path must not traverse parent directories' );
49 if (ref.kind !== undefined && ref.kind !== 'census-reconciliation' ) throw new Error ( 'artifactRef.kind is unsupported' );
50 if (ref.kind === 'census-reconciliation' && ! / ^ [0-9a-f] {64}$ / i . test (ref.sha256 || '' )) throw new Error ( 'census reconciliation artifact requires sha256' );
51 return {
52 ... (ref.kind ? { kind: ref.kind } : {}),
53 path: relativePath. replaceAll ( ' \\ ' , '/' ),
54 mediaType: assertSafeText (ref.mediaType || 'text/plain' , 'artifactRef.mediaType' , { max: 100 }),
55 ... (ref.sha256 ? { sha256: assertSafeText (ref.sha256, 'artifactRef.sha256' , { max: 128 }) } : {}),
56 };
57 }
58
59 // Optional machine-readable context a deterministic producer attaches so it can find its own
60 // request again (for example, the destination recovery request records the metasite ID and
61 // the observation it was raised for). Plain JSON only; never credentials.
62 function normalizeSubject ( subject ) {
63 if ( ! subject || typeof subject !== 'object' || Array. isArray (subject)) throw new Error ( 'subject must be an object' );
64 const text = JSON . stringify (subject);
65 if (text. length > 4000 ) throw new Error ( 'subject exceeds 4000 characters' );
66 assertSafeText (subject.type, 'subject.type' , { max: 100 });
67 return JSON . parse (text);
68 }
69
70 function normalizeRequest ( projectDir , input = {}) {
71 const kind = input.kind;
72 if ( ! KINDS . has (kind)) throw new Error ( `invalid user action kind: ${ kind }` );
73 const response = input.response || {};
74 const mode = response.mode || 'choice' ;
75 if ( ! RESPONSE_MODES . has (mode)) throw new Error ( `invalid response mode: ${ mode }` );
76 const choices = Array. isArray (input.choices) ? input.choices. map (( choice ) => ({
77 id: assertSafeText (choice && choice.id, 'choice.id' , { max: 100 }),
78 label: assertSafeText (choice && choice.label, 'choice.label' , { max: 200 }),
79 ... (choice.description ? { description: assertSafeText (choice.description, 'choice.description' , { max: 500 }) } : {}),
80 })) : [];
81 if (mode === 'choice' && choices. length === 0 ) throw new Error ( 'choice response requires choices' );
82 if ( new Set (choices. map (( choice ) => choice.id)).size !== choices. length ) throw new Error ( 'choice ids must be unique' );
83 if (mode !== 'choice' && choices. length ) throw new Error ( 'only choice responses may declare choices' );
84 const artifactRefs = Array. isArray (input.artifactRefs) ? input.artifactRefs. map (( ref ) => normalizeArtifact (projectDir, ref)) : [];
85 const requestId = input.requestId || newRequestId ();
86 if ( ! / ^ uar_ [0-9a-f-] +$ / . test (requestId)) throw new Error ( 'invalid request id' );
87 if (input.effect) {
88 const isApproval = input.effect.kind === 'approval' && [ 'mapping' , 'execution' ]. includes (input.effect.approvalType);
89 const isImportModeDecision = input.effect.kind === 'decision'
90 && input.effect.key === 'managementImportMode'
91 && input.effect.choices
92 && typeof input.effect.choices === 'object'
93 && ! Array. isArray (input.effect.choices)
94 && Object. keys (input.effect.choices). length > 0
95 && Object. entries (input.effect.choices). every (([ choice , value ]) => choices. some (( item ) => item.id === choice) && [ 'standard' , 'quick' ]. includes (value));
96 const isReadBackAck = input.effect.kind === 'read-back-omission-ack' && kind === 'manual_action' && mode === 'none'
97 && [ 'order' , 'member' , 'contact' , 'product' ]. includes (input.effect.entity) && typeof input.effect.runId === 'string'
98 && / ^ [a-f0-9] {64}$ / . test (input.effect.omissionSetDigest);
99 // The combined site-type choice and the Editor website scope guard.
100 const isSiteTypeDecision = isValidSiteTypeEffect (input.effect, choices);
101 const isEditorWebsiteDecision = isValidEditorWebsiteEffect (input.effect, choices);
102 // One durable question when an existing site holding orders or real products would have its settings changed.
103 const isSiteRegionalAnswer = input.effect.kind === 'site-locale-answer' && kind === 'question' && mode === 'choice'
104 && typeof input.effect.siteId === 'string' && input.effect.siteId && / ^ [0-9a-f] {64}$ / . test (input.effect.changesDigest || '' )
105 && choices. length === 2 && choices. every (( item ) => [ 'change' , 'keep' ]. includes (item.id));
106 const isNotificationMuteDecision = kind === 'question' && mode === 'choice' && require ( './notification-mute.js' ). isValidNotificationMuteEffect (input.effect, choices);
107 if ( ! isApproval && ! isImportModeDecision && ! isSiteTypeDecision && ! isEditorWebsiteDecision && ! isReadBackAck && ! isSiteRegionalAnswer && ! isNotificationMuteDecision) throw new Error ( 'unsupported user-action effect' );
108 }
109 return {
110 schemaVersion: SCHEMA_VERSION ,
111 requestId,
112 status: 'pending' ,
113 kind,
114 stage: assertSafeText (input.stage, 'stage' , { max: 100 }),
115 title: assertSafeText (input.title, 'title' , { max: 200 }),
116 prompt: assertSafeText (input.prompt, 'prompt' , { max: 4000 }),
117 response: { mode, required: response.required !== false },
118 choices,
119 artifactRefs,
120 ... (input.effect ? { effect: input.effect } : {}),
121 ... (input.subject ? { subject: normalizeSubject (input.subject) } : {}),
122 createdAt: new Date (). toISOString (),
123 answer: null ,
124 };
125 }
126
127 async function rebuildIndex ( projectDir ) {
128 const dir = userActionsDir (projectDir);
129 let entries = [];
130 try {
131 entries = await fs. readdir (dir, { withFileTypes: true });
132 } catch (error) {
133 if (error && error.code === 'ENOENT' ) return [];
134 throw error;
135 }
136 const rows = [];
137 for ( const entry of entries) {
138 if ( ! entry. isFile () || ! entry.name. endsWith ( '.json' ) || entry.name === 'index.json' ) continue ;
139 const value = JSON . parse ( await fs. readFile (path. join (dir, entry.name), 'utf8' ));
140 try {
141 if ( ! value || value.schemaVersion !== SCHEMA_VERSION || `${ value . requestId }.json` !== entry.name
142 || ! [ 'pending' , 'answered' , 'superseded' ]. includes (value.status) || typeof value.createdAt !== 'string' ) throw new Error ();
143 normalizeRequest (projectDir, value);
144 } catch {
145 throw new Error ( 'invalid durable user-action artifact' );
146 }
147 rows. push ({ requestId: value.requestId, status: value.status, kind: value.kind, title: value.title, createdAt: value.createdAt, answeredAt: value.answer?.answeredAt || null });
148 }
149 rows. sort (( left , right ) => left.createdAt. localeCompare (right.createdAt));
150 await writeJsonAtomic (path. join (dir, 'index.json' ), { schemaVersion: SCHEMA_VERSION , actions: rows });
151 return rows;
152 }
153
154 async function createUserAction ( projectDir , input ) {
155 const request = normalizeRequest (projectDir, input);
156 await fs. mkdir ( userActionsDir (projectDir), { recursive: true });
157 const filePath = requestPath (projectDir, request.requestId);
158 try {
159 await fs. access (filePath);
160 throw new Error ( `user action already exists: ${ request . requestId }` );
161 } catch (error) {
162 if ( ! error || error.code !== 'ENOENT' ) throw error;
163 }
164 await writeJsonAtomic (filePath, request);
165 await rebuildIndex (projectDir);
166 return request;
167 }
168
169 // A request whose subject no longer matches the world (another site, other values, another policy)
170 // is closed rather than answered; the person never sees a question about something that changed.
171 async function supersedeUserAction ( projectDir , requestId , { replacementId } = {}) {
172 const request = await readUserAction (projectDir, requestId);
173 if (request.status !== 'pending' ) return request;
174 request.status = 'superseded' ;
175 request.supersededAt = new Date (). toISOString ();
176 if (replacementId) request.supersededBy = replacementId;
177 await writeJsonAtomic ( requestPath (projectDir, requestId), request);
178 await rebuildIndex (projectDir);
179 return request;
180 }
181
182 async function readUserAction ( projectDir , requestId ) {
183 if ( ! / ^ uar_ [0-9a-f-] +$ / . test (requestId)) throw new Error ( 'invalid request id' );
184 return JSON . parse ( await fs. readFile ( requestPath (projectDir, requestId), 'utf8' ));
185 }
186
187 async function listUserActions ( projectDir , { status = null } = {}) {
188 const rows = await rebuildIndex (projectDir);
189 return status ? rows. filter (( row ) => row.status === status) : rows;
190 }
191
192 // Cutover actions remain open for the Cutover Operator, but they are not a reason to
193 // interrupt import routing. Keep this predicate shared by every pending-action consumer so
194 // the router, supervisor, and status surface agree while `listUserActions` still exposes them.
195 function isCutoverUserAction ( request ) {
196 return request?.stage === 'cutover' || request?.kind === 'cutover' ;
197 }
198
199 // Read the same blocking set for every consumer that can stop import progress. The public
200 // list remains unfiltered so cutover work stays visible and answerable.
201 async function listPendingBlockingUserActions ( projectDir ) {
202 const rows = await listUserActions (projectDir, { status: 'pending' });
203 const requests = [];
204 for ( const row of rows) {
205 const request = await readUserAction (projectDir, row.requestId);
206 if ( ! isCutoverUserAction (request)) requests. push (request);
207 }
208 return requests;
209 }
210
211 // Shared by interactive routing and the CLI supervisor. Do not turn an unreadable
212 // request/index into permission to proceed, and never ask for an answer we will reject.
213 async function pendingUserActionGate ( projectDir ) {
214 try {
215 const requests = await listPendingBlockingUserActions (projectDir);
216 if ( ! requests. length ) return null ;
217 const stale = [];
218 for ( const request of requests) {
219 if ( staleArtifactRefs (projectDir, request). length ) stale. push (request.requestId);
220 }
221 if (stale. length ) return { route: {
222 ok: true , nextState: 'user_action_recovery_running' , reason: 'user_action_stale' ,
223 nextResource: 'resources/rp-user-action-recovery/' , requestIds: stale,
224 } };
225 return { request: requests[ 0 ], route: {
226 ok: true , nextState: 'awaiting_user_action' , nextResource: null ,
227 reason: 'pending_user_action' , blocking: true , requestIds: requests. map (( request ) => request.requestId),
228 } };
229 } catch {
230 // JSON parse errors can include artifact content. Expose a stable code, never that text.
231 return { route: {
232 ok: true , nextState: 'blocked' , nextResource: null , blocking: true ,
233 reason: 'user_action_artifacts_unreadable' ,
234 errors: [ 'Repair unreadable orchestration/user-actions artifacts before resuming; preserve pending requests.' ],
235 } };
236 }
237 }
238
239 // Recovery must leave a fresh human checkpoint in place, never silently grant approval.
240 async function supersedeStaleUserAction ( projectDir , requestId , replacementId ) {
241 const request = await readUserAction (projectDir, requestId);
242 const replacement = await readUserAction (projectDir, replacementId);
243 if (request.status === 'superseded' && request.supersededBy === replacementId) return request;
244 if (request.effect?.kind === 'read-back-omission-ack' ) throw new Error ( 'Use recover-omission-ack so the producer can rebind acknowledgement evidence' );
245 if (requestId === replacementId || request.status !== 'pending' || ! staleArtifactRefs (projectDir, request). length ) {
246 throw new Error ( 'supersede requires a stale pending request and a different replacement' );
247 }
248 const scope = ( effect ) => {
249 if ( ! effect) return null ;
250 const { changesDigest , omissionSetDigest , ... identity } = effect;
251 return identity;
252 };
253 if (replacement.status !== 'pending' || staleArtifactRefs (projectDir, replacement). length
254 || replacement.kind !== request.kind || replacement.stage !== request.stage
255 || ! isDeepStrictEqual ( scope (replacement.effect), scope (request.effect))
256 || ! isDeepStrictEqual (replacement.subject || null , request.subject || null )
257 || request.artifactRefs. some (( ref ) => ref.sha256 && ! replacement.artifactRefs. some (( next ) => next.path === ref.path && next.kind === ref.kind && next.sha256))) {
258 throw new Error ( 'replacement must be a fresh pending request for the same stage, effect and pinned artifacts' );
259 }
260 return supersedeUserAction (projectDir, requestId, { replacementId });
261 }
262
263 // A ref recorded with `sha256` pins the artifact the person reviewed. Answering
264 // after that artifact changed (or disappeared) would approve something nobody looked at, so
265 // the answer is refused and the request stays pending. Refs without `sha256` are unchecked.
266 // Accepts a bare hex digest or a `sha256:`-prefixed one.
267 function staleArtifactRefs ( projectDir , request ) {
268 const stale = [];
269 for ( const ref of Array. isArray (request.artifactRefs) ? request.artifactRefs : []) {
270 if ( ! ref || ! ref.sha256) continue ;
271 const expected = String (ref.sha256). trim (). toLowerCase (). replace ( / ^ sha256:/ , '' );
272 let actual = null ;
273 try {
274 actual = hashArtifact (projectDir, ref.path);
275 } catch {
276 actual = null ;
277 }
278 if (actual !== expected) stale. push ({ path: ref.path, expected, actual });
279 }
280 return stale;
281 }
282
283 class UserActionStaleError extends Error {
284 constructor ( requestId , stale ) {
285 super ( `user_action_stale: ${ stale . map (( entry ) => entry . path ). join ( ', ' ) } changed since request ${ requestId } was created; the answer was not recorded. Regenerate the request from the current artifacts and ask again.` );
286 this .code = 'user_action_stale' ;
287 this .requestId = requestId;
288 this .staleArtifacts = stale;
289 }
290 }
291
292 async function answerUserAction ( projectDir , requestId , answer , { applyEffect } = {}) {
293 const request = await readUserAction (projectDir, requestId);
294 const mode = request.response?.mode;
295 let normalized;
296 if (answer.choice !== undefined ) {
297 if (mode !== 'choice' ) throw new Error ( 'this request does not accept a choice' );
298 if ( ! request.choices. some (( choice ) => choice.id === answer.choice)) throw new Error ( 'choice is not allowed for this request' );
299 const note = assertSafeText (answer.note, 'answer note' , { max: 10000 , required: false });
300 normalized = { choice: answer.choice, ... (note ? { note } : {}) };
301 } else if (answer.text !== undefined ) {
302 if (mode !== 'text' ) throw new Error ( 'this request does not accept text' );
303 normalized = { text: assertSafeText (answer.text, 'answer text' , { max: 10000 }) };
304 } else if (answer.acknowledged === true ) {
305 if (mode !== 'none' || request.kind !== 'manual_action' ) throw new Error ( 'only a manual action with response mode none accepts acknowledgement' );
306 const note = assertSafeText (answer.note, 'acknowledgement note' , { max: 10000 , required: false });
307 normalized = { acknowledged: true , ... (note ? { note } : {}) };
308 } else {
309 throw new Error ( 'answer requires choice, text, or acknowledgement' );
310 }
311 if (request.status === 'answered' ) {
312 const existing = request.answer && (request.answer.choice ? { choice: request.answer.choice, ... (request.answer.note ? { note: request.answer.note } : {}) } : request.answer.text ? { text: request.answer.text } : { acknowledged: request.answer.acknowledged === true , ... (request.answer.note ? { note: request.answer.note } : {}) });
313 if ( JSON . stringify (existing) === JSON . stringify (normalized)) {
314 if (request.effect?.kind === 'read-back-omission-ack' && typeof applyEffect === 'function' ) await applyEffect (request, normalized);
315 return request;
316 }
317 throw new Error ( 'user action already has a different answer' );
318 }
319 if (request.status !== 'pending' ) throw new Error ( `user action is not pending: ${ request . status }` );
320 const stale = staleArtifactRefs (projectDir, request);
321 const mappingApproval = request.effect?.kind === 'approval' && request.effect.approvalType === 'mapping' ;
322 const rejectingMapping = mappingApproval && [ 'declined' , 'reject' ]. includes (normalized.choice);
323 // The mapping binder owns census-reference refusal ordering (missing, stale inputs,
324 // then stale request). Other pinned artifacts retain the generic freshness gate.
325 const unboundStale = mappingApproval && typeof applyEffect === 'function'
326 ? stale. filter ( entry => ! request.artifactRefs. some ( ref => ref.kind === 'census-reconciliation' && ref.path === entry.path))
327 : stale;
328 if ( ! rejectingMapping && unboundStale. length ) throw new UserActionStaleError (requestId, unboundStale);
329 if (request.effect && typeof applyEffect === 'function' ) await applyEffect (request, normalized);
330 request.status = 'answered' ;
331 request.answer = { ... normalized, answeredAt: new Date (). toISOString () };
332 await writeJsonAtomic ( requestPath (projectDir, requestId), request);
333 await rebuildIndex (projectDir);
334 return request;
335 }
336
337 // Every public approval dispatcher uses this disk-bound reference builder.
338 async function approvalArtifactRefs ( projectDir , { approvalType , status , decidedBy , artifactRefs = [], requireRequestRef = false }) {
339 const requestRef = artifactRefs. find ( ref => ref && typeof ref === 'object' && ref.kind === 'census-reconciliation' ) || null ;
340 const { bindCensusReconciliation } = require ( './census-reconciliation.js' );
341 const bound = await bindCensusReconciliation (projectDir, { approvalType, status, decidedBy, requestRef: requireRequestRef ? requestRef : null });
342 if ( ! bound.ok) throw Object. assign ( new Error (bound.code), bound);
343 if (bound.ref && requireRequestRef && ! requestRef) throw Object. assign ( new Error ( 'approval_missing_census_reference' ), { code: 'approval_missing_census_reference' });
344 const refs = artifactRefs. filter ( ref => ! ref || typeof ref !== 'object' || ref.kind !== 'census-reconciliation' ). map ( ref => typeof ref === 'object' ? ref.path : ref);
345 return bound.ref ? [ ... refs, bound.ref] : refs;
346 }
347
348 module . exports = {
349 approvalArtifactRefs,
350 normalizeArtifact,
351 SCHEMA_VERSION,
352 userActionsDir,
353 requestPath,
354 createUserAction,
355 readUserAction,
356 supersedeUserAction,
357 listUserActions,
358 listPendingBlockingUserActions,
359 pendingUserActionGate,
360 supersedeStaleUserAction,
361 answerUserAction,
362 rebuildIndex,
363 isCutoverUserAction,
364 staleArtifactRefs,
365 UserActionStaleError,
366 };