cyborg-hunter 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
package/src/cli/ingest.js CHANGED
@@ -11,61 +11,182 @@
11
11
  // single cells. We unwrap them and route through Shape 1.
12
12
 
13
13
  import { readFileSync, readdirSync } from 'fs';
14
- import { join, extname } from 'path';
14
+ import { join, extname, resolve } from 'path';
15
15
  import { gunzipSync } from 'zlib';
16
16
  import Papa from 'papaparse';
17
17
  import { sanitizeId } from '../shared/constants.js';
18
18
  import { getByPath } from '../shared/paths.js';
19
19
  import { extractIntegrityData, ruleChronologicalCompare } from './extract-core.js';
20
+ // Spec §14 makes conversion the migration path for jsPsych-v1 recordings
21
+ // (players are v2-only, there is no dual-read), so the converter is a runtime
22
+ // dependency of the CLI rather than a developer tool. package.json's `files`
23
+ // list ships this one path for that reason, pinned by a test in
24
+ // tests/cli/replay-ingest.test.js. `convertRecording` is pure and imports
25
+ // nothing outside node: builtins; the tool's own CLI half (which reaches into
26
+ // tests/ for the strict validator) is never loaded by importing it.
27
+ import { convertRecording } from '../../tools/convert/jspsych-v1-to-v2.mjs';
28
+ import { detectGzip, validateStrict } from '../shared/schema-v2-validator.js';
20
29
 
21
30
  // Replay artifacts saved by the replay extension:
22
31
  // <sanitizedPid>-replay-<sessionStartEpochMs>.json[.gz]
23
32
  // They sit in dataDir (or replayDir) next to the participant files and must
24
- // never enter the participant-file pass.
33
+ // never enter the participant-file pass. Files from OTHER producers carry
34
+ // whatever name their tool chose and are found by content instead (A3, see
35
+ // the foreign-artifact pass in attachReplayArtifacts).
25
36
  const REPLAY_FILE_RE = /-replay-\d+\.json(\.gz)?$/i;
26
37
 
