diff --git a/CLAUDE.md b/CLAUDE.md index f80c619..8286ea7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Spawn is a Java 25 framework for programmatically launching and controlling processes, JVMs, and Docker containers. It provides a unified abstraction (`Platform` / `Application` / `Process`) over different execution environments. The core pattern: define a `Specification`, call `platform.launch(spec)`, get back an `Application` with `CompletableFuture`-based lifecycle hooks. -**Stack**: Java 25, Maven, Jackson, `build.base.*` and `build.codemodel.injection` +**Stack**: Java 25, Maven, `build.base.*` (incl. `build.base.json`) and `build.codemodel.injection` **Structure**: 8 Maven modules in a monorepo, each mapping to a JPMS module: - `spawn-option` → shared option types @@ -31,5 +31,5 @@ Tests requiring Docker are gated by `@EnabledIf("isDockerAvailable")`. The `spaw - All option types are immutable with static `of(...)` factories and `@Default` annotated defaults - `Customizer` inner classes on `Application` interfaces are auto-discovered and applied at launch -- Launcher registry: `META-INF/` properties files map `Application=Launcher` +- Launcher registry: JPMS `ServiceLoader` — modules declare `provides LauncherRegistration with ...` in `module-info.java` - Checkstyle enforced: no tabs, no star imports, final locals, no asserts, braces required diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 30df31c..31fe474 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -1,12 +1,12 @@ --- -last_mapped: 2026-04-12T00:00:00Z -total_files: 233 -total_tokens: ~180000 +last_mapped: 2026-05-04T00:00:00Z +total_files: 236 +total_tokens: ~193000 --- # Codebase Map -> Auto-generated by Cartographer. Last mapped: 2026-04-12 +> Auto-generated by Cartographer. Last mapped: 2026-05-04 ## System Overview @@ -87,7 +87,7 @@ sequenceDiagram participant Application User->>Platform: launch(Specification) - Platform->>Platform: Discover Launcher via META-INF registry + Platform->>Platform: Discover Launcher via ServiceLoader Platform->>Launcher: launch(platform, appClass, config) Launcher->>Launcher: Customizer.onPreparing() loop Launcher->>Launcher: Customizer.onLaunching() @@ -151,9 +151,10 @@ spawn.build/ | `Customizer.java` | Lifecycle observer stored as an `Option`; `onPreparing/onLaunching/onLaunched/onStart/onTerminated` | | `Launcher.java` | Functional interface; performs platform-specific launch work | | `Machine.java` | `Platform` that is also `Addressable`; `workingDirectory()`, `temporaryDirectory()` | -| `AbstractTemplatedPlatform.java` | META-INF registry discovery, expression resolution, customizer auto-discovery, cascading preparation loop | -| `AbstractTemplatedLauncher.java` | Full launch orchestration: facet assembly, argument conversion, diagnostics tabulation | -| `AbstractApplication.java` | Wires process I/O to console via Pipes; registers customizer callbacks; `@Inject Iterable>` | +| `LauncherRegistration.java` | SPI interface: maps `(platformClass, applicationClass) → launcherClass`; discovered via `ServiceLoader` | +| `AbstractTemplatedPlatform.java` | `ServiceLoader` discovery at construction; expression resolution; customizer auto-discovery; `min()` launcher selection by most-specific Application subtype | +| `AbstractTemplatedLauncher.java` | Full launch orchestration: facet assembly, argument conversion, diagnostics tabulation; DI via `bind(class).to(instance)`, `bindSet`, `addResolver` | +| `AbstractApplication.java` | Wires process I/O to console via Pipes; registers customizer callbacks; `@Inject Iterable> lifecycles = List.of()` (defaults to empty — safe without DI) | | `Console.java` | stdin/stdout/stderr abstraction; `Console.Supplier` option selects implementation | | `facet/Faceted.java` | JDK proxy implementing multiple unrelated interfaces simultaneously | | `facet/FacetedInvocationHandler.java` | Dispatch: primary map + lazy superinterface map + `Faceted.as()` escape hatch | @@ -162,6 +163,7 @@ spawn.build/ **Exports:** `build.spawn.application`, `.console`, `.facet`, `.option` **Dependencies:** `spawn-option`, `build.base.*`, `build.codemodel.injection`, `jakarta.inject` +**ServiceLoader SPI:** `uses LauncherRegistration` — modules contribute launcher registrations with `provides LauncherRegistration with ...` in their `module-info.java` **Option type pattern** (applies everywhere): - Private constructor + `static of(...)` factory @@ -251,7 +253,7 @@ Server (listens on spawn:// URI) | `LocalMachine.java` | Singleton `Machine`; extends `AbstractTemplatedPlatform`; PID from `RuntimeMXBean.getName()` | | `LocalProcess.java` | Wraps `java.lang.Process`; virtual thread watcher; `suspend/resume` via `kill -STOP/-CONT` | | `LocalLauncher.java` | `ProcessBuilder` invocation; sets env vars, working dir, arguments; double-quotes paths containing spaces | -| `META-INF/build.spawn.platform.local.LocalMachine` | Registry: `Application=LocalLauncher` | +| `LocalLauncherRegistration.java` | `LauncherRegistration` record: maps `LocalMachine + Application → LocalLauncher`; declared in `module-info.java` with `provides` | **`suspend()`/`resume()`:** Use POSIX signals — only work on Unix/Linux/macOS. **`shutdown()`:** SIGTERM (`process.destroy()`). **`destroy()`:** SIGKILL (`process.destroyForcibly()`). @@ -273,7 +275,7 @@ Server (listens on spawn:// URI) | `JDKHomeBasedPatternDetector.java` | Native JPMS service impl (registered via `module-info.java` `provides…with`); reads `java.home.properties` OS-specific globs; caches result in `AtomicReference` | | `LocalJDKLauncher.java` | Builds `java` command line including SpawnAgent injection; discovers or creates `spawn-agent.jar` | | `java.home.properties` | OS-keyed glob patterns: `mac@*`, `unix@*` entries for JDK installation directories | -| `META-INF/build.spawn.platform.local.LocalMachine` | Registry: `JDKApplication=LocalJDKLauncher` | +| `LocalJDKLauncherRegistration.java` | `LauncherRegistration` record: maps `LocalMachine + JDKApplication → LocalJDKLauncher`; declared in `module-info.java` with `provides` | **JDK detection two-phase approach (post commit `b7f1f96`):** 1. `paths()` — cheap: expand OS-specific globs, walk filesystem, return matching directory paths (no subprocess) @@ -314,7 +316,7 @@ Server (listens on spawn:// URI) | `DockerFileBuilder.java` | Fluent Dockerfile content builder | | `DockerContextBuilder.java` | Builds Docker build context tarball (extends `AbstractTarBuilder`) | -**Docker option types** — all implement `DockerOption.configure(ObjectNode, ObjectMapper)`: +**Docker option types** — `DockerOption` is a **sealed interface** (`permits Bind, Command, ExposedPort, ExtraHost, Link, PublishAllPorts, PublishPort`). Options carry data only; serialization into JSON is handled entirely by the command classes in `spawn-docker-jdk` via pattern-matching switch, keeping JSON libraries out of the public API: | Option | Docker JSON field | Notes | |--------|------------------|-------| | `ImageName` | (image reference) | Handles tags, SHA256 refs, registry prefixing | @@ -334,7 +336,7 @@ Server (listens on spawn:// URI) ### `spawn-docker-jdk` -**Purpose:** JDK-native concrete implementation of `spawn-docker` interfaces. Uses `java.net.http.HttpClient` for TCP and `java.nio.channels.SocketChannel` with `UnixDomainSocketAddress` for Unix domain sockets. No third-party HTTP dependencies. +**Purpose:** JDK-native concrete implementation of `spawn-docker` interfaces. Uses `java.net.http.HttpClient` for TCP and `java.nio.channels.SocketChannel` with `UnixDomainSocketAddress` for Unix domain sockets. JSON handled by `build.base.json` (base-json). No third-party dependencies. **Entry point:** Four `Session.Factory` implementations discovered via `ServiceLoader`, in priority order: 1. `UnixDomainSocketBasedSession.Factory` — Unix socket (`/var/run/docker.sock` or Docker Desktop socket) 2. `LocalHostBasedSessionFactory` — TCP `localhost:2375` @@ -359,7 +361,7 @@ Server (listens on spawn:// URI) | `event/GetSystemEvents.java` | Streaming JSON parser; publishes `ActionEvent`; virtual thread | | `model/DockerContainer.java` | Full `Container` impl; `@PostInject` wires event subscription for `onStart/onExit` | | `model/DockerImage.java` | `Image` impl; `start()` creates then starts container; auto-removes on start failure | -| `model/AbstractJsonBasedResult.java` | DI-injected `Session`, `JsonNode`, `ObjectMapper` for all model classes | +| `model/AbstractJsonBasedResult.java` | DI-injected `Session` + `JsonValue` (base-json); `at(keys)` / `text(keys)` / `intAt` / `boolAt` navigation helpers | **Known bugs:** - `GetSystemEvents` and `DockerContainer` have debug `System.out.println` calls in production code @@ -377,7 +379,11 @@ Server (listens on spawn:// URI) - Braces required on all blocks ### Launcher Registry Pattern -Platform classes have a `META-INF/` properties file on the classpath. Each line: `ApplicationClass=LauncherClass`. Multiple modules can contribute to the same registry file. `AbstractTemplatedPlatform` discovers launchers by reading all matching resources and selecting the most-specific `Application` supertype match (NOT `findFirst()` — avoids classpath-order bugs). +Launcher registration uses JPMS `ServiceLoader`. Each module that provides a launcher declares a `LauncherRegistration` implementation (typically a `record`) and registers it in `module-info.java`: +``` +provides build.spawn.application.LauncherRegistration with com.example.MyLauncherRegistration; +``` +`AbstractTemplatedPlatform` loads all `LauncherRegistration` providers at construction time and selects the most-specific `Application` subtype match using `min()` with class-hierarchy ordering — NOT `findFirst()`, which would produce silent order-dependent failures if multiple modules are on the classpath. ### Nested Class Conventions - `Application.Implementation` (or `JDKApplication.Implementation`) — public static concrete class found by `Application.getImplementationClass()` via reflection @@ -393,7 +399,7 @@ Tests requiring Docker are gated by `@EnabledIf("isDockerAvailable")` — they w The returned `Application` from `launch()` is a JDK `Proxy` (`Faceted` implementation), NOT a direct instance of `Application.Implementation`. `instanceof Application.Implementation` will always return `false`. Use interface types or `Faceted.as(Class)`. ### `Iterable> lifecycles` in `AbstractApplication` -This is `@Inject`-annotated. If an `AbstractApplication` subclass is created outside the DI framework, `lifecycles` will be `null` and `onStart()` will throw `NullPointerException`. +This field is `@Inject`-annotated but also initialized to `List.of()` as a default. Subclasses created outside the DI framework will have an empty lifecycle list rather than null — `onStart()` is safe. ### Default Environment Variables `@Default` on `EnvironmentVariables.none()` means child processes inherit NO environment variables by default. Add `EnvironmentVariables.inherited()` explicitly to pass through the parent JVM's environment. @@ -431,11 +437,14 @@ Child JDK processes are killed when the parent JVM exits by default. Use `Orphan 1. Create interface extending `Application` (or `JDKApplication`) 2. Add nested `public static class Implementation extends AbstractApplication` 3. Optionally add `public static class Customizer implements Customizer` for defaults -4. Register a `Launcher` in `META-INF/`: `MyApp=MyLauncher` -- Files: `Application.java`, `AbstractApplication.java`, `Launcher.java`, `AbstractTemplatedLauncher.java` +4. Create a `LauncherRegistration` record linking your platform, application class, and launcher class +5. Declare `provides build.spawn.application.LauncherRegistration with MyLauncherRegistration` in your `module-info.java` +- Files: `Application.java`, `AbstractApplication.java`, `Launcher.java`, `AbstractTemplatedLauncher.java`, `LauncherRegistration.java` **To add a Docker option:** -- Implement `DockerOption.configure(ObjectNode, ObjectMapper)` in a new class +- Add a new class to `spawn-docker/src/main/java/build/spawn/docker/option/` implementing `DockerOption` (data only — no JSON methods) +- Add the new type to `DockerOption`'s `permits` clause (sealed interface) +- Handle the new type in the relevant command class in `spawn-docker-jdk` (pattern-matching switch over `DockerOption`) - Implement `CollectedOption` if multiple instances should accumulate - Files: `DockerOption.java`, existing option classes in `spawn-docker/src/main/java/build/spawn/docker/option/`