Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 56 additions & 47 deletions src/lib/schema/fileOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,7 @@ import { simulationState, resetSimulation } from '$lib/pyodide/bridge';
import {
collectRequiredToolboxes,
findMissingRequirements,
performInstall,
discoverToolbox,
registerToolbox,
upsertToolbox,
getCatalogEntry
installAndRegisterToolbox
} from '$lib/toolbox';
import { getCachedPathsimVersion } from '$lib/toolbox/pathsimVersion';
import type { ToolboxRequirement } from '$lib/types/schema';
Expand Down Expand Up @@ -197,33 +193,13 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi

for (const req of missing) {
try {
const installResult = await performInstall(req.source, req.importPath || undefined);
const updated: ToolboxRequirement = {
...req,
importPath: installResult.importPath
};
const discovered = await discoverToolbox({
importPath: updated.importPath,
eventsImportPath: updated.eventsImportPath
});
const catalog = getCatalogEntry(req.id);
const config = {
await installAndRegisterToolbox({
id: req.id,
displayName: req.displayName,
source: req.source,
importPath: updated.importPath,
eventsImportPath: updated.eventsImportPath,
installedVersion: installResult.installedVersion,
blocks: discovered.blocks.map((b) => ({ className: b.className, enabled: true })),
events: discovered.events.map((e) => ({ className: e.className, enabled: true }))
};
registerToolbox(config, {
blocks: discovered.blocks,
events: discovered.events,
defaultCategory: catalog?.defaultCategory,
categoryByClass: catalog?.categoryByClass
importPath: req.importPath || undefined,
eventsImportPath: req.eventsImportPath
});
upsertToolbox(config);
consoleStore.info(`[toolbox] installed ${req.displayName}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Expand All @@ -235,7 +211,10 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
/**
* Load a GraphFile into the application state
*/
export async function loadGraphFile(file: GraphFile): Promise<void> {
export async function loadGraphFile(
file: GraphFile,
options: { deferToolboxInstall?: boolean; backendReady?: Promise<unknown> } = {}
): Promise<void> {
// Migrate old format if needed
file = migrateGraphFile(file);
// Validate version
Expand All @@ -246,36 +225,24 @@ export async function loadGraphFile(file: GraphFile): Promise<void> {
// Reset simulation state (stops running simulation, clears results and Python state)
resetSimulation(); // Fire and forget - synchronous part stops immediately

// Install any runtime toolboxes the file declared as required. Files
// saved before this field existed simply skip this step.
if (file.requiredToolboxes && file.requiredToolboxes.length > 0) {
await installRequiredToolboxes(file.requiredToolboxes);
}

// Clear previous state and wait for UI to update
// This ensures FlowCanvas sees empty state before new data arrives
graphStore.clear();
eventStore.clear();
consoleStore.clear();
await tick();

// Load graph (including annotations)
// Load graph (including annotations) — happens before toolbox install so
// the user sees the model immediately. Blocks whose toolbox isn't yet
// registered render as (missing) placeholders and upgrade themselves
// reactively via `registryVersion` once `installRequiredToolboxes`
// (below) completes.
graphStore.fromJSON(
file.graph?.nodes || [],
file.graph?.connections || [],
file.graph?.annotations || []
);

// Surface any block types that ended up unregistered after the install
// step (either because the user skipped install, or because the file
// has no requiredToolboxes block list — old files / hand-edited files).
const unknownTypes = validateNodeTypes(file.graph?.nodes || []);
if (unknownTypes.length > 0) {
consoleStore.warn(
`[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.`
);
}

// Load events
if (file.events && file.events.length > 0) {
eventStore.fromJSON(file.events);
Expand Down Expand Up @@ -317,6 +284,37 @@ export async function loadGraphFile(file: GraphFile): Promise<void> {

// Trigger assembly animation for loaded graph
requestAssemblyAnimation();

// Install runtime toolboxes the file declared as required, then surface
// any block types that remain unregistered (user skipped install, or file
// has no requiredToolboxes — old / hand-edited files). In defer mode this
// runs in the background after `backendReady` resolves, so the graph
// shows up before Pyodide is even initialised.
const installAndWarn = async (): Promise<void> => {
if (file.requiredToolboxes && file.requiredToolboxes.length > 0) {
await installRequiredToolboxes(file.requiredToolboxes);
}
const unknownTypes = validateNodeTypes(file.graph?.nodes || []);
if (unknownTypes.length > 0) {
consoleStore.warn(
`[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.`
);
}
};

if (options.deferToolboxInstall) {
void (async () => {
try {
if (options.backendReady) await options.backendReady;
await installAndWarn();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
consoleStore.error(`[toolbox] deferred install failed: ${msg}`);
}
})();
} else {
await installAndWarn();
}
}

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

/**
Expand Down Expand Up @@ -659,7 +665,10 @@ async function importModel(
simulationSettings: content.simulationSettings || INITIAL_SIMULATION_SETTINGS
};

await loadGraphFile(graphFile);
await loadGraphFile(graphFile, {
deferToolboxInstall: options.deferToolboxInstall,
backendReady: options.backendReady
});

// Update current file tracking
currentFileHandle = options.fileHandle || null;
Expand Down
48 changes: 7 additions & 41 deletions src/lib/toolbox/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@
*/

import { get } from 'svelte/store';
import { toolboxes, upsertToolbox, seedPreloadedToolboxes } from './store';
import { performInstall, discoverToolbox, registerToolbox } from './register';
import { getCatalogEntry } from './catalog';
import { toolboxes, seedPreloadedToolboxes } from './store';
import { installAndRegisterToolbox } from './installFlow';
import { primePathsimVersion } from './pathsimVersion';
import type { ToolboxConfig } from './types';

let bootstrapped = false;

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

for (const config of list) {
try {
const installResult = await performInstall(config.source, config.importPath || undefined);
const discovered = await discoverToolbox({
importPath: installResult.importPath,
await installAndRegisterToolbox({
id: config.id,
displayName: config.displayName,
source: config.source,
importPath: config.importPath || undefined,
eventsImportPath: config.eventsImportPath
});

// Reconcile selections against current discovery: preserves the
// user's enabled/override choices, adds new classes the upstream
// package introduced (enabled by default), and drops entries
// whose classes no longer exist.
const reconciled: ToolboxConfig = {
...config,
importPath: installResult.importPath,
installedVersion: installResult.installedVersion,
blocks: discovered.blocks.map(
(b) =>
config.blocks.find((s) => s.className === b.className) ?? {
className: b.className,
enabled: true
}
),
events: discovered.events.map(
(e) =>
config.events.find((s) => s.className === e.className) ?? {
className: e.className,
enabled: true
}
)
};

const catalog = getCatalogEntry(config.id);
registerToolbox(reconciled, {
blocks: discovered.blocks,
events: discovered.events,
defaultCategory: catalog?.defaultCategory,
categoryByClass: catalog?.categoryByClass
});

upsertToolbox(reconciled);
} catch (e) {
console.error(`[toolbox] bootstrap failed for "${config.id}":`, e);
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/toolbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ export { TOOLBOX_CATALOG, getCatalogEntry, type CatalogEntry } from './catalog';

export { bootstrapToolboxes } from './bootstrap';

export { installAndRegisterToolbox, type InstallSpec } from './installFlow';

export { seedPreloadedToolboxes } from './store';

export { collectRequiredToolboxes, findMissingRequirements } from './dependencies';
91 changes: 91 additions & 0 deletions src/lib/toolbox/installFlow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* High-level orchestrator for installing a toolbox end-to-end:
* `performInstall` → `discoverToolbox` → `registerToolbox` → `upsertToolbox`.
*
* Used by both the startup bootstrap and the per-file `requiredToolboxes`
* install path. Deduplicates concurrent calls keyed by toolbox `id` so the
* two paths can run in parallel without firing the same install twice.
*
* Reconciles selections against the current persisted store entry when one
* exists, so the user's enable/disable choices survive a re-install.
*/

import { get } from 'svelte/store';
import { toolboxes, upsertToolbox } from './store';
import { performInstall, discoverToolbox, registerToolbox } from './register';
import { getCatalogEntry } from './catalog';
import type { ToolboxConfig, ToolboxSource } from './types';

export interface InstallSpec {
id: string;
displayName: string;
source: ToolboxSource;
importPath?: string;
eventsImportPath?: string;
}

const inFlight = new Map<string, Promise<ToolboxConfig>>();

export async function installAndRegisterToolbox(spec: InstallSpec): Promise<ToolboxConfig> {
const existing = inFlight.get(spec.id);
if (existing) return existing;

const promise = (async (): Promise<ToolboxConfig> => {
const installResult = await performInstall(spec.source, spec.importPath);
const discovered = await discoverToolbox({
importPath: installResult.importPath,
eventsImportPath: spec.eventsImportPath
});

// Reconcile against the current persisted entry, if any: preserves
// user enable/disable choices, defaults newly discovered entries to
// enabled, drops entries whose classes no longer exist upstream.
const current = get(toolboxes).find((t) => t.id === spec.id);
const config: ToolboxConfig = {
id: spec.id,
displayName: spec.displayName,
source: spec.source,
importPath: installResult.importPath,
eventsImportPath: spec.eventsImportPath,
installedVersion: installResult.installedVersion,
blocks: discovered.blocks.map(
(b) =>
current?.blocks.find((s) => s.className === b.className) ?? {
className: b.className,
enabled: true
}
),
events: discovered.events.map(
(e) =>
current?.events.find((s) => s.className === e.className) ?? {
className: e.className,
enabled: true
}
)
};

const catalog = getCatalogEntry(spec.id);
registerToolbox(config, {
blocks: discovered.blocks,
events: discovered.events,
defaultCategory: catalog?.defaultCategory,
categoryByClass: catalog?.categoryByClass
});
upsertToolbox(config);

return config;
})();

inFlight.set(spec.id, promise);
promise
.catch(() => {
// Error is propagated to the original awaiter; we only swallow
// here so the in-flight cleanup below doesn't trigger an
// unhandled rejection warning.
})
.finally(() => {
if (inFlight.get(spec.id) === promise) inFlight.delete(spec.id);
});

return promise;
}
Loading
Loading