Setting the file. One moment.
Blog Post Build Report · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
Position
126 of 174 lib/ blog-post-build-report.js
JavaScript · 232 lines · 15 KB
freeze
({
9 code: 'blog-verification-spot-check' , severity: 'medium' , entity: 'blog-posts' ,
10 action: 'Cutover Operator: open these posts in preview and check them; Import Operator records the result (prompt 10)' ,
11 deadlineReason: 'before go-live: the run did not fetch these pages' ,
12 });
13
14 function atomicJson ( file , data ) {
15 fs. mkdirSync (path. dirname (file), { recursive: true });
16 const tmp = `${ file }.${ process . pid }.tmp` ;
17 const fd = fs. openSync (tmp, 'w' );
18 try { fs. writeSync (fd, `${ JSON . stringify ( data , null , 2 ) } \n ` ); fs. fsyncSync (fd); }
19 finally { fs. closeSync (fd); }
20 fs. renameSync (tmp, file);
21 }
22 function address ( post ) { return post.phase?.publishedPath || post.phase?.expectedPath || null ; }
23 function pendingMediaDetails ( projectDir ) {
24 const file = path. join (ledger. stateDir (projectDir), 'pending-blog-media.ndjson' );
25 if ( ! fs. existsSync (file)) return new Map ();
26 const text = fs. readFileSync (file, 'utf8' );
27 const lines = text. split ( ' \n ' );
28 const details = new Map ();
29 for ( let i = 0 ; i < lines. length ; i += 1 ) {
30 if ( ! lines[i]) continue ;
31 let row;
32 try { row = JSON . parse (lines[i]); }
33 catch (error) {
34 if (i === lines. length - 1 && ! text. endsWith ( ' \n ' )) break ;
35 throw new Error ( `pending-blog-media line ${ i + 1 }: ${ error . message }` );
36 }
37 if ( ! row.sourceStableKey) continue ;
38 if ( ! details. has (row.sourceStableKey)) details. set (row.sourceStableKey, new Map ());
39 const media = details. get (row.sourceStableKey);
40 if (row.outcome === 'resolved' ) { media. clear (); continue ; }
41 if (row.mediaKey) media. set (row.mediaKey, row);
42 }
43 return new Map ([ ... details]. map (([ key , rows ]) => [key, [ ... rows. values ()]
44 . filter ( row => row.outcome !== 'ready' ). map ( row => ({ mediaKey: row.mediaKey, kind: row.kind,
45 outcome: row.outcome, reason: row.reason || null , fileId: row.fileId || null }))]));
46 }
47 function blogSpotCheckOperatorAction ( coverage ) {
48 if ( ! coverage || [ 'not-required' , 'recorded' ]. includes (coverage.spotCheck.status)) return null ;
49 return { ... SPOT_CHECK_ACTION , affectedCount: coverage.requiredCount };
50 }
51 function verificationCoverage ( state ) {
52 const verified = [ ... state.posts. values ()]. filter ( post => post.phase?.phase === 'verified' && address (post));
53 const sampled = verified. filter ( post => post.snapshots.verify?.sampled === true );
54 const unsampled = verified. filter ( post => post.snapshots.verify?.sampled !== true );
55 const pairs = unsampled. map ( post => [post.sourceStableKey, address (post)]). sort (( a , b ) => a[ 0 ]. localeCompare (b[ 0 ]));
56 const coverageVersion = ledger. sha (pairs);
57 const sampledAddresses = sampled. map (address). sort ();
58 const unsampledAddresses = unsampled. map (address). sort ();
59 const requiredCount = Math. min ( 3 , unsampled. length );
60 const last = state.spotCheck;
61 let status = 'required' ;
62 if ( ! unsampled. length ) status = 'not-required' ;
63 else if (last && last.coverageVersion !== coverageVersion) status = 'stale' ;
64 else if (last?.posts?. some ( post => post.result === 'fail' )) status = 'failed' ;
65 else if (last && last.posts. length >= requiredCount) status = 'recorded' ;
66 return { schema: 1 , coverageVersion, sampled: sampled. length , total: verified. length ,
67 sampledAddresses, unsampledAddresses, requiredCount,
68 spotCheck: { status, seq: last?.seq || null , checkedBy: last?.checkedBy || null ,
69 checkedAt: last?.checkedAt || null , recordedBy: last?.recordedBy || null ,
70 posts: last?.posts || [] } };
71 }
72 function action ({ code , severity , entity , affectedCount , role , prompt , impact , closure , records }) {
73 return { code, severity, entity, affectedCount, role, prompt, impact, closure, records,
74 action: `${ role }: use blog report prompt ${ prompt }; closure: ${ closure }` ,
75 deadlineReason: severity === 'critical' ? `immediately: ${ impact }` : `before go-live: ${ impact }` };
76 }
77 function rebuildBlogReport ( projectDir ) {
78 const state = ledger. fold (ledger. readRows (projectDir));
79 const mediaDetails = pendingMediaDetails (projectDir);
80 const held = [], pending = [], verificationFailures = [], containmentUrgent = [], containmentRebuild = [], adoptionAmbiguous = [];
81 const addresses = [];
82 let publishedPostReads = 0 ;
83 let livePagesFetched = 0 ;
84 for ( const post of state.posts. values ()) {
85 const phase = post.phase;
86 if ( ! phase) continue ;
87 const lastContainment = [ ... post.rows]. reverse (). find ( row => row.phase === 'contained' );
88 const base = { sourceStableKey: post.sourceStableKey, draftId: phase.draftId,
89 postId: phase.postId, reason: phase.reason, expectedPath: phase.expectedPath,
90 publishedPath: phase.publishedPath, phase: phase.phase,
91 ... (lastContainment ? { exposedFrom: lastContainment.exposedFrom,
92 containedAt: lastContainment.containedAt } : {}) };
93 if ( address (post)) addresses. push ({ sourceStableKey: post.sourceStableKey, address: address (post), phase: phase.phase });
94 if (phase.phase === 'media-pending' ) pending. push ({ ... base, media: mediaDetails. get (post.sourceStableKey) || [] });
95 if (phase.phase === 'held' && [ 'adoption-ambiguous' , 'legacy-mismatch' , 'create-projection-mismatch' ]. includes (phase.reason)) {
96 adoptionAmbiguous. push ({ ... base, candidates: phase.candidates || [] });
97 } else if ([ 'held' , 'contained' ]. includes (phase.phase)) held. push ({ ... base,
98 openFindings: post.openFindings[ 'leftover-set' ] || [] });
99 if (phase.phase === 'verify-failed' ) verificationFailures. push ({ ... base,
100 openFindings: post.openFindings.verify || [] });
101 if (post.containmentOpen && phase.phase === 'contain-failed' ) {
102 (phase.reason === 'delete-failed' ? containmentUrgent : containmentRebuild). push (base);
103 }
104 publishedPostReads += post.snapshots.verify ? 1 : 0 ;
105 livePagesFetched += post.snapshots.verify?.sampled ? 1 : 0 ;
106 }
107 const extras = [ ... state.duplicates. values ()]. filter ( pair => ! pair.closed). map ( pair => ({
108 sourceStableKey: pair.sourceStableKey, recordedDraftId: pair.recordedDraftId,
109 extraDraftId: pair.extraDraftId, evidence: pair.evidence,
110 status: ! pair.evidence ? 'evidence-pending'
111 : pair.evidence.status === 'consistent' && pair.confirmation ? 'ownership-confirmed'
112 : 'owner-confirmation-needed' ,
113 ownershipConfirmedBy: pair.confirmation?.chosenBy || null ,
114 }));
115 const coverage = verificationCoverage (state);
116 const operatorActions = [];
117 if (held. length ) operatorActions. push ( action ({ code: 'blog-held-posts' , severity: 'high' , entity: 'blog-posts' ,
118 affectedCount: held. length , role: 'Import Operator' , prompt: 2 , impact: 'Posts remain unpublished' ,
119 closure: 'empty leftover-set, then published and verified' , records: held }));
120 if (adoptionAmbiguous. length ) operatorActions. push ({ code: 'blog-adoption-ambiguous' ,
121 severity: 'high' , entity: 'blog-posts' , affectedCount: adoptionAmbiguous. length ,
122 role: 'Import Operator' , prompt: null ,
123 impact: 'A candidate draft could belong to another post or person; no write was made' ,
124 action: 'Requires Import Operator: stop the blog step and inspect the listed draft ids, source post, and ledger identity evidence. Resolve the identity conflict with a reviewed recovery plan before any adoption, create, or deletion.' ,
125 deadlineReason: 'before go-live: the post remains unpublished or unverified' ,
126 closure: 'documented identity resolution and a later verified ledger row' , records: adoptionAmbiguous });
127 if (pending. length ) operatorActions. push ( action ({ code: 'blog-pending-media' , severity: 'high' , entity: 'blog-posts' ,
128 affectedCount: pending. length , role: 'Import Operator' , prompt: 3 ,
129 impact: 'Media is unresolved; new posts are held and existing crosswalk posts may already be live' ,
130 closure: 'media ready, then published and verified' , records: pending }));
131 if (verificationFailures. length ) operatorActions. push ( action ({ code: 'blog-verification-failed' , severity: 'high' , entity: 'blog-posts' ,
132 affectedCount: verificationFailures. length , role: 'Import Operator' , prompt: 4 ,
133 impact: 'Published posts have failed checks' , closure: 'reverified or accepted' , records: verificationFailures }));
134 if (containmentUrgent. length ) operatorActions. push ( action ({ code: 'blog-may-still-be-live' , severity: 'critical' , entity: 'blog-posts' ,
135 affectedCount: containmentUrgent. length , role: 'Cutover Operator; Requires Import Operator' , prompt: 5 ,
136 impact: 'A post that failed a blocking check may still be live' , closure: 'not-found read, then contained' , records: containmentUrgent }));
137 if (containmentRebuild. length ) operatorActions. push ( action ({ code: 'blog-rebuild-draft' , severity: 'high' , entity: 'blog-posts' ,
138 affectedCount: containmentRebuild. length , role: 'Import Operator' , prompt: 6 ,
139 impact: 'Posts are off site and lack a draft' , closure: 'contained with draft id, then verified' , records: containmentRebuild }));
140 for ( const status of [ 'evidence-pending' , 'owner-confirmation-needed' , 'ownership-confirmed' ]) {
141 const records = extras. filter ( extra => extra.status === status);
142 if (records. length ) operatorActions. push ( action ({ code: `blog-extra-draft-${ status }` , severity: 'high' , entity: 'blog-posts' ,
143 affectedCount: records. length , role: status === 'ownership-confirmed' ? 'Cutover Operator; Requires Import Operator' : 'Import Operator' ,
144 prompt: status === 'evidence-pending' ? 9 : status === 'ownership-confirmed' ? 7 : 8 ,
145 impact: 'An extra unpublished draft may belong to someone else' ,
146 closure: 'provenance, kept, not-importer, or confirmed choice with re-test' , records }));
147 }
148 const spot = blogSpotCheckOperatorAction (coverage);
149 if (spot) operatorActions. push ({ ... spot, role: 'Cutover Operator; Requires Import Operator' , prompt: 10 ,
150 records: coverage.unsampledAddresses });
151 const report = { schema: 1 , ledger: path. relative (projectDir, ledger. ledgerPath (projectDir)),
152 totals: { posts: state.posts.size, publishedPostReads, livePagesFetched },
153 addresses: addresses. sort (( a , b ) => a.sourceStableKey. localeCompare (b.sourceStableKey)),
154 held, adoptionAmbiguous, pending, verificationFailures, containmentUrgent, containmentRebuild, extraDrafts: extras,
155 verificationCoverage: coverage, operatorActions };
156 atomicJson (path. join (ledger. stateDir (projectDir), REPORT ), report);
157 return report;
158 }
159
160 async function refreshBlogHandoff ( projectDir ) {
161 const report = rebuildBlogReport (projectDir);
162 const completion = require ( '../../../lib/completion-report' );
163 const file = path. join (projectDir, completion. COMPLETION_REPORT_FILE );
164 if ( ! fs. existsSync (file)) return { report, status: 'completion-report-missing' };
165 const existing = JSON . parse (fs. readFileSync (file, 'utf8' ));
166 // Only the row this refresh adds is normalized. The report's existing rows were written by the
167 // completion writer, including producers whose severity it keeps as-is ("blocker"); validating
168 // them again here threw after the post was created (observed in a live run: the written
169 // post was journaled succeeded_unverified).
170 const actions = (existing.operatorActions || []). filter ( row => row.code !== SPOT_CHECK_ACTION .code);
171 const spot = blogSpotCheckOperatorAction (report.verificationCoverage);
172 const normalized = spot ? [ ... actions, completion. normalizeOperatorAction (spot, actions. length )] : actions;
173 const updated = { ... existing, operatorActions: normalized };
174 atomicJson (file, updated);
175 const summaryFile = path. join (projectDir, completion. COMPLETION_SUMMARY_FILE );
176 fs. mkdirSync (path. dirname (summaryFile), { recursive: true });
177 fs. writeFileSync (summaryFile, completion. renderCompletionSummary (updated));
178 const { generatePostExecutionHandoff } = require ( '../../../lib/website-handoff' );
179 const handoff = await generatePostExecutionHandoff (projectDir, { appendLog: false });
180 return { report, status: 'refreshed' , handoff };
181 }
182
183 function recordBlogSpotCheck ( projectDir , coverageVersion , posts , checkedBy , checkedAt , recordedBy , note ) {
184 return ledger. withLock (projectDir, async () => {
185 const report = ( await refreshBlogHandoff (projectDir)).report;
186 const coverage = report.verificationCoverage;
187 if (coverageVersion !== coverage.coverageVersion) throw new Error ( 'spot-check coverageVersion is stale' );
188 if ( ! String (checkedBy || '' ). trim () || ! String (recordedBy || '' ). trim ()) throw new Error ( 'checkedBy and recordedBy are required' );
189 if ( ! checkedAt || Number. isNaN (Date. parse (checkedAt))) throw new Error ( 'checkedAt must be a date' );
190 if ( ! Array. isArray (posts) || posts. length < coverage.requiredCount) throw new Error ( `at least ${ coverage . requiredCount } unsampled posts required` );
191 const allowed = new Set (coverage.unsampledAddresses);
192 const seen = new Set ();
193 for ( const post of posts) {
194 if ( ! allowed. has (post.address) || seen. has (post.address)) throw new Error ( `sampled, unverified, or duplicate address: ${ post . address }` );
195 if ( ! [ 'pass' , 'fail' ]. includes (post.result)) throw new Error ( 'spot-check result must be pass or fail' );
196 if ( ! String (post.note || '' ). trim ()) throw new Error ( 'spot-check observation note is required' );
197 seen. add (post.address);
198 }
199 const byAddress = new Map (report.addresses. map ( row => [row.address, row.sourceStableKey]));
200 const row = ledger. appendRow (projectDir, { kind: 'spot-check' , coverageVersion,
201 posts: posts. map ( post => ({ sourceStableKey: byAddress. get (post.address), ... post })),
202 checkedBy: checkedBy. trim (), checkedAt, recordedBy: recordedBy. trim (), note });
203 return { row, ... ( await refreshBlogHandoff (projectDir)) };
204 });
205 }
206
207 function acceptBlogFinding ( projectDir , sourceStableKey , snapshotKind , findingKey , note , acceptedBy ) {
208 return ledger. withLock (projectDir, async () => {
209 if ( ! String (acceptedBy || '' ). trim () || ! String (note || '' ). trim ()) throw new Error ( 'acceptance needs a person and note' );
210 const state = ledger. fold (ledger. readRows (projectDir));
211 const post = state.posts. get (sourceStableKey);
212 const snapshot = post?.snapshots[snapshotKind];
213 const finding = snapshot?.findings. find ( item => item.findingKey === findingKey);
214 if ( ! finding || ! post.openFindings[snapshotKind]. some ( item => item.findingKey === findingKey)) {
215 throw new Error ( 'findingKey is not open in the current snapshot' );
216 }
217 if (finding.blocking) throw new Error ( 'a blocking finding cannot be accepted' );
218 const row = ledger. appendRow (projectDir, { kind: 'accepted' , sourceStableKey,
219 snapshotKind, findingKey, snapshotSeq: snapshot.seq, note, acceptedBy: acceptedBy. trim () });
220 const after = ledger. fold (ledger. readRows (projectDir)).posts. get (sourceStableKey);
221 if (after.phase?.phase === 'verify-failed' && Object. values (after.openFindings). every ( items => ! items. length )) {
222 ledger. appendRow (projectDir, { kind: 'phase' , sourceStableKey, phase: 'verified' ,
223 draftId: after.phase.draftId, postId: after.phase.postId,
224 publishedPath: after.phase.publishedPath, via: 'accepted' });
225 }
226 return { row, ... ( await refreshBlogHandoff (projectDir)) };
227 });
228 }
229
230 module . exports = { REPORT, SPOT_CHECK_ACTION, verificationCoverage,
231 blogSpotCheckOperatorAction, rebuildBlogReport, refreshBlogHandoff,
232 recordBlogSpotCheck, acceptBlogFinding };