@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.
- package/esm/JBrowseConfig/RootConfiguration.d.ts +4 -0
- package/esm/JBrowseConfig/index.d.ts +4 -0
- package/esm/JBrowseModel/index.d.ts +4 -0
- package/esm/JbApi/index.d.ts +2 -0
- package/esm/JbApi/index.js +1 -0
- package/esm/JbApi/jbApi.d.ts +322 -0
- package/esm/JbApi/jbApi.js +867 -0
- package/esm/RootMenu/index.d.ts +2 -11
- package/esm/RootMenu/index.js +0 -11
- package/esm/SessionSpec/loadSessionSpec.js +145 -78
- package/esm/SessionSpec/types.d.ts +10 -12
- package/esm/WorkspaceLayout/model.d.ts +24 -4
- package/esm/WorkspaceLayout/model.js +79 -17
- package/esm/WorkspaceLayout/spec.d.ts +40 -10
- package/esm/WorkspaceLayout/spec.js +81 -8
- package/esm/index.d.ts +3 -1
- package/esm/index.js +3 -0
- package/esm/menus.d.ts +15 -29
- package/esm/menus.js +76 -92
- package/esm/ui/App/AppReadyMarker.d.ts +10 -1
- package/esm/ui/App/AppReadyMarker.js +27 -21
- package/esm/ui/App/ViewButtons.js +2 -2
- package/esm/ui/App/ViewLauncher.js +1 -1
- package/package.json +5 -5
|
@@ -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
|
+
}
|