27
- // Content sniff: replay artifacts (ours or #3661's) are identified by
28
- // structure, not just filename — schema_version plus either our recorder
29
- // stamp or a #3661-shaped trials array.
30
- function looksLikeReplayArtifact(text) {
38
+ // A recording is JSON on the wire (spec §2); `.gz` is CH's own transport
39
+ // compression. Everything else in a data directory (CSV above all) is skipped
40
+ // before it is ever read as a recording candidate.
41
+ const ARTIFACT_EXT_RE = /\.json(\.gz)?$/i;
42
+
43
+ // The filename route's claim for one participant. ANCHORED: a bare prefix
44
+ // would let participant "a" swallow "a-replay-replay-<epoch>.json", which
45
+ // belongs to participant "a-replay". Case-tolerant for discovery; ownership
46
+ // is verified against the embedded participant_id at attach time.
47
+ function participantArtifactRe(sanePid) {
48
+ const escaped = sanePid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ return new RegExp(`^${escaped}-replay-\\d+\\.json(\\.gz)?$`, 'i');
50
+ }
51
+
52
+ // Content sniff: which producer's vocabulary an artifact speaks, or null when
53
+ // the object is not a session recording at all. Three shapes qualify:
54
+ // - 'v2' SessionRecording v2 (spec r2, ANY producer): schema_version
55
+ // 2 with a `recorder` identity and a `segments` array. Same
56
+ // three keys the §11 tolerant loader identifies a recording
57
+ // by (viewer-model.js), so the sniff and the loader cannot
58
+ // disagree about what a recording is.
59
+ // - 'ch' the CH v1 era: our own `metadata.recorder` stamp.
60
+ // - 'jspsych-v1' a #3661-shaped `trials` array from jsPsych's recorder.
61
+ //
62
+ // THE ARM ORDER IS LOAD-BEARING. A CH v1 recording also carries a `trials`
63
+ // array whose entries have `events` and `initial_dom`, so it matches the
64
+ // jsPsych arm too. Reading the CH stamp first is what keeps CH v1 out of the
65
+ // jsPsych converter, which would refuse it — spec §14 gives CH v1 a different
66
+ // migration path, and A6's decision makes that path "regenerate the demo
67
+ // assets", not "convert" (stray old files stay playable at the 0.7.x tag).
68
+ function artifactKind(j) {
69
+ if (!j || typeof j !== 'object' || !('schema_version' in j)) return null;
70
+ if (j.schema_version === 2 && !!j.recorder && typeof j.recorder.name === 'string' &&
71
+ Array.isArray(j.segments)) return 'v2';
72
+ if (String(j.metadata?.recorder || '').startsWith('cyborg-hunter-replay')) return 'ch';
73
+ // jsPsych v1 is identified by VERSION + shape, not by every trial being
74
+ // well-formed: the converter owns trial validation and refuses with a remedy
75
+ // sentence, and a shape-based sniff sent malformed files past it in silence
76
+ // (A3 review, finding 2). An empty `trials` array is a recording too.
77
+ if (j.schema_version === 1 && Array.isArray(j.trials)) return 'jspsych-v1';
78
+ return null;
79
+ }
80
+
81
+ // Reads one file as a recording candidate: { json } or { error }.
82
+ // Gzip is decompressed HERE, not only at attach time: reading the compressed
83
+ // bytes as utf8 and JSON.parsing them made every readable `.json.gz` artifact
84
+ // announce itself as a truncated upload (T5 Task 10 review M-4).
85
+ function readArtifactJson(path) {
86
+ let text;
31
87
  try {
32
- const j = JSON.parse(text);
33
- return j && typeof j === 'object' && 'schema_version' in j &&
34
- (String(j.metadata?.recorder || '').startsWith('cyborg-hunter-replay') ||
35
- (Array.isArray(j.trials) && j.trials.length > 0 &&
36
- j.trials.every(t => t && 'events' in t && 'initial_dom' in t)));
88
+ const buf = readFileSync(path);
89
+ // Suffix OR magic bytes (RFC 1952): a gzip export renamed to `.json` is
90
+ // still gzip, and decoding it as UTF-8 announced a truncated upload that
91
+ // did not exist (A3 review, finding 4).
92
+ const gz = /\.gz$/i.test(path) || detectGzip(buf);
93
+ text = gz ? gunzipSync(buf).toString('utf8') : buf.toString('utf8');
37
94
  } catch (e) {
38
- return true; // unparseable + replay-named → let the replay pass report it
95
+ return { error: e.message };
96
+ }
97
+ try {
98
+ return { json: JSON.parse(text) };
99
+ } catch (e) {
100
+ return { error: e.message };
39
101
  }
40
102
  }
41
103
 
104
+ // jsPsych-v1 recordings ingest BY CONVERSION (spec §14; the players are
105
+ // v2-only and there is no dual-read, so this tool is the only door — and
106
+ // ingest is where an analyst walks through it without needing to know it
107
+ // exists). Everything else passes through untouched.
108
+ //
109
+ // IN MEMORY, NEVER TO DISK. Ingest reads the analyst's data directory and
110
+ // writes nothing into it; a converted sibling would also be picked up by the
111
+ // NEXT run as a second artifact for the same participant, and it would rewrite
112
+ // fixtures other tasks own (the committed demo trio is v1 and A6 regenerates
113
+ // it). Provenance survives anyway: the converter stamps
114
+ // `extensions["cyborg-hunter"].converter` with its version and the canonical
115
+ // `source_sha256` of the input, and the source file stays byte-identical on
116
+ // disk, so file + hash + tool version reproduce the conversion exactly.
117
+ //
118
+ // Returns { recording, converted? } or { refusal }. The converter refuses
119
+ // rather than defaulting a missing field or renumbering a trial, and those
120
+ // refusals ARE its contract, so its message travels intact to the analyst.
121
+ // `convert` is injectable for tests only (the converter never throws a plain
122
+ // exception on any input we could construct, so the catch below can't be
123
+ // exercised through a file); production always uses convertRecording.
124
+ export function migrateArtifact(json, kind, convert = convertRecording) {
125
+ if (kind !== 'jspsych-v1') return { recording: json };
126
+ try {
127
+ const recording = convert(json);
128
+ // A2 (spec §11): the in-memory conversion is strict-validated like the
129
+ // converter CLI validates its file output — but a failure WARNS and still
130
+ // attaches. Refusing here would lose a participant's replay to a defect the
131
+ // viewer's tolerant profile absorbs (e.g. `stylesheets: {}` → played
132
+ // unstyled); before this, that absorption was silent (finding 6).
133
+ const verdict = validateStrict(recording);
134
+ return { recording, converted: true, strictErrors: verdict.ok ? null : verdict.errors };
135
+ } catch (e) {
136
+ // Only the converter's declared refusals (`.reasons`) are about the FILE.
137
+ // Anything else is the converter failing, and blaming participant data
138
+ // for it hid the stack from whoever has to fix the converter (finding 7).
139
+ if (Array.isArray(e.reasons)) return { refusal: e.reasons.join('; ') };
140
+ return { internal: e.stack || e.message };
141
+ }
142
+ }
143
+
144
+ // Identity fields moved between the two wire versions: v1 kept them in a
145
+ // `metadata` block, v2 states them at the top level under different names.
146
+ // The read is VERSION-AWARE on purpose — a chain like `metadata?.x ?? x`
147
+ // would let a stray `metadata` block on a v2 file (junk: v2 has no such
148
+ // block) override the authoritative field, and both of these decide which
149
+ // participant an artifact belongs to and which session is the latest.
150
+ function ownField(recording, which) {
151
+ const v2 = recording.schema_version === 2;
152
+ if (which === 'participant_id') {
153
+ return v2 ? recording.participant_id : recording.metadata?.participant_id;
154
+ }
155
+ // 'start_time'
156
+ return v2 ? recording.recording_started_at : recording.metadata?.start_time;
157
+ }
158
+
42
159
  export async function ingest(config) {
43
160
  const allFiles = findFiles(config.dataDir, config.filePattern);
44
161
  const participants = [];
45
162
  const warnings = [];
46
163
 
47
- // Replay artifacts are excluded from the participant pass by filename —
48
- // but only after a content check, so a participant export that happens to
49
- // match the naming pattern is never silently dropped.
164
+ // Session recordings are excluded from the participant pass by CONTENT, not
165
+ // by name (A3): v2 is producer-agnostic, so a conforming artifact can arrive
166
+ // called `session.json` or anything else, and putting one through the
167
+ // participant extractor produced a phantom "unknown" participant plus two
168
+ // junk warnings. Files matching CH's own naming keep their extra guarantee —
169
+ // a participant export that happens to be named like an artifact is rescued
170
+ // with a rename hint rather than silently dropped.
50
171
  const files = [];
51
172
  for (const file of allFiles) {
52
- if (REPLAY_FILE_RE.test(file)) {
53
- let text = null;
54
- try { text = readFileSync(file, 'utf8'); } catch (e) { text = null; }
55
- let parseable = true;
56
- if (text !== null) {
57
- try { JSON.parse(text); } catch (e) { parseable = false; }
58
- }
59
- if (text === null || !parseable) {
60
- // Never let a replay-named file vanish silently: if its pid maps to
61
- // a discovered participant the attach pass warns again with more
62
- // context, but an orphan (no matching participant) would otherwise
63
- // disappear without a trace.
173
+ if (!ARTIFACT_EXT_RE.test(file)) { files.push(file); continue; } // e.g. CSV
174
+ const named = REPLAY_FILE_RE.test(file);
175
+ const read = readArtifactJson(file);
176
+ if (read.error) {
177
+ // Only a replay-NAMED file is claimed here. Never let one vanish
178
+ // silently: if its pid maps to a discovered participant the attach pass
179
+ // warns again with more context, but an orphan (no matching participant)
180
+ // would otherwise disappear without a trace. Anything else that fails to
181
+ // parse belongs to the participant pass, which reports its own failure.
182
+ if (named) {
64
183
  warnings.push({ file,
65
184
  warnings: ['Replay-named file could not be parsed (truncated upload or a misnamed participant export?) — skipped from the participant pass; if a matching participant exists, the replay pass reports it too.'] });
66
185
  continue;
67
186
  }
68
- if (looksLikeReplayArtifact(text)) continue;
187
+ } else if (artifactKind(read.json) !== null) {
188
+ continue; // a session recording is never participant data
189
+ } else if (named) {
69
190
  warnings.push({ file,
70
191
  warnings: ['File matches the replay-artifact naming pattern (<pid>-replay-<epoch>.json) but contains participant data — parsed as a participant file. Consider renaming it to avoid ambiguity.'] });
71
192
  }
@@ -115,7 +236,7 @@ export async function ingest(config) {
115
236
  }
116
237
  }
117
238
 
118
- attachReplayArtifacts(participants, config, warnings);
239
+ attachReplayArtifacts(participants, config, warnings, allFiles);
119
240
 
120
241
  return { participants, warnings };
121
242
  }
@@ -126,7 +247,9 @@ export async function ingest(config) {
126
247
  // { error: 'parse_failed', reason } — artifact exists but unreadable
127
248
  // null — no artifact (silent unless meta
128
249
  // says one went to 'download')
129
- function attachReplayArtifacts(participants, config, warnings) {
250
+ // `participantPassFiles`: what findFiles handed the participant pass, so the
251
+ // foreign scan knows which unreadable files already reported themselves there.
252
+ function attachReplayArtifacts(participants, config, warnings, participantPassFiles = []) {
130
253
  const dir = config.replayDir || config.dataDir;
131
254
  let entries = [];
132
255
  try {
@@ -170,15 +293,70 @@ function attachReplayArtifacts(participants, config, warnings) {
170
293
  }
171
294
  }
172
295
 
296
+ // ── Discovery, second route: artifacts named by their own producer ───────
297
+ // CH's recorder writes `<sanitizedPid>-replay-<epoch>.json[.gz]`
298
+ // (persistence.js), which is what the per-participant filename match below
299
+ // is built from. A v2 recording from anywhere else — jsPsych's recorder, a
300
+ // browser download, a hand-copied `session.json` — carries a name CH cannot
301
+ // read anything out of, so it is found by CONTENT here and attached by
302
+ // IDENTITY ONLY: its embedded participant_id must name a participant in this
303
+ // dataset. No filename fallback exists for these and none is invented, which
304
+ // is what keeps Task 10's ownership defense whole for foreign producers.
305
+ //
306
+ // The scan keeps the NAME and the embedded id, never the recording: holding
307
+ // every foreign artifact parsed at once would put a cohort's worth of
308
+ // dom-tier recordings in memory simultaneously, where the filename route
309
+ // holds one at a time. The matching files are loaded below, through the same
310
+ // code path CH-named artifacts take, so there is one loading reading.
311
+ const embeddedId = (rec) => {
312
+ const v = ownField(rec, 'participant_id');
313
+ return v == null ? null : String(v);
314
+ };
315
+ // Files the filename route will claim: `<sanitizedPid>-replay-<epoch>` for
316
+ // a pid in THIS dataset. Anything else — including a foreign artifact whose
317
+ // name merely LOOKS like CH's pattern (`download-replay-<epoch>.json`) — is
318
+ // scanned by content. Excluding by REPLAY_FILE_RE alone let such a file
319
+ // fall between both routes with no warning (A3 review, finding 1).
320
+ const claimedByName = new Set();
321
+ for (const p of participants) {
322
+ const re = participantArtifactRe(sanitize(p.participantId));
323
+ for (const f of entries) if (re.test(f)) claimedByName.add(f);
324
+ }
325
+ // Which unreadable files are ours to report: everything in an explicit
326
+ // replayDir (nothing else lives there), plus anything in dataDir the
327
+ // participant pass never saw (e.g. `session.json.gz` under a `*.json`
328
+ // pattern). A file the participant pass DID read reports its own failure
329
+ // there (finding 3).
330
+ const seenByParticipantPass = new Set(participantPassFiles.map(f => resolve(f)));
331
+ const foreign = [];
332
+ for (const f of entries) {
333
+ if (claimedByName.has(f)) continue; // the filename route owns these
334
+ if (!ARTIFACT_EXT_RE.test(f)) continue;
335
+ const full = join(dir, f);
336
+ const read = readArtifactJson(full);
337
+ if (read.error) {
338
+ if (config.replayDir || !seenByParticipantPass.has(resolve(full))) {
339
+ warnings.push({ file: full,
340
+ warnings: [`Replay candidate ${f} could not be read: ${read.error} — skipped; if it is a session recording, the participant it belongs to has no replay.`] });
341
+ }
342
+ continue;
343
+ }
344
+ const kind = artifactKind(read.json);
345
+ if (kind === null) continue;
346
+ foreign.push({ file: f, kind, id: embeddedId(read.json) });
347
+ }
348
+ const claimed = new Set();
349
+
173
350
  for (const p of participants) {
174
351
  // Same sanitization the browser-side filename builder applies. The
175
352
  // match is ANCHORED (^<sane>-replay-<digits>.json$): a bare prefix
176
353
  // would let participant "a" swallow "a-replay-replay-<epoch>.json",
177
354
  // which belongs to participant "a-replay".
178
355
  const sane = sanitize(p.participantId);
179
- const escaped = sane.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
- const exactRe = new RegExp(`^${escaped}-replay-\\d+\\.json(\\.gz)?$`, 'i');
356
+ const exactRe = participantArtifactRe(sane);
181
357
  const mine = entries.filter(f => exactRe.test(f));
358
+ // Foreign-named artifacts that named THIS participant inside themselves.
359
+ const mineForeign = foreign.filter(a => a.id !== null && a.id === String(p.participantId));
182
360
  // The meta pointer rides on every trial row via addProperties.
183
361
  const meta = (p.trials && p.trials[0] && p.trials[0].integrityReplayMeta) || null;
184
362
  // Replay finalize failures ride the same way — surface them where the
@@ -189,7 +367,7 @@ function attachReplayArtifacts(participants, config, warnings) {
189
367
  warnings: [`Replay finalize failed for ${p.participantId}: ${finErr} — the artifact was likely never saved.`] });
190
368
  }
191
369
 
192
- if (mine.length === 0) {
370
+ if (mine.length === 0 && mineForeign.length === 0) {
193
371
  p.replay = null;
194
372
  if (meta && meta.saved_to === 'download') {
195
373
  warnings.push({
@@ -200,21 +378,18 @@ function attachReplayArtifacts(participants, config, warnings) {
200
378
  continue;
201
379
  }
202
380
 
381
+ // ONE loading path for both discovery routes: read, sniff, migrate.
203
382
  const parsed = [];
204
- for (const f of mine) {
205
- try {
206
- const buf = readFileSync(join(dir, f));
207
- const json = f.toLowerCase().endsWith('.gz')
208
- ? gunzipSync(buf).toString('utf8')
209
- : buf.toString('utf8');
210
- // Same structural sniff as the participant pass: a misnamed
211
- // participant export was rescued as participant data there and
212
- // must not double as its own "replay" here.
213
- if (!looksLikeReplayArtifact(json)) continue;
214
- parsed.push({ file: f, recording: JSON.parse(json) });
215
- } catch (e) {
216
- parsed.push({ file: f, reason: e.message });
217
- }
383
+ for (const a of mineForeign) claimed.add(a.file);
384
+ for (const f of [...mine, ...mineForeign.map(a => a.file)]) {
385
+ const read = readArtifactJson(join(dir, f));
386
+ if (read.error) { parsed.push({ file: f, reason: read.error }); continue; }
387
+ // Same structural sniff as the participant pass: a misnamed
388
+ // participant export was rescued as participant data there and
389
+ // must not double as its own "replay" here.
390
+ const kind = artifactKind(read.json);
391
+ if (kind === null) continue;
392
+ parsed.push({ file: f, ...migrateArtifact(read.json, kind) });
218
393
  }
219
394
  if (parsed.length === 0) {
220
395
  p.replay = null;
@@ -223,16 +398,37 @@ function attachReplayArtifacts(participants, config, warnings) {
223
398
 
224
399
  // Every unreadable artifact warns individually — a corrupt NEWEST
225
400
  // session must never be silently masked by an older readable one.
226
- for (const bad of parsed.filter(x => !x.recording)) {
401
+ for (const bad of parsed.filter(x => x.reason)) {
227
402
  warnings.push({ file: join(dir, bad.file),
228
403
  warnings: [`Replay artifact ${bad.file} unreadable: ${bad.reason} — if this is the newest session, its replay is lost.`] });
229
404
  }
405
+ // A refused conversion is NOT a corruption: the file read fine and the
406
+ // migration is what stopped. It carries the converter's own sentences
407
+ // because they are the ones naming the remedy.
408
+ for (const bad of parsed.filter(x => x.refusal)) {
409
+ warnings.push({ file: join(dir, bad.file),
410
+ warnings: [`Replay artifact ${bad.file} is a jsPsych v1 recording that could not be converted to v2: ${bad.refusal} — not playable in this report; the file on disk is unchanged.`] });
411
+ }
412
+ for (const bad of parsed.filter(x => x.internal)) {
413
+ warnings.push({ file: join(dir, bad.file),
414
+ warnings: [`Replay artifact ${bad.file}: internal conversion failure (a converter bug, not a data problem — please report it): ${bad.internal}`] });
415
+ }
416
+ for (const soft of parsed.filter(x => x.strictErrors)) {
417
+ warnings.push({ file: join(dir, soft.file),
418
+ warnings: [`Replay artifact ${soft.file} converted from jsPsych v1 but fails schema-v2 strict validation (${soft.strictErrors.length}): ${soft.strictErrors.join('; ')} — attached anyway (spec §11 tolerant load); the viewer applies documented defaults, so what is malformed here is what will look wrong there.`] });
419
+ }
230
420
  const readable = parsed.filter(x => x.recording);
231
421
  if (readable.length === 0) {
232
- p.replay = { error: 'parse_failed', reason: parsed[0].reason, file: parsed[0].file };
422
+ // A refusal outranks a parse failure when both are present: it is the
423
+ // more specific diagnosis, and "corrupted" would send the analyst
424
+ // hunting a truncated upload that does not exist.
425
+ const refused = parsed.find(x => x.refusal) || parsed.find(x => x.internal);
426
+ p.replay = refused
427
+ ? { error: 'unloadable', reason: refused.refusal || refused.internal, file: refused.file }
428
+ : { error: 'parse_failed', reason: parsed[0].reason, file: parsed[0].file };
233
429
  continue;
234
430
  }
235
- if (mine.length > 1) {
431
+ if (parsed.length > 1) {
236
432
  warnings.push({ file: dir,
237
433
  warnings: [`Multiple replay artifacts for ${p.participantId} (page reload?) — using the latest by start_time.`] });
238
434
  }
@@ -242,7 +438,15 @@ function attachReplayArtifacts(participants, config, warnings) {
242
438
  // (e.g. plain #3661 recordings) attach with a soft warning.
243
439
  const owned = [];
244
440
  for (const cand of readable) {
245
- const embedded = cand.recording.metadata?.participant_id;
441
+ // v2 carries the id at the TOP LEVEL; v1 carried it in `metadata`.
442
+ // Reading only the v1 site would send every v2 artifact down the
443
+ // ownerless branch, which verifies by filename alone — and a file
444
+ // recorded for 'a/b' but named for the sanitized 'a_b' would then
445
+ // attach to the wrong participant, which is the one case this check
446
+ // exists for. VERSION-AWARE rather than a fallback chain: a v2 file has
447
+ // no `metadata` block (spec §2), so one appearing there is junk, and a
448
+ // chain that consulted it first would let that junk decide ownership.
449
+ const embedded = ownField(cand.recording, 'participant_id');
246
450
  if (embedded == null) {
247
451
  // Ownerless artifacts skip id verification entirely, so the filename
248
452
  // must match EXACT-case (our recorder writes sanitize(pid) verbatim).
@@ -279,10 +483,16 @@ function attachReplayArtifacts(participants, config, warnings) {
279
483
  p.replay = null;
280
484
  continue;
281
485
  }
282
- // Latest-session pick tolerates non-ISO start_time in third-party
283
- // artifacts: ISO string → numeric epoch → the filename's own epoch.
486
+ // Latest-session pick tolerates non-ISO start times in third-party
487
+ // artifacts: the recording's own start field (per version, see ownField)
488
+ // as an ISO string → a numeric epoch → the filename's own epoch. For a
489
+ // CH artifact the filename epoch is DERIVED from the same field
490
+ // (persistence.js), so the fallback agrees rather than guesses. A
491
+ // producer-named artifact has no epoch in its name to fall back TO, so one
492
+ // whose start field is unreadable sorts oldest — which is the conservative
493
+ // direction: it can lose to a dated sibling, never beat one.
284
494
  const sessionEpoch = (cand) => {
285
- const v = cand.recording.metadata?.start_time;
495
+ const v = ownField(cand.recording, 'start_time');
286
496
  const n = typeof v === 'number' ? v : Date.parse(v);
287
497
  if (Number.isFinite(n)) return n;
288
498
  const m = cand.file.match(/-replay-(\d+)\.json/i);
@@ -290,11 +500,55 @@ function attachReplayArtifacts(participants, config, warnings) {
290
500
  };
291
501
  owned.sort((a, b) => sessionEpoch(a) - sessionEpoch(b));
292
502
  const chosen = owned[owned.length - 1];
293
- if (chosen.recording.schema_version !== 1) {
503
+ // (T5 Task 10) The targeted version is 2: the viewer is v2-only and a v1
504
+ // artifact is skipped with a note when the report is built
505
+ // (replay-assets.js). Warning "this CLI targets 1" was the previous
506
+ // era's sentence and is now exactly backwards. Attach either way —
507
+ // ingest never drops participant data on a version judgement.
508
+ if (chosen.recording.schema_version !== 2) {
509
+ warnings.push({ file: join(dir, chosen.file),
510
+ warnings: [`Replay schema_version ${chosen.recording.schema_version} (this CLI targets 2) — attaching anyway; the viewer plays v2 only and will report this artifact as unloadable.`] });
511
+ }
512
+ // A converted recording is a DERIVED artifact: what plays in the report is
513
+ // something ingest built, not the bytes the recorder wrote. Say so once,
514
+ // with the link that reproduces it — the source is untouched on disk and
515
+ // the stamp carries the canonical hash of its content.
516
+ if (chosen.converted) {
517
+ const prov = chosen.recording.extensions['cyborg-hunter'].converter;
294
518
  warnings.push({ file: join(dir, chosen.file),
295
- warnings: [`Replay schema_version ${chosen.recording.schema_version} (this CLI targets 1) — attaching anyway; the viewer may degrade.`] });
519
+ warnings: [`Replay artifact ${chosen.file} is a jsPsych v1 recording, converted to SessionRecording v2 in memory for this report (${prov.tool} ${prov.version}, source_sha256 ${prov.source_sha256}) — spec §14 makes conversion the migration path and the viewer plays v2 only. The file on disk is unchanged; \`node tools/convert/${prov.tool}.mjs ${chosen.file}\` reproduces the conversion.`] });
520
+ }
521
+ // `converted` rides along so report surfaces (and tests) can tell a
522
+ // converted jsPsych recording from a native one.
523
+ p.replay = { recording: chosen.recording, file: chosen.file, meta, converted: !!chosen.converted };
524
+ }
525
+
526
+ // A recording found by CONTENT that attached to nobody must not vanish
527
+ // silently: unlike a CH-named artifact, its filename gives an analyst
528
+ // nothing to notice its absence by. Suppressed under --participant, where
529
+ // every other participant's artifact is out of scope by construction and
530
+ // would otherwise warn on every single-participant run.
531
+ if (!config.singleParticipant) {
532
+ for (const a of foreign) {
533
+ if (claimed.has(a.file)) continue;
534
+ let why;
535
+ if (a.id !== null) {
536
+ why = `was recorded for "${a.id}", which matches no participant in this dataset — not attached.`;
537
+ } else if (a.kind === 'jspsych-v1') {
538
+ // Worth naming the format: jsPsych v1 records no participant_id at
539
+ // all, so renaming is the ONLY way to attach one, and an analyst who
540
+ // does rename it gets the conversion for free.
541
+ why = 'is a jsPsych v1 session recording, and the v1 format carries no participant_id — ' +
542
+ 'with a filename outside the <participantId>-replay-<epoch>.json convention there is nothing ' +
543
+ 'to identify its owner, so it is not attached. Rename it after the participant; ingest ' +
544
+ 'converts it to v2 on the way in.';
545
+ } else {
546
+ why = 'carries no participant_id, and its name does not follow the ' +
547
+ '<participantId>-replay-<epoch>.json convention — nothing identifies its owner, so it is ' +
548
+ 'not attached. Rename it after the participant to attach it.';
549
+ }
550
+ warnings.push({ file: join(dir, a.file), warnings: [`Replay artifact ${a.file} ${why}`] });
296
551
  }
297
- p.replay = { recording: chosen.recording, file: chosen.file, meta };
298
552
  }
299
553
  }
300
554