Setting the file. One moment.
Execution Health Check · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page
Number 45.10
Position 10 of 182
Type JavaScript
Size 20 KB
Lines 415 scripts/ execution-health-check.js
JavaScript · 415 lines · 20 KB
'node:fs'
);
14 const os = require ( 'node:os' );
15 const path = require ( 'node:path' );
16 const { createProgressLogger , parseProgressArgs } = require ( '../lib/progress-log.js' );
17 // Note: `process.kill(pid, 0)` throwing does not
18 // by itself mean the process is dead -- Node surfaces the same exception for ESRCH (no such
19 // process) and EPERM (a real, live process this user cannot signal, e.g. owned by a different
20 // UID on a shared sandbox). Only ESRCH means dead. This used to be a separate copy of the same
21 // check duplicated from `local-state.js`'s `withStateLock` -- now shared from one place.
22 const { isProcessAlive } = require ( '../lib/local-state.js' );
23
24 const DEFAULT_SCRIPT = 'run-import' ;
25 const DEFAULT_STALE_AFTER = '5m' ;
26
27 let progress;
28
29 function usage () {
30 console. error ( `Usage:
31 node skills/wix-replatform/scripts/execution-health-check.js <projectDir> [--script <name>] [--stale-after <duration>]
32
33 Reads <projectDir>/execution/progress.ndjson (the NDJSON progress contract) and
34 <projectDir>/execution/completion-report.json and reports whether the named script's most
35 recent run is healthy, stalled, or complete. Writes no state and touches no target.
36
37 --script script id to filter progress records by (substring match against each
38 record's own "script" field); default "${ DEFAULT_SCRIPT }"
39 --stale-after how old the most recent matching record may be before it counts as stale,
40 absent a live local process claim; default "${ DEFAULT_STALE_AFTER }"
41 (accepts "<n>ms", "<n>s", "<n>m", "<n>h", "<n>d", or a bare millisecond
42 integer)
43 ` );
44 }
45
46 function parseArgs ( argv ) {
47 const args = { script: DEFAULT_SCRIPT , staleAfter: DEFAULT_STALE_AFTER };
48 for ( let i = 0 ; i < argv. length ; i += 1 ) {
49 const token = argv[i];
50 if (token === '--script' ) {
51 args.script = argv[i + 1 ];
52 i += 1 ;
53 } else if (token === '--stale-after' ) {
54 args.staleAfter = argv[i + 1 ];
55 i += 1 ;
56 } else if ( ! args.projectDir) {
57 args.projectDir = token;
58 } else {
59 throw new Error ( `unknown argument: ${ token }` );
60 }
61 }
62 return args;
63 }
64
65 const DURATION_RE = / ^ ( \d + (?: \. \d + ) ? )(ms | s | m | h | d) ?$ / i ;
66 const DURATION_UNIT_MS = { ms: 1 , s: 1000 , m: 60000 , h: 3600000 , d: 86400000 };
67
68 function parseDurationMs ( value ) {
69 const match = DURATION_RE . exec ( String (value === undefined ? '' : value). trim ());
70 if ( ! match) {
71 throw new Error ( `invalid --stale-after duration: ${ JSON . stringify ( value ) } (expected e.g. "5m", "30s", "1h", or a bare millisecond integer)` );
72 }
73 const amount = Number (match[ 1 ]);
74 const unit = (match[ 2 ] || 'ms' ). toLowerCase ();
75 return amount * DURATION_UNIT_MS [unit];
76 }
77
78 function readProgressRecords ( progressLogPath ) {
79 let raw;
80 try {
81 raw = fs. readFileSync (progressLogPath, 'utf8' );
82 } catch (error) {
83 if (error && error.code === 'ENOENT' ) {
84 return { records: [], anyLineSeen: false };
85 }
86 throw error;
87 }
88 const records = [];
89 let anyLineSeen = false ;
90 const lines = raw. split ( / \r ? \n / );
91 for ( let index = 0 ; index < lines. length ; index += 1 ) {
92 const line = lines[index]. trim ();
93 if ( ! line) {
94 continue ;
95 }
96 anyLineSeen = true ;
97 try {
98 records. push ( JSON . parse (line));
99 } catch (error) {
100 // This used to throw and crash the WHOLE health-check on a
101 // single malformed/truncated line -- e.g. a generated run-import.js SIGKILLed/OOM-killed
102 // mid-write leaves a truncated final line in progress.ndjson. That turned the exact
103 // crashed-process case this tool exists to diagnose into an opaque failure (exit 1 instead
104 // of "stalled"), and every future poll on that project would fail the same way, since the
105 // append-only file is never rewritten. Skip the malformed line and keep reading: the health
106 // check still evaluates off the last VALID record, which is exactly what "healthy, stalled,
107 // or complete" needs. `local-state.js` already tolerates malformed/historical rows the same
108 // way on read for the same reason (see tolerateIdentifierOnlyMessage).
109 process.stderr. write ( `warning: ${ progressLogPath }:${ index + 1 } invalid NDJSON, skipped: ${ error . message } \n ` );
110 }
111 }
112 // `records.length === 0` alone cannot tell "the log never
113 // existed / has zero lines" apart from "the log has lines, but every one was malformed and
114 // skipped above" -- both looked identical to `checkHealth`'s `noProgressRecordsAtAll`, letting
115 // an all-malformed log fall through to trusting a stale, unrelated completion-report.json as
116 // proof of completion. `anyLineSeen` lets the caller tell the two apart.
117 return { records, anyLineSeen };
118 }
119
120 // Per the progress-log contract: `seq` is only strictly increasing WITHIN one `runId` -- a shared file can
121 // interleave more than one `runId`'s lines. "The current state of this script" is therefore the
122 // highest-`seq` record belonging to the MOST RECENT `runId` that produced a matching line, never
123 // the raw last line in file order and never a max `seq` compared across different `runId`s.
124 // "The most recent runId" used to be picked by array position --
125 // whichever matching record happened to be LAST IN FILE-APPEND ORDER (`matching[matching.length -
126 // 1].runId`), not by actual timestamp. That is racy if two processes for the same --script ever
127 // append to the same progress.ndjson concurrently (e.g. a health-check falsely reports "stalled"
128 // on a run that's actually alive, the driving agent redispatches per this file's own SKILL.md
129 // guidance without confirming the first process is dead, and now two processes append heartbeats
130 // to the same file): whichever write reaches the file last would silently win, ignoring the other
131 // process's later completion or crash. Every record already carries its own genuine `ts`
132 // (progress-log.js's `append` writes `new Date().toISOString()` on every record, never supplied
133 // by a caller), so pick the runId that owns the record with the latest real timestamp instead of
134 // trusting file position. A millisecond-exact tie between two different runIds' records is not
135 // disambiguated further (ISO timestamps only carry millisecond resolution) -- an acceptable
136 // residual limitation, not the append-order bug this closes.
137 function latestMatchingRecord ( records , scriptFilter ) {
138 const matching = records. filter (( record ) => (
139 record && typeof record.script === 'string' && record.script. includes (scriptFilter)
140 ));
141 if ( ! matching. length ) {
142 return null ;
143 }
144 const recordTimeMs = ( record ) => {
145 const parsed = Date. parse (record?.ts);
146 return Number. isFinite (parsed) ? parsed : - Infinity ;
147 };
148 const mostRecentRunId = matching. reduce (( best , record ) => (
149 best === null || recordTimeMs (record) > recordTimeMs (best) ? record : best
150 ), null ).runId;
151 const sameRun = matching. filter (( record ) => record.runId === mostRecentRunId);
152 return sameRun. reduce (( best , record ) => (
153 best === null || ( Number (record.seq) || 0 ) > ( Number (best.seq) || 0 ) ? record : best
154 ), null );
155 }
156
157 // Best-effort, local-machine-only liveness: reuses the SAME PID/host claim
158 // model `state/.lock/` already uses (see local-state.js's withStateLock and
159 // rp-execute-import/SKILL.md). A run driven from a different host cannot be corroborated this
160 // way -- staleness alone still produces a correct verdict for it.
161 //
162 // This only ever covers a process that calls `withStateLock` -- an import writer. A read-only
163 // script (a generated `run-extract.js`, or any future non-writing script) never acquires this
164 // lock even while genuinely alive, so this check alone cannot corroborate it. See
165 // `hasLiveRecordedPid` below for the script-agnostic complement.
166 // this used to treat ANY live PID claim under state/.lock/ for
167 // this host as proof the QUERIED script (`scriptFilter`) was alive, regardless of which
168 // script/writer actually holds that claim. A genuinely crashed run-import.js could be falsely
169 // reported healthy merely because an unrelated concurrent writer (e.g. run-extract.js) holds its
170 // own live claim in the same project. `local-state.js`'s `withStateLock` now writes a `script`
171 // field into the claim's own JSON content (defaulting to `process.argv[1]` of whichever process
172 // acquired it) -- require that identity to match `scriptFilter` before trusting the claim at all,
173 // mirroring how `hasLiveRecordedPid` below is already scoped to a specific progress record's own
174 // `script` field rather than any record's pid.
175 function hasLiveLocalProcess ( projectDir , scriptFilter ) {
176 const lockDir = path. join (projectDir, 'state' , '.lock' );
177 let entries;
178 try {
179 entries = fs. readdirSync (lockDir);
180 } catch (error) {
181 return false ;
182 }
183 const host = Buffer. from (os. hostname ()). toString ( 'base64url' );
184 for ( const entry of entries) {
185 const match = / ^ ( [ ^ .] + ) \. ( \d + ) \. [a-f0-9-] + \. owner $ / . exec (entry);
186 if ( ! match || match[ 1 ] !== host) {
187 continue ;
188 }
189 let claim = null ;
190 try {
191 claim = JSON . parse (fs. readFileSync (path. join (lockDir, entry), 'utf8' ));
192 } catch (error) {
193 // Unreadable/malformed/removed-mid-read claim: its identity cannot be corroborated, so it
194 // cannot count as evidence for ANY specific script. Keep checking other claims present.
195 }
196 // A claim written before this field existed, or with no recognizable script identity, cannot
197 // be trusted as evidence for this specific scriptFilter either -- the whole point of this
198 // fix is that an uncorroborated claim must not pass as "this script is alive".
199 if ( ! claim || typeof claim.script !== 'string' || ! claim.script. includes (scriptFilter)) {
200 continue ;
201 }
202 if ( isProcessAlive ( Number (match[ 2 ]))) {
203 return true ;
204 }
205 // ESRCH: that claim's process is dead; keep checking any other claim present.
206 }
207 return false ;
208 }
209
210 // Script-agnostic complement to `hasLiveLocalProcess`: every progress-log record now carries its
211 // own writer's `host`/`pid` (see lib/progress-log.js), so a read-only script that never touches
212 // `state/.lock/` -- an extraction pass, or anything else that only reads -- can still be
213 // corroborated as live directly from its own most recent heartbeat, the same way an import
214 // writer is corroborated via its lock claim. Only trusts a pid on THIS host, same as the lock
215 // check.
216 function hasLiveRecordedPid ( record ) {
217 if ( ! record || typeof record.pid !== 'number' || typeof record.host !== 'string' ) {
218 return false ;
219 }
220 if (record.host !== Buffer. from (os. hostname ()). toString ( 'base64url' )) {
221 return false ;
222 }
223 return isProcessAlive (record.pid);
224 }
225
226 // Either signal corroborates liveness: an import writer's lock claim (now scoped to the same
227 // scriptFilter this whole check is asked about), or (for any script, writer or read-only) its own
228 // most recent heartbeat's recorded pid still being alive on this host.
229 function hasLiveSignal ( projectDir , latestRecord , scriptFilter ) {
230 return hasLiveLocalProcess (projectDir, scriptFilter) || hasLiveRecordedPid (latestRecord);
231 }
232
233 function checkHealth ({ projectDir , scriptFilter , staleAfterMs }) {
234 const completionReportPath = path. join (projectDir, 'execution' , 'completion-report.json' );
235 const progressLogPath = path. join (projectDir, 'execution' , 'progress.ndjson' );
236 const { records , anyLineSeen } = readProgressRecords (progressLogPath);
237 const latest = latestMatchingRecord (records, scriptFilter);
238 const lastEventAt = latest ? latest.ts : null ;
239 const runId = latest ? latest.runId : null ;
240 // A missing/unparsable `ts` used to throw here, crashing the
241 // whole check (exit 1) instead of producing a verdict -- the same failure mode the
242 // malformed-NDJSON-line fix closed for a syntactically-broken line, just left open for a
243 // schema-level defect (no `ts`, or one `Date.parse` can't read) in an otherwise-valid record,
244 // e.g. from a hand-rolled heartbeat that never went through lib/progress-log.js's `append()`.
245 // Treat it as "this record can't corroborate freshness" rather than fatal: `ageMs` stays null,
246 // which the staleness check below (`ageMs <= staleAfterMs`) cannot satisfy, so evaluation falls
247 // through to the live-signal checks instead of ever treating an unknown age as fresh.
248 let ageMs = null ;
249 if (latest) {
250 const parsedAgeMs = Date. now () - Date. parse (latest.ts);
251 if ( ! Number. isNaN (parsedAgeMs)) {
252 ageMs = parsedAgeMs;
253 } else {
254 process.stderr. write ( `warning: ${ progressLogPath }: most recent matching record has an unparsable ts, treating its age as unknown: ${ JSON . stringify ( latest . ts ) } \n ` );
255 }
256 }
257
258 // A completion artifact settles the verdict regardless of progress-log staleness -- the run
259 // finished, so a stale-looking last heartbeat before completion is not evidence of a stall.
260 // But a completion report is only evidence for the run it was written for: a resumed project
261 // carries its PREVIOUS run's completion-report.json into the new run's entire duration (it is
262 // only overwritten at the new run's own end), so existence alone would falsely call an actively
263 // resuming run "complete" from its very first tick. Only trust it when its own recorded `runId`
264 // matches the most recent run this progress log has seen -- if a newer `runId` is already
265 // writing progress lines, the report is stale evidence of a PRIOR run, not this one.
266 let completionReport = null ;
267 if (fs. existsSync (completionReportPath)) {
268 try {
269 completionReport = JSON . parse (fs. readFileSync (completionReportPath, 'utf8' ));
270 } catch (error) {
271 throw new Error ( `${ completionReportPath }: invalid JSON: ${ error . message }` );
272 }
273 }
274 // `runId` (from `latest`) is null in two different situations that must NOT be conflated:
275 // the progress log has no records at all (a legacy script never instrumented with
276 // heartbeats -- the completion report is the only signal there is and must be trusted), or
277 // the progress log has records, just none matching THIS `--script` filter (a filter/script-id
278 // mismatch, or a different script actively writing under a name this call isn't watching).
279 // Only the first case may fall back to trusting an unmatched completion report; the second
280 // must not, or a caller who mis-names `--script` gets a false "complete" from some unrelated
281 // run's leftover report instead of a "stalled"/"no signal" verdict it would actually notice.
282 //
283 // Even the first case must not trust the report BLINDLY: a resumed/fresh run can be genuinely
284 // alive and simply hasn't flushed its first heartbeat yet, while a stale completion-report.json
285 // from a wholly earlier run still sits on disk. Corroborate with the same liveness signal used
286 // everywhere else in this file -- if something claims to be running right now, that wins over
287 // an old report, exactly like the "past the staleness window but a live claim exists" branch
288 // below already does for the non-empty-log case.
289 //
290 // Note: a redispatched run that crashes
291 // before writing its first heartbeat or acquiring its lock claim is, from this file's evidence
292 // alone, INDISTINGUISHABLE from "a legacy script that never emits heartbeats at all" -- both
293 // produce zero progress records and no live signal. A runId-based heuristic was tried here and
294 // reverted: `tests/execution-health/execution-health-check-test.js`'s own "a legacy
295 // uninstrumented script's completion report is still trusted" case deliberately gives its
296 // completion report a real, non-null `runId` (`'legacy-run'`), so "does the report have a real
297 // runId" cannot separate the two cases -- it isn't a signal this file has. Closing this race
298 // for real needs a NEW artifact recording that a dispatch attempt started (written by the
299 // dispatcher before it launches the script, not by the script itself, since the crash this
300 // finding describes happens before the script can write anything) -- out of scope for a
301 // same-file fix; flagged as a known, accepted limitation rather than silently left unaddressed.
302 const noProgressRecordsAtAll = ! anyLineSeen;
303 const legacyReportTrustworthy = noProgressRecordsAtAll && ! hasLiveSignal (projectDir, null , scriptFilter);
304 if (completionReport && (legacyReportTrustworthy || (runId && completionReport.runId === runId))) {
305 return {
306 ok: true ,
307 status: 'complete' ,
308 lastEventAt,
309 ageMs,
310 runId,
311 detail: `${ completionReportPath } exists and matches the most recent run (${ completionReport . runId })` ,
312 };
313 }
314
315 if ( ! latest) {
316 // No matching heartbeat AND no (trustworthy) completion report. A live local process claim
317 // still corroborates "genuinely running, just hasn't flushed its first heartbeat yet" over
318 // "stalled" -- the same "a live claim beats an old/absent signal" rule the staleness branch
319 // below applies. `hasLiveRecordedPid` needs a record to check a pid against, so only the
320 // lock-claim signal is available here.
321 if ( hasLiveLocalProcess (projectDir, scriptFilter)) {
322 return {
323 ok: true ,
324 status: 'healthy' ,
325 lastEventAt: null ,
326 ageMs: null ,
327 runId: null ,
328 detail: `no progress record for script matching "${ scriptFilter }" in ${ progressLogPath } yet, but a live local process claim was found` ,
329 };
330 }
331 return {
332 ok: true ,
333 status: 'stalled' ,
334 lastEventAt: null ,
335 ageMs: null ,
336 runId: null ,
337 detail: `no progress record for script matching "${ scriptFilter }" in ${ progressLogPath }, and no completion report` ,
338 };
339 }
340
341 if (ageMs !== null && ageMs <= staleAfterMs) {
342 return {
343 ok: true ,
344 status: 'healthy' ,
345 lastEventAt,
346 ageMs,
347 runId,
348 detail: `last matching event ${ ageMs }ms ago, within the ${ staleAfterMs }ms staleness window` ,
349 };
350 }
351
352 if ( ! hasLiveSignal (projectDir, latest, scriptFilter)) {
353 return {
354 ok: true ,
355 status: 'stalled' ,
356 lastEventAt,
357 ageMs,
358 runId,
359 detail: `last matching event ${ ageMs }ms ago, past the ${ staleAfterMs }ms staleness window, and no live local process claim or recorded pid was found` ,
360 };
361 }
362
363 // Older than the staleness window but a live local process claim OR the record's own recorded
364 // pid still claims this run: the process being alive is the stronger signal when the two
365 // disagree. A slow batch without a fresh heartbeat is not itself a failure.
366 return {
367 ok: true ,
368 status: 'healthy' ,
369 lastEventAt,
370 ageMs,
371 runId,
372 detail: `last matching event ${ ageMs }ms ago, past the ${ staleAfterMs }ms staleness window, but a live local process claim or recorded pid was found` ,
373 };
374 }
375
376 function main () {
377 const parsed = parseProgressArgs (process.argv. slice ( 2 ));
378 progress = createProgressLogger ({
379 script: 'skills/wix-replatform/scripts/execution-health-check.js' ,
380 ... parsed.progress,
381 });
382 progress. start ( 'Execution health check started' , { phase: 'execution' , step: 'health-check' });
383
384 const args = parseArgs (parsed.args);
385 if ( ! args.projectDir) {
386 usage ();
387 throw new Error ( 'Missing project directory' );
388 }
389
390 const projectDir = path. resolve (args.projectDir);
391 const staleAfterMs = parseDurationMs (args.staleAfter);
392 const result = checkHealth ({ projectDir, scriptFilter: args.script, staleAfterMs });
393
394 console. log ( JSON . stringify (result, null , 2 ));
395 progress. complete ( 'Execution health check completed' , {
396 phase: 'execution' ,
397 step: 'health-check' ,
398 artifact: projectDir,
399 status: result.status,
400 });
401 }
402
403 try {
404 main ();
405 process. exit ( 0 );
406 } catch (error) {
407 if (progress) {
408 progress. error (error && error.message ? error.message : 'Execution health check failed' , {
409 phase: 'execution' ,
410 step: 'health-check' ,
411 });
412 }
413 console. error (error && error.message ? error.message : error);
414 process. exit ( 1 );
415 }