From 84102baab425fdaa21d630b76d14abae4f958c83 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 17 Jul 2026 09:50:12 +0200 Subject: [PATCH] CB-304: bridge_list roster + live herdr join (worktree/branch); add GET /workers --- .../java/dev/ltms/bridged/mcp/BridgeMcp.java | 27 +++++++------- .../dev/ltms/bridged/rest/BridgedApp.java | 14 ++++++++ .../ltms/bridged/session/SessionManager.java | 25 +++++++++++++ .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 21 +++++++++-- .../dev/ltms/bridged/rest/BridgedAppTest.java | 35 ++++++++++++++++++- 5 files changed, 105 insertions(+), 17 deletions(-) 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 cdfe69b..56df99b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -24,6 +24,8 @@ import jakarta.servlet.http.HttpServlet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; /** * The MCP SERVER face (CB-105): a Streamable-HTTP MCP server whose tools are thin adapters @@ -111,7 +113,7 @@ public final class BridgeMcp { return spawn(sessions, str(a, "profile"), str(a, "cwd"), callerCwd, callerTerminal(exchange), worktreeRequest(a)); }) - .toolCall(listTool(), (_, _) -> listWorkers(workers)) + .toolCall(listTool(), (_, _) -> listWorkers(workers, sessions)) .toolCall(stopTool(), (_, req) -> stop(sessions, str(req.arguments(), "paneId"))) .toolCall(profilesTool(), (_, _) -> profiles(workers)) .build(); @@ -346,10 +348,15 @@ public final class BridgeMcp { "default", workers.defaultProfile() == null ? "" : workers.defaultProfile()))); } - /** {@code bridge_list}: every worker herdr tracks (session id, pane, status). */ - static McpSchema.CallToolResult listWorkers(WorkerService workers) { + /** {@code bridge_list}: bridge-owned roster merged with live herdr status by paneId. */ + static McpSchema.CallToolResult listWorkers(WorkerService workers, SessionManager sessions) { try { - List> out = workers.list().stream().map(BridgeMcp::workerView).toList(); + Map live = workers.list().stream() + .filter(a -> a.paneId() != null) + .collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b)); + List> out = sessions.roster().stream() + .map(s -> SessionManager.rosterView(s, live.get(s.paneId()))) + .toList(); return text(json(Map.of("workers", out))); } catch (HerdrException e) { return error("herdr error listing workers: " + e.getMessage()); @@ -369,15 +376,6 @@ public final class BridgeMcp { } } - /** Projection a delegator can act on: sessionId (for bridge_send) + paneId (for bridge_stop). */ - private static Map workerView(Agent a) { - Map m = new LinkedHashMap<>(); - m.put("sessionId", a.terminalId()); // the id bridge_send / bridge_status take - m.put("paneId", a.paneId()); - m.put("status", a.status().name().toLowerCase()); - return m; - } - /** CB-301 projection from the authoritative session registry. */ private static Map workerView(WorkerSession s) { Map m = new LinkedHashMap<>(); @@ -466,7 +464,8 @@ public final class BridgeMcp { private static McpSchema.Tool listTool() { return tool("bridge_list", - "List the worker sessions the bridge tracks — each with its sessionId, paneId, and status.", + "List the worker sessions the bridge tracks — each with its sessionId, paneId, profile, " + + "state, optional worktree/branch/owner, and live herdr status.", 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 9d17d71..2ebf9f1 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -22,6 +22,8 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; /** * The REST surface — {@code bridged}'s contract and its testability seam. Every @@ -75,6 +77,7 @@ public final class BridgedApp { app.get("/healthz", this::healthz); app.get("/sessions", this::sessions); app.get("/agents", this::agents); + app.get("/workers", this::listWorkers); // CB-304: registry roster + live herdr status app.get("/profiles", this::profiles); // configured worker profiles app.post("/workers", this::spawnWorker); // optional ?profile= or {"profile":…} app.delete("/workers/{paneId}", this::stopWorker); @@ -123,6 +126,17 @@ public final class BridgedApp { ctx.status(200).json(Map.of("agents", workers.list().stream().map(BridgedApp::view).toList())); } + /** CB-304: bridge-owned roster merged with live herdr status by paneId. */ + private void listWorkers(Context ctx) { + Map live = workers.list().stream() + .filter(a -> a.paneId() != null) + .collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b)); + List> out = sessions.roster().stream() + .map(s -> SessionManager.rosterView(s, live.get(s.paneId()))) + .toList(); + ctx.status(200).json(Map.of("workers", out)); + } + /** The configured worker profiles and which one a no-argument spawn uses. */ private void profiles(Context ctx) { ctx.status(200).json(Map.of( diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java index 42110f9..7165c64 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -8,7 +8,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.security.SecureRandom; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -187,6 +189,29 @@ public final class SessionManager implements TurnListener { return List.copyOf(registry.values()); } + /** + * CB-304 merged roster+live view. The registry is authoritative for worktree, branch, + * profile, owner, and state; the optional live agent supplies the herdr-reported status. + */ + public static Map rosterView(WorkerSession session, Agent live) { + Map m = new LinkedHashMap<>(); + m.put("sessionId", session.terminalId()); + m.put("paneId", session.paneId()); + m.put("profile", session.profile()); + m.put("state", session.state().name().toLowerCase()); + if (session.worktree() != null) { + m.put("worktree", session.worktree()); + } + if (session.branch() != null) { + m.put("branch", session.branch()); + } + if (session.ownerTerminal() != null) { + m.put("owner", session.ownerTerminal()); + } + m.put("liveStatus", live == null ? "unknown" : live.status().name().toLowerCase()); + return m; + } + /** Lifecycle hook: worker became available on the bridge MCP. */ void onReady(String terminalId) { transitionByTerminal(terminalId, WorkerSession.State.SPAWNING, WorkerSession.State.READY); 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 c233485..c305402 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -8,7 +8,10 @@ import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.inject.Injector; import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; +import dev.ltms.bridged.session.FakeWorktrees; import dev.ltms.bridged.session.SessionManager; +import dev.ltms.bridged.session.WorkerSession; +import dev.ltms.bridged.session.WorktreeRequest; import dev.ltms.bridged.worker.WorkerService; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.Test; @@ -237,9 +240,23 @@ class BridgeMcpTest { @Test void listReportsTrackedWorkers() { FakeHerdr h = new FakeHerdr(); - McpSchema.CallToolResult res = BridgeMcp.listWorkers(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), worktrees); + WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary", + new WorktreeRequest("cb-304", null)); + + McpSchema.CallToolResult res = BridgeMcp.listWorkers(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions); + assertNotEquals(Boolean.TRUE, res.isError()); - assertTrue(textOf(res).contains("\"sessionId\":\"term_a\""), textOf(res)); + String out = textOf(res); + assertTrue(out.contains("\"sessionId\":\"" + s.terminalId() + "\""), out); + assertTrue(out.contains("\"paneId\":\"" + s.paneId() + "\""), out); + assertTrue(out.contains("\"profile\":\"ltms-local\""), out); + assertTrue(out.contains("\"state\":\"spawning\""), out); + assertTrue(out.contains("\"worktree\":\"" + s.worktree() + "\""), out); + assertTrue(out.contains("\"branch\":\"" + s.branch() + "\""), out); + assertTrue(out.contains("\"owner\":\"term_primary\""), out); + assertTrue(out.contains("\"liveStatus\":\"unknown\""), out); } @Test 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 167ef28..6ca1134 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -12,7 +12,10 @@ import dev.ltms.bridged.inject.StatusPoller; import dev.ltms.bridged.inject.WorkerPresence; import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; +import dev.ltms.bridged.session.FakeWorktrees; +import dev.ltms.bridged.session.GitWorktrees; import dev.ltms.bridged.session.SessionManager; +import dev.ltms.bridged.session.Worktrees; import dev.ltms.bridged.worker.WorkerService; import io.javalin.Javalin; import org.junit.jupiter.api.AfterEach; @@ -53,6 +56,10 @@ class BridgedAppTest { } private int start(FakeHerdr herdr, String workerBaseUrl, Set allow, String placement) { + return start(herdr, workerBaseUrl, allow, placement, new GitWorktrees()); + } + + private int start(FakeHerdr herdr, String workerBaseUrl, Set allow, String placement, Worktrees worktrees) { BridgedConfig.Worker wcfg = new BridgedConfig.Worker( "ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, placement, "bridged-workers", "worker: {profile} #{n}", null, null, null); @@ -61,7 +68,7 @@ class BridgedAppTest { agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow), Map.of(wcfg.profile(), wcfg), wcfg.profile(), k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null); - SessionManager sessions = new SessionManager(workers); + SessionManager sessions = new SessionManager(workers, worktrees); this.presence = sessions.asPresence(); Injector injector = new Injector(agents); poller = new StatusPoller(agents, injector, 5); // delivers when the fake reports idle @@ -177,6 +184,32 @@ class BridgedAppTest { assertEquals("ltms-local", body.get("profiles").get(0).asText()); } + @Test + void workersEndpointReturnsRegistryRosterWithLiveStatus() throws Exception { + FakeHerdr herdr = new FakeHerdr(); + int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"), "tab", + new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt")); + + HttpResponse spawn = req(port, "POST", "/workers?worktree=true&ticket=cb-304"); + assertEquals(201, spawn.statusCode()); + JsonNode spawned = mapper.readTree(spawn.body()); + String paneId = spawned.get("paneId").asText(); + + HttpResponse res = req(port, "GET", "/workers"); + assertEquals(200, res.statusCode()); + JsonNode workers = mapper.readTree(res.body()).get("workers"); + assertEquals(1, workers.size()); + JsonNode w = workers.get(0); + assertEquals(spawned.get("terminalId").asText(), w.get("sessionId").asText()); + assertEquals(paneId, w.get("paneId").asText()); + assertEquals("ltms-local", w.get("profile").asText()); + assertEquals("spawning", w.get("state").asText()); + assertTrue(w.has("worktree"), "worktree-backed session exposes worktree"); + assertTrue(w.has("branch"), "worktree-backed session exposes branch"); + assertEquals("unknown", w.get("liveStatus").asText(), + "liveStatus is unknown when herdr has no matching pane"); + } + @Test void spawnWithACwdParamRootsTheWorkerThere() throws Exception { FakeHerdr herdr = new FakeHerdr();