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