Setting the file. One moment.
Telemetry Recorder · Rp Telemetry · wix/skills · Skills Docs
ContentsBack to the top of the page function checkBoundedText
— line 202
This file
Number 45.4
Position 4 of 5
Type JavaScript
Size 87 KB
Lines 2,186 lib/ telemetry-recorder.js
JavaScript · 2,186 lines · 87 KB
15 // current (unfinalized) run
16 // run-telemetry.json latest finalized signal doc
17 // telemetry/runs/run-<attempt>-<run_id>.json archived finalized signal docs
18 // telemetry/runs/run-<attempt>-<run_id>.jsonl archived raw journals
19 //
20 // The journal is the source of truth during a run; the finalized document is a
21 // pure function of the journal (assembleDocument), so a crash between the
22 // finalize append and the document write is recoverable on the next call.
23
24 const fs = require ( 'node:fs' );
25 const path = require ( 'node:path' );
26 const os = require ( 'node:os' );
27 const crypto = require ( 'node:crypto' );
28 const { execFileSync } = require ( 'node:child_process' );
29 const biSink = require ( './bi-sink.js' );
30
31 // 1.1.0 — added `skills_commit` to the run-start record and rollup (additive).
32 // 1.2.0 — BI sink: added `telemetry_health.bi_push_failures`, the
33 // `bi_push_failed`/`bi_push_truncated` journal record types (additive;
34 // readers per the external reader contract skip unknown record types).
35 // 1.3.0 — added the `discovered_entity_types` dimension and its finalize
36 // cross-check flags (`discovered_entity_types_null_after_discovery`,
37 // `volume_row_missing:<type>`), so a discovered entity class silently
38 // absent from `volumes` is mechanically visible (additive; local-only —
39 // not a registered BI column).
40 // 1.4.0 — BI backlog healing: `bi_push_ok` watermark + `bi_push_skipped`
41 // journal record types, `telemetry_health.bi_push_skipped`, and a full
42 // idempotent re-push whenever a session boundary (resume, identity
43 // arrival) or an event push finds journal rows with no confirming
44 // watermark. Before this, mid-run push failures were healed only by
45 // finalize's full re-push — a run that halted and never finalized
46 // stranded its tail in the local journal forever (the 2026-07-29
47 // grastontechnique contacts run). Additive; readers per the external
48 // reader contract skip unknown record types.
49 // 1.5.0 — spec 0039: `transcript_digest` record type — a deterministic parse
50 // of the Claude Code session transcript (cost, turn counts, tool
51 // failures, retry-loop signatures, hook errors, per-stage wall time),
52 // computed by a script rather than self-reported. Carried by
53 // `replay()`/`assembleDocument()` into `rollup.transcript_digest`, so
54 // `finalize`'s existing BI push carries it too — no new BI call, no
55 // new free text, no scrub work (every field is counts/versions/tool
56 // names). Additive; readers per the external reader contract skip
57 // unknown record types.
58 const TELEMETRY_SCHEMA_VERSION = '1.5.0' ;
59 const FREE_TEXT_MAX = 400 ;
60 const SHAPES_KEPT_PER_CLASS = 3 ;
61 const SHAPES_MAX_PER_CALL = 10 ;
62 const EVIDENCE_REFS_MAX = 5 ;
63 // A resume gap below this is treated as in-session noise, not a wait interval.
64 const IMPLICIT_WAIT_MIN_MS = 60 * 1000 ;
65 // A wait at least this long with no halt_needs_user event in its stage means the
66 // capture lost *why* the run stalled — flagged at finalize, never silent.
67 const WAIT_WITHOUT_HALT_FLAG_MS = 5 * 60 * 1000 ;
68 // Metered durations may legitimately overlap slightly with elapsed time (clock granularity,
69 // concurrent API calls inside one stage), so allow modest slack before calling it a contradiction.
70 const OVER_ATTRIBUTION_TOLERANCE = 1.25 ;
71 // Absolute slack on top of the ratio, so a very short stage is not flagged over a few hundred ms.
72 const OVER_ATTRIBUTION_FLOOR_MS = 5 * 1000 ;
73 const EVIDENCE_SIZE_FLAG_BYTES = 2 * 1024 * 1024 ;
74
75 const STAGES = [
76 'config' , 'discovery' , 'mcp_gate' , 'mapping' , 'mapping_review' , 'setup_discovery' ,
77 'codegen' , 'approval_gate' , 'setup_provisioning' , 'storefront_build' , 'extract' ,
78 'import' , 'finish' ,
79 ];
80 const STAGE_OUTCOMES = [ 'passed' , 'halted' , 'failed' , 'skipped' ];
81 const TERMINAL_STATES = [ 'completed' , 'halted_needs_user' , 'failed' , 'abandoned_by_user' ];
82 const SEVERITIES = [ 'blocking' , 'degraded' , 'cosmetic' , 'info' ];
83 const SOURCE_PLATFORMS = [ 'wordpress' , 'woocommerce' , 'shopify' , 'csv' , 'other' ];
84 const DELIVERY_MODES = [ 'management' , 'website' ];
85 const DESTINATION_STRATEGIES = [ 'new_site' , 'existing_site' ];
86 const OPERATOR_ACCEPTANCE = [ 'accepted' , 'rework_needed' , 'rejected' , 'unknown' ];
87 const VOLUME_TARGETS = [ 'native' , 'cms' , 'none' ];
88 const VERIFICATION_METHODS = [ 'query_back' , 'route_check' , 'manual_inspection' ];
89
90 const EVENT_SUBTYPES = {
91 halt_needs_user: [ 'missing_input' , 'manual_only' , 'systemic_failure' ],
92 manual_action_required: [ 'plan_or_billing' , 'dashboard_only' , 'external_dependency' , 'other' ],
93 error: null ,
94 fidelity_loss: [ 'dropped_field' , 'unverified_enum' , 'no_target' , 'coerced_value' ],
95 api_gap: [ 'missing_api' , 'missing_capability' , 'internal_only' , 'other' ],
96 skill_coverage_gap: [ 'guessed_value' , 'undocumented_workaround' , 'ambiguous_instruction' , 'path_not_covered' ],
97 user_decision: [ 'accepted' , 'declined' , 'deferred' , 'amended' ],
98 pipeline_defect: [ 'state_inconsistency' , 'ordering_violation' , 'record_defect' , 'other' ],
99 };
100 const EVENT_TYPES = Object. keys ( EVENT_SUBTYPES );
101
102 // Privacy tiers travel with the record so no sink can ignore them. Floors, not
103 // defaults: identifying:* fields are never transmitted raw without the
104 // pseudonymization floor or the locus-appropriate authority.
105 const FIELD_TIERS = {
106 'rollup.source_site_url' : 'identifying:client' ,
107 'rollup.wix_user_id' : 'identifying:operator' ,
108 '*' : 'behavioral' ,
109 };
110
111 const OUTCOME_FOR_TERMINAL = {
112 completed: 'passed' ,
113 failed: 'failed' ,
114 halted_needs_user: 'halted' ,
115 abandoned_by_user: 'halted' ,
116 };
117
118 // Last-line-of-defense scrub over free text, shape field names, and locators.
119 // Order matters: broader assignment/JWT shapes before generic opaque strings.
120 const SCRUB_PATTERNS = [
121 [ 'secret_assignment' , / \b (?:token | secret | password | passwd | api [_-] ? key | authorization | bearer) \b \s * [:=]\s * \S + / gi ],
122 [ 'jwt' , / \b eyJ [A-Za-z0-9_-] {8,} (?: \. [A-Za-z0-9_-] {4,} ) {1,4}\b / g ],
123 [ 'url' , / \b (?:https ?| ftp): \/\/ \S + / gi ],
124 [ 'email' , / [A-Za-z0-9._%+-] + @ [A-Za-z0-9-] + (?: \. [A-Za-z0-9-] + ) + / g ],
125 [ 'opaque' , /(?= [A-Za-z0-9+/_-] * [0-9] ) [A-Za-z0-9+/_-] {32,} = {0,2} / g ],
126 ];
127
128 class ValidationError extends Error {
129 constructor ( errors , hint ) {
130 super (Array. isArray (errors) ? errors. join ( '; ' ) : String (errors));
131 this .name = 'ValidationError' ;
132 this .errors = Array. isArray (errors) ? errors : [ String (errors)];
133 this .hint = hint || null ;
134 }
135 }
136
137 function nowIso ( now ) {
138 if (now instanceof Date ) return now. toISOString ();
139 if ( typeof now === 'string' ) return new Date (now). toISOString ();
140 return new Date (). toISOString ();
141 }
142
143 function tsMs ( iso ) {
144 return Date. parse (iso);
145 }
146
147 function telemetryDir ( projectDir ) {
148 return path. join (projectDir, 'telemetry' );
149 }
150
151 function journalPath ( projectDir ) {
152 return path. join ( telemetryDir (projectDir), 'events.jsonl' );
153 }
154
155 function runsDir ( projectDir ) {
156 return path. join ( telemetryDir (projectDir), 'runs' );
157 }
158
159 function signalPath ( projectDir ) {
160 return path. join (projectDir, 'run-telemetry.json' );
161 }
162
163 function scrubText ( value ) {
164 let hits = 0 ;
165 let text = String (value);
166 for ( const [ name , re ] of SCRUB_PATTERNS ) {
167 text = text. replace (re, () => {
168 hits += 1 ;
169 return `[scrubbed:${ name }]` ;
170 });
171 }
172 return { text, hits };
173 }
174
175 function normalizeToken ( value ) {
176 return String (value)
177 . toLowerCase ()
178 . trim ()
179 . replace ( / [ ^ a-z0-9] + / g , '_' )
180 . replace ( / ^ _ +| _ +$ / g , '' );
181 }
182
183 function stableStringify ( value ) {
184 if (Array. isArray (value)) {
185 return `[${ value . map ( stableStringify ). join ( ',' ) }]` ;
186 }
187 if (value && typeof value === 'object' ) {
188 const keys = Object. keys (value). sort ();
189 return `{${ keys . map (( k ) => `${ JSON . stringify ( k ) }:${ stableStringify ( value [ k ]) }` ). join ( ',' ) }}` ;
190 }
191 return JSON . stringify (value);
192 }
193
194 function checkEnum ( errors , field , value , allowed ) {
195 if ( ! allowed. includes (value)) {
196 errors. push ( `${ field } must be one of: ${ allowed . join ( ', ' ) } (got: ${ JSON . stringify ( value ) })` );
197 return false ;
198 }
199 return true ;
200 }
201
202 function checkBoundedText ( errors , field , value , { required = false , max = FREE_TEXT_MAX } = {}) {
203 if (value === undefined || value === null ) {
204 if (required) errors. push ( `${ field } is required (a sentence or two, observation-only)` );
205 return null ;
206 }
207 if ( typeof value !== 'string' || value. trim () === '' ) {
208 errors. push ( `${ field } must be a non-empty string` );
209 return null ;
210 }
211 if (value. length > max) {
212 errors. push ( `${ field } exceeds ${ max } chars (got ${ value . length }) — keep it to a sentence or two; the coded fields carry the structure` );
213 return null ;
214 }
215 return value. trim ();
216 }
217
218 function checkApiSurface ( errors , field , value ) {
219 if (value === undefined || value === null ) return null ;
220 const str = String (value);
221 if ( /: \/\/ / . test (str) || / [?\s] / . test (str)) {
222 errors. push ( `${ field } must be an API/endpoint class like "stores/v3" or "wp/v2/posts", never a URL (URLs can embed auth)` );
223 return null ;
224 }
225 if (str. length > 64 || ! / ^ [A-Za-z0-9/_.:-] +$ / . test (str)) {
226 errors. push ( `${ field } must be a short endpoint class matching [A-Za-z0-9/_.:-], max 64 chars` );
227 return null ;
228 }
229 return str;
230 }
231
232 function checkOpaqueId ( errors , field , value , { max = 64 } = {}) {
233 if (value === undefined || value === null ) return null ;
234 const str = String (value);
235 if ( / [\s] / . test (str) || /: \/\/ / . test (str) || str. length > max) {
236 errors. push ( `${ field } must be an opaque identifier (no whitespace, no URL), max ${ max } chars` );
237 return null ;
238 }
239 return str;
240 }
241
242 function checkCount ( errors , field , value , { defaultValue = 0 , min = 0 } = {}) {
243 if (value === undefined || value === null ) return defaultValue;
244 if ( ! Number. isInteger (value) || value < min) {
245 errors. push ( `${ field } must be an integer >= ${ min }` );
246 return defaultValue;
247 }
248 return value;
249 }
250
251 function normalizeOrigin ( errors , value ) {
252 try {
253 const url = new URL ( String (value));
254 if (url.protocol !== 'http:' && url.protocol !== 'https:' ) {
255 errors. push ( 'source_site_url must be an http(s) URL' );
256 return null ;
257 }
258 // Bare origin only — drops path, query, and any embedded credentials.
259 return `${ url . protocol }//${ url . host }` ;
260 } catch {
261 errors. push ( `source_site_url is not a parseable URL: ${ JSON . stringify ( String ( value )) }` );
262 return null ;
263 }
264 }
265
266 function sanitizeShapeValue ( value , errors , keyPath , depth , counter ) {
267 if (value === null || typeof value === 'number' || typeof value === 'boolean' ) {
268 return value;
269 }
270 if ( typeof value === 'string' ) {
271 if (value. length > 64 ) {
272 errors. push ( `observed_shapes${ keyPath } string value exceeds 64 chars — shapes carry field names and types, never record values` );
273 return null ;
274 }
275 const { text , hits } = scrubText (value);
276 counter.hits += hits;
277 return text;
278 }
279 if (depth >= 4 ) {
280 errors. push ( `observed_shapes${ keyPath } nests deeper than 4 levels` );
281 return null ;
282 }
283 if (Array. isArray (value)) {
284 return value. map (( item , i ) => sanitizeShapeValue (item, errors, `${ keyPath }[${ i }]` , depth + 1 , counter));
285 }
286 if ( typeof value === 'object' ) {
287 const out = {};
288 for ( const [ key , item ] of Object. entries (value)) {
289 // Custom field names on a client site can themselves be identifying.
290 const { text , hits } = scrubText (key);
291 counter.hits += hits;
292 out[text] = sanitizeShapeValue (item, errors, `${ keyPath }.${ key }` , depth + 1 , counter);
293 }
294 return out;
295 }
296 errors. push ( `observed_shapes${ keyPath } has unsupported value type ${ typeof value }` );
297 return null ;
298 }
299
300 function validateEvidenceRefs ( errors , value ) {
301 if (value === undefined || value === null ) return null ;
302 if ( ! Array. isArray (value) || value. length === 0 ) {
303 errors. push ( 'evidence_refs must be a non-empty array of {artifact, locator?} or omitted' );
304 return null ;
305 }
306 if (value. length > EVIDENCE_REFS_MAX ) {
307 errors. push ( `evidence_refs is capped at ${ EVIDENCE_REFS_MAX } sample refs per event` );
308 return null ;
309 }
310 const refs = [];
311 for ( const [ i , ref ] of value. entries ()) {
312 if ( ! ref || typeof ref !== 'object' || typeof ref.artifact !== 'string' ) {
313 errors. push ( `evidence_refs[${ i }] must be {artifact: <project-relative path>, locator?: <heading/anchor>}` );
314 continue ;
315 }
316 const artifact = ref.artifact. replace ( / \\ / g , '/' );
317 if (path. isAbsolute (artifact) || artifact. split ( '/' ). includes ( '..' )) {
318 errors. push ( `evidence_refs[${ i }].artifact must be a project-root-relative path (no absolute paths, no ..)` );
319 continue ;
320 }
321 if ( /( ^| \/ )config \/ / . test (artifact) || artifact. endsWith ( '.env' )) {
322 errors. push ( `evidence_refs[${ i }].artifact must never point at secret-bearing config files` );
323 continue ;
324 }
325 let locator = null ;
326 if (ref.locator !== undefined && ref.locator !== null ) {
327 if ( typeof ref.locator !== 'string' || ref.locator. length > 120 ) {
328 errors. push ( `evidence_refs[${ i }].locator must be a string of at most 120 chars (prefer a section heading — line ranges break on regeneration)` );
329 continue ;
330 }
331 locator = scrubText (ref.locator).text;
332 }
333 refs. push ({ artifact, locator });
334 }
335 return refs;
336 }
337
338 // --- dimension (rollup identity) fields -------------------------------------
339
340 // --- cost/latency metering -------------------------------------------------
341 // Duration fields split `active_ms` into its three real components. Before this existed,
342 // active_ms was a single wall-clock number fusing agent reasoning, subprocess execution, remote
343 // API latency and defect-repair time — which made it impossible to localize a bottleneck.
344 //
345 // Anything NOT attributed by a meter call stays visible as `unattributed_ms`. That is deliberate:
346 // a partially-metered stage must not look fully explained.
347 const METER_DURATION_KEYS = [ 'model_ms' , 'api_ms' , 'script_ms' ];
348 const METER_COUNT_KEYS = [ 'input_tokens' , 'output_tokens' , 'cache_read_tokens' , 'cache_write_tokens' , 'api_calls' , 'api_retries' ];
349 const METER_ALLOWED_KEYS = new Set ([ ... METER_DURATION_KEYS , ... METER_COUNT_KEYS , 'stage' ]);
350
351 function validateMeter ( input , { openStage , lastStage }) {
352 const errors = [];
353 if ( ! input || typeof input !== 'object' || Array. isArray (input)) {
354 throw new ValidationError ([ 'meter payload must be a JSON object' ]);
355 }
356 for ( const key of Object. keys (input)) {
357 if ( ! METER_ALLOWED_KEYS . has (key)) {
358 errors. push ( `unknown meter field: ${ key } (allowed: ${ [ ... METER_ALLOWED_KEYS ]. join ( ', ' ) })` );
359 }
360 }
361 const stageName = input.stage || openStage || lastStage;
362 if ( ! stageName) errors. push ( 'no stage is open and none was named; pass --stage <stage>' );
363 else checkEnum (errors, 'stage' , stageName, STAGES );
364
365 const out = { stage: stageName };
366 let any = false ;
367 for ( const key of [ ... METER_DURATION_KEYS , ... METER_COUNT_KEYS ]) {
368 if (input[key] === undefined || input[key] === null ) continue ;
369 const value = input[key];
370 if ( ! Number. isFinite (value) || value < 0 || Math. floor (value) !== value) {
371 errors. push ( `${ key } must be a non-negative integer` );
372 continue ;
373 }
374 out[key] = value;
375 any = true ;
376 }
377 if ( ! any) errors. push ( `meter needs at least one measurement (${ [ ... METER_DURATION_KEYS , ... METER_COUNT_KEYS ]. join ( ', ' ) })` );
378 if (errors. length > 0 ) {
379 throw new ValidationError (errors, 'meter records measured numbers only — never estimate them by hand' );
380 }
381 return out;
382 }
383
384 // A pricing snapshot makes historical cost recomputable when list prices change. Rates are per
385 // MILLION tokens, matching how model pricing is published.
386 function validatePricingSnapshot ( errors , value ) {
387 if ( ! value || typeof value !== 'object' || Array. isArray (value)) {
388 errors. push ( 'model_pricing_snapshot must be an object of { <model>: { input_per_mtok, output_per_mtok, ... } }' );
389 return undefined ;
390 }
391 const out = {};
392 const rateKeys = [ 'input_per_mtok' , 'output_per_mtok' , 'cache_read_per_mtok' , 'cache_write_per_mtok' ];
393 for ( const [ model , rates ] of Object. entries (value)) {
394 if ( typeof model !== 'string' || model. length > 64 ) {
395 errors. push ( 'model_pricing_snapshot keys must be short model ids' );
396 continue ;
397 }
398 if ( ! rates || typeof rates !== 'object' || Array. isArray (rates)) {
399 errors. push ( `model_pricing_snapshot.${ model } must be an object of numeric rates` );
400 continue ;
401 }
402 const entry = {};
403 for ( const [ key , rate ] of Object. entries (rates)) {
404 if ( ! rateKeys. includes (key)) {
405 errors. push ( `model_pricing_snapshot.${ model }.${ key } is not a known rate (allowed: ${ rateKeys . join ( ', ' ) })` );
406 continue ;
407 }
408 if ( ! Number. isFinite (rate) || rate < 0 ) {
409 errors. push ( `model_pricing_snapshot.${ model }.${ key } must be a non-negative number` );
410 continue ;
411 }
412 entry[key] = rate;
413 }
414 out[model] = entry;
415 }
416 return out;
417 }
418
419 const DIM_VALIDATORS = {
420 model_pricing_snapshot : ( errors , v ) => validatePricingSnapshot (errors, v),
421 source_platform : ( errors , v ) => ( checkEnum (errors, 'source_platform' , v, SOURCE_PLATFORMS ) ? v : undefined ),
422 source_platform_version : ( errors , v ) => {
423 if ( typeof v !== 'string' || v. length > 32 ) {
424 errors. push ( 'source_platform_version must be a short version string' );
425 return undefined ;
426 }
427 return v;
428 },
429 source_site_url : ( errors , v ) => {
430 const origin = normalizeOrigin (errors, v);
431 return origin === null ? undefined : origin;
432 },
433 source_extensions : ( errors , v ) => {
434 if ( ! Array. isArray (v) || v. length > 64 || v. some (( item ) => typeof item !== 'string' || item. length > 48 )) {
435 errors. push ( 'source_extensions must be an array of short extension/plugin class names' );
436 return undefined ;
437 }
438 return [ ...new Set (v. map (normalizeToken). filter (Boolean))];
439 },
440 discovered_entity_types : ( errors , v ) => {
441 if ( ! Array. isArray (v) || v. length > 64 || v. some (( item ) => typeof item !== 'string' || item. length > 48 )) {
442 errors. push ( 'discovered_entity_types must be an array of short entity class tokens (e.g. product, post, event)' );
443 return undefined ;
444 }
445 return [ ...new Set (v. map (normalizeToken). filter (Boolean))];
446 },
447 source_acquisition : ( errors , v ) => {
448 const token = normalizeToken (v);
449 if ( ! token || token. length > 32 ) {
450 errors. push ( 'source_acquisition must be a short class token, e.g. admin_api | public_storefront | file_export' );
451 return undefined ;
452 }
453 return token;
454 },
455 delivery_mode : ( errors , v ) => ( checkEnum (errors, 'delivery_mode' , v, DELIVERY_MODES ) ? v : undefined ),
456 destination_strategy : ( errors , v ) => ( checkEnum (errors, 'destination_strategy' , v, DESTINATION_STRATEGIES ) ? v : undefined ),
457 site_id : ( errors , v ) => {
458 const id = checkOpaqueId (errors, 'site_id' , v);
459 return id === null ? undefined : id;
460 },
461 wix_user_id : ( errors , v ) => {
462 const id = checkOpaqueId (errors, 'wix_user_id' , v);
463 return id === null ? undefined : id;
464 },
465 skills_version : ( errors , v ) => {
466 if ( typeof v !== 'string' || v. length > 32 ) {
467 errors. push ( 'skills_version must be a short version string' );
468 return undefined ;
469 }
470 return v. trim ();
471 },
472 skills_commit : ( errors , v ) => {
473 if ( typeof v !== 'string' || ! / ^ [0-9a-f] {7,40}$ / . test (v. trim ())) {
474 errors. push ( 'skills_commit must be a git commit SHA (7-40 hex chars)' );
475 return undefined ;
476 }
477 return v. trim ();
478 },
479 runtime_env : ( errors , v ) => {
480 if ( ! v || typeof v !== 'object' || Array. isArray (v)) {
481 errors. push ( 'runtime_env must be an object with {os?, node_version?, agent_runtime?, model?}' );
482 return undefined ;
483 }
484 const out = {};
485 for ( const key of [ 'os' , 'node_version' , 'agent_runtime' , 'model' ]) {
486 if (v[key] === undefined || v[key] === null ) continue ;
487 if ( typeof v[key] !== 'string' || v[key]. length > 64 || / [/ \\ ] / . test (v[key])) {
488 errors. push ( `runtime_env.${ key } must be a coarse platform fact (short string, no paths)` );
489 continue ;
490 }
491 out[key] = v[key];
492 }
493 const unknown = Object. keys (v). filter (( k ) => ! [ 'os' , 'node_version' , 'agent_runtime' , 'model' ]. includes (k));
494 if (unknown. length > 0 ) {
495 errors. push ( `runtime_env has unknown keys: ${ unknown . join ( ', ' ) }` );
496 return undefined ;
497 }
498 return out;
499 },
500 };
501
502 function validateDims ( input ) {
503 if (input === undefined || input === null ) return {};
504 if ( typeof input !== 'object' || Array. isArray (input)) {
505 throw new ValidationError ([ 'dimensions must be a JSON object' ]);
506 }
507 const errors = [];
508 const dims = {};
509 for ( const [ key , value ] of Object. entries (input)) {
510 if (value === undefined || value === null ) continue ;
511 const validator = DIM_VALIDATORS [key];
512 if ( ! validator) {
513 errors. push ( `unknown dimension field: ${ key } (allowed: ${ Object . keys ( DIM_VALIDATORS ). join ( ', ' ) })` );
514 continue ;
515 }
516 const normalized = validator (errors, value);
517 if (normalized !== undefined ) dims[key] = normalized;
518 }
519 if (errors. length > 0 ) throw new ValidationError (errors);
520 return dims;
521 }
522
523 // --- event validation --------------------------------------------------------
524
525 const EVENT_ALLOWED_KEYS = new Set ([
526 'event_type' , 'subtype' , 'stage' , 'skill' , 'entity_type' , 'severity' ,
527 'wix_api_surface' , 'source_api_surface' , 'wix_app_id' , 'error_code' ,
528 'what_happened' , 'expected' , 'actual' , 'observed_shapes' , 'evidence_refs' ,
529 'count' , 'retry_count' , 'recovered' , 'decision_point' ,
530 ]);
531
532 function validateEvent ( input , context ) {
533 if ( ! input || typeof input !== 'object' || Array. isArray (input)) {
534 throw new ValidationError ([ 'event must be a JSON object' ]);
535 }
536 const errors = [];
537 const scrub = { hits: 0 };
538
539 for ( const key of Object. keys (input)) {
540 if ( ! EVENT_ALLOWED_KEYS . has (key)) {
541 errors. push ( `unknown event field: ${ key } — there is deliberately no fix/root_cause/recommendation field; record the observation only` );
542 }
543 }
544
545 const eventType = input.event_type;
546 checkEnum (errors, 'event_type' , eventType, EVENT_TYPES );
547
548 let subtype = null ;
549 if ( EVENT_SUBTYPES [eventType] === null ) {
550 if (input.subtype !== undefined && input.subtype !== null ) {
551 errors. push ( `event_type ${ eventType } defines no subtypes (error_code is its discriminator)` );
552 }
553 } else if ( EVENT_SUBTYPES [eventType]) {
554 if ( checkEnum (errors, `subtype (for ${ eventType })` , input.subtype, EVENT_SUBTYPES [eventType])) {
555 subtype = input.subtype;
556 }
557 }
558
559 let stage = input.stage;
560 if (stage === undefined || stage === null ) {
561 stage = context.openStage || context.lastStage || null ;
562 if ( ! stage) {
563 errors. push ( 'stage is required (no stage has been started yet to default to)' );
564 }
565 } else {
566 checkEnum (errors, 'stage' , stage, STAGES );
567 }
568
569 if ( typeof input.skill !== 'string' || ! / ^ [a-z][a-z0-9-] *$ / . test (input.skill)) {
570 errors. push ( 'skill is required — the active rp-* resource (e.g. "rp-mapper") or "wix-replatform"' );
571 }
572
573 checkEnum (errors, 'severity' , input.severity, SEVERITIES );
574
575 let entityType = null ;
576 if (input.entity_type !== undefined && input.entity_type !== null ) {
577 entityType = normalizeToken (input.entity_type);
578 if ( ! entityType || entityType. length > 32 ) {
579 errors. push ( 'entity_type must be a short entity class token (e.g. product, post, media)' );
580 entityType = null ;
581 }
582 }
583
584 const wixApiSurface = checkApiSurface (errors, 'wix_api_surface' , input.wix_api_surface);
585 const sourceApiSurface = checkApiSurface (errors, 'source_api_surface' , input.source_api_surface);
586 const wixAppId = checkOpaqueId (errors, 'wix_app_id' , input.wix_app_id);
587
588 let errorCode = null ;
589 if (input.error_code !== undefined && input.error_code !== null ) {
590 const str = String (input.error_code);
591 if (str. length > 48 || / \s / . test (str)) {
592 errors. push ( 'error_code must be a short machine code (e.g. 428, WDE0110)' );
593 } else {
594 errorCode = str;
595 }
596 }
597 if (eventType === 'error' && errorCode === null ) {
598 errors. push ( 'error events require error_code — it is the discriminator for this type' );
599 }
600 if (eventType === 'api_gap' && wixApiSurface === null ) {
601 errors. push ( 'api_gap events require wix_api_surface — it is part of the stable signature' );
602 }
603
604 const whatHappened = checkBoundedText (errors, 'what_happened' , input.what_happened, { required: true });
605 const expected = checkBoundedText (errors, 'expected' , input.expected);
606 const actual = checkBoundedText (errors, 'actual' , input.actual);
607
608 const count = checkCount (errors, 'count' , input.count, { defaultValue: 1 , min: 1 });
609
610 let retryCount;
611 let recovered;
612 if (eventType === 'error' ) {
613 retryCount = checkCount (errors, 'retry_count' , input.retry_count, { defaultValue: 0 , min: 0 });
614 recovered = input.recovered === undefined || input.recovered === null ? false : input.recovered;
615 if ( typeof recovered !== 'boolean' ) {
616 errors. push ( 'recovered must be a boolean' );
617 recovered = false ;
618 }
619 } else {
620 if (input.retry_count !== undefined || input.recovered !== undefined ) {
621 errors. push ( 'retry_count/recovered are only valid on error events' );
622 }
623 }
624
625 let decisionPoint;
626 if (eventType === 'user_decision' ) {
627 decisionPoint = typeof input.decision_point === 'string' ? normalizeToken (input.decision_point) : '' ;
628 if ( ! decisionPoint || decisionPoint. length > 48 ) {
629 errors. push ( 'user_decision events require decision_point — which checkpoint or fork (e.g. mapping_review, approval_gate)' );
630 }
631 } else if (input.decision_point !== undefined ) {
632 errors. push ( 'decision_point is only valid on user_decision events' );
633 }
634
635 let observedShapes = null ;
636 if (input.observed_shapes !== undefined && input.observed_shapes !== null ) {
637 if ( ! Array. isArray (input.observed_shapes) || input.observed_shapes. length === 0 ) {
638 errors. push ( 'observed_shapes must be a non-empty array of sanitized shape objects or omitted' );
639 } else if (input.observed_shapes. length > SHAPES_MAX_PER_CALL ) {
640 errors. push ( `observed_shapes is capped at ${ SHAPES_MAX_PER_CALL } shapes per call (folding keeps ${ SHAPES_KEPT_PER_CLASS } distinct per class)` );
641 } else {
642 observedShapes = input.observed_shapes. map (( shape , i ) => {
643 if ( ! shape || typeof shape !== 'object' || Array. isArray (shape)) {
644 errors. push ( `observed_shapes[${ i }] must be an object of field names and types` );
645 return null ;
646 }
647 return sanitizeShapeValue (shape, errors, `[${ i }]` , 0 , scrub);
648 });
649 }
650 }
651
652 const evidenceRefs = validateEvidenceRefs (errors, input.evidence_refs);
653
654 if (errors. length > 0 ) {
655 throw new ValidationError (errors, classKeyFor ({
656 event_type: eventType, stage, entity_type: entityType, wix_api_surface: wixApiSurface,
657 source_api_surface: sourceApiSurface, wix_app_id: wixAppId, error_code: errorCode,
658 subtype, decision_point: decisionPoint,
659 }));
660 }
661
662 const scrubbedWhat = scrubText (whatHappened);
663 const scrubbedExpected = expected === null ? null : scrubText (expected);
664 const scrubbedActual = actual === null ? null : scrubText (actual);
665 scrub.hits += scrubbedWhat.hits + (scrubbedExpected ? scrubbedExpected.hits : 0 ) + (scrubbedActual ? scrubbedActual.hits : 0 );
666
667 const event = {
668 event_type: eventType,
669 subtype,
670 stage,
671 skill: input.skill,
672 entity_type: entityType,
673 severity: input.severity,
674 wix_api_surface: wixApiSurface,
675 source_api_surface: sourceApiSurface,
676 wix_app_id: wixAppId,
677 error_code: errorCode,
678 what_happened: scrubbedWhat.text,
679 expected: scrubbedExpected ? scrubbedExpected.text : null ,
680 actual: scrubbedActual ? scrubbedActual.text : null ,
681 observed_shapes: observedShapes,
682 evidence_refs: evidenceRefs,
683 count,
684 };
685 if (eventType === 'error' ) {
686 event.retry_count = retryCount;
687 event.recovered = recovered;
688 }
689 if (eventType === 'user_decision' ) {
690 event.decision_point = decisionPoint;
691 }
692 return { event, classKey: classKeyFor (event), scrubHits: scrub.hits };
693 }
694
695 function classKeyFor ( event ) {
696 const parts = [
697 event.event_type, event.stage, event.entity_type, event.wix_api_surface,
698 event.source_api_surface, event.wix_app_id, event.error_code, event.subtype,
699 ];
700 if (event.event_type === 'user_decision' ) parts. push (event.decision_point);
701 return parts. map (( p ) => (p === null || p === undefined ? '' : String (p))). join ( '|' );
702 }
703
704 // --- rollup (finalize) validation --------------------------------------------
705
706 function validateVolumes ( errors , input ) {
707 if (input === undefined || input === null ) return [];
708 if ( ! Array. isArray (input)) {
709 errors. push ( 'volumes must be an array of per-entity-type volume records' );
710 return [];
711 }
712 const volumes = [];
713 for ( const [ i , raw ] of input. entries ()) {
714 if ( ! raw || typeof raw !== 'object' ) {
715 errors. push ( `volumes[${ i }] must be an object` );
716 continue ;
717 }
718 const entityType = typeof raw.entity_type === 'string' ? normalizeToken (raw.entity_type) : '' ;
719 if ( ! entityType) {
720 errors. push ( `volumes[${ i }].entity_type is required` );
721 continue ;
722 }
723 let target = null ;
724 if (raw.target !== undefined && raw.target !== null ) {
725 if ( checkEnum (errors, `volumes[${ i }].target` , raw.target, VOLUME_TARGETS )) target = raw.target;
726 }
727 const targetSurface = checkApiSurface (errors, `volumes[${ i }].target_surface` , raw.target_surface);
728 const volume = {
729 entity_type: entityType,
730 target,
731 target_surface: targetSurface,
732 discovered: checkCount (errors, `volumes[${ i }].discovered` , raw.discovered),
733 planned: raw.planned === undefined || raw.planned === null
734 ? null
735 : checkCount (errors, `volumes[${ i }].planned` , raw.planned),
736 attempted: checkCount (errors, `volumes[${ i }].attempted` , raw.attempted),
737 succeeded: checkCount (errors, `volumes[${ i }].succeeded` , raw.succeeded),
738 failed: checkCount (errors, `volumes[${ i }].failed` , raw.failed),
739 skipped: checkCount (errors, `volumes[${ i }].skipped` , raw.skipped),
740 already_imported: checkCount (errors, `volumes[${ i }].already_imported` , raw.already_imported),
741 };
742 if (volume.attempted !== volume.succeeded + volume.failed) {
743 errors. push ( `volumes[${ i }] (${ entityType }): attempted must equal succeeded + failed (${ volume . attempted } != ${ volume . succeeded } + ${ volume . failed })` );
744 }
745 volumes. push (volume);
746 }
747 return volumes;
748 }
749
750 function validateVerification ( errors , input ) {
751 if (input === undefined || input === null ) return [];
752 if ( ! Array. isArray (input)) {
753 errors. push ( 'verification must be an array of verification records' );
754 return [];
755 }
756 const records = [];
757 for ( const [ i , raw ] of input. entries ()) {
758 if ( ! raw || typeof raw !== 'object' ) {
759 errors. push ( `verification[${ i }] must be an object` );
760 continue ;
761 }
762 const subject = typeof raw.subject === 'string' ? normalizeToken (raw.subject) : '' ;
763 if ( ! subject) {
764 errors. push ( `verification[${ i }].subject is required (an entity_type, or "routes")` );
765 continue ;
766 }
767 if ( ! checkEnum (errors, `verification[${ i }].method` , raw.method, VERIFICATION_METHODS )) continue ;
768 const record = {
769 subject,
770 method: raw.method,
771 checked: checkCount (errors, `verification[${ i }].checked` , raw.checked),
772 passed: checkCount (errors, `verification[${ i }].passed` , raw.passed),
773 failed: checkCount (errors, `verification[${ i }].failed` , raw.failed),
774 };
775 if (record.passed + record.failed > record.checked) {
776 errors. push ( `verification[${ i }] (${ subject }): passed + failed must not exceed checked` );
777 }
778 records. push (record);
779 }
780 return records;
781 }
782
783 function validateFinalizeInput ( input , context ) {
784 if ( ! input || typeof input !== 'object' || Array. isArray (input)) {
785 throw new ValidationError ([ 'finalize payload must be a JSON object' ]);
786 }
787 const errors = [];
788 const dimKeys = {};
789 const known = new Set ([ 'terminal_state' , 'stopped_at_stage' , 'operator_acceptance' , 'volumes' , 'verification' ]);
790 for ( const [ key , value ] of Object. entries (input)) {
791 if (known. has (key)) continue ;
792 if ( DIM_VALIDATORS [key]) {
793 dimKeys[key] = value;
794 continue ;
795 }
796 errors. push ( `unknown finalize field: ${ key }` );
797 }
798
799 checkEnum (errors, 'terminal_state' , input.terminal_state, TERMINAL_STATES );
800
801 let stoppedAtStage = null ;
802 if (input.terminal_state === 'completed' ) {
803 if (input.stopped_at_stage !== undefined && input.stopped_at_stage !== null ) {
804 errors. push ( 'stopped_at_stage must be null when terminal_state is completed' );
805 }
806 } else if (input.stopped_at_stage !== undefined && input.stopped_at_stage !== null ) {
807 if ( checkEnum (errors, 'stopped_at_stage' , input.stopped_at_stage, STAGES )) {
808 stoppedAtStage = input.stopped_at_stage;
809 }
810 } else {
811 stoppedAtStage = context.openStage || context.lastStage || 'config' ;
812 }
813
814 let operatorAcceptance = 'unknown' ;
815 if (input.operator_acceptance !== undefined && input.operator_acceptance !== null ) {
816 if ( checkEnum (errors, 'operator_acceptance' , input.operator_acceptance, OPERATOR_ACCEPTANCE )) {
817 operatorAcceptance = input.operator_acceptance;
818 }
819 }
820
821 const volumes = validateVolumes (errors, input.volumes);
822 const verification = validateVerification (errors, input.verification);
823
824 let dims = {};
825 try {
826 dims = validateDims (dimKeys);
827 } catch (error) {
828 errors. push ( ... error.errors);
829 }
830
831 if (errors. length > 0 ) throw new ValidationError (errors);
832 return {
833 terminal_state: input.terminal_state,
834 stopped_at_stage: stoppedAtStage,
835 operator_acceptance: operatorAcceptance,
836 volumes,
837 verification,
838 dims,
839 };
840 }
841
842 // --- transcript_digest validation (spec 0039) ---------------------------------
843 // Every field is derived, machine-computed data (transcript-digest.js), never
844 // agent free text — so the gate here checks shape and bounds, not prose rules.
845
846 const TOOL_NAME_MAX = 80 ;
847
848 function checkToolName ( errors , field , value ) {
849 if ( typeof value !== 'string' || value. length === 0 || value. length > TOOL_NAME_MAX || / \s / . test (value)) {
850 errors. push ( `${ field } must be a short tool name (no whitespace, max ${ TOOL_NAME_MAX } chars)` );
851 return null ;
852 }
853 return value;
854 }
855
856 function validateNamedCountList ( errors , field , value , { max = 10 } = {}) {
857 if (value === undefined || value === null ) return [];
858 if ( ! Array. isArray (value) || value. length > max) {
859 errors. push ( `${ field } must be an array of at most ${ max } entries` );
860 return [];
861 }
862 return value. map (( entry , i ) => {
863 if ( ! entry || typeof entry !== 'object' ) {
864 errors. push ( `${ field }[${ i }] must be an object` );
865 return null ;
866 }
867 return entry;
868 }). filter (Boolean);
869 }
870
871 function validateTranscriptDigest ( input ) {
872 if ( ! input || typeof input !== 'object' || Array. isArray (input)) {
873 throw new ValidationError ([ 'transcript_digest payload must be a JSON object' ]);
874 }
875 const errors = [];
876 const known = new Set ([ 'source' , 'cost' , 'interaction' , 'time' , 'tools' , 'signals' ]);
877 for ( const key of Object. keys (input)) {
878 if ( ! known. has (key)) errors. push ( `unknown transcript_digest field: ${ key }` );
879 }
880
881 const source = input.source || {};
882 const sourceOut = {
883 sessions: checkCount (errors, 'source.sessions' , source.sessions, { defaultValue: 1 , min: 1 }),
884 transcript_turns: checkCount (errors, 'source.transcript_turns' , source.transcript_turns),
885 cc_version: null ,
886 tail_complete: typeof source.tail_complete === 'boolean' ? source.tail_complete : null ,
887 };
888 if (sourceOut.tail_complete === null ) errors. push ( 'source.tail_complete must be a boolean' );
889 if (source.cc_version !== undefined && source.cc_version !== null ) {
890 if ( typeof source.cc_version !== 'string' || source.cc_version. length > 32 ) {
891 errors. push ( 'source.cc_version must be a short version string' );
892 } else {
893 sourceOut.cc_version = source.cc_version;
894 }
895 }
896
897 const cost = input.cost || {};
898 const costOut = {
899 input_tokens: checkCount (errors, 'cost.input_tokens' , cost.input_tokens),
900 output_tokens: checkCount (errors, 'cost.output_tokens' , cost.output_tokens),
901 cache_read_input_tokens: checkCount (errors, 'cost.cache_read_input_tokens' , cost.cache_read_input_tokens),
902 cache_creation_input_tokens: checkCount (errors, 'cost.cache_creation_input_tokens' , cost.cache_creation_input_tokens),
903 };
904
905 const interaction = input.interaction || {};
906 const interactionOut = {
907 human_turns: checkCount (errors, 'interaction.human_turns' , interaction.human_turns),
908 agent_turns: checkCount (errors, 'interaction.agent_turns' , interaction.agent_turns),
909 sidechain_turns: checkCount (errors, 'interaction.sidechain_turns' , interaction.sidechain_turns),
910 };
911
912 const time = input.time || {};
913 const timeOut = {
914 wall_ms: checkCount (errors, 'time.wall_ms' , time.wall_ms),
915 active_ms: checkCount (errors, 'time.active_ms' , time.active_ms),
916 idle_ms: checkCount (errors, 'time.idle_ms' , time.idle_ms),
917 longest_gap_ms: checkCount (errors, 'time.longest_gap_ms' , time.longest_gap_ms),
918 };
919
920 const tools = input.tools || {};
921 const topFailing = validateNamedCountList (errors, 'tools.top_failing' , tools.top_failing). map (( entry , i ) => ({
922 tool: checkToolName (errors, `tools.top_failing[${ i }].tool` , entry.tool),
923 n: checkCount (errors, `tools.top_failing[${ i }].n` , entry.n, { defaultValue: 1 , min: 1 }),
924 }));
925 const toolsOut = {
926 calls: checkCount (errors, 'tools.calls' , tools.calls),
927 failures: checkCount (errors, 'tools.failures' , tools.failures),
928 top_failing: topFailing,
929 };
930
931 const signals = input.signals || {};
932 const retryLoops = validateNamedCountList (errors, 'signals.retry_loops' , signals.retry_loops). map (( entry , i ) => {
933 let stage = null ;
934 if (entry.stage !== undefined && entry.stage !== null ) {
935 if ( checkEnum (errors, `signals.retry_loops[${ i }].stage` , entry.stage, STAGES )) stage = entry.stage;
936 }
937 return {
938 tool: checkToolName (errors, `signals.retry_loops[${ i }].tool` , entry.tool),
939 repeats: checkCount (errors, `signals.retry_loops[${ i }].repeats` , entry.repeats, { defaultValue: 1 , min: 1 }),
940 stage,
941 };
942 });
943 const signalsOut = {
944 retry_loops: retryLoops,
945 hook_blocks: checkCount (errors, 'signals.hook_blocks' , signals.hook_blocks),
946 hook_errors: checkCount (errors, 'signals.hook_errors' , signals.hook_errors),
947 files_edited_mid_run: checkCount (errors, 'signals.files_edited_mid_run' , signals.files_edited_mid_run),
948 };
949
950 if (errors. length > 0 ) throw new ValidationError (errors);
951 return {
952 source: sourceOut, cost: costOut, interaction: interactionOut, time: timeOut, tools: toolsOut, signals: signalsOut,
953 };
954 }
955
956 // --- journal ------------------------------------------------------------------
957
958 function readJournalFile ( file ) {
959 const records = [];
960 let malformed = 0 ;
961 for ( const line of fs. readFileSync (file, 'utf8' ). split ( ' \n ' )) {
962 if (line. trim () === '' ) continue ;
963 try {
964 records. push ( JSON . parse (line));
965 } catch {
966 malformed += 1 ;
967 }
968 }
969 return { records, malformed };
970 }
971
972 function readJournal ( projectDir ) {
973 const file = journalPath (projectDir);
974 if ( ! fs. existsSync (file)) return null ;
975 return readJournalFile (file);
976 }
977
978 function appendJournal ( projectDir , record ) {
979 const file = journalPath (projectDir);
980 fs. mkdirSync (path. dirname (file), { recursive: true });
981 fs. appendFileSync (file, `${ JSON . stringify ( record ) } \n ` , 'utf8' );
982 }
983
984 function replay ( records ) {
985 const state = {
986 runStart: null ,
987 dims: {},
988 sessionCount: 1 ,
989 seq: 0 ,
990 events: [],
991 rejected: [],
992 entries: [],
993 waits: [],
994 meters: [],
995 openStage: null ,
996 openStageStart: null ,
997 lastStage: null ,
998 openWait: null ,
999 scrubHits: 0 ,
1000 biPushFailures: 0 ,
1001 biPushTruncated: 0 ,
1002 biPushSkipped: 0 ,
1003 lastBiPushOkSeq: 0 ,
1004 lastEventSeq: 0 ,
1005 finalize: null ,
1006 lastTs: null ,
1007 transcriptDigest: null ,
1008 };
1009 for ( const record of records) {
1010 state.seq = Math. max (state.seq, record.seq || 0 );
1011 state.lastTs = record.ts || state.lastTs;
1012 switch (record.type) {
1013 case 'run_start' :
1014 state.runStart = record;
1015 Object. assign (state.dims, record.dims || {});
1016 break ;
1017 case 'session_start' :
1018 state.sessionCount = record.session;
1019 break ;
1020 case 'dims' :
1021 Object. assign (state.dims, record.dims || {});
1022 break ;
1023 case 'stage_start' :
1024 state.openStage = record.stage;
1025 state.openStageStart = record.ts;
1026 state.lastStage = record.stage;
1027 break ;
1028 case 'stage_end' :
1029 if (state.openStage === record.stage) {
1030 state.entries. push ({ stage: record.stage, start: state.openStageStart, end: record.ts, outcome: record.outcome });
1031 state.openStage = null ;
1032 state.openStageStart = null ;
1033 }
1034 break ;
1035 case 'wait_start' :
1036 state.openWait = { stage: record.stage, start: record.ts };
1037 break ;
1038 case 'wait_end' :
1039 if (state.openWait) {
1040 state.waits. push ({ ... state.openWait, end: record.ts });
1041 state.openWait = null ;
1042 }
1043 break ;
1044 case 'meter' :
1045 // Accumulate: a stage may be entered more than once, and each generated script reports its
1046 // own invocation. Summing is the only reading that survives a resume.
1047 state.meters. push (record.meter);
1048 break ;
1049 case 'event' :
1050 state.events. push (record);
1051 state.scrubHits += record.scrub_hits || 0 ;
1052 state.lastEventSeq = Math. max (state.lastEventSeq, record.seq || 0 );
1053 break ;
1054 case 'rejected' :
1055 state.rejected. push (record);
1056 break ;
1057 case 'bi_push_failed' :
1058 state.biPushFailures += 1 ;
1059 break ;
1060 case 'bi_push_truncated' :
1061 state.biPushTruncated += record.rows || 1 ;
1062 break ;
1063 case 'bi_push_skipped' :
1064 state.biPushSkipped += record.rows || 1 ;
1065 break ;
1066 case 'bi_push_ok' :
1067 state.lastBiPushOkSeq = Math. max (state.lastBiPushOkSeq, record.through_seq || 0 );
1068 break ;
1069 case 'finalize' :
1070 state.finalize = record;
1071 break ;
1072 case 'transcript_digest' :
1073 state.transcriptDigest = {
1074 source: record.source,
1075 cost: record.cost,
1076 interaction: record.interaction,
1077 time: record.time,
1078 tools: record.tools,
1079 signals: record.signals,
1080 };
1081 break ;
1082 default :
1083 break ;
1084 }
1085 }
1086 return state;
1087 }
1088
1089 function requireActiveRun ( projectDir ) {
1090 const journal = readJournal (projectDir);
1091 if ( ! journal) {
1092 throw new ValidationError (
1093 [ 'no active telemetry run in this project' ],
1094 "call `rp-telemetry.js start '<dims-json>'` first" ,
1095 );
1096 }
1097 const state = replay (journal.records);
1098 if ( ! state.runStart) {
1099 throw new ValidationError ([ 'telemetry journal is corrupt: missing run-start record' ]);
1100 }
1101 if (state.finalize) {
1102 // A crash after the finalize append but before archival: finish the archival
1103 // now, then report no active run.
1104 archiveFinalizedJournal (projectDir, journal, state);
1105 throw new ValidationError (
1106 [ 'the previous run is already finalized' ],
1107 "call `rp-telemetry.js start '<dims-json>'` to open the next run" ,
1108 );
1109 }
1110 return { journal, state };
1111 }
1112
1113 // --- fold + document assembly ---------------------------------------------------
1114
1115 function foldEvents ( eventRecords , runId ) {
1116 const byClass = new Map ();
1117 for ( const record of eventRecords) {
1118 const e = record.event;
1119 let folded = byClass. get (record.class_key);
1120 if ( ! folded) {
1121 folded = {
1122 run_id: runId,
1123 seq: record.seq,
1124 count: 0 ,
1125 stage: e.stage,
1126 skill: e.skill,
1127 event_type: e.event_type,
1128 subtype: e.subtype,
1129 entity_type: e.entity_type,
1130 severity: e.severity,
1131 wix_api_surface: e.wix_api_surface,
1132 source_api_surface: e.source_api_surface,
1133 wix_app_id: e.wix_app_id,
1134 error_code: e.error_code,
1135 what_happened: e.what_happened,
1136 expected: e.expected,
1137 actual: e.actual,
1138 observed_shapes: null ,
1139 evidence_refs: null ,
1140 _shapes: new Map (),
1141 _refs: new Map (),
1142 };
1143 if (e.event_type === 'error' ) {
1144 folded.retry_count = 0 ;
1145 folded.recovered = false ;
1146 }
1147 if (e.event_type === 'user_decision' ) folded.decision_point = e.decision_point;
1148 byClass. set (record.class_key, folded);
1149 }
1150 folded.count += e.count;
1151 if (e.event_type === 'error' ) {
1152 folded.retry_count += e.retry_count || 0 ;
1153 // A retry chain is often recorded per attempt. The latest occurrence knows
1154 // whether the operation ultimately succeeded, and a recovering occurrence's
1155 // `actual` carries the change that made it succeed — the first (failing)
1156 // attempt's fields must never erase that half of the observation.
1157 folded.recovered = Boolean (e.recovered);
1158 if (e.recovered && e.actual) folded.actual = e.actual;
1159 }
1160 for ( const shape of e.observed_shapes || []) {
1161 const key = stableStringify (shape);
1162 if ( ! folded._shapes. has (key)) folded._shapes. set (key, shape);
1163 }
1164 for ( const ref of e.evidence_refs || []) {
1165 const key = `${ ref . artifact }#${ ref . locator || ''}` ;
1166 if ( ! folded._refs. has (key)) folded._refs. set (key, ref);
1167 }
1168 }
1169 const events = [ ... byClass. values ()]. sort (( a , b ) => a.seq - b.seq);
1170 for ( const event of events) {
1171 const shapes = [ ... event._shapes. values ()];
1172 if (shapes. length > 0 ) {
1173 event.observed_shapes = shapes. slice ( 0 , SHAPES_KEPT_PER_CLASS );
1174 // Surfaced bound, never a silent one.
1175 if (shapes. length > SHAPES_KEPT_PER_CLASS ) event.shapes_seen = shapes. length ;
1176 }
1177 const refs = [ ... event._refs. values ()];
1178 if (refs. length > 0 ) event.evidence_refs = refs. slice ( 0 , EVIDENCE_REFS_MAX );
1179 delete event._shapes;
1180 delete event._refs;
1181 }
1182 return events;
1183 }
1184
1185 // Event types that mean the stage did not run clean. `contained_recovery` is DERIVED from these
1186 // rather than self-reported, because a stage that spent its time debugging is exactly the stage an
1187 // agent is least likely to remember to flag.
1188 const RECOVERY_EVENT_TYPES = new Set ([ 'error' , 'pipeline_defect' ]);
1189
1190 function computeStages ( state , finalizeTs ) {
1191 const entries = [ ... state.entries];
1192 const waits = [ ... state.waits];
1193 if (state.openWait) {
1194 waits. push ({ ... state.openWait, end: finalizeTs });
1195 }
1196 const byStage = new Map ();
1197 const order = [];
1198 const stageRecord = ( stage ) => {
1199 if ( ! byStage. has (stage)) {
1200 byStage. set (stage, { stage, outcome: 'halted' , active_ms: 0 , waiting_ms: 0 , _entries: [] });
1201 order. push (stage);
1202 }
1203 return byStage. get (stage);
1204 };
1205 for ( const entry of entries) {
1206 const record = stageRecord (entry.stage);
1207 record.outcome = entry.outcome;
1208 record._entries. push (entry);
1209 }
1210 for ( const wait of waits) {
1211 const record = stageRecord (wait.stage || 'config' );
1212 record.waiting_ms += Math. max ( 0 , tsMs (wait.end) - tsMs (wait.start));
1213 }
1214 // Metered measurements, summed per stage. A stage named by a meter but never opened still gets a
1215 // record, so a mis-attributed meter is visible instead of silently discarded.
1216 for ( const meter of state.meters || []) {
1217 const record = stageRecord (meter.stage);
1218 for ( const key of [ ... METER_DURATION_KEYS , ... METER_COUNT_KEYS ]) {
1219 if (meter[key] === undefined ) continue ;
1220 record[key] = (record[key] || 0 ) + meter[key];
1221 }
1222 }
1223
1224 // Stages that contained an error or a pipeline defect. Without this, a clean run and a thrash are
1225 // indistinguishable in the rollup, and every per-stage duration silently includes repair time.
1226 const recoveryStages = new Set (
1227 (state.events || [])
1228 . filter (( e ) => RECOVERY_EVENT_TYPES . has (e.event && e.event.event_type ? e.event.event_type : e.event_type))
1229 . map (( e ) => (e.event && e.event.stage ? e.event.stage : e.stage))
1230 . filter (Boolean),
1231 );
1232
1233 for ( const stage of order) {
1234 const record = byStage. get (stage);
1235 let active = 0 ;
1236 for ( const entry of record._entries) {
1237 active += Math. max ( 0 , tsMs (entry.end) - tsMs (entry.start));
1238 for ( const wait of waits) {
1239 if ((wait.stage || 'config' ) !== stage) continue ;
1240 const overlap = Math. min ( tsMs (entry.end), tsMs (wait.end)) - Math. max ( tsMs (entry.start), tsMs (wait.start));
1241 if (overlap > 0 ) active -= overlap;
1242 }
1243 }
1244 record.active_ms = Math. max ( 0 , active);
1245 record.contained_recovery = recoveryStages. has (stage);
1246 // The remainder of active_ms that no meter explained. This is the honesty field: it is how much
1247 // of the stage is still a black box, and it must never be silently folded into a component.
1248 const attributed = METER_DURATION_KEYS . reduce (( sum , key ) => sum + (record[key] || 0 ), 0 );
1249 record.unattributed_ms = Math. max ( 0 , record.active_ms - attributed);
1250 delete record._entries;
1251 }
1252 return order. map (( stage ) => byStage. get (stage));
1253 }
1254
1255 // Cost is DERIVED from token counts and the pricing snapshot, never recorded as a figure — a stored
1256 // dollar amount silently goes wrong the moment list prices change, while tokens are a fact.
1257 function computeCost ( stages , pricingSnapshot , model ) {
1258 const totals = { input_tokens: 0 , output_tokens: 0 , cache_read_tokens: 0 , cache_write_tokens: 0 };
1259 for ( const stage of stages) {
1260 for ( const key of Object. keys (totals)) totals[key] += stage[key] || 0 ;
1261 }
1262 const anyTokens = Object. values (totals). some (( v ) => v > 0 );
1263 const rates = pricingSnapshot && model ? pricingSnapshot[model] : null ;
1264 if ( ! anyTokens || ! rates) {
1265 return { tokens: totals, estimated_cost_usd: null , cost_basis: rates ? 'no_tokens_recorded' : 'no_pricing_snapshot' };
1266 }
1267 const perM = ( count , rate ) => (rate === undefined ? 0 : (count / 1e6 ) * rate);
1268 const usd = perM (totals.input_tokens, rates.input_per_mtok)
1269 + perM (totals.output_tokens, rates.output_per_mtok)
1270 + perM (totals.cache_read_tokens, rates.cache_read_per_mtok)
1271 + perM (totals.cache_write_tokens, rates.cache_write_per_mtok);
1272 return { tokens: totals, estimated_cost_usd: Number (usd. toFixed ( 6 )), cost_basis: 'derived_from_snapshot' };
1273 }
1274
1275 const MANIFEST_EXCLUDE_DIRS = new Set ([ 'config' , 'data' , 'preview' , 'node_modules' , 'frontend' , 'telemetry' ]);
1276 // .ndjson: the pipeline's audit logs (logs/audit-*.ndjson) are spec-named
1277 // evidence — a pipeline_defect deep-dive depends on them being in the bundle.
1278 const MANIFEST_EXTENSIONS = new Set ([ '.md' , '.json' , '.jsonl' , '.ndjson' , '.js' , '.mjs' , '.cjs' , '.ts' , '.sh' , '.yaml' , '.yml' ]);
1279
1280 function walkEvidence ( projectDir ) {
1281 const files = [];
1282 const walk = ( rel ) => {
1283 const abs = rel === '' ? projectDir : path. join (projectDir, rel);
1284 const entries = fs. readdirSync (abs, { withFileTypes: true }). sort (( a , b ) => a.name. localeCompare (b.name));
1285 for ( const entry of entries) {
1286 if (entry.name. startsWith ( '.' )) continue ;
1287 const relPath = rel === '' ? entry.name : `${ rel }/${ entry . name }` ;
1288 if (entry. isDirectory ()) {
1289 if ( MANIFEST_EXCLUDE_DIRS . has (entry.name)) continue ;
1290 walk (relPath);
1291 continue ;
1292 }
1293 if ( ! entry. isFile ()) continue ;
1294 if (entry.name. endsWith ( '.env' )) continue ;
1295 if (relPath === 'run-telemetry.json' ) continue ; // added explicitly, first
1296 if ( ! MANIFEST_EXTENSIONS . has (path. extname (entry.name))) continue ;
1297 files. push ({ path: relPath, bytes: fs. statSync (path. join (projectDir, relPath)).size });
1298 }
1299 };
1300 walk ( '' );
1301 return files;
1302 }
1303
1304 function assembleDocument ( projectDir , records , malformedLines = 0 ) {
1305 const state = replay (records);
1306 state.malformedLines = malformedLines;
1307 const runStart = state.runStart;
1308 const payload = state.finalize.payload;
1309 const finalizeTs = state.finalize.ts;
1310 const dims = { ... state.dims, ... payload.dims };
1311
1312 const events = foldEvents (state.events, runStart.run_id);
1313 const stages = computeStages (state, finalizeTs);
1314 const activeMs = stages. reduce (( sum , s ) => sum + s.active_ms, 0 );
1315 const waitingMs = stages. reduce (( sum , s ) => sum + s.waiting_ms, 0 );
1316
1317 // The headline question this schema exists to answer: how much of the run was deterministic code
1318 // versus model reasoning. `unattributed_ms` is reported alongside so the split is never mistaken
1319 // for a complete accounting of active_ms.
1320 const sumOver = ( key ) => stages. reduce (( sum , s ) => sum + (s[key] || 0 ), 0 );
1321 const agenticMs = sumOver ( 'model_ms' );
1322 const deterministicMs = sumOver ( 'script_ms' ) + sumOver ( 'api_ms' );
1323 const unattributedMs = sumOver ( 'unattributed_ms' );
1324 const recoveryStages = stages. filter (( s ) => s.contained_recovery). map (( s ) => s.stage);
1325 const cost = computeCost (
1326 stages,
1327 dims.model_pricing_snapshot || null ,
1328 (runStart.runtime_env && runStart.runtime_env.model) || (dims.runtime_env && dims.runtime_env.model) || null ,
1329 );
1330 const timing = {
1331 active_ms: activeMs,
1332 waiting_ms: waitingMs,
1333 agentic_ms: agenticMs,
1334 deterministic_ms: deterministicMs,
1335 unattributed_ms: unattributedMs,
1336 // What fraction of the EXPLAINED time was the model. Null when nothing was metered, rather
1337 // than a misleading 0.
1338 agentic_share: agenticMs + deterministicMs > 0
1339 ? Number ((agenticMs / (agenticMs + deterministicMs)). toFixed ( 4 ))
1340 : null ,
1341 api_calls: sumOver ( 'api_calls' ),
1342 api_retries: sumOver ( 'api_retries' ),
1343 stages_with_recovery: recoveryStages,
1344 };
1345
1346 const eventCountByType = {};
1347 for ( const event of events) {
1348 const entry = eventCountByType[event.event_type] || { classes: 0 , occurrences: 0 };
1349 entry.classes += 1 ;
1350 entry.occurrences += event.count;
1351 eventCountByType[event.event_type] = entry;
1352 }
1353
1354 const acceptedClasses = new Set (state.events. map (( r ) => r.class_key));
1355 const dropped = state.rejected. filter (( r ) => ! r.class_key || ! acceptedClasses. has (r.class_key)). length ;
1356
1357 const flags = [];
1358 const hasEventType = ( ... types ) => events. some (( e ) => types. includes (e.event_type));
1359 if (payload.volumes. some (( v ) => v.failed > 0 ) && ! hasEventType ( 'error' )) {
1360 flags. push ( 'volumes_report_failures_but_no_error_events' );
1361 }
1362 for ( const volume of payload.volumes) {
1363 if (volume.planned !== null && volume.attempted > volume.planned + volume.already_imported) {
1364 // Execution diverging from the approved plan must surface mechanically.
1365 flags. push ( `attempted_exceeds_planned:${ volume . entity_type }` );
1366 }
1367 if (volume.attempted + volume.skipped + volume.already_imported > volume.discovered) {
1368 flags. push ( `volumes_exceed_discovered:${ volume . entity_type }` );
1369 }
1370 }
1371 // Metering flags cover data that is WRONG, never data that is merely ABSENT.
1372 //
1373 // A run that meters nothing is not unhealthy — it is a runtime that does not report usage, which
1374 // describes every run recorded before metering existed. Flagging those would fire on ~all
1375 // historical runs and train readers to ignore the flag list. Absence is already visible, and
1376 // queryable, in `timing`: `agentic_share` is null and `unattributed_ms` carries the whole stage.
1377 // So only contradictions are flagged here, and each one implies metering was in use.
1378 for ( const stage of stages) {
1379 const attributed = METER_DURATION_KEYS . reduce (( sum , key ) => sum + (stage[key] || 0 ), 0 );
1380 if (attributed === 0 ) continue ;
1381 // Partially metered is more dangerous than not metered at all: it looks explained.
1382 if (stage.unattributed_ms > stage.active_ms * 0.5 ) {
1383 flags. push ( `stage_mostly_unattributed:${ stage . stage }` );
1384 }
1385 // Attributed time exceeding elapsed time is impossible, so it means the measurements are wrong:
1386 // usually the same interval metered twice across a resume, or concurrent work summed serially.
1387 // unattributed_ms clamps at 0, so without this flag the contradiction would be invisible.
1388 if (attributed > stage.active_ms * OVER_ATTRIBUTION_TOLERANCE + OVER_ATTRIBUTION_FLOOR_MS ) {
1389 flags. push ( `stage_over_attributed:${ stage . stage }` );
1390 }
1391 }
1392 if (cost.cost_basis === 'no_pricing_snapshot' && (cost.tokens.input_tokens > 0 || cost.tokens.output_tokens > 0 )) {
1393 flags. push ( 'tokens_recorded_without_pricing_snapshot' );
1394 }
1395 if (payload.verification. some (( v ) => v.failed > 0 ) && ! hasEventType ( 'error' , 'fidelity_loss' )) {
1396 flags. push ( 'verification_failures_but_no_matching_events' );
1397 }
1398 if (state.malformedLines > 0 ) {
1399 flags. push ( `journal_malformed_lines:${ state . malformedLines }` );
1400 }
1401 if (state.biPushTruncated > 0 ) {
1402 flags. push ( `bi_push_truncated:${ state . biPushTruncated }` );
1403 }
1404 // Rows swallowed by RP_TELEMETRY_BI_DISABLED while operator identity was
1405 // known. Legitimate only for tests/offline dev — on a production run this
1406 // flag means the kill-switch leaked into the session.
1407 if (state.biPushSkipped > 0 ) {
1408 flags. push ( 'bi_sink_disabled_during_run' );
1409 }
1410 // A stalled wait whose stage has no halt_needs_user event lost the "why" of
1411 // the stall — the single most actionable signal for where runs stall.
1412 const haltStages = new Set (events. filter (( e ) => e.event_type === 'halt_needs_user' ). map (( e ) => e.stage));
1413 const allWaits = [ ... state.waits, ... (state.openWait ? [{ ... state.openWait, end: finalizeTs }] : [])];
1414 const flaggedWaitStages = new Set ();
1415 for ( const w of allWaits) {
1416 const waitStage = w.stage || 'config' ;
1417 if ( tsMs (w.end) - tsMs (w.start) < WAIT_WITHOUT_HALT_FLAG_MS ) continue ;
1418 if (haltStages. has (waitStage) || flaggedWaitStages. has (waitStage)) continue ;
1419 flaggedWaitStages. add (waitStage);
1420 flags. push ( `wait_without_halt_event:${ waitStage }` );
1421 }
1422 // Discovery is where extension classes become known; a null left after a
1423 // passed discovery usually means the dims call was skipped, not that the
1424 // source has no extensions (stamp [] explicitly for that).
1425 const discoveryPassed = state.entries. some (( e ) => e.stage === 'discovery' && e.outcome === 'passed' );
1426 if (discoveryPassed && (dims.source_extensions === undefined || dims.source_extensions === null )
1427 && dims.source_platform !== 'csv' ) {
1428 flags. push ( 'source_extensions_null_after_discovery' );
1429 }
1430 // Same rationale for entity classes — and they exist for every source platform,
1431 // csv included, so no exemption. Stamp [] explicitly for an empty source.
1432 if (discoveryPassed && (dims.discovered_entity_types === undefined || dims.discovered_entity_types === null )) {
1433 flags. push ( 'discovered_entity_types_null_after_discovery' );
1434 }
1435 // The demand signal for unsupported verticals is the volume rows (target
1436 // "none", discovered/skipped counts). A discovered class with no volume row
1437 // under-reports that demand invisibly, so cross-check on a completed run —
1438 // earlier terminal states legitimately finalize before volumes exist.
1439 if (payload.terminal_state === 'completed' && Array. isArray (dims.discovered_entity_types)) {
1440 const volumeTypes = new Set (payload.volumes. map (( v ) => v.entity_type));
1441 for ( const entityType of dims.discovered_entity_types) {
1442 if ( ! volumeTypes. has (entityType)) flags. push ( `volume_row_missing:${ entityType }` );
1443 }
1444 }
1445
1446 const evidence = walkEvidence (projectDir);
1447 const evidenceBytes = evidence. reduce (( sum , f ) => sum + f.bytes, 0 );
1448 if (evidenceBytes > EVIDENCE_SIZE_FLAG_BYTES ) {
1449 flags. push ( `evidence_unusually_large:${ Math . round ( evidenceBytes / 1024 ) }kb` );
1450 }
1451
1452 const rollup = {
1453 run_id: runStart.run_id,
1454 project_id: runStart.project_id,
1455 attempt: runStart.attempt,
1456 session_count: state.sessionCount,
1457 schema_version: TELEMETRY_SCHEMA_VERSION ,
1458 skills_version: dims.skills_version || runStart.skills_version || null ,
1459 skills_commit: dims.skills_commit || runStart.skills_commit || null ,
1460 runtime_env: runStart.runtime_env || dims.runtime_env
1461 ? { ... (runStart.runtime_env || {}), ... (dims.runtime_env || {}) }
1462 : null ,
1463 site_id: dims.site_id ?? null ,
1464 wix_user_id: dims.wix_user_id ?? null ,
1465 source_platform: dims.source_platform ?? null ,
1466 source_platform_version: dims.source_platform_version ?? null ,
1467 source_site_url: dims.source_site_url ?? null ,
1468 source_extensions: dims.source_extensions ?? null ,
1469 discovered_entity_types: dims.discovered_entity_types ?? null ,
1470 source_acquisition: dims.source_acquisition ?? null ,
1471 delivery_mode: dims.delivery_mode ?? null ,
1472 destination_strategy: dims.destination_strategy ?? null ,
1473 terminal_state: payload.terminal_state,
1474 stopped_at_stage: payload.stopped_at_stage,
1475 stages,
1476 volumes: payload.volumes,
1477 verification: payload.verification,
1478 operator_acceptance: payload.operator_acceptance,
1479 telemetry_health: {
1480 rejected_calls: state.rejected. length ,
1481 dropped_events: dropped,
1482 scrub_hits: state.scrubHits,
1483 bi_push_failures: state.biPushFailures,
1484 bi_push_skipped: state.biPushSkipped,
1485 flags,
1486 },
1487 event_count_by_type: eventCountByType,
1488 active_ms: activeMs,
1489 waiting_ms: waitingMs,
1490 timing,
1491 cost,
1492 transcript_digest: state.transcriptDigest,
1493 model_pricing_snapshot: dims.model_pricing_snapshot ?? null ,
1494 run_started: runStart.ts,
1495 run_ended: finalizeTs,
1496 bundle_manifest: [ 'run-telemetry.json' , ... evidence. map (( f ) => f.path)],
1497 };
1498
1499 return {
1500 document: {
1501 schema_version: TELEMETRY_SCHEMA_VERSION ,
1502 field_tiers: FIELD_TIERS ,
1503 rollup,
1504 events,
1505 },
1506 evidenceBytes,
1507 evidenceFiles: evidence,
1508 };
1509 }
1510
1511 function writeJsonAtomic ( filePath , data ) {
1512 fs. mkdirSync (path. dirname (filePath), { recursive: true });
1513 const tempPath = `${ filePath }.${ process . pid }.tmp` ;
1514 fs. writeFileSync (tempPath, `${ JSON . stringify ( data , null , 2 ) } \n ` , 'utf8' );
1515 fs. renameSync (tempPath, filePath);
1516 }
1517
1518 function archiveName ( state ) {
1519 return `run-${ state . runStart . attempt }-${ state . runStart . run_id }` ;
1520 }
1521
1522 function archiveFinalizedJournal ( projectDir , journal , state ) {
1523 // Deterministic completion of a finalize: rebuild the document from the
1524 // journal (idempotent), write signal + archive copies, move the journal.
1525 const assembled = assembleDocument (projectDir, journal.records, journal.malformed);
1526 const base = archiveName (state);
1527 fs. mkdirSync ( runsDir (projectDir), { recursive: true });
1528 writeJsonAtomic ( signalPath (projectDir), assembled.document);
1529 writeJsonAtomic (path. join ( runsDir (projectDir), `${ base }.json` ), assembled.document);
1530 fs. renameSync ( journalPath (projectDir), path. join ( runsDir (projectDir), `${ base }.jsonl` ));
1531 return assembled;
1532 }
1533
1534 function lastFinalizedAttempt ( projectDir ) {
1535 let last = 0 ;
1536 const signal = signalPath (projectDir);
1537 if (fs. existsSync (signal)) {
1538 try {
1539 const doc = JSON . parse (fs. readFileSync (signal, 'utf8' ));
1540 if (doc && doc.rollup && Number. isInteger (doc.rollup.attempt)) last = Math. max (last, doc.rollup.attempt);
1541 } catch {
1542 // unreadable prior signal file — fall through to the archive scan
1543 }
1544 }
1545 const dir = runsDir (projectDir);
1546 if (fs. existsSync (dir)) {
1547 for ( const name of fs. readdirSync (dir)) {
1548 const match = / ^ run-( \d + )-/ . exec (name);
1549 if (match) last = Math. max (last, Number (match[ 1 ]));
1550 }
1551 }
1552 return last;
1553 }
1554
1555 function resolveSkillsVersion () {
1556 let dir = __dirname;
1557 for ( let depth = 0 ; depth < 7 ; depth += 1 ) {
1558 const candidate = path. join (dir, 'VERSION' );
1559 if (fs. existsSync (candidate)) {
1560 const version = fs. readFileSync (candidate, 'utf8' ). trim ();
1561 if ( / ^ \d + \. \d + \. \d +$ / . test (version)) return version;
1562 }
1563 const parent = path. dirname (dir);
1564 if (parent === dir) break ;
1565 dir = parent;
1566 }
1567 return null ;
1568 }
1569
1570 function resolveSkillsCommit () {
1571 // The exact source commit behind this bundle — the drill-down provenance the
1572 // coarse, hand-bumped `skills_version` (semver) cannot give. Two sources, in
1573 // order of authority:
1574 //
1575 // 1. `.publish-manifest.json`, stamped by publish-skills-to-wix.sh next to
1576 // VERSION at publish time. This is the only source that works in a partner
1577 // runtime (no git there) AND the only correct one once the bundle lives
1578 // inside the consuming wix/skills repo — where a git probe would return
1579 // that repo's HEAD, not the replatform source commit. So it wins.
1580 // 2. Dev-mode git fallback: runs straight from the replatform checkout, where
1581 // the recorder's own directory is inside the source repo, have no manifest
1582 // but do have git — so `git -C __dirname rev-parse --short HEAD` is the source
1583 // commit. Never reached once a manifest is present (case 1).
1584 let dir = __dirname;
1585 for ( let depth = 0 ; depth < 7 ; depth += 1 ) {
1586 const candidate = path. join (dir, '.publish-manifest.json' );
1587 if (fs. existsSync (candidate)) {
1588 try {
1589 const manifest = JSON . parse (fs. readFileSync (candidate, 'utf8' ));
1590 if ( typeof manifest.sourceCommit === 'string' && / ^ [0-9a-f] {7,40}$ / . test (manifest.sourceCommit)) {
1591 return manifest.sourceCommit;
1592 }
1593 } catch {
1594 // unreadable manifest — fall through to the dev-mode git probe
1595 }
1596 }
1597 const parent = path. dirname (dir);
1598 if (parent === dir) break ;
1599 dir = parent;
1600 }
1601 try {
1602 const sha = execFileSync ( 'git' , [ '-C' , __dirname, 'rev-parse' , '--short' , 'HEAD' ], {
1603 encoding: 'utf8' ,
1604 stdio: [ 'ignore' , 'pipe' , 'ignore' ],
1605 }). trim ();
1606 if ( / ^ [0-9a-f] {7,40}$ / . test (sha)) return sha;
1607 } catch {
1608 // no git, not a repo, or git unavailable — commit provenance stays null
1609 }
1610 return null ;
1611 }
1612
1613 // --- BI sink ----------------------------------------------------------
1614
1615 // Push rows to BI and journal any failures/truncation/skips so telemetry loss
1616 // is visible in telemetry_health, never silent. Never throws — BI unreachable,
1617 // slow, or rejecting is a telemetry failure, not a run failure. Returns the
1618 // last journal seq used plus sent/failed/skipped row counts.
1619 //
1620 // `throughSeq` is the healing watermark: when every row of a push lands, a
1621 // `bi_push_ok` record asserts "all pushable journal content with seq <=
1622 // through_seq is in BI". Anything journaled past the watermark — because a
1623 // push failed, the sink was disabled, or the journal predates the watermark
1624 // record type — is backlog, and the next push opportunity re-sends it in full
1625 // (idempotent: the reviewer dedupes on the natural keys, latest row wins).
1626 async function pushToBi ( projectDir , ts , seq , rows , loggedUserId , target , { throughSeq } = {}) {
1627 const result = await biSink. pushRows (rows, loggedUserId);
1628 if (result.skipped > 0 ) {
1629 seq += 1 ;
1630 appendJournal (projectDir, { type: 'bi_push_skipped' , ts, seq, target, rows: result.skipped });
1631 }
1632 if (result.truncated > 0 ) {
1633 seq += 1 ;
1634 appendJournal (projectDir, { type: 'bi_push_truncated' , ts, seq, target, rows: result.truncated });
1635 }
1636 for ( const failure of result.failures) {
1637 seq += 1 ;
1638 appendJournal (projectDir, {
1639 type: 'bi_push_failed' , ts, seq, target, rows: failure.rows, detail: failure.detail,
1640 });
1641 }
1642 if (throughSeq !== undefined && result.sent > 0 && result.failures. length === 0 && result.skipped === 0 ) {
1643 seq += 1 ;
1644 appendJournal (projectDir, { type: 'bi_push_ok' , ts, seq, target, through_seq: throughSeq });
1645 }
1646 return {
1647 seq,
1648 sent: result.sent,
1649 failed: result.failures. reduce (( sum , f ) => sum + f.rows, 0 ),
1650 skipped: result.skipped,
1651 };
1652 }
1653
1654 // True when journal content that belongs in BI has no confirming watermark:
1655 // a prior push failed or was skipped, or the journal predates schema 1.4.0.
1656 function biBacklog ( state ) {
1657 return state.lastBiPushOkSeq === 0 || state.lastBiPushOkSeq < state.lastEventSeq;
1658 }
1659
1660 // The full idempotent re-push set: the started row plus every folded event
1661 // class (the finalized rollup only exists at finalize, which builds its own).
1662 function healRows ( state , wixUserId , extraEventRecords = []) {
1663 const folded = foldEvents ([ ... state.events, ... extraEventRecords], state.runStart.run_id);
1664 return [biSink. startedRunRow (state.runStart, wixUserId), ... folded. map (biSink.eventRow)];
1665 }
1666
1667 // The folded BI row for one event class: prior journal records of the class plus
1668 // the just-appended one. Re-pushes of a growing class are safe — the reviewer
1669 // dedupes on (run_id, seq), latest row wins with the cumulative count.
1670 function foldedRowForClass ( state , eventRecord ) {
1671 const classRecords = [
1672 ... state.events. filter (( r ) => r.class_key === eventRecord.class_key),
1673 eventRecord,
1674 ];
1675 return biSink. eventRow ( foldEvents (classRecords, state.runStart.run_id)[ 0 ]);
1676 }
1677
1678 // --- public command API ---------------------------------------------------------
1679
1680 async function start ( projectDir , dimsInput , { now } = {}) {
1681 const ts = nowIso (now);
1682 const dims = validateDims (dimsInput);
1683 const journal = readJournal (projectDir);
1684
1685 if (journal) {
1686 const state = replay (journal.records);
1687 if ( ! state.runStart) {
1688 throw new ValidationError ([ 'telemetry journal is corrupt: missing run-start record' ]);
1689 }
1690 if (state.finalize) {
1691 archiveFinalizedJournal (projectDir, journal, state);
1692 } else {
1693 // Resume: same run_id, same attempt, one more session. Cross-session user
1694 // latency lands in waiting_ms, never in a phantom second run.
1695 let seq = state.seq;
1696 if (state.openWait) {
1697 seq += 1 ;
1698 appendJournal (projectDir, { type: 'wait_end' , ts, seq });
1699 } else if (state.lastTs && tsMs (ts) - tsMs (state.lastTs) >= IMPLICIT_WAIT_MIN_MS ) {
1700 // The run stopped without an open wait (e.g. a crash); the dead interval
1701 // is waiting time, not active time.
1702 const stage = state.openStage || state.lastStage || 'config' ;
1703 seq += 1 ;
1704 appendJournal (projectDir, { type: 'wait_start' , ts: state.lastTs, seq, stage, imputed: true });
1705 seq += 1 ;
1706 appendJournal (projectDir, { type: 'wait_end' , ts, seq, imputed: true });
1707 }
1708 seq += 1 ;
1709 appendJournal (projectDir, { type: 'session_start' , ts, seq, session: state.sessionCount + 1 });
1710 if (Object. keys (dims). length > 0 ) {
1711 seq += 1 ;
1712 appendJournal (projectDir, { type: 'dims' , ts, seq, dims });
1713 }
1714 // Session-boundary heal: re-push the started row and every folded event
1715 // class whenever operator identity is known — from this call's dims or
1716 // the journal. A halted run may never reach finalize's full re-push, so
1717 // the resume is the one reliable moment to recover rows the previous
1718 // session's transport lost (re-sends are harmless: reviewer-side dedup).
1719 const resumeUserId = biSink. isGuid (dims.wix_user_id)
1720 ? dims.wix_user_id
1721 : (biSink. isGuid (state.dims.wix_user_id) ? state.dims.wix_user_id : null );
1722 if (resumeUserId) {
1723 await pushToBi (projectDir, ts, seq, healRows (state, resumeUserId),
1724 resumeUserId, 'resume_heal' , { throughSeq: seq });
1725 }
1726 return {
1727 resumed: true ,
1728 run_id: state.runStart.run_id,
1729 attempt: state.runStart.attempt,
1730 session_count: state.sessionCount + 1 ,
1731 open_stage: state.openStage,
1732 };
1733 }
1734 }
1735
1736 const attempt = lastFinalizedAttempt (projectDir) + 1 ;
1737 const runtimeEnv = {
1738 os: `${ os . platform () } ${ os . release () }` ,
1739 node_version: process.version,
1740 agent_runtime: (dims.runtime_env && dims.runtime_env.agent_runtime) || null ,
1741 model: (dims.runtime_env && dims.runtime_env.model) || null ,
1742 ... (dims.runtime_env || {}),
1743 };
1744 delete dims.runtime_env;
1745 const skillsVersion = dims.skills_version || resolveSkillsVersion ();
1746 delete dims.skills_version;
1747 const skillsCommit = dims.skills_commit || resolveSkillsCommit ();
1748 delete dims.skills_commit;
1749
1750 const record = {
1751 type: 'run_start' ,
1752 ts,
1753 seq: 1 ,
1754 run_id: crypto. randomUUID (),
1755 project_id: path. basename (path. resolve (projectDir)),
1756 attempt,
1757 session: 1 ,
1758 schema_version: TELEMETRY_SCHEMA_VERSION ,
1759 skills_version: skillsVersion,
1760 skills_commit: skillsCommit,
1761 runtime_env: runtimeEnv,
1762 dims,
1763 };
1764 appendJournal (projectDir, record);
1765 // Emit `replatform_run` phase:started. Without a GUID operator
1766 // identity there is no BI route yet — a later dims call carrying wix_user_id
1767 // (or the finalize re-push) sends it then.
1768 if (biSink. isGuid (dims.wix_user_id)) {
1769 await pushToBi (projectDir, ts, record.seq,
1770 [biSink. startedRunRow (record, dims.wix_user_id)], dims.wix_user_id, 'run_started' ,
1771 { throughSeq: record.seq });
1772 }
1773 return {
1774 resumed: false ,
1775 run_id: record.run_id,
1776 project_id: record.project_id,
1777 attempt,
1778 session_count: 1 ,
1779 skills_version: skillsVersion,
1780 skills_commit: skillsCommit,
1781 };
1782 }
1783
1784 async function dims ( projectDir , dimsInput , { now } = {}) {
1785 const ts = nowIso (now);
1786 const { state } = requireActiveRun (projectDir);
1787 const validated = validateDims (dimsInput);
1788 if (Object. keys (validated). length === 0 ) {
1789 throw new ValidationError ([ 'dims payload contains no known dimension fields' ]);
1790 }
1791 appendJournal (projectDir, { type: 'dims' , ts, seq: state.seq + 1 , dims: validated });
1792 // Identity becoming known mid-run unlocks the BI route: heal the whole
1793 // backlog (started row + every folded event class), not just the started
1794 // row — held-back event rows must not wait on a finalize that may never come.
1795 if (biSink. isGuid (validated.wix_user_id)) {
1796 await pushToBi (projectDir, ts, state.seq + 1 ,
1797 healRows (state, validated.wix_user_id), validated.wix_user_id, 'identity_heal' ,
1798 { throughSeq: state.seq + 1 });
1799 }
1800 return { recorded: Object. keys (validated) };
1801 }
1802
1803 async function record ( projectDir , eventInput , { now } = {}) {
1804 const ts = nowIso (now);
1805 const { state } = requireActiveRun (projectDir);
1806 let validated;
1807 try {
1808 validated = validateEvent (eventInput, state);
1809 } catch (error) {
1810 if (error instanceof ValidationError ) {
1811 // The schema gate refused the call: count it so telemetry loss is never
1812 // silent, then surface the errors for a retry.
1813 appendJournal (projectDir, {
1814 type: 'rejected' ,
1815 ts,
1816 seq: state.seq + 1 ,
1817 class_key: error.hint || null ,
1818 reasons: error.errors,
1819 });
1820 error.hint = 'fix the listed fields and retry the record call' ;
1821 }
1822 throw error;
1823 }
1824 const eventRecord = {
1825 type: 'event' ,
1826 ts,
1827 seq: state.seq + 1 ,
1828 class_key: validated.classKey,
1829 scrub_hits: validated.scrubHits,
1830 event: validated.event,
1831 };
1832 appendJournal (projectDir, eventRecord);
1833 // Emit the folded `replatform_run_event` row. No identity yet →
1834 // hold back; the next heal point (resume, identity dims, finalize) sends
1835 // every folded class. With backlog behind the watermark, this push carries
1836 // the full heal set instead of just the new class — an event push is often
1837 // the first working transport after a failure.
1838 if (biSink. isGuid (state.dims.wix_user_id)) {
1839 const rows = biBacklog (state)
1840 ? healRows (state, state.dims.wix_user_id, [eventRecord])
1841 : [ foldedRowForClass (state, eventRecord)];
1842 await pushToBi (projectDir, ts, eventRecord.seq, rows, state.dims.wix_user_id, 'run_event' ,
1843 { throughSeq: eventRecord.seq });
1844 }
1845 const priorInClass = state.events. filter (( r ) => r.class_key === validated.classKey). length ;
1846 return {
1847 recorded: true ,
1848 seq: state.seq + 1 ,
1849 event_type: validated.event.event_type,
1850 folded_into_existing_class: priorInClass > 0 ,
1851 scrub_hits: validated.scrubHits,
1852 };
1853 }
1854
1855 function stage ( projectDir , action , stageName , { outcome , now } = {}) {
1856 const ts = nowIso (now);
1857 const { state } = requireActiveRun (projectDir);
1858 const errors = [];
1859 checkEnum (errors, 'stage' , stageName, STAGES );
1860 if (errors. length > 0 ) throw new ValidationError (errors);
1861 if (state.openWait) {
1862 throw new ValidationError (
1863 [ `a wait interval is open (stage ${ state . openWait . stage })` ],
1864 'call `wait end` before stage boundaries' ,
1865 );
1866 }
1867 if (action === 'start' ) {
1868 if (state.openStage) {
1869 throw new ValidationError (
1870 [ `stage ${ state . openStage } is still open` ],
1871 `call \` stage end ${ state . openStage } --outcome <passed|halted|failed|skipped> \` first` ,
1872 );
1873 }
1874 appendJournal (projectDir, { type: 'stage_start' , ts, seq: state.seq + 1 , stage: stageName });
1875 return { stage: stageName, started: true };
1876 }
1877 if (action === 'end' ) {
1878 if (state.openStage !== stageName) {
1879 throw new ValidationError ([
1880 state.openStage
1881 ? `open stage is ${ state . openStage }, not ${ stageName }`
1882 : `no stage is open (last stage: ${ state . lastStage || 'none'})` ,
1883 ]);
1884 }
1885 const resolvedOutcome = outcome === undefined || outcome === null ? 'passed' : outcome;
1886 const outcomeErrors = [];
1887 checkEnum (outcomeErrors, 'outcome' , resolvedOutcome, STAGE_OUTCOMES );
1888 if (outcomeErrors. length > 0 ) throw new ValidationError (outcomeErrors);
1889 appendJournal (projectDir, { type: 'stage_end' , ts, seq: state.seq + 1 , stage: stageName, outcome: resolvedOutcome });
1890 return { stage: stageName, ended: true , outcome: resolvedOutcome };
1891 }
1892 throw new ValidationError ([ `stage action must be start or end (got: ${ action })` ]);
1893 }
1894
1895 // Attach measured latency/token counts to a stage. Separate from `stage end` on purpose: a stage may
1896 // be metered several times (each generated script reports its own invocation), and token counts are
1897 // known by the agent runtime rather than by whatever closed the stage.
1898 function meter ( projectDir , input , { now } = {}) {
1899 const ts = nowIso (now);
1900 const { state } = requireActiveRun (projectDir);
1901 const payload = validateMeter (input, { openStage: state.openStage, lastStage: state.lastStage });
1902 appendJournal (projectDir, { type: 'meter' , ts, seq: state.seq + 1 , stage: payload.stage, meter: payload });
1903 const recorded = Object. keys (payload). filter (( k ) => k !== 'stage' );
1904 return { metered: true , stage: payload.stage, fields: recorded };
1905 }
1906
1907 // Records the one `transcript_digest` per run (spec 0039 §4): a script-computed
1908 // parse of the Claude Code session transcript, never an agent self-report. No
1909 // BI push here — the fields ride to BI inside `finalize`'s existing push, once
1910 // `rollup.transcript_digest` carries them (§5). A second call in the same run
1911 // overwrites the prior one (replay keeps the latest), which is what an offline
1912 // re-run against a more complete transcript is for.
1913 async function transcriptDigest ( projectDir , payload , { now } = {}) {
1914 const ts = nowIso (now);
1915 const { state } = requireActiveRun (projectDir);
1916 const validated = validateTranscriptDigest (payload);
1917 const seq = state.seq + 1 ;
1918 appendJournal (projectDir, { type: 'transcript_digest' , ts, seq, run_id: state.runStart.run_id, ... validated });
1919 return { recorded: true , seq };
1920 }
1921
1922 async function wait ( projectDir , action , { now , haltSubtype , skill , what } = {}) {
1923 const ts = nowIso (now);
1924 const { state } = requireActiveRun (projectDir);
1925 if (action === 'start' ) {
1926 if (state.openWait) {
1927 throw new ValidationError ([ 'a wait interval is already open' ]);
1928 }
1929 const attributedStage = state.openStage || state.lastStage || 'config' ;
1930 let seq = state.seq;
1931 let haltRecorded = false ;
1932 if (haltSubtype !== undefined && haltSubtype !== null ) {
1933 // The paired halt_needs_user event is emitted mechanically so a stalled
1934 // run never loses the "why" to a missed record call.
1935 const validated = validateEvent ({
1936 event_type: 'halt_needs_user' ,
1937 subtype: haltSubtype,
1938 stage: attributedStage,
1939 skill: skill || 'wix-replatform' ,
1940 severity: 'blocking' ,
1941 what_happened: what || `Run halted at ${ attributedStage } waiting on the user (${ haltSubtype }).` ,
1942 }, state);
1943 seq += 1 ;
1944 const eventRecord = {
1945 type: 'event' , ts, seq, class_key: validated.classKey, scrub_hits: validated.scrubHits, event: validated.event,
1946 };
1947 appendJournal (projectDir, eventRecord);
1948 if (biSink. isGuid (state.dims.wix_user_id)) {
1949 const rows = biBacklog (state)
1950 ? healRows (state, state.dims.wix_user_id, [eventRecord])
1951 : [ foldedRowForClass (state, eventRecord)];
1952 const pushed = await pushToBi (projectDir, ts, seq, rows, state.dims.wix_user_id, 'run_event' ,
1953 { throughSeq: seq });
1954 seq = pushed.seq;
1955 }
1956 haltRecorded = true ;
1957 } else if (skill !== undefined || what !== undefined ) {
1958 throw new ValidationError ([ '--skill/--what on wait start are only valid together with --halt <subtype>' ]);
1959 }
1960 seq += 1 ;
1961 appendJournal (projectDir, { type: 'wait_start' , ts, seq, stage: attributedStage });
1962 return { waiting: true , stage: attributedStage, halt_recorded: haltRecorded };
1963 }
1964 if (action === 'end' ) {
1965 if ( ! state.openWait) {
1966 throw new ValidationError ([ 'no wait interval is open' ]);
1967 }
1968 appendJournal (projectDir, { type: 'wait_end' , ts, seq: state.seq + 1 });
1969 return { waiting: false , waited_ms: Math. max ( 0 , tsMs (ts) - tsMs (state.openWait.start)) };
1970 }
1971 throw new ValidationError ([ `wait action must be start or end (got: ${ action })` ]);
1972 }
1973
1974 async function finalize ( projectDir , rollupInput , { now } = {}) {
1975 const ts = nowIso (now);
1976 const { journal , state } = requireActiveRun (projectDir);
1977 const payload = validateFinalizeInput (rollupInput, state);
1978
1979 let seq = state.seq;
1980 if (state.openWait) {
1981 seq += 1 ;
1982 appendJournal (projectDir, { type: 'wait_end' , ts, seq });
1983 }
1984 if (state.openStage) {
1985 seq += 1 ;
1986 appendJournal (projectDir, {
1987 type: 'stage_end' ,
1988 ts,
1989 seq,
1990 stage: state.openStage,
1991 outcome: OUTCOME_FOR_TERMINAL [payload.terminal_state],
1992 });
1993 }
1994 seq += 1 ;
1995 appendJournal (projectDir, { type: 'finalize' , ts, seq, payload });
1996
1997 // BI push before archival, so a failed push is journaled into the run and
1998 // lands in the archived telemetry_health. The full re-push (started row +
1999 // every folded event class + the finalized rollup) heals any mid-run failure
2000 // or identity-not-yet-known hold-back: the stream is append-only and the
2001 // reviewer dedupes on the natural keys, latest row wins. The pushed rollup's
2002 // own health can't include this push's outcome (it is assembled first) — if
2003 // the push fails there is no BI row at all, and the local document, which is
2004 // authoritative, records the failure.
2005 let biPush = { sent: 0 , failed: 0 , skipped: 0 };
2006 {
2007 const provisionalJournal = readJournal (projectDir);
2008 const provisional = assembleDocument (projectDir, provisionalJournal.records, provisionalJournal.malformed);
2009 const rollup = provisional.document.rollup;
2010 if (biSink. isGuid (rollup.wix_user_id)) {
2011 const rows = [
2012 biSink. startedRunRow ( replay (provisionalJournal.records).runStart, rollup.wix_user_id),
2013 ... provisional.document.events. map (biSink.eventRow),
2014 biSink. finalizedRunRow (rollup),
2015 ];
2016 // A disabled sink lands here too: pushRows reports the rows as skipped
2017 // and pushToBi journals them, so the kill-switch is never silent.
2018 const pushed = await pushToBi (projectDir, ts, seq, rows, rollup.wix_user_id, 'finalize' ,
2019 { throughSeq: seq });
2020 seq = pushed.seq;
2021 biPush = { sent: pushed.sent, failed: pushed.failed, skipped: pushed.skipped };
2022 } else if ( ! biSink. disabled ()) {
2023 // No operator identity by run end: the run is unroutable in BI. Journal
2024 // it as a push failure — this is real telemetry loss, not a hold-back.
2025 const rowCount = provisional.document.events. length + 2 ;
2026 seq += 1 ;
2027 appendJournal (projectDir, {
2028 type: 'bi_push_failed' , ts, seq, target: 'finalize' , rows: rowCount, detail: 'no_operator_identity' ,
2029 });
2030 biPush = { sent: 0 , failed: rowCount, skipped: 0 };
2031 }
2032 }
2033
2034 const finalJournal = readJournal (projectDir);
2035 const finalState = replay (finalJournal.records);
2036 const assembled = archiveFinalizedJournal (projectDir, finalJournal, finalState);
2037
2038 const health = assembled.document.rollup.telemetry_health;
2039 return {
2040 finalized: true ,
2041 run_id: finalState.runStart.run_id,
2042 attempt: finalState.runStart.attempt,
2043 terminal_state: payload.terminal_state,
2044 bi_push: biPush,
2045 signal_file: 'run-telemetry.json' ,
2046 event_classes: assembled.document.events. length ,
2047 telemetry_health: health,
2048 evidence_bytes: assembled.evidenceBytes,
2049 ... (assembled.evidenceBytes > EVIDENCE_SIZE_FLAG_BYTES
2050 ? {
2051 evidence_breakdown: assembled.evidenceFiles
2052 . sort (( a , b ) => b.bytes - a.bytes)
2053 . slice ( 0 , 10 ),
2054 }
2055 : {}),
2056 };
2057 }
2058
2059 async function rebuild ( projectDir , { attempt , push } = {}) {
2060 // Re-assemble a finalized run's signal document from its archived journal.
2061 // The document is a pure function of the journal, so recorder fixes (fold
2062 // semantics, manifest rules, health flags) can be applied to past runs
2063 // without touching the captured record itself. With `push`, the rebuilt run
2064 // is re-emitted to BI (outage backfill / post-fix re-push) — idempotent by
2065 // the reviewer's query-time dedup on the natural keys.
2066 const dir = runsDir (projectDir);
2067 const archived = fs. existsSync (dir)
2068 ? fs. readdirSync (dir)
2069 . map (( name ) => {
2070 const match = / ^ run-( \d + )- . + \. jsonl $ / . exec (name);
2071 return match ? { attempt: Number (match[ 1 ]), name } : null ;
2072 })
2073 . filter (Boolean)
2074 : [];
2075 if (archived. length === 0 ) {
2076 throw new ValidationError (
2077 [ 'no archived run journals to rebuild from' ],
2078 'rebuild re-assembles a finalized signal document from telemetry/runs/run-<attempt>-<run_id>.jsonl' ,
2079 );
2080 }
2081 const latestAttempt = archived. reduce (( max , a ) => Math. max (max, a.attempt), 0 );
2082 const target = attempt === undefined || attempt === null
2083 ? archived. find (( a ) => a.attempt === latestAttempt)
2084 : archived. find (( a ) => a.attempt === attempt);
2085 if ( ! target) {
2086 throw new ValidationError ([ `no archived journal for attempt ${ attempt } (have: ${ archived . map (( a ) => a . attempt ). join ( ', ' ) })` ]);
2087 }
2088 const journal = readJournalFile (path. join (dir, target.name));
2089 const state = replay (journal.records);
2090 if ( ! state.runStart || ! state.finalize) {
2091 throw new ValidationError ([ `archived journal ${ target . name } is not a finalized run` ]);
2092 }
2093 const assembled = assembleDocument (projectDir, journal.records, journal.malformed);
2094 writeJsonAtomic (path. join (dir, `${ archiveName ( state ) }.json` ), assembled.document);
2095 const signalUpdated = target.attempt === latestAttempt;
2096 if (signalUpdated) {
2097 writeJsonAtomic ( signalPath (projectDir), assembled.document);
2098 }
2099 // The archived journal is immutable, so a backfill push's outcome cannot be
2100 // journaled into the run — it is reported here, to the operator running the
2101 // explicit backfill, instead.
2102 let biPush;
2103 if (push) {
2104 const rollup = assembled.document.rollup;
2105 const rows = [
2106 biSink. startedRunRow (state.runStart, rollup.wix_user_id),
2107 ... assembled.document.events. map (biSink.eventRow),
2108 biSink. finalizedRunRow (rollup),
2109 ];
2110 const result = await biSink. pushRows (rows, rollup.wix_user_id);
2111 biPush = {
2112 sent: result.sent,
2113 failed: result.failures. reduce (( sum , f ) => sum + f.rows, 0 ),
2114 ... (result.failures. length > 0 ? { failure_details: [ ...new Set (result.failures. map (( f ) => f.detail))] } : {}),
2115 };
2116 }
2117 return {
2118 rebuilt: true ,
2119 run_id: state.runStart.run_id,
2120 attempt: target.attempt,
2121 signal_file_updated: signalUpdated,
2122 event_classes: assembled.document.events. length ,
2123 telemetry_health: assembled.document.rollup.telemetry_health,
2124 ... (biPush ? { bi_push: biPush } : {}),
2125 };
2126 }
2127
2128 function status ( projectDir ) {
2129 const journal = readJournal (projectDir);
2130 if ( ! journal) {
2131 const lastAttempt = lastFinalizedAttempt (projectDir);
2132 return { active: false , finalized_attempts: lastAttempt };
2133 }
2134 const state = replay (journal.records);
2135 return {
2136 active: ! state.finalize,
2137 run_id: state.runStart ? state.runStart.run_id : null ,
2138 attempt: state.runStart ? state.runStart.attempt : null ,
2139 session_count: state.sessionCount,
2140 open_stage: state.openStage,
2141 open_wait: Boolean (state.openWait),
2142 events_recorded: state.events. length ,
2143 distinct_classes: new Set (state.events. map (( r ) => r.class_key)).size,
2144 rejected_calls: state.rejected. length ,
2145 last_seq: state.seq,
2146 bi_push_ok_seq: state.lastBiPushOkSeq,
2147 bi_backlog: state.runStart ? biBacklog (state) : true ,
2148 };
2149 }
2150
2151 module . exports = {
2152 TELEMETRY_SCHEMA_VERSION,
2153 STAGES,
2154 STAGE_OUTCOMES,
2155 TERMINAL_STATES,
2156 SEVERITIES,
2157 EVENT_SUBTYPES,
2158 FIELD_TIERS,
2159 FREE_TEXT_MAX,
2160 SHAPES_KEPT_PER_CLASS,
2161 ValidationError,
2162 scrubText,
2163 validateDims,
2164 validateEvent,
2165 validateFinalizeInput,
2166 validateTranscriptDigest,
2167 journalPath,
2168 signalPath,
2169 runsDir,
2170 readJournal,
2171 replay,
2172 start,
2173 dims,
2174 record,
2175 stage,
2176 meter,
2177 transcriptDigest,
2178 wait,
2179 finalize,
2180 rebuild,
2181 status,
2182 validateMeter,
2183 computeCost,
2184 METER_DURATION_KEYS,
2185 METER_COUNT_KEYS,
2186 };