diff --git a/sail-infra/src/main/java/ai/singlr/sail/commands/UpCommand.java b/sail-infra/src/main/java/ai/singlr/sail/commands/UpCommand.java index 4f65d106..0e608e71 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/commands/UpCommand.java +++ b/sail-infra/src/main/java/ai/singlr/sail/commands/UpCommand.java @@ -9,10 +9,12 @@ import ai.singlr.sail.engine.Banner; import ai.singlr.sail.engine.ContainerExec; import ai.singlr.sail.engine.ContainerManager; +import ai.singlr.sail.engine.ContainerSailSetup; import ai.singlr.sail.engine.ContainerState; import ai.singlr.sail.engine.HostDetector; import ai.singlr.sail.engine.NameValidator; import ai.singlr.sail.engine.ResourceChecker; +import ai.singlr.sail.engine.ShellExec; import ai.singlr.sail.engine.ShellExecutor; import java.util.LinkedHashMap; import picocli.CommandLine.Command; @@ -52,6 +54,7 @@ private void execute() throws Exception { switch (state) { case ContainerState.Running r -> { + reconcileSetup(shell, name); if (json) { printJson("running", r.ipv4()); return; @@ -68,6 +71,7 @@ private void execute() throws Exception { System.out.println(Ansi.AUTO.string(" @|bold Starting|@ " + name + "...")); } mgr.start(name); + reconcileSetup(shell, name); var newState = mgr.queryState(name); var ip = newState instanceof ContainerState.Running r ? r.ipv4() : null; if (json) { @@ -89,6 +93,21 @@ private void execute() throws Exception { } } + /** + * Reconciles the sail-owned surface — the socket bind mount and the helper scripts — every time a + * project comes up or is confirmed up, so a container that slept through the server's boot sweep + * still converges the moment anyone reaches for it. Idempotent and best-effort: a wedged + * container is a warning, never a failed start. + */ + static void reconcileSetup(ShellExec shell, String name) { + try { + ContainerSailSetup.ensureInstalled(shell, name); + } catch (Exception e) { + System.err.println( + " [up] Warning: could not refresh the sail helpers in " + name + ": " + e.getMessage()); + } + } + private void printJson(String status, String ip) { var map = new LinkedHashMap(); map.put("name", name); diff --git a/sail-infra/src/test/java/ai/singlr/sail/commands/UpCommandTest.java b/sail-infra/src/test/java/ai/singlr/sail/commands/UpCommandTest.java new file mode 100644 index 00000000..f0081b0b --- /dev/null +++ b/sail-infra/src/test/java/ai/singlr/sail/commands/UpCommandTest.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Standard Applied Intelligence Labs + * SPDX-License-Identifier: MIT + */ + +package ai.singlr.sail.commands; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.singlr.sail.engine.ScriptedShellExecutor; +import ai.singlr.sail.engine.ShellExec; +import org.junit.jupiter.api.Test; + +class UpCommandTest { + + @Test + void reconcileSetupRefreshesTheMountAndHelperScripts() { + var shell = new ScriptedShellExecutor(new ShellExec.Result(0, "", "")); + + UpCommand.reconcileSetup(shell, "acme"); + + var commands = String.join("\n", shell.invocations()); + assertTrue( + commands.contains("config device"), + "up must force-refresh the socket bind mount: " + commands); + assertTrue( + commands.contains("grep -qsF"), + "up must probe the helper scripts' staleness markers: " + commands); + } + + @Test + void reconcileSetupIsBestEffortAndNeverBlocksTheStart() { + var wedged = + new ScriptedShellExecutor(new ShellExec.Result(0, "", "")) + .onFail("device add acme", "container wedged"); + + assertDoesNotThrow(() -> UpCommand.reconcileSetup(wedged, "acme")); + } +}