BundleLibraryController

class BundleLibraryController(onBundlesChanged: () -> Unit = {}) : AutoCloseable(source)

Substrate-level bundle-library bookkeeping shared by every configuration-shaped app that supports interactive bundle loading: the list of LoadedBundles currently in scope, the BundleModelProvider adapter that exposes them as a single picker source, and the load-jar mutator that grows that set while de-duplicating by bundleId and reloading a JAR in place when the same path is re-loaded with changed content.

Pre-decomposition, Scenario / Experiment / Simopt each reimplemented this with byte-identical state + a byte-identical loadBundleJar body + a byte-identical nested sealed class LoadBundleResult. This type captures that shared shape so each app controller composes one instance instead of carrying ~30 LOC of duplicate plumbing.

What this owns

  • The loadedBundles StateFlow — the set in scope, populated by discoverFromClasspath and loadJar; a reload replaces a JAR's prior entries in place.

  • The bundleProvider StateFlow — a BundleModelProvider wrapping the current set, or null when no bundles are loaded.

  • discoverFromClasspath — one-shot probe of the JVM classpath via BundleLoader.loadFromClasspath; hosts call once in init.

  • loadJar — JAR-load with de-duplication, in-place reload of a rebuilt JAR, and deferred close of any bundles it displaces.

  • findBundlebundleId-keyed lookup helper.

  • close — closes every loaded and retired bundle's classloader and resets to empty; safe to call when empty and safe to call twice.

What this deliberately does NOT own

  • Host-side fan-out on bundle changes. Experiment and Simopt re-resolve `currentModelDescriptor` whenever the loaded set grows (a previously-unresolvable ModelReference.ByBundleAndModelId may now resolve). The substrate exposes this via the onBundlesChanged constructor callback rather than a built-in coroutine collector — hosts pass ::refreshModelDescriptor (or any other side-effect lambda) at construction time. Scenario passes the default no-op.

  • The host's existing loadBundleJar public method. Hosts typically forward through to loadJar to keep their existing public surface stable, but the substrate doesn't dictate the method name.

  • Host lifecycle. The substrate's close handles bundle cleanup only; hosts integrate it into their broader AutoCloseable.close() (run-handle cancellation, session cleanup, scope cancellation).

Semantics of the four mutators

MethodEffectTypical caller
discoverFromClasspathappend classpath bundles (if any)host init {}
loadJarappend / reload a JAR's bundles (dedup by bundleId; reload by sourceJar)host's loadBundleJar
findBundleread-only lookuphost descriptor resolution
closeclose every loaded + retired bundle, reset to emptyhost close()

Substrate-level API — usable by any UI shell. Owns no coroutine scope and no background work; the only async-shaped contract is the onBundlesChanged callback which is invoked synchronously inside the mutator that grew the set.

Not thread-safe: it assumes single-threaded (typically EDT-confined) mutation, matching how the four Swing apps drive it. A future multi-threaded host would need to serialize calls externally.

Parameters

onBundlesChanged

invoked after every mutation that changes the loaded set (discoverFromClasspath's successful add, and loadJar's Loaded and Reloaded outcomes). NOT invoked when loadJar returns AlreadyLoaded, NoBundles, or Failed. Defaults to a no-op.

Constructors

Link copied to clipboard
constructor(onBundlesChanged: () -> Unit = {})

Types

Link copied to clipboard
sealed class LoadBundleResult

Outcome of loadJar.

Properties

Link copied to clipboard

Adapter exposing every loaded bundle as a single BundleModelProvider. null whenever loadedBundles is empty; non-null and rebuilt on every successful add.

Link copied to clipboard

Same-(bundleId, version) duplicates that newest-wins dedup dropped from loadedBundles (a copy built more recently won). Surfaced passively — the Loaded Bundles dialog lists these so a user can see redundant JARs were collapsed and which one is active — never as a startup interruption. Empty when no (bundleId, version) is loaded more than once.

Link copied to clipboard

All bundles currently in scope — discovered (classpath / ~/.ksl/bundles/) + every JAR successfully loaded via loadJar, newest-wins-deduped so each (bundleId, version) appears once: the most recently built copy stays, the rest move to ignoredCopies. Apart from that dedup, bundles are removed only by a reload that replaces a whole JAR's prior entries (keyed on sourceJar, the atomic classloader unit) or by close.

Functions

Link copied to clipboard
open override fun close()

Close every loaded and retired bundle's classloader and reset the controller to empty. Wraps each close in runCatching so a single failure does not skip the remainder. Safe when empty; safe to call twice (the second call sees empty lists and is a no-op). After this call loadedBundles is empty and bundleProvider is null, so no consumer can read a stale, closed bundle.

Link copied to clipboard

Discover bundles already on the JVM classpath via BundleLoader.loadFromClasspath and append them to loadedBundles. Typically called once from the host's init {} block so a packaged app immediately shows available models in the picker.

Link copied to clipboard

Discover bundles the user has installed into ~/.ksl/bundles/ via BundleLoader.loadDirectory and append them to loadedBundles. The directory is created if it does not yet exist, giving users a well-known place to drop bundle JARs (e.g. the KSL Book Examples bundle). Typically called once from the host's init {} block in place of discoverFromClasspath, so a released app ships with no baked-in bundles yet still loads whatever the user installed.

Link copied to clipboard
fun findBundle(bundleId: String): LoadedBundle?

Look up a loaded bundle by bundleId. Returns null when no loaded bundle carries that id.

Link copied to clipboard

Load every KSLModelBundle from the JAR at jarPath. The outcome depends on whether that path — and that path's content — is already loaded: