From 7f1b6b3a0de2ffee71b55e6994b9370a50a9f143 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Wed, 15 Jul 2026 15:54:31 +0200 Subject: [PATCH] =?UTF-8?q?CB-111:=20multi-profile=20workers=20=E2=80=94?= =?UTF-8?q?=20named=20backends=20selectable=20at=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge was hard-wired to one worker profile. Config now takes a 'workers' map keyed by profile name plus 'defaultWorker'; WorkerService holds the map and gains spawn(profile) (spawn() uses the default). Selection threads through the surfaces: REST POST /workers ?profile= / {"profile":…} + GET /profiles; MCP bridge_spawn {profile?} + new bridge_profiles. Each profile's base_url is guard-checked independently, so gx10 and ollama can run side by side and you address each worker by its returned sessionId. Backward-compatible: the legacy singular 'worker:' block still loads as a one-entry profile map. --- bridged/bridged.example.yaml | 51 +++++++++------ .../main/java/dev/ltms/bridged/Bridged.java | 3 +- .../ltms/bridged/config/BridgedConfig.java | 56 +++++++++++++++-- .../java/dev/ltms/bridged/mcp/BridgeMcp.java | 35 +++++++++-- .../dev/ltms/bridged/rest/BridgedApp.java | 31 ++++++++- .../ltms/bridged/worker/WorkerService.java | 63 +++++++++++++++---- .../bridged/config/BridgedConfigTest.java | 38 +++++++++++ .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 27 +++++++- .../dev/ltms/bridged/rest/BridgedAppTest.java | 21 ++++++- .../bridged/worker/WorkerServiceTest.java | 30 ++++++++- 10 files changed, 303 insertions(+), 52 deletions(-) diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index e105ac2..ad898a2 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -12,28 +12,41 @@ bind: # (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}). herdrSocket: ~/.config/herdr/herdr.sock -# How a worker session is spawned. Stage-1 uses the existing ccs `ltms-local` -# profile, whose .claude.json routes to the gx00 vLLM below. -worker: - profile: ltms-local - baseUrl: http://gx00.gw:8000 # the gx00 vLLM (models: coder / deepseek-v4-flash) - model: coder - # Placement: each worker lands in its OWN tab inside a dedicated worker space, so it - # never splits or clutters your real work spaces. Use `pane` for the legacy behaviour - # (split the currently-focused tab). - placement: tab # tab | pane - workspace: bridged-workers # the dedicated worker space (found-or-created, shared) - tabLabel: "worker: {profile} #{n}" # {profile}/{model}/{n} substituted; {n} keeps sibling tabs distinct - # Step 4 — real worker: launch via ccs (profile sets CLAUDE_CONFIG_DIR + off-sub base_url; put - # `defaultMode: "auto"` in the profile so it runs autonomously). bridged appends the bridge MCP - # (--mcp-config, inline) + reply charter (--append-system-prompt) as flags — nothing is written - # to the profile. Omit mcpUrl (and use a bash argv) for a mechanics-only probe that never replies. - mcpUrl: http://127.0.0.1:8765/mcp - argv: ["ccs", "ltms-local"] +# How worker sessions are spawned. Define one or more named profiles (backends) under +# `workers`; each key is the profile name (also the ccs profile). `defaultWorker` picks +# which one a no-argument spawn uses (bridge_spawn with no profile / POST /workers). +# +# Shared knobs (placement/workspace/tabLabel) can be repeated per profile; they usually match. +# placement: tab → each worker lands in its OWN tab in a dedicated worker space (default). +# Use `pane` for the legacy behaviour (split the focused tab). +# mcpUrl → bridged mounts the bridge MCP (--mcp-config, inline) + reply charter +# (--append-system-prompt) as launch flags; nothing is written to the profile. +# tokenEnv → host env var holding the worker's auth token (value never stored in config); +# omit for a backend that needs no token (e.g. a local ollama). +# Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously. +workers: + gx10: # ccs profile name (NOT a hostname) + baseUrl: http://gx01.gw:8000 # the vLLM host this profile targets (gx00.gw / gx01.gw) + model: coder + placement: tab + workspace: bridged-workers + tabLabel: "worker: {profile} #{n}" # {profile}/{model}/{n} substituted; {n} keeps sibling tabs distinct + mcpUrl: http://127.0.0.1:8765/mcp + tokenEnv: BRIDGED_WORKER_TOKEN + argv: ["ccs", "gx10"] + ollama: + baseUrl: http://ollama.ltms.dev # local/self-hosted; usually no token + placement: tab + workspace: bridged-workers + tabLabel: "worker: {profile} #{n}" + mcpUrl: http://127.0.0.1:8765/mcp + argv: ["ccs", "ollama"] +defaultWorker: gx10 # Subscription boundary. A worker's base_url host MUST be one of these; the primary -# must carry none. Grounded in ltms-local's real endpoints. +# must carry none. Every profile above must have its host listed here. guard: offSubscriptionHosts: - gx00.gw + - gx01.gw - ollama.ltms.dev diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index fc1131f..212871b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -51,7 +51,8 @@ public final class Bridged { AgentControl agents = new AgentControl(herdr); WorkspaceControl spaces = new WorkspaceControl(herdr); - WorkerService workers = new WorkerService(agents, spaces, guard, cfg.worker(), System::getenv); + WorkerService workers = new WorkerService(agents, spaces, guard, + cfg.workerProfiles(), cfg.defaultProfile(), System::getenv); // Status-gated injector (CB-103): the single writer into workers, fed by a poller. // The blocking message endpoint (CB-104) is the producer; the poller is inert until then. diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 4c8a417..a66c5d0 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -8,7 +8,9 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -16,16 +18,21 @@ import java.util.Set; * {@code bridged.example.yaml}). Unknown keys are ignored so config can grow ahead * of the code. * - * @param bind REST/MCP listen host:port - * @param herdrSocket path to herdr's Unix socket ({@code null} → client default) - * @param worker worker-spawn settings - * @param guard subscription-boundary allowlist + * @param bind REST/MCP listen host:port + * @param herdrSocket path to herdr's Unix socket ({@code null} → client default) + * @param worker single worker profile (legacy; superseded by {@code workers}) + * @param workers named worker profiles, keyed by profile name (multi-backend fleet) + * @param defaultWorker which {@code workers} key a no-argument spawn uses ({@code null} → the + * single {@code worker}, or the sole/first profile) + * @param guard subscription-boundary allowlist */ @JsonIgnoreProperties(ignoreUnknown = true) public record BridgedConfig( Bind bind, String herdrSocket, Worker worker, + Map workers, + String defaultWorker, Guard guard) { @JsonIgnoreProperties(ignoreUnknown = true) @@ -69,6 +76,11 @@ public record BridgedConfig( tabLabel = (tabLabel == null || tabLabel.isBlank()) ? "worker: {profile} #{n}" : tabLabel; } + /** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */ + public Worker withProfile(String p) { + return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, mcpUrl); + } + /** True when workers should land in their own tab in the worker space. */ public boolean tabPlacement() { return "tab".equals(placement); @@ -108,6 +120,40 @@ public record BridgedConfig( } } + /** + * The effective worker profiles, keyed by profile name. Prefers the {@code workers} map (each + * value's {@code profile} defaulted to its key); falls back to the legacy singular {@code worker} + * (keyed by its own profile). Empty if neither is configured. + */ + public Map workerProfiles() { + if (workers != null && !workers.isEmpty()) { + Map out = new LinkedHashMap<>(); + workers.forEach((name, w) -> out.put(name, + (w.profile() == null || w.profile().isBlank()) ? w.withProfile(name) : w)); + return Map.copyOf(out); + } + if (worker != null) { + String name = (worker.profile() == null || worker.profile().isBlank()) ? "default" : worker.profile(); + return Map.of(name, worker); + } + return Map.of(); + } + + /** + * The profile a no-argument spawn uses: {@code defaultWorker} if set, else the legacy single + * {@code worker}'s profile, else the sole/first configured profile, else {@code null}. + */ + public String defaultProfile() { + if (defaultWorker != null && !defaultWorker.isBlank()) { + return defaultWorker; + } + if (worker != null && worker.profile() != null && !worker.profile().isBlank()) { + return worker.profile(); + } + Map p = workerProfiles(); + return p.isEmpty() ? null : p.keySet().iterator().next(); + } + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); /** Load and validate config from {@code path}. */ @@ -124,6 +170,6 @@ public record BridgedConfig( public BridgedConfig withDefaults() { Bind b = bind != null ? bind : new Bind(null, 0); Guard g = guard != null ? guard : new Guard(List.of()); - return new BridgedConfig(b, herdrSocket, worker, g); + return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g); } } diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index 714242b..96a303a 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -76,9 +76,10 @@ public final class BridgeMcp { .toolCall(pollTool(), (_, req) -> poll(messages, str(req.arguments(), "ticket"))) // Fleet management (CB-108): spawn/list/stop over WorkerService. - .toolCall(spawnTool(), (_, _) -> spawn(workers)) + .toolCall(spawnTool(), (_, req) -> spawn(workers, str(req.arguments(), "profile"))) .toolCall(listTool(), (_, _) -> listWorkers(workers)) .toolCall(stopTool(), (_, req) -> stop(workers, str(req.arguments(), "paneId"))) + .toolCall(profilesTool(), (_, _) -> profiles(workers)) .build(); } @@ -195,17 +196,30 @@ public final class BridgeMcp { // --- fleet management logic (CB-108) ------------------------------------------------------- - /** {@code bridge_spawn}: launch a guard-checked worker and return its session id + pane id. */ - static McpSchema.CallToolResult spawn(WorkerService workers) { + /** + * {@code bridge_spawn}: launch a guard-checked worker for {@code profile} (blank → the default + * profile) and return its session id + pane id. + */ + static McpSchema.CallToolResult spawn(WorkerService workers, String profile) { try { - return text(json(workerView(workers.spawn()))); + Agent worker = isBlank(profile) ? workers.spawn() : workers.spawn(profile); + return text(json(workerView(worker))); } catch (GuardException e) { return error("subscription boundary: " + e.getMessage()); + } catch (IllegalArgumentException e) { + return error(e.getMessage()); // unknown / no-default profile } catch (HerdrException e) { return error("herdr error spawning worker: " + e.getMessage()); } } + /** {@code bridge_profiles}: the configured worker profiles and the default. */ + static McpSchema.CallToolResult profiles(WorkerService workers) { + return text(json(Map.of( + "profiles", workers.profiles(), + "default", workers.defaultProfile() == null ? "" : workers.defaultProfile()))); + } + /** {@code bridge_list}: every worker herdr tracks (session id, pane, status). */ static McpSchema.CallToolResult listWorkers(WorkerService workers) { try { @@ -273,8 +287,17 @@ public final class BridgeMcp { private static McpSchema.Tool spawnTool() { return tool("bridge_spawn", - "Spawn a new off-subscription worker session for the configured profile. Returns its " - + "sessionId (use with bridge_send) and paneId (use with bridge_stop).", + "Spawn a new off-subscription worker session. Pass a profile (from bridge_profiles) to " + + "pick the backend, or omit it for the default. Returns the worker's sessionId " + + "(use with bridge_send) and paneId (use with bridge_stop).", + objectSchema(Map.of( + "profile", stringProp("Worker profile to spawn (omit for the default profile)")), + List.of())); + } + + private static McpSchema.Tool profilesTool() { + return tool("bridge_profiles", + "List the configured worker profiles (backends) and which one bridge_spawn uses by default.", objectSchema(Map.of(), List.of())); } diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index c224d22..3871f2f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -63,7 +63,8 @@ public final class BridgedApp { app.get("/healthz", this::healthz); app.get("/sessions", this::sessions); app.get("/agents", this::agents); - app.post("/workers", this::spawnWorker); + app.get("/profiles", this::profiles); // configured worker profiles + app.post("/workers", this::spawnWorker); // optional ?profile= or {"profile":…} app.delete("/workers/{paneId}", this::stopWorker); app.post("/sessions/{id}/message", this::sendMessage); // bridge_send (primary; blocking or wait:false) app.post("/sessions/{id}/reply", this::replyMessage); // bridge_reply (worker) @@ -109,13 +110,37 @@ public final class BridgedApp { ctx.status(200).json(Map.of("agents", workers.list().stream().map(BridgedApp::view).toList())); } - /** Spawn a guard-checked worker. 403 if the base_url would breach the subscription boundary. */ + /** The configured worker profiles and which one a no-argument spawn uses. */ + private void profiles(Context ctx) { + ctx.status(200).json(Map.of( + "profiles", workers.profiles(), + "default", workers.defaultProfile() == null ? "" : workers.defaultProfile())); + } + + /** + * Spawn a guard-checked worker. An optional {@code profile} (query param or {@code {"profile":…}} + * body) picks which configured profile; omitted → the default. 403 if the base_url would breach + * the subscription boundary, 400 for an unknown profile. + */ private void spawnWorker(Context ctx) { + String profile = ctx.queryParam("profile"); + if (profile == null || profile.isBlank()) { + try { + String body = ctx.body(); + if (!body.isBlank()) { + profile = mapper.readTree(body).path("profile").asText(null); + } + } catch (Exception ignored) { + // A malformed/empty body just means "no profile" → fall through to the default. + } + } try { - Agent worker = workers.spawn(); + Agent worker = (profile == null || profile.isBlank()) ? workers.spawn() : workers.spawn(profile); ctx.status(201).json(view(worker)); } catch (GuardException e) { ctx.status(403).json(Map.of("error", "subscription_boundary", "detail", e.getMessage())); + } catch (IllegalArgumentException e) { + ctx.status(400).json(Map.of("error", "unknown_profile", "detail", e.getMessage())); } } diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java index 0839578..3cf74c1 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -44,7 +45,8 @@ public final class WorkerService { private final AgentControl agents; private final WorkspaceControl spaces; private final SubscriptionGuard guard; - private final BridgedConfig.Worker cfg; + private final Map profiles; // profile name → spawn settings + private final String defaultProfile; // profile a no-arg spawn uses (nullable) private final Function env; // host env lookup (injectable for tests) private final AtomicLong nameSeq = new AtomicLong(); // per-worker counter (also the tab #) @@ -64,16 +66,42 @@ public final class WorkerService { private final String nameNonce = String.format("%06x", new SecureRandom().nextInt(1 << 24)); public WorkerService(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard, - BridgedConfig.Worker cfg, Function env) { + Map profiles, String defaultProfile, + Function env) { this.agents = agents; this.spaces = spaces; this.guard = guard; - this.cfg = cfg; + this.profiles = Map.copyOf(profiles); + this.defaultProfile = defaultProfile; this.env = env; } - /** Spawn a worker for the configured profile. Guard runs before any herdr call. */ + /** The configured worker profile names (what {@code spawn(profile)} accepts). */ + public Set profiles() { + return profiles.keySet(); + } + + /** The profile a no-argument {@link #spawn()} uses, or {@code null} if none is configured. */ + public String defaultProfile() { + return defaultProfile; + } + + /** Spawn a worker for the default profile. Guard runs before any herdr call. */ public Agent spawn() { + if (defaultProfile == null || defaultProfile.isBlank()) { + throw new IllegalArgumentException("no default worker profile is configured — " + + "pass a profile; configured: " + profiles.keySet()); + } + return spawn(defaultProfile); + } + + /** Spawn a worker for a named profile. Guard runs before any herdr call. */ + public Agent spawn(String profileName) { + BridgedConfig.Worker cfg = profiles.get(profileName); + if (cfg == null) { + throw new IllegalArgumentException("unknown worker profile '" + profileName + + "' — configured: " + profiles.keySet()); + } String baseUrl = cfg.baseUrl(); guard.assertWorker(baseUrl); // hard stop before we spawn anything @@ -86,9 +114,9 @@ public final class WorkerService { // Mount the bridge MCP + reply charter as launch FLAGS (non-invasive: nothing written to // the worker's profile/config dir). Identity is connection-based, so the mount is shared. - List argv = argvWithBridge(); + List argv = argvWithBridge(cfg); - return cfg.tabPlacement() ? spawnInTab(workerEnv, argv) : spawnAsPane(workerEnv, argv); + return cfg.tabPlacement() ? spawnInTab(cfg, workerEnv, argv) : spawnAsPane(cfg, workerEnv, argv); } /** @@ -96,7 +124,7 @@ public final class WorkerService { * the bridge server and {@code --append-system-prompt} for the {@link #REPLY_CHARTER}. Neither * touches the profile's config; both are pure command-line flags. */ - private List argvWithBridge() { + private List argvWithBridge(BridgedConfig.Worker cfg) { if (!cfg.hasMcp()) { return cfg.argv(); } @@ -111,7 +139,7 @@ public final class WorkerService { } /** Dedicated worker space → own tab → drop the placeholder shell so only the worker remains. */ - private Agent spawnInTab(Map workerEnv, List argv) { + private Agent spawnInTab(BridgedConfig.Worker cfg, Map workerEnv, List argv) { Workspace space = spaces.ensureWorkspace(cfg.workspace()); Tab.Created tab = spaces.createTab(space.workspaceId()); log.info("spawning worker profile={} base_url={} space={} tab={}", @@ -119,7 +147,7 @@ public final class WorkerService { Started started; try { - started = startUniquelyNamed(workerEnv, argv, tab.tab().tabId()); + started = startUniquelyNamed(cfg, workerEnv, argv, tab.tab().tabId()); } catch (RuntimeException e) { // The worker never started — don't leave the tab we just created orphaned. // Best-effort cleanup; never let it mask the real spawn failure. @@ -159,10 +187,10 @@ public final class WorkerService { } /** Legacy placement: herdr splits the currently-focused tab. */ - private Agent spawnAsPane(Map workerEnv, List argv) { + private Agent spawnAsPane(BridgedConfig.Worker cfg, Map workerEnv, List argv) { log.info("spawning worker (pane placement) profile={} base_url={} argv={}", cfg.profile(), cfg.baseUrl(), argv); - Agent worker = startUniquelyNamed(workerEnv, argv, null).agent(); + Agent worker = startUniquelyNamed(cfg, workerEnv, argv, null).agent(); log.info("worker started pane={} terminal={}", worker.paneId(), worker.terminalId()); return worker; } @@ -181,7 +209,8 @@ public final class WorkerService { * retry is a belt-and-braces backstop for the astronomically unlikely nonce+seq clash; * the name is a label only — herdr detects kind and status from terminal output, not it. */ - private Started startUniquelyNamed(Map workerEnv, List argv, String tabId) { + private Started startUniquelyNamed(BridgedConfig.Worker cfg, Map workerEnv, + List argv, String tabId) { HerdrException last = null; for (int attempt = 0; attempt < NAME_RETRIES; attempt++) { long seq = nameSeq.incrementAndGet(); @@ -214,7 +243,10 @@ public final class WorkerService { * a genuinely failed teardown is not reported as done. */ public void stop(String paneId) { - WorkspaceControl.PaneLocation loc = cfg.tabPlacement() ? spaces.locatePane(paneId) : null; + // Teardown knows only the paneId, not which profile spawned it. Attempt tab cleanup when any + // profile uses tab placement (so the bridge may have created a dedicated worker tab); the + // single-occupant check below is what actually protects the user's shared tabs. + WorkspaceControl.PaneLocation loc = usesTabPlacement() ? spaces.locatePane(paneId) : null; try { agents.close(paneId); } catch (HerdrException e) { @@ -229,6 +261,11 @@ public final class WorkerService { } } + /** Whether any configured profile places workers in their own tab (so tabs may need cleanup). */ + private boolean usesTabPlacement() { + return profiles.values().stream().anyMatch(BridgedConfig.Worker::tabPlacement); + } + /** True when a herdr error means the target is already gone (safe to treat as done). */ private static boolean isAlreadyGone(HerdrException e) { return e.code() != null && e.code().endsWith("_not_found"); diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index ce1644e..f798eab 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -46,6 +47,43 @@ class BridgedConfigTest { assertTrue(cfg.guard().offSubscriptionHosts().isEmpty()); } + @Test + void singleWorkerBecomesAOneEntryProfileMapWithItselfAsDefault(@TempDir Path dir) throws Exception { + Path f = dir.resolve("single.yaml"); + Files.writeString(f, """ + worker: + profile: ltms-local + baseUrl: http://gx00.gw:8000 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(Set.of("ltms-local"), cfg.workerProfiles().keySet(), "legacy worker → one profile"); + assertEquals("ltms-local", cfg.defaultProfile()); + } + + @Test + void loadsMultipleWorkerProfilesWithADefault(@TempDir Path dir) throws Exception { + Path f = dir.resolve("multi.yaml"); + Files.writeString(f, """ + workers: + gx10: + baseUrl: http://gx10.gw:8000 + argv: ["ccs", "gx10"] + ollama: + baseUrl: http://ollama.ltms.dev + argv: ["ccs", "ollama"] + defaultWorker: gx10 + guard: + offSubscriptionHosts: [gx10.gw, ollama.ltms.dev] + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(Set.of("gx10", "ollama"), cfg.workerProfiles().keySet()); + assertEquals("gx10", cfg.defaultProfile()); + assertEquals("ollama", cfg.workerProfiles().get("ollama").profile(), "profile defaults to its map key"); + assertEquals("http://gx10.gw:8000", cfg.workerProfiles().get("gx10").baseUrl()); + } + @Test void ignoresUnknownKeys(@TempDir Path dir) throws Exception { Path f = dir.resolve("future.yaml"); diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index b216db1..22c9ef6 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -12,6 +12,7 @@ import dev.ltms.bridged.worker.WorkerService; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.Test; +import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -39,7 +40,7 @@ class BridgeMcpTest { "ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null); return new WorkerService(new AgentControl(h), new WorkspaceControl(h), - new SubscriptionGuard(allow), cfg, _ -> "tok"); + new SubscriptionGuard(allow), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> "tok"); } @Test @@ -122,7 +123,7 @@ class BridgeMcpTest { @Test void spawnReturnsTheNewWorkersSessionAndPane() { FakeHerdr h = new FakeHerdr(); - McpSchema.CallToolResult res = BridgeMcp.spawn(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + McpSchema.CallToolResult res = BridgeMcp.spawn(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null); assertNotEquals(Boolean.TRUE, res.isError()); String out = textOf(res); assertTrue(out.contains("\"sessionId\":\"term_new\""), out); @@ -132,12 +133,32 @@ class BridgeMcpTest { @Test void spawnRejectsAnOffAllowlistProfileWithoutTouchingHerdr() { FakeHerdr h = new FakeHerdr(); - McpSchema.CallToolResult res = BridgeMcp.spawn(workerService(h, "https://api.anthropic.com", Set.of("gx00.gw"))); + McpSchema.CallToolResult res = + BridgeMcp.spawn(workerService(h, "https://api.anthropic.com", Set.of("gx00.gw")), null); assertTrue(res.isError()); assertTrue(textOf(res).contains("subscription boundary")); assertFalse(h.called("agent.start"), "the guard must block before any spawn"); } + @Test + void spawnRejectsAnUnknownProfileAsAnError() { + FakeHerdr h = new FakeHerdr(); + McpSchema.CallToolResult res = + BridgeMcp.spawn(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "nope"); + assertTrue(res.isError()); + assertTrue(textOf(res).contains("unknown worker profile"), textOf(res)); + } + + @Test + void profilesListsConfiguredProfilesAndDefault() { + FakeHerdr h = new FakeHerdr(); + McpSchema.CallToolResult res = BridgeMcp.profiles(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + assertNotEquals(Boolean.TRUE, res.isError()); + String out = textOf(res); + assertTrue(out.contains("ltms-local"), out); + assertTrue(out.contains("\"default\":\"ltms-local\""), out); + } + @Test void listReportsTrackedWorkers() { FakeHerdr h = new FakeHerdr(); diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index e204de8..fc1563a 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -55,7 +55,8 @@ class BridgedAppTest { placement, "bridged-workers", "worker: {profile} #{n}", null); AgentControl agents = new AgentControl(herdr); WorkerService workers = new WorkerService( - agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow), wcfg, + agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow), + Map.of(wcfg.profile(), wcfg), wcfg.profile(), k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null); Injector injector = new Injector(agents); poller = new StatusPoller(agents, injector, 5); // delivers when the fake reports idle @@ -162,6 +163,24 @@ class BridgedAppTest { "tab label carries the worker number so siblings stay distinct"); } + @Test + void profilesEndpointListsConfiguredProfilesAndDefault() throws Exception { + int port = startHealthy(); + JsonNode body = mapper.readTree(req(port, "GET", "/profiles").body()); + assertEquals("ltms-local", body.get("default").asText()); + assertEquals("ltms-local", body.get("profiles").get(0).asText()); + } + + @Test + void spawnWithAnUnknownProfileIs400() throws Exception { + FakeHerdr herdr = new FakeHerdr(); + int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw")); + HttpResponse res = req(port, "POST", "/workers?profile=nope"); + assertEquals(400, res.statusCode()); + assertEquals("unknown_profile", mapper.readTree(res.body()).get("error").asText()); + assertFalse(herdr.called("agent.start"), "an unknown profile must not spawn anything"); + } + @Test void spawnWorkerReusesExistingWorkerSpace() throws Exception { // A space labelled "bridged-workers" already exists → no second workspace.create. diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java index b34703c..8c2574e 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java @@ -21,7 +21,7 @@ class WorkerServiceTest { "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl); return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), - new SubscriptionGuard(Set.of("gx00.gw")), cfg, _ -> null); + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); } @SuppressWarnings("unchecked") @@ -49,4 +49,32 @@ class WorkerServiceTest { service(herdr, List.of("bash", "-c", "sleep 1"), null).spawn(); assertEquals(List.of("bash", "-c", "sleep 1"), spawnedArgv(herdr), "argv untouched without mcpUrl"); } + + private WorkerService multiProfile(FakeHerdr herdr) { + BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder", + null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "gx10"), "tab", "bridged-workers", "w #{n}", null); + BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null, + null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ollama"), "tab", "bridged-workers", "w #{n}", null); + return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")), + Map.of("gx10", gx10, "ollama", ollama), "gx10", _ -> "tok"); + } + + @Test + @SuppressWarnings("unchecked") + void spawnPicksTheNamedProfilesBaseUrlAndArgv() { + FakeHerdr herdr = new FakeHerdr(); + multiProfile(herdr).spawn("ollama"); + + Map start = (Map) herdr.lastCall("agent.start").params(); + Map env = (Map) start.get("env"); + assertEquals("http://ollama.ltms.dev", env.get("ANTHROPIC_BASE_URL"), "the named profile's base_url"); + assertEquals(List.of("ccs", "ollama"), start.get("argv"), "the named profile's launch command"); + } + + @Test + void spawnRejectsAnUnknownProfile() { + FakeHerdr herdr = new FakeHerdr(); + assertThrows(IllegalArgumentException.class, () -> multiProfile(herdr).spawn("nope")); + } }