@jbrowse/app-core 5.0.0-beta.1 → 5.0.0-beta.3

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.
@@ -0,0 +1,867 @@
1
+ import { setReExportRegistry } from '@jbrowse/core/ReExports/registry';
2
+ import { getConf, getConfigurationSchemaDefinition, isSlotDefinitionEntry, readConfObject, } from '@jbrowse/core/configuration';
3
+ import { releaseAdapterSession, retainAdapterSession, } from '@jbrowse/core/data_adapters/adapterSessionRefcount';
4
+ import { adapterConfigCacheKey } from '@jbrowse/core/data_adapters/dataAdapterCache';
5
+ import { getFeatureAdapterOrThrow } from '@jbrowse/core/data_adapters/getFeatureAdapter';
6
+ import { adapterByteLimit } from '@jbrowse/core/rpc/byteBudget';
7
+ import { getRpcSessionId, isElectron, isSessionWithAddSessionTrack, objectHash, parseLocString, renameRegionsIfNeeded, } from '@jbrowse/core/util';
8
+ import { openTracks, openViews } from '@jbrowse/core/util/openViews';
9
+ import { createStopToken, stopStopToken } from '@jbrowse/core/util/stopToken';
10
+ import { allSessionTracks, getConfAssemblyNamesOrNone, guessTrackConfForLocation, isSameAssemblyName, viewCanDisplayTrack, viewDisplayNames, } from '@jbrowse/core/util/tracks';
11
+ import * as mst from '@jbrowse/mobx-state-tree';
12
+ import { getSnapshot, getType, isStateTreeNode } from '@jbrowse/mobx-state-tree';
13
+ import * as mobx from 'mobx';
14
+ // relative, not '@jbrowse/app-core': a package self-import would make this
15
+ // module depend on the barrel that exports it
16
+ import { loadSessionSpec } from '../SessionSpec/index.js';
17
+ // Matches BaseTrackModel's DEFAULT_EXPORT_BYTE_LIMIT: both bound a fetch
18
+ // someone asked for by name rather than one a view scrolled into, so they
19
+ // should refuse at the same size.
20
+ const DEFAULT_AGENT_BYTE_LIMIT = 5_000_000;
21
+ /**
22
+ * What jb.getFeatures may pull before it refuses. An explicit request wins so
23
+ * an agent can mean it; otherwise the adapter's own declared limit, so this
24
+ * does not disagree with the size the track's display already refuses to
25
+ * render; otherwise the default.
26
+ */
27
+ export function agentByteLimit(declaredFetchSizeLimit, requested) {
28
+ return (requested ??
29
+ adapterByteLimit(declaredFetchSizeLimit, DEFAULT_AGENT_BYTE_LIMIT));
30
+ }
31
+ function viewSelf(view) {
32
+ return view;
33
+ }
34
+ // The census helpers over the views' own `ownViews`/`ownTracks`, so this module
35
+ // carries no copy of the view nesting: a synteny stack's rows and per-band
36
+ // containers, a breakpoint split view's panels, and any depth of nesting all
37
+ // arrive through each view declaring what it holds.
38
+ function viewTracks(view) {
39
+ return view.ownTracks;
40
+ }
41
+ function allTracks(session) {
42
+ return openTracks(session);
43
+ }
44
+ // The drawn display's on-screen state. `displayPhase` is the tree's own
45
+ // single-sourced vocabulary (render-core/displayPhase.ts) and the same one
46
+ // AppReadyMarker reads; `tooLarge` and `renderError` replace the display
47
+ // subtree rather than raising a snackbar, so a settle reporting only
48
+ // notifications calls them ready.
49
+ function displayState(track) {
50
+ const display = track.activeDisplay;
51
+ if (!display) {
52
+ return {};
53
+ }
54
+ try {
55
+ const phase = display.displayPhase;
56
+ return {
57
+ display: display.type,
58
+ // "make it all fit in the window" is a normal thing to be asked, and
59
+ // without this the only way to answer it is measuring DOM rectangles
60
+ ...(typeof display.height === 'number' ? { height: display.height } : {}),
61
+ ...(phase === undefined || phase === 'ready' ? {} : { phase }),
62
+ ...(phase === 'tooLarge' && display.regionTooLargeReason
63
+ ? { reason: display.regionTooLargeReason }
64
+ : {}),
65
+ ...(display.error ? { error: String(display.error) } : {}),
66
+ };
67
+ }
68
+ catch (e) {
69
+ return { display: display.type, error: String(e) };
70
+ }
71
+ }
72
+ export function sessionOf(pluginManager) {
73
+ return pluginManager?.rootModel?.session;
74
+ }
75
+ // A view whose init failed (its assembly was never found) paints the error in
76
+ // place of a genome and raises no toast, and stays uninitialized: without
77
+ // this, a spec with "volvix" for "volvox" settled false with nothing said.
78
+ function viewState(v) {
79
+ return {
80
+ ...(v.error === undefined ? {} : { error: String(v.error) }),
81
+ ...(v.error === undefined && v.initialized === false
82
+ ? { phase: 'initializing' }
83
+ : {}),
84
+ };
85
+ }
86
+ function viewSummary(view) {
87
+ const v = viewSelf(view);
88
+ return {
89
+ id: v.id,
90
+ type: v.type,
91
+ ...viewState(v),
92
+ ...(v.displayName ? { displayName: v.displayName } : {}),
93
+ ...(v.assemblyNames?.length ? { assemblyNames: v.assemblyNames } : {}),
94
+ ...(v.coarseVisibleLocStrings
95
+ ? { visibleRegion: v.coarseVisibleLocStrings }
96
+ : {}),
97
+ // the stack's own height, beside each track's below it: "does this all fit
98
+ // on screen" is then arithmetic rather than three rounds of screenshotting
99
+ ...(typeof v.height === 'number' ? { height: v.height } : {}),
100
+ ...(viewTracks(view).length
101
+ ? {
102
+ tracks: viewTracks(view).map(t => ({
103
+ trackId: t.configuration.trackId,
104
+ trackType: t.type,
105
+ ...displayState(t),
106
+ })),
107
+ }
108
+ : {}),
109
+ ...(Array.isArray(v.views) && v.views.length
110
+ ? { views: v.views.map(sub => viewSummary(sub)) }
111
+ : {}),
112
+ };
113
+ }
114
+ function sessionSummary(session) {
115
+ return {
116
+ name: session.name,
117
+ assemblyNames: session.assemblyNames,
118
+ views: session.views.map(v => viewSummary(v)),
119
+ };
120
+ }
121
+ // MST nodes serialize as their snapshot (toJSON), so the live-model walk below
122
+ // can stringify whatever it lands on; the replacer guards what a snapshot
123
+ // cannot contain but a getter's return can.
124
+ export function safeJson(value) {
125
+ // the ANCESTOR chain, not every object seen: MST snapshots are cached and
126
+ // structurally shared, so the same frozen object legitimately appears at two
127
+ // paths, and a visited-set reports the second one as a cycle that is not
128
+ // there. `this` is the holder JSON.stringify is currently walking, which is
129
+ // what lets the chain unwind on the way back up — so this must stay a
130
+ // `function`, not an arrow.
131
+ const chain = [];
132
+ // typed by hand: JSON.stringify's declared return is string, but a bare
133
+ // Symbol/undefined at the top level really does yield undefined at runtime
134
+ const json = JSON.stringify(isStateTreeNode(value) ? getSnapshot(value) : value, function (_key, v) {
135
+ while (chain.length > 0 && chain.at(-1) !== this) {
136
+ chain.pop();
137
+ }
138
+ if (typeof v === 'function') {
139
+ return '[function]';
140
+ }
141
+ if (typeof v === 'bigint') {
142
+ return `${v}`;
143
+ }
144
+ if (v !== null && typeof v === 'object') {
145
+ if (chain.includes(v)) {
146
+ return '[circular]';
147
+ }
148
+ chain.push(v);
149
+ }
150
+ return v;
151
+ });
152
+ return json === undefined ? '"[unserializable]"' : json;
153
+ }
154
+ function describeBrief(value) {
155
+ if (Array.isArray(value)) {
156
+ return `array(${value.length})`;
157
+ }
158
+ if (value !== null && typeof value === 'object') {
159
+ const json = safeJson(value);
160
+ return json.length > 120 ? `object(~${json.length} bytes)` : json;
161
+ }
162
+ const json = safeJson(value);
163
+ return json.length > 120 ? `${json.slice(0, 117)}...` : json;
164
+ }
165
+ // The names a live node answers beyond its snapshot: MST defines views as own
166
+ // getter properties on the instance, which is exactly the high-value surface
167
+ // (visibleLocStrings, assemblyNames, totalBp, ...) a snapshot filters out.
168
+ function memberNames(node) {
169
+ const getters = [];
170
+ const methods = [];
171
+ for (const [key, desc] of Object.entries(Object.getOwnPropertyDescriptors(node))) {
172
+ if (!key.startsWith('$') && key !== 'toJSON') {
173
+ if (desc.get) {
174
+ getters.push(key);
175
+ }
176
+ else if (typeof desc.value === 'function') {
177
+ methods.push(key);
178
+ }
179
+ }
180
+ }
181
+ return { getters: getters.sort(), methods: methods.sort() };
182
+ }
183
+ function inspectSession(session, args) {
184
+ const path = typeof args.path === 'string' ? args.path : '';
185
+ const maxBytes = typeof args.maxBytes === 'number' ? args.maxBytes : 20_000;
186
+ let node = session;
187
+ const walked = [];
188
+ for (const segment of path.split('.').filter(Boolean)) {
189
+ node =
190
+ node !== null && typeof node === 'object'
191
+ ? node[segment]
192
+ : undefined;
193
+ if (node === undefined) {
194
+ throw new Error(`Nothing at "${path}" (undefined after "${walked.join('.') || '(root)'}"). Inspect the parent path to see its keys and getters.`);
195
+ }
196
+ walked.push(segment);
197
+ }
198
+ const members = node !== null && typeof node === 'object' && !Array.isArray(node)
199
+ ? memberNames(node)
200
+ : { getters: [], methods: [] };
201
+ const json = safeJson(node);
202
+ const base = {
203
+ path: path || '(session root)',
204
+ // the name the docs tool files a type under: docs topic "model:<modelType>"
205
+ ...(isStateTreeNode(node) ? { modelType: getType(node).name } : {}),
206
+ bytes: json.length,
207
+ ...(members.getters.length ? { getters: members.getters } : {}),
208
+ ...(members.methods.length ? { actions: members.methods } : {}),
209
+ };
210
+ if (json.length <= maxBytes) {
211
+ return { ...base, value: JSON.parse(json) };
212
+ }
213
+ const plain = JSON.parse(json);
214
+ return Array.isArray(plain)
215
+ ? {
216
+ ...base,
217
+ note: `too large to return whole — ${plain.length} items; index in with .N or raise maxBytes`,
218
+ items: plain.slice(0, 20).map(item => describeBrief(item)),
219
+ }
220
+ : {
221
+ ...base,
222
+ note: 'too large to return whole — drill down by path or raise maxBytes',
223
+ keys: Object.fromEntries(Object.entries(plain).map(([k, v]) => [
224
+ k,
225
+ describeBrief(v),
226
+ ])),
227
+ };
228
+ }
229
+ const delay = (ms) => new Promise(resolve => {
230
+ setTimeout(resolve, ms);
231
+ });
232
+ // The readiness contract is AppReadyMarker's, not this file's: it publishes
233
+ // data-app-phase from the session (ready = no view resolving an assembly, no
234
+ // display fetching), and says so in its own comment.
235
+ //
236
+ // The hold is why the number is a second: `ready` has to survive past the
237
+ // ~600ms FetchVisibleRegions debounce, or one sample taken right after a
238
+ // navigation reads the pre-navigation frame as finished. Same reasoning and
239
+ // same constant as `waitForAppSettled` in products/jbrowse-capture/src/waits.ts
240
+ // (APP_SETTLED_HOLD_MS) — reached independently there, and measured.
241
+ const READY_HOLD_MS = 1000;
242
+ // An error toast stays up until a human dismisses it, and an info toast leaves
243
+ // on its own — so "the last five messages" repeated one stale error in every
244
+ // settle result for the rest of a run and named no level for the rest. Each
245
+ // toast is delivered to the caller once, by identity: the mobx array keeps the
246
+ // object it was pushed as, and removeSnackbarMessage relies on that too.
247
+ const deliveredNotifications = new WeakMap();
248
+ export function undeliveredNotifications(session) {
249
+ const entries = 'snackbarMessages' in session
250
+ ? session.snackbarMessages
251
+ : [];
252
+ const known = deliveredNotifications.get(session);
253
+ const delivered = known ?? new WeakSet();
254
+ if (!known) {
255
+ deliveredNotifications.set(session, delivered);
256
+ }
257
+ const fresh = entries.filter(m => !delivered.has(m));
258
+ for (const m of fresh) {
259
+ delivered.add(m);
260
+ }
261
+ return fresh.map(m => ({
262
+ level: m.level ?? 'info',
263
+ message: m.message,
264
+ }));
265
+ }
266
+ export async function waitReady(timeoutMs, session,
267
+ // scoped rather than document-wide because @jbrowse/react-app2 embeds this
268
+ // app in a host page: two mounted apps publish two ready markers, and a
269
+ // document-wide query answers for whichever is first in document order
270
+ root = document) {
271
+ const deadline = Date.now() + timeoutMs;
272
+ let readySince;
273
+ let outcome;
274
+ while (outcome === undefined) {
275
+ const ready = root.querySelector('[data-app-phase="ready"]') !== null &&
276
+ root.querySelector('[data-testid="loading-overlay"]') === null;
277
+ readySince = ready ? (readySince ?? Date.now()) : undefined;
278
+ if (readySince !== undefined && Date.now() - readySince >= READY_HOLD_MS) {
279
+ outcome = { settled: true };
280
+ }
281
+ else if (Date.now() >= deadline) {
282
+ outcome = {
283
+ settled: false,
284
+ note: 'rendering had not settled by the timeout; tracks may still be loading',
285
+ };
286
+ }
287
+ else {
288
+ await delay(200);
289
+ }
290
+ }
291
+ const messages = session ? undeliveredNotifications(session) : [];
292
+ // A display that refuses to draw — over the fetch-size gate, or errored —
293
+ // replaces its own subtree instead of raising a snackbar, so notifications
294
+ // alone report a clean settle over a browser with a blank track in it.
295
+ const notReady = session
296
+ ? [
297
+ ...openViews(session)
298
+ .map(v => ({
299
+ viewId: v.id,
300
+ type: v.type,
301
+ ...viewState(viewSelf(v)),
302
+ }))
303
+ .filter(v => 'phase' in v || 'error' in v),
304
+ ...allTracks(session)
305
+ .map(t => ({ trackId: t.configuration.trackId, ...displayState(t) }))
306
+ .filter(t => 'phase' in t || 'error' in t),
307
+ ]
308
+ : [];
309
+ const offscreen = session ? offscreenViews(session, root) : undefined;
310
+ return {
311
+ ...outcome,
312
+ ...(messages.length ? { notifications: messages } : {}),
313
+ ...(notReady.length ? { notReady } : {}),
314
+ ...(offscreen ? { offscreen } : {}),
315
+ };
316
+ }
317
+ // A session taller than the window scrolls, and a screenshot of the viewport
318
+ // then shows a plausible browser with the bottom view cut off or the top one
319
+ // scrolled away: every filmed take spent turns shrinking tracks after a
320
+ // picture, and one found its genome view at y = -267. Reported from the DOM
321
+ // the views are laid out in, so the agent knows before it looks.
322
+ function offscreenViews(session, root) {
323
+ const win = root.ownerDocument?.defaultView ?? window;
324
+ const windowHeight = win.innerHeight;
325
+ const pageHeight = win.document.documentElement.scrollHeight;
326
+ const containers = new Map([
327
+ ...root.querySelectorAll('[data-testid^="view-container-"]'),
328
+ ].map(el => [el.dataset.testid.slice('view-container-'.length), el]));
329
+ const views = openViews(session).flatMap(view => {
330
+ const rect = containers.get(view.id)?.getBoundingClientRect();
331
+ return rect && (rect.top < 0 || rect.bottom > windowHeight)
332
+ ? [
333
+ {
334
+ viewId: view.id,
335
+ top: Math.round(rect.top),
336
+ bottom: Math.round(rect.bottom),
337
+ },
338
+ ]
339
+ : [];
340
+ });
341
+ return pageHeight > windowHeight || views.length
342
+ ? {
343
+ pageHeight,
344
+ windowHeight,
345
+ scrollY: Math.round(win.scrollY),
346
+ views,
347
+ note: 'the session is taller than the window; a viewport screenshot cuts these views off — shrink track heights, or screenshot with fullPage: true. A view with a negative top is scrolled out above the viewport (scrollY says by how much), not missing',
348
+ }
349
+ : undefined;
350
+ }
351
+ function trackEntry(conf) {
352
+ const adapter = readConfObject(conf, 'adapter');
353
+ return {
354
+ trackId: conf.trackId,
355
+ name: readConfObject(conf, 'name'),
356
+ type: conf.type,
357
+ ...(adapter.type ? { adapterType: adapter.type } : {}),
358
+ // getConfAssemblyNamesOrNone, not the slot: an assembly's sequence track
359
+ // has no assemblyNames slot and answers through its parent assembly
360
+ assemblyNames: getConfAssemblyNamesOrNone(conf),
361
+ };
362
+ }
363
+ // Each assembly's own sequence track, which no track list holds: the config
364
+ // keeps it under the assembly as `sequence`, so `jb.getFeatures` on its
365
+ // trackId reads bases while `listTracks` never named it.
366
+ function sequenceTracks(session) {
367
+ return session.assemblyManager.assemblyList.map(asm => asm.sequence);
368
+ }
369
+ function listTracks(session, searchArg, limitArg) {
370
+ const search = searchArg?.toLowerCase() ?? '';
371
+ const limit = limitArg ?? 100;
372
+ // allSessionTracks, not session.tracks: connection-supplied tracks (hubs,
373
+ // registries) are absent from the session lists but fully showable — a
374
+ // hand-rolled union here hid them from agents entirely
375
+ const matches = [...allSessionTracks(session), ...sequenceTracks(session)]
376
+ .map(c => trackEntry(c))
377
+ .filter(t => !search ||
378
+ t.trackId.toLowerCase().includes(search) ||
379
+ t.name.toLowerCase().includes(search));
380
+ return {
381
+ total: matches.length,
382
+ ...(matches.length > limit
383
+ ? { note: `showing ${limit} of ${matches.length}; refine with search` }
384
+ : {}),
385
+ tracks: matches.slice(0, limit),
386
+ };
387
+ }
388
+ function pickView(session, args, capability, wants) {
389
+ const viewId = typeof args.viewId === 'string' ? args.viewId : '';
390
+ const candidates = openViews(session);
391
+ if (viewId) {
392
+ const named = candidates.find(v => v.id === viewId);
393
+ if (!named) {
394
+ throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => `${v.id} (${v.type})`).join(', ')}`);
395
+ }
396
+ if (typeof viewSelf(named)[capability] !== 'function') {
397
+ throw new Error(`View ${named.id} (${named.type}) does not support this`);
398
+ }
399
+ return named;
400
+ }
401
+ const able = candidates.filter(v => typeof viewSelf(v)[capability] === 'function');
402
+ if (!able.length) {
403
+ throw new Error(`No open view supports this. Open views: ${candidates.map(v => v.type).join(', ') || 'none'} — jb.loadSessionSpec can open one.`);
404
+ }
405
+ // A view on another assembly would show the track and render nothing, with a
406
+ // successful-looking result — the same silent mismatch visibleRegionsOf
407
+ // guards against for reads
408
+ const onAssembly = wants?.assembly
409
+ ? able.filter(v => viewSelf(v).assemblyNames?.some(name => isSameAssemblyName(name, wants.assembly, session.assemblyManager)))
410
+ : able;
411
+ if (!onAssembly.length) {
412
+ throw new Error(`No open view is on assembly "${wants.assembly}" (open views: ${able.map(v => `${v.id} on ${viewSelf(v).assemblyNames?.join(', ')}`).join('; ')}) — open one, or pass show: false`);
413
+ }
414
+ const canDisplay = wants?.trackType && wants.pluginManager
415
+ ? onAssembly.filter(v => viewCanDisplayTrack(wants.pluginManager, viewDisplayNames(wants.pluginManager, v.type), wants.trackType))
416
+ : onAssembly;
417
+ if (!canDisplay.length) {
418
+ throw new Error(`No open view can display a ${wants.trackType} (open views: ${onAssembly.map(v => v.type).join(', ')})`);
419
+ }
420
+ return onlyView(canDisplay, 'could take this');
421
+ }
422
+ function describeView(view) {
423
+ const v = viewSelf(view);
424
+ const on = v.assemblyNames?.length ? ` on ${v.assemblyNames.join(', ')}` : '';
425
+ const at = v.coarseVisibleLocStrings ? ` at ${v.coarseVisibleLocStrings}` : '';
426
+ return `${v.id} (${v.type}${on}${at})`;
427
+ }
428
+ // A name that matches several views has no right first answer: two linear
429
+ // views of one assembly at two loci both "show the track", and taking the
430
+ // first restyles one row or reads one region while the settle reports both.
431
+ // The named-but-missing case already throws and lists the open views, so the
432
+ // unnamed-and-plural case does the same.
433
+ function onlyView(candidates, relation) {
434
+ const [first, ...rest] = candidates;
435
+ if (!first) {
436
+ throw new Error('No open view');
437
+ }
438
+ if (rest.length) {
439
+ throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(v)).join('; ')} — pass viewId to say which`);
440
+ }
441
+ return first;
442
+ }
443
+ function viewById(session, viewId) {
444
+ const candidates = openViews(session);
445
+ if (viewId !== undefined) {
446
+ const named = candidates.find(v => v.id === viewId);
447
+ if (!named) {
448
+ throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(v)).join('; ') || 'none'}`);
449
+ }
450
+ return named;
451
+ }
452
+ if (!candidates.length) {
453
+ throw new Error('No view is open — jb.loadSessionSpec can open one');
454
+ }
455
+ return onlyView(candidates, 'are open');
456
+ }
457
+ function shownTrackModel(session, trackId, viewId) {
458
+ const pool = viewId === undefined
459
+ ? allTracks(session)
460
+ : viewTracks(viewById(session, viewId));
461
+ const shown = pool.filter(t => t.configuration.trackId === trackId);
462
+ if (shown.length > 1) {
463
+ const where = openViews(session)
464
+ .filter(v => viewTracks(v).some(t => t.configuration.trackId === trackId))
465
+ .map(v => describeView(v));
466
+ throw new Error(`"${trackId}" is shown in ${shown.length} views: ${where.join('; ')} — pass viewId to say which`);
467
+ }
468
+ const [track] = shown;
469
+ // undefined here was followed by ".activeDisplay" on the next line of every
470
+ // agent's code, and a TypeError says nothing about which of the two causes
471
+ // it was
472
+ if (!track) {
473
+ throw new Error(session.getTrackById(trackId)
474
+ ? `"${trackId}" is not shown in ${viewId === undefined ? 'any open view' : `view ${viewId}`} — view.showTrack("${trackId}") first; jb.sessionSummary() lists what each view shows`
475
+ : `No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
476
+ }
477
+ return track;
478
+ }
479
+ async function locToRegion(session, conf, loc, assemblyArg) {
480
+ // getConfAssemblyNamesOrNone, not the assemblyNames slot: an assembly's own
481
+ // sequence track has no such slot and answers through its parent assembly
482
+ const trackAssemblies = getConfAssemblyNamesOrNone(conf);
483
+ const assemblyName = assemblyArg ?? trackAssemblies[0];
484
+ if (assemblyName === undefined) {
485
+ throw new Error('The track names no assembly; pass assembly explicitly');
486
+ }
487
+ // a named assembly the track is not on renames the region against the wrong
488
+ // alias set and answers with the wrong assembly's coordinates, or nothing
489
+ if (assemblyArg !== undefined &&
490
+ trackAssemblies.length &&
491
+ !trackAssemblies.some(name => isSameAssemblyName(name, assemblyArg, session.assemblyManager))) {
492
+ throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${assemblyArg}"`);
493
+ }
494
+ const assembly = await session.assemblyManager.waitForAssembly(assemblyName);
495
+ if (!assembly) {
496
+ throw new Error(`Assembly "${assemblyName}" could not be loaded`);
497
+ }
498
+ const parsed = parseLocString(loc, refName => assembly.isValidRefName(refName));
499
+ const refName = assembly.getCanonicalRefName(parsed.refName) ?? parsed.refName;
500
+ const bounds = assembly.regions?.find(r => r.refName === refName);
501
+ return {
502
+ assemblyName,
503
+ refName,
504
+ start: parsed.start ?? bounds?.start ?? 0,
505
+ end: parsed.end ?? bounds?.end ?? Number.MAX_SAFE_INTEGER,
506
+ };
507
+ }
508
+ async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies = []) {
509
+ // `in`, not evaluation: visibleRegions is a getter that THROWS ("width
510
+ // undefined") until the view's component mounts and sets a width — a
511
+ // freshly spec-loaded view stays in that state briefly even after the
512
+ // app-phase marker reads ready, since a view with no width has no display
513
+ // fetching anything.
514
+ const regionBearing = openViews(session).filter(v => (!viewId || v.id === viewId) && 'visibleRegions' in v);
515
+ if (!regionBearing.length) {
516
+ throw new Error('No view that shows a region — pass loc, or open a linear view first');
517
+ }
518
+ // a view on another assembly than the track would hand its region to a file
519
+ // that has no such sequence, which answers nothing, silently
520
+ const candidates = trackAssemblies.length
521
+ ? regionBearing.filter(v => !viewSelf(v).assemblyNames?.length ||
522
+ viewSelf(v).assemblyNames.some(name => trackAssemblies.some(t => isSameAssemblyName(name, t, session.assemblyManager))))
523
+ : regionBearing;
524
+ if (!candidates.length) {
525
+ throw new Error(`No open view is on ${trackAssemblies.join(', ')} (open views: ${regionBearing.map(v => describeView(v)).join('; ')}) — pass loc, or open a view on that assembly`);
526
+ }
527
+ // among region-bearing views, the ones actually showing the track — two
528
+ // views on two assemblies would otherwise send the first view's namespace to
529
+ // the second view's file, which answers nothing, silently
530
+ const showing = candidates.filter(v => viewTracks(v).some(t => t.configuration.trackId === preferTrackId));
531
+ const chosen = onlyView(showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region');
532
+ const view = viewSelf(chosen);
533
+ const deadline = Date.now() + 10_000;
534
+ let visible;
535
+ while (visible === undefined) {
536
+ if (view.error !== undefined) {
537
+ throw new Error(`View ${view.id} failed to initialize: ${String(view.error)} — pass loc with assembly, or open a view that loads`);
538
+ }
539
+ if (view.initialized !== false) {
540
+ try {
541
+ visible = view.visibleRegions;
542
+ }
543
+ catch {
544
+ // not mounted yet
545
+ }
546
+ }
547
+ if (visible?.length) {
548
+ break;
549
+ }
550
+ visible = undefined;
551
+ if (Date.now() >= deadline) {
552
+ throw new Error('The view has not finished initializing a visible region — pass loc, or navigate first');
553
+ }
554
+ await delay(250);
555
+ }
556
+ // whole bases: a block edge is a fractional bp, and 30000.000000000004
557
+ // reads as a bug in every answer that echoes it
558
+ return visible.map(({ refName, start, end, assemblyName }) => ({
559
+ refName,
560
+ start: Math.floor(start),
561
+ end: Math.ceil(end),
562
+ assemblyName,
563
+ }));
564
+ }
565
+ // The same RPCs the track's display issues, on the same worker: the sessionId
566
+ // is `adapterConfigCacheKey` of the adapter config, which is exactly what
567
+ // `BaseTrackModel.rpcSessionId` derives, so a shown track's parsed index and
568
+ // chunk cache are reused rather than rebuilt in a main-thread twin that nothing
569
+ // ever freed. Retained and released like a track does, so an un-shown track's
570
+ // worker cache is dropped once this read is done. Regions are renamed to the
571
+ // file's own refNames during serialization (RpcMethodTypeWithRenameRegions),
572
+ // and the features come back rebuilt as SimpleFeature. What crosses the worker
573
+ // boundary is bounded by the byte gate that runs first.
574
+ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
575
+ const conf = session.getTrackById(trackId);
576
+ if (!conf) {
577
+ throw new Error(`No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
578
+ }
579
+ const adapterConfig = readConfObject(conf, 'adapter');
580
+ const sessionId = adapterConfigCacheKey(adapterConfig);
581
+ // The adapter's own declared limit where it has one, so this does not quietly
582
+ // disagree with the size the track's display already refuses to render —
583
+ // the reasoning BaseTrackModel.exportByteLimit spells out for "Save track
584
+ // data", which is the other path that fetches because someone asked rather
585
+ // than because a view scrolled.
586
+ const byteLimit = agentByteLimit(readConfObject(conf, ['adapter', 'fetchSizeLimit']), requestedByteLimit);
587
+ const { rpcManager } = session;
588
+ const stopToken = createStopToken();
589
+ // desktop's MCP relay gives up at 150s, so the read must not outlive it —
590
+ // and an agent-triggered read of a dense region wants a ceiling either way
591
+ const stopTimer = setTimeout(() => {
592
+ stopStopToken(stopToken);
593
+ }, 120_000);
594
+ retainAdapterSession(rpcManager, sessionId);
595
+ try {
596
+ // The agent's "region too large". A display refuses to fetch over its own
597
+ // gate and paints the reason; this path has no display, so without the
598
+ // same question asked here an agent that names a whole chromosome pulls it
599
+ // — over someone else's data host, into the page it is about to
600
+ // screenshot. Same index-only estimate the gated RPC takes first, and a
601
+ // refusal rather than a truncation: a short answer that looked like the
602
+ // whole answer is the failure this surface exists to prevent.
603
+ const bytes = await rpcManager.call(sessionId, 'CoreGetRegionByteEstimate',
604
+ // eslint-disable-next-line no-restricted-syntax -- reports nothing: an agent awaits the returned features and no display or dialog exists to show a phase label or bar
605
+ { adapterConfig, regions, scope: 'largestRegion', stopToken });
606
+ if (bytes !== undefined && bytes > byteLimit) {
607
+ throw new Error(`region too large for jb.getFeatures: the largest region is ~${bytes} bytes against a limit of ${byteLimit}. Narrow the region, or pass an explicit byteLimit if you mean to pull this much.`);
608
+ }
609
+ // eslint-disable-next-line no-restricted-syntax -- reports nothing: see the estimate above
610
+ return await rpcManager.call(sessionId, 'CoreGetFeatures', {
611
+ adapterConfig,
612
+ regions,
613
+ stopToken,
614
+ });
615
+ }
616
+ finally {
617
+ clearTimeout(stopTimer);
618
+ void releaseAdapterSession(rpcManager, sessionId);
619
+ }
620
+ }
621
+ // A LocalPathLocation only reads under Electron: in a browser openLocation
622
+ // throws "can't use local files in the browser" at the FIRST READ, not here, so
623
+ // accepting one would report a track added and then fail inside the display
624
+ // where the agent is least likely to connect it to what it asked for.
625
+ function fileLocation(spec) {
626
+ if (/^https?:\/\//.test(spec)) {
627
+ return { uri: spec, locationType: 'UriLocation' };
628
+ }
629
+ // The app's working directory is not the agent's, and a relative path
630
+ // resolves against the app's: under a packaged app that is "/", so the read
631
+ // fails at the first fetch and reports through the display, not here.
632
+ if (!/^(?:\/|[a-zA-Z]:[\\/]|\\\\)/.test(spec)) {
633
+ throw new Error(`jb.addTrack needs an absolute local path or a URL: "${spec}" is relative and would resolve against the app's working directory, not yours.`);
634
+ }
635
+ if (!isElectron) {
636
+ throw new Error(`jb.addTrack needs a URL in a browser, and "${spec}" is a local path. JBrowse Web cannot read local files except through the Add track file picker, which needs a real file chosen by hand.`);
637
+ }
638
+ return { localPath: spec, locationType: 'LocalPathLocation' };
639
+ }
640
+ // A stacked set of bigWigs is one track over a MultiWiggleAdapter, which the
641
+ // per-file guesser cannot express — the web take hand-wrote the config. The
642
+ // subadapters form rather than the `bigWigs` shorthand: that one takes
643
+ // absolute URLs only, and desktop's locations are paths.
644
+ // the file's name without its bigWig extension, or undefined for any other file
645
+ function bigWigStem(location) {
646
+ const fileName = location.split(/[/\\]/).pop() ?? location;
647
+ const match = /^(.+)\.(bw|bigwig)$/i.exec(fileName);
648
+ return match?.[1];
649
+ }
650
+ function multiWiggleTrackConf(locations, assemblyName) {
651
+ const stems = locations.map(l => bigWigStem(l));
652
+ const notBigWig = locations.filter((_, i) => stems[i] === undefined);
653
+ if (notBigWig.length) {
654
+ throw new Error(`jb.addTrack takes a list of locations only for bigWigs (one stacked MultiQuantitativeTrack); not bigWig: ${notBigWig.join(', ')}. Add other formats one at a time.`);
655
+ }
656
+ const adapter = {
657
+ type: 'MultiWiggleAdapter',
658
+ subadapters: locations.map((l, i) => ({
659
+ type: 'BigWigAdapter',
660
+ name: stems[i],
661
+ bigWigLocation: fileLocation(l),
662
+ })),
663
+ };
664
+ return {
665
+ trackId: `multiwiggle-${objectHash(adapter).slice(0, 8)}`,
666
+ type: 'MultiQuantitativeTrack',
667
+ name: stems.join(', '),
668
+ assemblyNames: [assemblyName],
669
+ adapter,
670
+ };
671
+ }
672
+ // one location, a list of them, or nothing usable
673
+ function locationsOf(value) {
674
+ return Array.isArray(value)
675
+ ? value.filter((l) => typeof l === 'string')
676
+ : typeof value === 'string'
677
+ ? value
678
+ : '';
679
+ }
680
+ // Vocabulary introspection: every config slot a live config node's schema
681
+ // defines, so code never has to guess which settings keys exist — an unknown
682
+ // key is not an error, only an entry in applyDisplaySettings' `unapplied` list.
683
+ function describeSlots(conf) {
684
+ const definition = getConfigurationSchemaDefinition(conf) ?? {};
685
+ return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) => isSlotDefinitionEntry(def)
686
+ ? [
687
+ [
688
+ name,
689
+ {
690
+ type: def.type,
691
+ ...(def.description ? { description: def.description } : {}),
692
+ defaultValue: def.defaultValue,
693
+ },
694
+ ],
695
+ ]
696
+ : []));
697
+ }
698
+ // The raw primitive under all of the above: Claude-authored code against the
699
+ // live model graph. The renderer already runs with nodeIntegration and the
700
+ // bridge socket is user-only, so this grants what the surface as a whole
701
+ // already grants — expressed directly instead of through a curated verb.
702
+ // The re-export registry is what pluginManager.jbrequire serves: the same
703
+ // pinned ABI module names external plugins link against (ReExports/modules.ts,
704
+ // abiBaseline.json). It stays empty until a runtime plugin loads, so each
705
+ // evaluate (re)installs it — import() is memoized, so this is one lookup and
706
+ // an idempotent assignment, with no module-level flag to hold.
707
+ export async function ensureReExports() {
708
+ setReExportRegistry((await import('@jbrowse/core/ReExports/modules')).default);
709
+ }
710
+ // The front door for an agent that just found `jb` and knows nothing else —
711
+ // jbrowse-web's console banner and meta tag point here, because a browser
712
+ // agent has no MCP docs tool and no initialize instructions to learn the
713
+ // contract from.
714
+ const JB_HELP = `jb drives this JBrowse app programmatically (window.jb in a browser; the same object is the "jb" argument of JBrowse Desktop's run_javascript MCP tool).
715
+
716
+ Orient first: jb.sessionSummary(). Introspect, never guess: jb.listTracks(search?, limit?) answers { total, tracks } with the trackIds; jb.describeSlots(jb.trackModel('someTrackId').activeDisplay.configuration) for the settings keys a display accepts — an unknown settings key is not an error, it lands in applyDisplaySettings' "unapplied" list, so read the report; jb.inspect('views.0') for a live node's getters, actions and modelType.
717
+
718
+ The model is mobx-state-tree: mutate only through actions (raw assignment throws), and write display settings with track.applyDisplaySettings(settings). Build views declaratively with jb.loadSessionSpec({ views: [{ type: 'LinearGenomeView', assembly, loc, tracks: [...] }] }); arrange the views already open into panels with session.layoutViews({ direction: 'horizontal', children: [{ views: [viewId] }, ...] }) — leaves name view ids or indexes into session.views; add data with jb.addTrack({ location }) (an absolute path or a URL); read data with await jb.getFeatures({ trackId, loc?, assembly?, byteLimit? }) (or jb.getFeatures(trackId, loc?, opts?)), which renames refNames ("chr1" vs "1") so the file answers and reads on the worker the track's display uses — raw adapter code must call jb.renameRegionsIfNeeded itself. After changing anything, await jb.waitReady(ms) and read its notifications and notReady lists before trusting the screen.
719
+
720
+ Views nest and several can be open. jb.view(viewId?) is the open view, and jb.view(), jb.trackModel(trackId), jb.visibleRegions() and jb.addTrack throw naming the candidates rather than picking one when more than one view could answer — pass viewId (from jb.sessionSummary()) to say which.
721
+
722
+ Full guide: https://jbrowse.org/jb2/docs/agents_live_model (JBrowse Desktop serves the same guide offline through its MCP docs tool — Help menu, "Connect an AI agent...").`;
723
+ // The helper library an agent drives the app through. Built from the plugin
724
+ // manager alone, so one of these serves a whole app rather than one session —
725
+ // which is what lets jbrowse-web hand the same object to every caller for the
726
+ // life of a plugin manager.
727
+ export function createJbApi(pluginManager) {
728
+ // Resolved per call, never captured: jb.loadSessionSpec REPLACES the session,
729
+ // and a helper bound to the old one keeps answering from a detached tree —
730
+ // which reads as stale data rather than throwing, so the agent is told about
731
+ // a session that no longer exists. `jb.session` is how code re-reads it after
732
+ // a spec load.
733
+ const live = () => {
734
+ const current = sessionOf(pluginManager);
735
+ if (!current) {
736
+ throw new Error('No session is open, so there is nothing for this helper to read. Build one with jb.loadSessionSpec({ views: [...] }), which needs no session of its own, or load a config with the open tool.');
737
+ }
738
+ return current;
739
+ };
740
+ const jb = {
741
+ help: JB_HELP,
742
+ get session() {
743
+ return live();
744
+ },
745
+ get rootModel() {
746
+ return pluginManager.rootModel;
747
+ },
748
+ require: pluginManager.jbrequire,
749
+ ensureRequire: ensureReExports,
750
+ mst,
751
+ mobx,
752
+ readConfObject,
753
+ getConf,
754
+ describeSlots,
755
+ parseLocString,
756
+ getFeatureAdapterOrThrow,
757
+ getRpcSessionId,
758
+ renameRegionsIfNeeded,
759
+ createStopToken,
760
+ stopStopToken,
761
+ // a default because an omitted number made the deadline NaN, and a view
762
+ // that never readied then held the call open to the relay's own timeout
763
+ waitReady: (timeoutMs = 30_000) => waitReady(timeoutMs, live()),
764
+ sessionSummary: () => sessionSummary(live()),
765
+ inspect: (path, maxInspectBytes) => inspectSession(live(), { path, maxBytes: maxInspectBytes }),
766
+ listTracks: (search, limit) => listTracks(live(), search, limit),
767
+ view: (viewId) => viewById(live(), viewId),
768
+ trackModel: (trackId, viewId) => shownTrackModel(live(), trackId, viewId),
769
+ visibleRegions: (viewId) => visibleRegionsOf(live(), viewId),
770
+ loadSessionSpec: (spec, settleMs) => loadSpec(pluginManager, { spec, settleMs }),
771
+ addTrack: (opts) => addTrack(pluginManager, live(), opts),
772
+ // Two of four filmed takes wrote jb.getFeatures('trackId', loc) and lost a
773
+ // turn to "No track with trackId undefined", and a third wrote
774
+ // jb.getFeatures('trackId', loc, { assembly }) and had the options
775
+ // silently ignored — so the positional form is accepted alongside the
776
+ // object it was documented as, options third.
777
+ getFeatures: async (args, positionalLoc, positionalOpts) => {
778
+ const fetchArgs = typeof args === 'string'
779
+ ? { trackId: args, loc: positionalLoc, ...positionalOpts }
780
+ : args;
781
+ const session = live();
782
+ const conf = session.getTrackById(fetchArgs.trackId);
783
+ if (!conf) {
784
+ throw new Error(`No track with trackId "${fetchArgs.trackId}" — jb.listTracks() shows what is available`);
785
+ }
786
+ const regions = fetchArgs.regions ??
787
+ (fetchArgs.loc !== undefined
788
+ ? [
789
+ await locToRegion(session, conf, fetchArgs.loc, fetchArgs.assembly),
790
+ ]
791
+ : await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf)));
792
+ return fetchFeatures(session, fetchArgs.trackId, regions, fetchArgs.byteLimit);
793
+ },
794
+ };
795
+ return jb;
796
+ }
797
+ // a spec load on a busy machine passed 30s and answered settled:false over
798
+ // views that were fine; the wait exits the moment they are ready
799
+ const ADD_TRACK_SETTLE_MS = 60_000;
800
+ async function addTrack(pluginManager, session, args) {
801
+ if (!isSessionWithAddSessionTrack(session)) {
802
+ throw new Error('This session cannot add tracks');
803
+ }
804
+ const location = locationsOf(args.location);
805
+ if (!location.length) {
806
+ throw new Error('jb.addTrack needs a location (an absolute local path or a URL)');
807
+ }
808
+ const requested = typeof args.assembly === 'string' ? args.assembly : session.assemblyNames[0];
809
+ if (requested === undefined) {
810
+ throw new Error('The session has no assemblies to attach the track to');
811
+ }
812
+ // resolved to the session's own spelling: an alias ("vvx" for "volvox",
813
+ // "GRCh38" for "hg38") names the assembly and would otherwise be refused
814
+ const assembly = session.assemblyNames.find(name => isSameAssemblyName(name, requested, session.assemblyManager));
815
+ if (assembly === undefined) {
816
+ throw new Error(`Assembly "${requested}" is not in this session (has: ${session.assemblyNames.join(', ')})`);
817
+ }
818
+ const conf = Array.isArray(location)
819
+ ? multiWiggleTrackConf(location, assembly)
820
+ : guessTrackConfForLocation(fileLocation(location), typeof args.index === 'string' ? fileLocation(args.index) : undefined, pluginManager, assembly);
821
+ session.addSessionTrackConf({
822
+ ...conf,
823
+ ...(typeof args.name === 'string' ? { name: args.name } : {}),
824
+ });
825
+ const summary = {
826
+ trackId: conf.trackId,
827
+ trackType: conf.type,
828
+ adapterType: conf.adapter.type,
829
+ assembly,
830
+ };
831
+ if (args.show === false) {
832
+ return summary;
833
+ }
834
+ const view = pickView(session, args, 'showTrack', {
835
+ assembly,
836
+ trackType: conf.type,
837
+ pluginManager,
838
+ });
839
+ viewSelf(view).showTrack(conf.trackId);
840
+ const shown = { ...summary, shownInView: view.id };
841
+ // 0 skips the settle, so several adds can share one jb.waitReady
842
+ const settleMs = typeof args.settleMs === 'number' ? args.settleMs : ADD_TRACK_SETTLE_MS;
843
+ return settleMs > 0
844
+ ? { ...shown, ...(await waitReady(settleMs, session)) }
845
+ : shown;
846
+ }
847
+ // Under the 45 s one evaluation gets from the Claude in Chrome extension: a
848
+ // cold hosted config settling for the full minute answered nothing there,
849
+ // while the settle result already names what is still not ready
850
+ const SPEC_SETTLE_DEFAULT_MS = 30_000;
851
+ async function loadSpec(pluginManager, args) {
852
+ const spec = args.spec;
853
+ const valid = typeof spec === 'object' &&
854
+ spec !== null &&
855
+ Array.isArray(spec.views);
856
+ if (!valid) {
857
+ throw new Error('loadSessionSpec needs a spec object with a views array');
858
+ }
859
+ await loadSessionSpec(spec, pluginManager);
860
+ // the session was REPLACED by the spec load — settle against the new one
861
+ const session = sessionOf(pluginManager);
862
+ const settle = await waitReady(args.settleMs ?? SPEC_SETTLE_DEFAULT_MS, session);
863
+ return {
864
+ ...settle,
865
+ ...(session ? { session: sessionSummary(session) } : {}),
866
+ };
867
+ }