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.
- package/CHANGELOG.md +63 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/report.js +6 -0
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +59 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- 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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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 (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 =>
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
283
|
-
// artifacts:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|