Skip to content

Commit 603056b

Browse files
authored
Merge pull request #284 from pathsim/feat/async-toolbox-bootstrap
Async toolbox bootstrap and URL-param model load
2 parents 1b41d52 + 32fa95d commit 603056b

5 files changed

Lines changed: 190 additions & 101 deletions

File tree

src/lib/schema/fileOps.ts

Lines changed: 56 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,7 @@ import { simulationState, resetSimulation } from '$lib/pyodide/bridge';
2323
import {
2424
collectRequiredToolboxes,
2525
findMissingRequirements,
26-
performInstall,
27-
discoverToolbox,
28-
registerToolbox,
29-
upsertToolbox,
30-
getCatalogEntry
26+
installAndRegisterToolbox
3127
} from '$lib/toolbox';
3228
import { getCachedPathsimVersion } from '$lib/toolbox/pathsimVersion';
3329
import type { ToolboxRequirement } from '$lib/types/schema';
@@ -197,33 +193,13 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
197193

198194
for (const req of missing) {
199195
try {
200-
const installResult = await performInstall(req.source, req.importPath || undefined);
201-
const updated: ToolboxRequirement = {
202-
...req,
203-
importPath: installResult.importPath
204-
};
205-
const discovered = await discoverToolbox({
206-
importPath: updated.importPath,
207-
eventsImportPath: updated.eventsImportPath
208-
});
209-
const catalog = getCatalogEntry(req.id);
210-
const config = {
196+
await installAndRegisterToolbox({
211197
id: req.id,
212198
displayName: req.displayName,
213199
source: req.source,
214-
importPath: updated.importPath,
215-
eventsImportPath: updated.eventsImportPath,
216-
installedVersion: installResult.installedVersion,
217-
blocks: discovered.blocks.map((b) => ({ className: b.className, enabled: true })),
218-
events: discovered.events.map((e) => ({ className: e.className, enabled: true }))
219-
};
220-
registerToolbox(config, {
221-
blocks: discovered.blocks,
222-
events: discovered.events,
223-
defaultCategory: catalog?.defaultCategory,
224-
categoryByClass: catalog?.categoryByClass
200+
importPath: req.importPath || undefined,
201+
eventsImportPath: req.eventsImportPath
225202
});
226-
upsertToolbox(config);
227203
consoleStore.info(`[toolbox] installed ${req.displayName}`);
228204
} catch (e) {
229205
const msg = e instanceof Error ? e.message : String(e);
@@ -235,7 +211,10 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
235211
/**
236212
* Load a GraphFile into the application state
237213
*/
238-
export async function loadGraphFile(file: GraphFile): Promise<void> {
214+
export async function loadGraphFile(
215+
file: GraphFile,
216+
options: { deferToolboxInstall?: boolean; backendReady?: Promise<unknown> } = {}
217+
): Promise<void> {
239218
// Migrate old format if needed
240219
file = migrateGraphFile(file);
241220
// Validate version
@@ -246,36 +225,24 @@ export async function loadGraphFile(file: GraphFile): Promise<void> {
246225
// Reset simulation state (stops running simulation, clears results and Python state)
247226
resetSimulation(); // Fire and forget - synchronous part stops immediately
248227

249-
// Install any runtime toolboxes the file declared as required. Files
250-
// saved before this field existed simply skip this step.
251-
if (file.requiredToolboxes && file.requiredToolboxes.length > 0) {
252-
await installRequiredToolboxes(file.requiredToolboxes);
253-
}
254-
255228
// Clear previous state and wait for UI to update
256229
// This ensures FlowCanvas sees empty state before new data arrives
257230
graphStore.clear();
258231
eventStore.clear();
259232
consoleStore.clear();
260233
await tick();
261234

262-
// Load graph (including annotations)
235+
// Load graph (including annotations) — happens before toolbox install so
236+
// the user sees the model immediately. Blocks whose toolbox isn't yet
237+
// registered render as (missing) placeholders and upgrade themselves
238+
// reactively via `registryVersion` once `installRequiredToolboxes`
239+
// (below) completes.
263240
graphStore.fromJSON(
264241
file.graph?.nodes || [],
265242
file.graph?.connections || [],
266243
file.graph?.annotations || []
267244
);
268245

269-
// Surface any block types that ended up unregistered after the install
270-
// step (either because the user skipped install, or because the file
271-
// has no requiredToolboxes block list — old files / hand-edited files).
272-
const unknownTypes = validateNodeTypes(file.graph?.nodes || []);
273-
if (unknownTypes.length > 0) {
274-
consoleStore.warn(
275-
`[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.`
276-
);
277-
}
278-
279246
// Load events
280247
if (file.events && file.events.length > 0) {
281248
eventStore.fromJSON(file.events);
@@ -317,6 +284,37 @@ export async function loadGraphFile(file: GraphFile): Promise<void> {
317284

318285
// Trigger assembly animation for loaded graph
319286
requestAssemblyAnimation();
287+
288+
// Install runtime toolboxes the file declared as required, then surface
289+
// any block types that remain unregistered (user skipped install, or file
290+
// has no requiredToolboxes — old / hand-edited files). In defer mode this
291+
// runs in the background after `backendReady` resolves, so the graph
292+
// shows up before Pyodide is even initialised.
293+
const installAndWarn = async (): Promise<void> => {
294+
if (file.requiredToolboxes && file.requiredToolboxes.length > 0) {
295+
await installRequiredToolboxes(file.requiredToolboxes);
296+
}
297+
const unknownTypes = validateNodeTypes(file.graph?.nodes || []);
298+
if (unknownTypes.length > 0) {
299+
consoleStore.warn(
300+
`[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.`
301+
);
302+
}
303+
};
304+
305+
if (options.deferToolboxInstall) {
306+
void (async () => {
307+
try {
308+
if (options.backendReady) await options.backendReady;
309+
await installAndWarn();
310+
} catch (e) {
311+
const msg = e instanceof Error ? e.message : String(e);
312+
consoleStore.error(`[toolbox] deferred install failed: ${msg}`);
313+
}
314+
})();
315+
} else {
316+
await installAndWarn();
317+
}
320318
}
321319

322320
/**
@@ -504,6 +502,14 @@ export interface ImportOptions {
504502
position?: Position; // Where to place components (ignored for models)
505503
fileHandle?: FileSystemFileHandle; // For native file picker (enables Save)
506504
fileName?: string; // Display name (for URL imports or fallback)
505+
// When true, the toolbox install step (which requires Pyodide) is fired
506+
// off in the background — the graph fills immediately and (missing)
507+
// blocks upgrade themselves via `registryVersion` once their toolbox
508+
// registers. Used by the URL-param load on app start, where Pyodide may
509+
// still be initialising. The deferred install awaits `backendReady`
510+
// first, so it's safe to pass a not-yet-ready promise.
511+
deferToolboxInstall?: boolean;
512+
backendReady?: Promise<unknown>;
507513
}
508514

509515
/**
@@ -659,7 +665,10 @@ async function importModel(
659665
simulationSettings: content.simulationSettings || INITIAL_SIMULATION_SETTINGS
660666
};
661667

662-
await loadGraphFile(graphFile);
668+
await loadGraphFile(graphFile, {
669+
deferToolboxInstall: options.deferToolboxInstall,
670+
backendReady: options.backendReady
671+
});
663672

664673
// Update current file tracking
665674
currentFileHandle = options.fileHandle || null;

src/lib/toolbox/bootstrap.ts

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,9 @@
1212
*/
1313

1414
import { get } from 'svelte/store';
15-
import { toolboxes, upsertToolbox, seedPreloadedToolboxes } from './store';
16-
import { performInstall, discoverToolbox, registerToolbox } from './register';
17-
import { getCatalogEntry } from './catalog';
15+
import { toolboxes, seedPreloadedToolboxes } from './store';
16+
import { installAndRegisterToolbox } from './installFlow';
1817
import { primePathsimVersion } from './pathsimVersion';
19-
import type { ToolboxConfig } from './types';
2018

2119
let bootstrapped = false;
2220

@@ -35,45 +33,13 @@ export async function bootstrapToolboxes(): Promise<void> {
3533

3634
for (const config of list) {
3735
try {
38-
const installResult = await performInstall(config.source, config.importPath || undefined);
39-
const discovered = await discoverToolbox({
40-
importPath: installResult.importPath,
36+
await installAndRegisterToolbox({
37+
id: config.id,
38+
displayName: config.displayName,
39+
source: config.source,
40+
importPath: config.importPath || undefined,
4141
eventsImportPath: config.eventsImportPath
4242
});
43-
44-
// Reconcile selections against current discovery: preserves the
45-
// user's enabled/override choices, adds new classes the upstream
46-
// package introduced (enabled by default), and drops entries
47-
// whose classes no longer exist.
48-
const reconciled: ToolboxConfig = {
49-
...config,
50-
importPath: installResult.importPath,
51-
installedVersion: installResult.installedVersion,
52-
blocks: discovered.blocks.map(
53-
(b) =>
54-
config.blocks.find((s) => s.className === b.className) ?? {
55-
className: b.className,
56-
enabled: true
57-
}
58-
),
59-
events: discovered.events.map(
60-
(e) =>
61-
config.events.find((s) => s.className === e.className) ?? {
62-
className: e.className,
63-
enabled: true
64-
}
65-
)
66-
};
67-
68-
const catalog = getCatalogEntry(config.id);
69-
registerToolbox(reconciled, {
70-
blocks: discovered.blocks,
71-
events: discovered.events,
72-
defaultCategory: catalog?.defaultCategory,
73-
categoryByClass: catalog?.categoryByClass
74-
});
75-
76-
upsertToolbox(reconciled);
7743
} catch (e) {
7844
console.error(`[toolbox] bootstrap failed for "${config.id}":`, e);
7945
}

src/lib/toolbox/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ export { TOOLBOX_CATALOG, getCatalogEntry, type CatalogEntry } from './catalog';
3434

3535
export { bootstrapToolboxes } from './bootstrap';
3636

37+
export { installAndRegisterToolbox, type InstallSpec } from './installFlow';
38+
39+
export { seedPreloadedToolboxes } from './store';
40+
3741
export { collectRequiredToolboxes, findMissingRequirements } from './dependencies';

src/lib/toolbox/installFlow.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* High-level orchestrator for installing a toolbox end-to-end:
3+
* `performInstall` → `discoverToolbox` → `registerToolbox` → `upsertToolbox`.
4+
*
5+
* Used by both the startup bootstrap and the per-file `requiredToolboxes`
6+
* install path. Deduplicates concurrent calls keyed by toolbox `id` so the
7+
* two paths can run in parallel without firing the same install twice.
8+
*
9+
* Reconciles selections against the current persisted store entry when one
10+
* exists, so the user's enable/disable choices survive a re-install.
11+
*/
12+
13+
import { get } from 'svelte/store';
14+
import { toolboxes, upsertToolbox } from './store';
15+
import { performInstall, discoverToolbox, registerToolbox } from './register';
16+
import { getCatalogEntry } from './catalog';
17+
import type { ToolboxConfig, ToolboxSource } from './types';
18+
19+
export interface InstallSpec {
20+
id: string;
21+
displayName: string;
22+
source: ToolboxSource;
23+
importPath?: string;
24+
eventsImportPath?: string;
25+
}
26+
27+
const inFlight = new Map<string, Promise<ToolboxConfig>>();
28+
29+
export async function installAndRegisterToolbox(spec: InstallSpec): Promise<ToolboxConfig> {
30+
const existing = inFlight.get(spec.id);
31+
if (existing) return existing;
32+
33+
const promise = (async (): Promise<ToolboxConfig> => {
34+
const installResult = await performInstall(spec.source, spec.importPath);
35+
const discovered = await discoverToolbox({
36+
importPath: installResult.importPath,
37+
eventsImportPath: spec.eventsImportPath
38+
});
39+
40+
// Reconcile against the current persisted entry, if any: preserves
41+
// user enable/disable choices, defaults newly discovered entries to
42+
// enabled, drops entries whose classes no longer exist upstream.
43+
const current = get(toolboxes).find((t) => t.id === spec.id);
44+
const config: ToolboxConfig = {
45+
id: spec.id,
46+
displayName: spec.displayName,
47+
source: spec.source,
48+
importPath: installResult.importPath,
49+
eventsImportPath: spec.eventsImportPath,
50+
installedVersion: installResult.installedVersion,
51+
blocks: discovered.blocks.map(
52+
(b) =>
53+
current?.blocks.find((s) => s.className === b.className) ?? {
54+
className: b.className,
55+
enabled: true
56+
}
57+
),
58+
events: discovered.events.map(
59+
(e) =>
60+
current?.events.find((s) => s.className === e.className) ?? {
61+
className: e.className,
62+
enabled: true
63+
}
64+
)
65+
};
66+
67+
const catalog = getCatalogEntry(spec.id);
68+
registerToolbox(config, {
69+
blocks: discovered.blocks,
70+
events: discovered.events,
71+
defaultCategory: catalog?.defaultCategory,
72+
categoryByClass: catalog?.categoryByClass
73+
});
74+
upsertToolbox(config);
75+
76+
return config;
77+
})();
78+
79+
inFlight.set(spec.id, promise);
80+
promise
81+
.catch(() => {
82+
// Error is propagated to the original awaiter; we only swallow
83+
// here so the in-flight cleanup below doesn't trigger an
84+
// unhandled rejection warning.
85+
})
86+
.finally(() => {
87+
if (inFlight.get(spec.id) === promise) inFlight.delete(spec.id);
88+
});
89+
90+
return promise;
91+
}

0 commit comments

Comments
 (0)