CB-557: adopt the member taxonomy in config

A member is anything a lead spawns. Every member carries two independent
attributes:

  role    — which contract: architect, dev or reviewer. It picks the launch
            charter, the role file, the playbook skill and the authz row.
  profile — which backend: model, CLI adapter, credentials, cost.

They vary on their own. A reviewer may run on the same profile as the dev
whose diff it reads, which is the case that proves the two cannot be one
field.

Config changes (breaking — we are in active development, so no aliases):

  workers:       -> profiles:        it was never a list of workers; it is a
                                     catalogue of backends
  defaultWorker: -> defaultProfile:
  architects:    -> members:         each slot now names its role

An old config is rejected at load with the new key named, rather than being
warned about once and then running with zero profiles — that failure would
surface much later, at the first spawn, pointing nowhere near the cause.

Also:
  - BridgedConfig.Worker    -> BridgedConfig.Profile
  - ArchitectRegistry       -> MemberRegistry
  - new peer.MemberRole enum, validated at startup
  - profiles map is normalized once in the compact constructor, so the raw
    map and the derived one can no longer disagree
  - the legacy singular worker: block is dropped
  - workerProfiles() -> profiles(); defaultProfile() -> effectiveDefaultProfile()
    (the record component now owns the plain name)

mvn clean install: 578 tests, 0 failures, 0 errors, BUILD SUCCESS.
This commit is contained in:
Dai Ha
2026-08-14 07:02:17 +02:00
parent 15b53c6cfa
commit 246f50b778
24 changed files with 783 additions and 354 deletions
@@ -302,7 +302,7 @@ class CallerResolverTest {
@Test
void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() {
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
Map::of, () -> Map.of("term_a", "lead-designer"))
.resolve("127.0.0.1", 42, null);
@@ -315,7 +315,7 @@ class CallerResolverTest {
@Test
void anArchitectNeedsNoTokenEvenInTokenMode() {
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), true, "s3cret",
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), true, "s3cret",
Map::of, () -> Map.of("term_a", "lead-designer"))
.resolve("127.0.0.1", 42, null);
@@ -326,7 +326,7 @@ class CallerResolverTest {
@Test
void anUnboundPaneStillResolvesAsAWorker() {
Map<String, String> arch = Map.of("term_elsewhere", "reviewer");
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
Map::of, () -> arch).resolve("127.0.0.1", 42, null);
assertEquals(Role.WORKER, p.role());
@@ -336,7 +336,7 @@ class CallerResolverTest {
/** CB-548 precedence: lead > architect > worker, so a pane named in BOTH is still a lead. */
@Test
void aLeadWinsOverAnArchitectBindingForTheSamePane() {
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer"))
.resolve("127.0.0.1", 42, null);
@@ -350,7 +350,7 @@ class CallerResolverTest {
@Test
void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() {
Map<String, String> live = new java.util.HashMap<>();
CallerResolver r = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
Map::of, () -> live);
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
@@ -358,7 +358,7 @@ class CallerResolverTest {
live.put("term_a", "lead-designer"); // the later lifecycle binds the slot
assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role());
assertEquals("lead-designer", r.architects().get("term_a"));
assertEquals("lead-designer", r.members().get("term_a"));
}
@Test
@@ -380,7 +380,7 @@ class CallerResolverTest {
/** An architect acts only as its own pane — the same ownsSession rule as a worker or lead. */
@Test
void anArchitectOwnsItsOwnPaneAndNoOther() {
Principal arch = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Principal arch = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null);
assertTrue(arch.ownsSession("term_a"));
@@ -20,13 +20,13 @@ import static org.junit.jupiter.api.Assertions.*;
* {@link CallerResolverTest}; this pins the registry object itself — its invariants and their
* thread-safety.
*/
class ArchitectRegistryTest {
class MemberRegistryTest {
private static final Map<String, BridgedConfig.Architect> SLOTS = Map.of(
"lead-designer", new BridgedConfig.Architect("sonnet"),
"reviewer", new BridgedConfig.Architect("gx10"));
private static final Map<String, BridgedConfig.Member> SLOTS = Map.of(
"lead-designer", new BridgedConfig.Member("architect", "sonnet"),
"reviewer", new BridgedConfig.Member("architect", "gx10"));
private final ArchitectRegistry registry = new ArchitectRegistry(SLOTS);
private final MemberRegistry registry = new MemberRegistry(SLOTS);
@Test
void exposesTheConfiguredSlots() {
@@ -228,10 +228,10 @@ class ArchitectRegistryTest {
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
@Test
void theSlotSnapshotIsFixedByConstruction() {
Map<String, BridgedConfig.Architect> mutable = new HashMap<>(SLOTS);
ArchitectRegistry r = new ArchitectRegistry(mutable);
Map<String, BridgedConfig.Member> mutable = new HashMap<>(SLOTS);
MemberRegistry r = new MemberRegistry(mutable);
mutable.put("hijack", new BridgedConfig.Architect("gx10"));
mutable.put("hijack", new BridgedConfig.Member("architect", "gx10"));
assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state");
}
@@ -1,6 +1,6 @@
package dev.ltms.bridged.config;
import dev.ltms.bridged.auth.ArchitectRegistry;
import dev.ltms.bridged.auth.MemberRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -22,10 +22,10 @@ class BridgedConfigTest {
host: 127.0.0.1
port: 8080
herdrSocket: ~/.config/herdr/herdr.sock
worker:
profile: ltms-local
baseUrl: http://gx00.gw:8000
model: coder
profiles:
ltms-local:
baseUrl: http://gx00.gw:8000
model: coder
guard:
offSubscriptionHosts:
- gx00.gw
@@ -34,7 +34,8 @@ class BridgedConfigTest {
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(8080, cfg.bind().port());
assertEquals("ltms-local", cfg.worker().profile());
// The profile field is defaulted to the map key by the compact constructor.
assertEquals("ltms-local", cfg.profiles().get("ltms-local").profile());
assertTrue(cfg.guard().hostSet().contains("gx00.gw"));
assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev"));
}
@@ -52,45 +53,47 @@ class BridgedConfigTest {
}
@Test
void singleWorkerBecomesAOneEntryProfileMapWithItselfAsDefault(@TempDir Path dir) throws Exception {
void aSoleProfileIsTheDefaultWithoutBeingNamed(@TempDir Path dir) throws Exception {
Path f = dir.resolve("single.yaml");
Files.writeString(f, """
worker:
profile: ltms-local
baseUrl: http://gx00.gw:8000
profiles:
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());
assertEquals(Set.of("ltms-local"), cfg.profiles().keySet());
assertNull(cfg.defaultProfile(), "nothing named a default");
assertEquals("ltms-local", cfg.effectiveDefaultProfile(),
"with one profile configured there is nothing to choose between");
}
@Test
void loadsMultipleWorkerProfilesWithADefault(@TempDir Path dir) throws Exception {
Path f = dir.resolve("multi.yaml");
Files.writeString(f, """
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
argv: ["ccs", "gx10"]
ollama:
baseUrl: http://ollama.ltms.dev
argv: ["ccs", "ollama"]
defaultWorker: gx10
defaultProfile: gx10
guard:
offSubscriptionHosts: [gx10.gw, ollama.ltms.dev]
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(Set.of("gx10", "ollama"), cfg.workerProfiles().keySet());
assertEquals(Set.of("gx10", "ollama"), cfg.profiles().keySet());
// Order, not just membership: placement breaks an exact-weight tie on definition order, so a
// hash-ordered map here would make equal-weight placement differ from one restart to the next.
assertEquals(java.util.List.of("gx10", "ollama"),
java.util.List.copyOf(cfg.workerProfiles().keySet()),
"workerProfiles must preserve YAML definition order");
java.util.List.copyOf(cfg.profiles().keySet()),
"profiles must preserve YAML definition order");
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());
assertEquals("ollama", cfg.profiles().get("ollama").profile(), "profile defaults to its map key");
assertEquals("http://gx10.gw:8000", cfg.profiles().get("gx10").baseUrl());
}
@Test
@@ -120,8 +123,8 @@ class BridgedConfigTest {
bind:
port: 8080
herdrSocket: /tmp/s
workers: {}
defaultWorker: a
profiles: {}
defaultProfile: a
guard: {}
worktreeRoot: /tmp
lifecycle: {}
@@ -130,7 +133,7 @@ class BridgedConfigTest {
broker: {}
primary: {}
leaders: {}
architects: {}
members: {}
leadScan: {}
leadHeartbeat: {}
placement: fixed
@@ -236,7 +239,7 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
tabLabel: "lead: {profile} #{n}"
leadScan:
@@ -254,7 +257,7 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
baseUrl: http://gx00.gw:8000
leadScan: {}
@@ -269,7 +272,7 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
tabLabel: "lead: {profile}"
""");
@@ -382,22 +385,24 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
role: architect
profile: sonnet
reviewer:
role: architect
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(Set.of("lead-designer", "reviewer"), cfg.architects().keySet(),
assertEquals(Set.of("lead-designer", "reviewer"), cfg.members().keySet(),
"slot names are the keys — gateway-local unique by construction");
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
"each slot carries its strong-model profile reference");
assertEquals("sonnet", cfg.architects().get("reviewer").profile());
assertEquals("sonnet", cfg.members().get("reviewer").profile());
}
@Test
@@ -409,20 +414,20 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
terminal: term_design
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
"the profile is still read even when a stray terminal is ignored");
// The registry built from this config owns no bindings: the slot is idle at startup.
ArchitectRegistry r = new ArchitectRegistry(cfg.architects());
MemberRegistry r = new MemberRegistry(cfg.members());
assertTrue(r.snapshot().isEmpty());
assertNull(r.slotForTerminal("term_design"),
"a config terminal must not resolve an architect — slots start idle");
@@ -433,8 +438,8 @@ class BridgedConfigTest {
Path f = dir.resolve("no-arch.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
assertNull(BridgedConfig.load(f).architects(),
"no architects: block ⇒ no architect identity, exactly as before CB-548");
assertNull(BridgedConfig.load(f).members(),
"no members: block ⇒ no architect identity, exactly as before CB-548");
}
@Test
@@ -445,17 +450,18 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
worker:
profile: ltms-local
baseUrl: http://gx10.gw:8000
architects:
profiles:
ltms-local:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: ltms-local
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateArchitects);
assertEquals("ltms-local", cfg.architects().get("lead-designer").profile());
assertDoesNotThrow(cfg::validateMembers);
assertEquals("ltms-local", cfg.members().get("lead-designer").profile());
}
@Test
@@ -464,16 +470,17 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
role: architect
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile");
}
@@ -484,38 +491,41 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
role: architect
profile: ""
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
}
@Test
void aValidArchitectRegistryPassesValidation(@TempDir Path dir) throws Exception {
void aValidMemberRegistryPassesValidation(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-ok.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
gx10:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
role: architect
profile: sonnet
reviewer:
role: architect
profile: gx10
""");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers());
}
@Test
@@ -523,7 +533,7 @@ class BridgedConfigTest {
Path f = dir.resolve("no-arch.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers());
}
@Test
@@ -532,13 +542,15 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
role: architect
profile: sonnet
lead-designer:
role: architect
profile: sonnet
""");
@@ -546,7 +558,7 @@ class BridgedConfigTest {
assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("lead-designer"),
"the refusal names the duplicated slot, was: " + e.getMessage());
assertTrue(e.getMessage().contains("duplicate architect"),
assertTrue(e.getMessage().contains("duplicate member"),
"the refusal says the slot name is duplicated");
}
@@ -558,14 +570,14 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
sonnet:
baseUrl: http://gx10.gw:8000
""");
// Last-wins for a non-architect duplicate is untouched: only the architects block is walked.
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).workerProfiles().keySet());
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).profiles().keySet());
}
@Test
@@ -577,15 +589,17 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
architects:
members:
nested-slot:
k: v
nested-slot:
k: v
architects:
members:
lead-designer:
role: architect
profile: sonnet
lead-designer:
role: architect
profile: sonnet
""");
@@ -593,7 +607,7 @@ class BridgedConfigTest {
assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("lead-designer"),
"the real top-level duplicate must be reported, was: " + e.getMessage());
assertTrue(e.getMessage().contains("duplicate architect"),
assertTrue(e.getMessage().contains("duplicate member"),
"the refusal says the slot name is duplicated");
}
@@ -607,11 +621,12 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
members:
lead-designer:
role: architect
profile: sonnet
extra:
a: 1
@@ -619,10 +634,10 @@ class BridgedConfigTest {
""");
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
assertDoesNotThrow(cfg::validateArchitects,
assertDoesNotThrow(cfg::validateMembers,
"a nested duplicate inside a slot is not a duplicate slot and must not refuse startup");
assertEquals(Set.of("lead-designer"), cfg.architects().keySet());
assertEquals("sonnet", cfg.architects().get("lead-designer").profile());
assertEquals(Set.of("lead-designer"), cfg.members().keySet());
assertEquals("sonnet", cfg.members().get("lead-designer").profile());
}
@Test
@@ -701,14 +716,14 @@ class BridgedConfigTest {
void workerKindDefaultsToClaudeCodeWhenOmitted(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-absent.yaml");
Files.writeString(f, """
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
argv: ["ccs", "gx10"]
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(BridgedConfig.Worker.KIND_CLAUDE_CODE, cfg.workerProfiles().get("gx10").kind(),
assertEquals(BridgedConfig.Profile.KIND_CLAUDE_CODE, cfg.profiles().get("gx10").kind(),
"a worker with no kind: is a claude-code worker (backward compatible)");
}
@@ -716,7 +731,7 @@ class BridgedConfigTest {
void opencodeKindIsNormalizedToLowerCase(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-opencode.yaml");
Files.writeString(f, """
workers:
profiles:
gemini:
kind: OpenCode
model: google/gemini-2.5-pro
@@ -724,7 +739,7 @@ class BridgedConfigTest {
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(BridgedConfig.Worker.KIND_OPENCODE, cfg.workerProfiles().get("gemini").kind(),
assertEquals(BridgedConfig.Profile.KIND_OPENCODE, cfg.profiles().get("gemini").kind(),
"kind is normalised to lower-case so YAML casing does not matter");
}
@@ -732,7 +747,7 @@ class BridgedConfigTest {
void kindPredicatesReflectTheResolvedKind(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-predicates.yaml");
Files.writeString(f, """
workers:
profiles:
claude:
baseUrl: http://gx10.gw:8000
gemini:
@@ -741,8 +756,8 @@ class BridgedConfigTest {
""");
BridgedConfig cfg = BridgedConfig.load(f);
BridgedConfig.Worker claude = cfg.workerProfiles().get("claude");
BridgedConfig.Worker gemini = cfg.workerProfiles().get("gemini");
BridgedConfig.Profile claude = cfg.profiles().get("claude");
BridgedConfig.Profile gemini = cfg.profiles().get("gemini");
assertTrue(claude.isClaudeCode(), "the default-kind worker is claude-code");
assertFalse(claude.isOpenCode(), "a claude-code worker is not opencode");
assertTrue(gemini.isOpenCode(), "the kind: opencode worker is opencode");
@@ -753,7 +768,7 @@ class BridgedConfigTest {
void argvDefaultsToTheKindBinaryWhenUnset(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-argv.yaml");
Files.writeString(f, """
workers:
profiles:
claude:
baseUrl: http://gx10.gw:8000
gemini:
@@ -762,9 +777,9 @@ class BridgedConfigTest {
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(java.util.List.of("claude"), cfg.workerProfiles().get("claude").argv(),
assertEquals(java.util.List.of("claude"), cfg.profiles().get("claude").argv(),
"a claude-code worker with no argv defaults to the claude binary");
assertEquals(java.util.List.of("opencode"), cfg.workerProfiles().get("gemini").argv(),
assertEquals(java.util.List.of("opencode"), cfg.profiles().get("gemini").argv(),
"an opencode worker with no argv defaults to the opencode binary, never claude");
}
@@ -837,7 +852,7 @@ class BridgedConfigTest {
BridgedConfig cfg = BridgedConfig.load(example);
assertEquals(8765, cfg.bind().port(), "example binds the documented default port");
assertTrue(cfg.workerProfiles().containsKey("gx10"), "example documents the gx10 profile");
assertTrue(cfg.profiles().containsKey("gx10"), "example documents the gx10 profile");
assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves");
assertTrue(cfg.guard().hostSet().contains("gx01.gw"),
"every example profile's base_url host must be in the example allowlist");
@@ -858,7 +873,7 @@ class BridgedConfigTest {
spawnReadyTimeoutMs: 25000
spawnReadyPollMs: 400
worktreeRoot: /tmp/bridged-worktrees
workers:
profiles:
gx10:
kind: claude-code
baseUrl: http://gx01.gw:8000
@@ -892,7 +907,7 @@ class BridgedConfigTest {
assertEquals(400, cfg.spawnReadyPollMs(), "spawnReadyPollMs is camelCase, not snake_case");
assertEquals("/tmp/bridged-worktrees", cfg.worktreeRoot());
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
BridgedConfig.Profile w = cfg.profiles().get("gx10");
assertEquals("/tmp/ccs/gx10", w.configDir());
assertEquals("/tmp/repo", w.cwd());
assertEquals(java.util.List.of(".mcp.json", ".env"), w.parityOverlay());
@@ -921,7 +936,7 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
""");
@@ -936,13 +951,13 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
""");
BridgedConfig cfg = BridgedConfig.load(f);
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
BridgedConfig.Profile w = cfg.profiles().get("gx10");
assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0");
assertNull(w.maxLoad(), "absent maxLoad defaults to unlimited (null)");
}
@@ -951,7 +966,7 @@ class BridgedConfigTest {
void subscriptionFlagBindsAndDefaultsFalse(@TempDir Path dir) throws Exception {
Path f = dir.resolve("subscription.yaml");
Files.writeString(f, """
workers:
profiles:
sonnet:
subscription: true
argv: ["ccs", "sonnet"]
@@ -960,9 +975,9 @@ class BridgedConfigTest {
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertTrue(cfg.workerProfiles().get("sonnet").isSubscription(),
assertTrue(cfg.profiles().get("sonnet").isSubscription(),
"subscription: true binds as an explicit opt-in");
assertFalse(cfg.workerProfiles().get("opted").isSubscription(),
assertFalse(cfg.profiles().get("opted").isSubscription(),
"a profile without the key stays off-subscription (the default)");
}
@@ -972,7 +987,7 @@ class BridgedConfigTest {
void aSubscriptionProfileWithAnthropicBaseUrlInEnvIsRejected(@TempDir Path dir) throws Exception {
Path f = dir.resolve("baseUrl.yaml");
Files.writeString(f, """
workers:
profiles:
sonnet:
subscription: true
argv: ["ccs", "sonnet"]
@@ -992,7 +1007,7 @@ class BridgedConfigTest {
void aSubscriptionProfileWithAnthropicAuthTokenInEnvIsRejected(@TempDir Path dir) throws Exception {
Path f = dir.resolve("authToken.yaml");
Files.writeString(f, """
workers:
profiles:
sonnet:
subscription: true
argv: ["ccs", "sonnet"]
@@ -1012,7 +1027,7 @@ class BridgedConfigTest {
void aSubscriptionProfileWithACleanEnvPassesValidation(@TempDir Path dir) throws Exception {
Path f = dir.resolve("clean.yaml");
Files.writeString(f, """
workers:
profiles:
sonnet:
subscription: true
argv: ["ccs", "sonnet"]
@@ -1031,7 +1046,7 @@ class BridgedConfigTest {
// plain profile's env: is still overwritten by the launcher's guard-checked value (CB-511).
Path f = dir.resolve("nonsub.yaml");
Files.writeString(f, """
workers:
profiles:
gx10:
baseUrl: http://gx10.gw:8000
env:
@@ -1042,4 +1057,171 @@ class BridgedConfigTest {
assertDoesNotThrow(cfg::validateSubscriptionProfiles,
"only subscription:true profiles are checked — off-subscription ones keep the baseUrl guard");
}
// --- member taxonomy (CB-557) ---------------------------------------------------------
/**
* A pre-rename config must fail loudly, not load empty.
*
* <p>This is the whole point of the hard error. Without it, {@code workers:} would be an unknown
* top-level key: the load would warn once and then run with zero profiles, so the first spawn
* fails with a message that points nowhere near the real cause.
*/
@Test
void aConfigStillUsingTheOldWorkersKeyIsRejectedAndNamesTheNewKey(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
workers:
gx10:
baseUrl: http://gx00.gw:8000
""");
IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("'workers' is now 'profiles'"),
"the error must name the replacement key, not just say the key is wrong: " + e.getMessage());
}
@Test
void everyRenamedTopLevelKeyIsReportedAtOnce(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
workers:
gx10:
baseUrl: http://gx00.gw:8000
defaultWorker: gx10
architects:
architect-1:
profile: gx10
""");
IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("'architects' is now 'members'"), e.getMessage());
assertTrue(e.getMessage().contains("'defaultWorker' is now 'defaultProfile'"), e.getMessage());
assertTrue(e.getMessage().contains("'workers' is now 'profiles'"), e.getMessage());
}
@Test
void aMemberSlotCarriesBothItsRoleAndItsProfile(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
opus:
baseUrl: http://gx00.gw:8000
members:
architect-1:
role: architect
profile: opus
reviewer-1:
role: reviewer
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateMembers);
assertEquals("architect", cfg.members().get("architect-1").role());
assertEquals("opus", cfg.members().get("architect-1").profile());
assertEquals("reviewer", cfg.members().get("reviewer-1").role());
}
/**
* Role and profile are separate axes: two members may share a backend and still differ in what
* they are allowed to do. This is the case that stops the two collapsing into one field.
*/
@Test
void twoMembersWithDifferentRolesMayShareOneProfile(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
members:
dev-1:
role: dev
profile: gx10
reviewer-1:
role: reviewer
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateMembers);
assertEquals(cfg.members().get("dev-1").profile(), cfg.members().get("reviewer-1").profile());
assertNotEquals(cfg.members().get("dev-1").role(), cfg.members().get("reviewer-1").role());
}
@Test
void aMemberSlotWithAnUnknownRoleIsRejectedAndListsTheValidRoles(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
members:
slot-1:
role: archtiect
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("archtiect"), e.getMessage());
assertTrue(e.getMessage().contains("architect, dev, reviewer"),
"the error must list the valid spellings so the typo is fixable from it: " + e.getMessage());
}
@Test
void aMemberSlotWithNoRoleIsRejected(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
members:
slot-1:
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("has no role:"), e.getMessage());
}
@Test
void theProfilesMapIsNormalizedOnceAtConstruction(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
terra:
profile: explicitly-set
baseUrl: http://gx01.gw:8000
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals("gx10", cfg.profiles().get("gx10").profile(),
"a profile that names no profile: field is defaulted to its map key");
assertEquals("explicitly-set", cfg.profiles().get("terra").profile(),
"an explicit profile: field is left alone");
assertEquals(List.of("gx10", "terra"), List.copyOf(cfg.profiles().keySet()),
"YAML definition order survives — placement tie-breaks on it");
}
@Test
void effectiveDefaultProfileFallsBackToTheFirstConfiguredProfile(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
terra:
baseUrl: http://gx01.gw:8000
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertNull(cfg.defaultProfile(), "the raw config value is absent");
assertEquals("gx10", cfg.effectiveDefaultProfile(), "the resolved value is the first profile");
}
}
@@ -55,7 +55,7 @@ class BridgeMcpAuthzTest {
/** A fully wired BridgeMcp on fakes — constructing it is itself part of what is under test. */
private BridgeMcp mcp(boolean enforce) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr),
@@ -52,7 +52,7 @@ class BridgeMcpTest {
}
private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set<String> allow) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
return new ClaudeCodeLauncher(new AgentControl(h), new WorkspaceControl(h),
@@ -0,0 +1,67 @@
package dev.ltms.bridged.peer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MemberRoleTest {
@Test
void theThreeRolesAreArchitectDevAndReviewer() {
assertEquals(3, MemberRole.values().length,
"a new role changes the charter, the role file, the skill and the authz row — "
+ "adding one is a deliberate act, so this count is meant to fail first");
assertEquals("architect", MemberRole.ARCHITECT.wireName());
assertEquals("dev", MemberRole.DEV.wireName());
assertEquals("reviewer", MemberRole.REVIEWER.wireName());
}
@Test
void parseAcceptsTheWireSpelling() {
for (MemberRole r : MemberRole.values()) {
assertSame(r, MemberRole.parse(r.wireName()));
}
}
@Test
void parseIsCaseInsensitiveAndTrimsSurroundingSpace() {
assertSame(MemberRole.ARCHITECT, MemberRole.parse("Architect"));
assertSame(MemberRole.DEV, MemberRole.parse(" DEV "));
assertSame(MemberRole.REVIEWER, MemberRole.parse("ReViEwEr"));
}
@Test
void parseRejectsAnUnknownRoleAndListsTheValidOnes() {
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("archtiect"));
assertTrue(e.getMessage().contains("archtiect"), e.getMessage());
assertTrue(e.getMessage().contains("architect, dev, reviewer"),
"a typo in config should be fixable from the message alone: " + e.getMessage());
}
@Test
void parseRejectsNullAndBlank() {
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(null));
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(""));
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(" "));
}
/**
* A lead is not a member role. A lead is not spawned — it is a pre-existing session that config
* recognises — so it has no launch charter to pick and never appears in a {@code members:} slot.
*/
@Test
void leadIsNotAMemberRole() {
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("lead"));
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("primary"));
}
/** "worker" is the name this taxonomy replaced; it must not quietly keep working. */
@Test
void workerIsNoLongerARole() {
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("worker"));
}
}
@@ -52,7 +52,7 @@ class BridgedAppAuthTest {
*/
private int start(long pid, boolean tokenMode, String token) {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
BridgedConfig.Profile wcfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
AgentControl agents = new AgentControl(herdr);
@@ -206,7 +206,7 @@ class BridgedAppAuthTest {
// The 29 pre-existing acceptance tests rely on this: no auth fixture, no enforcement.
FakeHerdr herdr = new FakeHerdr();
AgentControl agents = new AgentControl(herdr);
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
BridgedConfig.Profile wcfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
@@ -62,7 +62,7 @@ class BridgedAppTest {
}
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> allow, String placement, Worktrees worktrees) {
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
BridgedConfig.Profile wcfg = new BridgedConfig.Profile(
"ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
placement, "bridged-workers", "worker: {profile} #{n}", null, null, null);
AgentControl agents = new AgentControl(herdr);
@@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.*;
class SessionManagerTest {
private SessionManager sessionManager(FakeHerdr herdr) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, null, null);
@@ -44,7 +44,7 @@ class SessionManagerTest {
private SessionManager sessionManager(FakeHerdr herdr, LongSupplier clock, int contextCap,
boolean clearAfterTurn) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, null, null);
@@ -432,7 +432,7 @@ class SessionManagerTest {
long[] clock = {0};
// Gate-enabled launcher (1 ms timeout + no-op sleeper that advances clock past deadline)
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, null, null);
@@ -29,7 +29,7 @@ class SessionReaperTest {
private static ClaudeCodeLauncher launcher() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, null, null);
@@ -23,7 +23,7 @@ import static org.junit.jupiter.api.Assertions.*;
class WorktreeSessionManagerTest {
private static ClaudeCodeLauncher workerService(FakeHerdr herdr) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, null, null);
@@ -245,7 +245,7 @@ class WorktreeSessionManagerTest {
FakeHerdr herdr = new FakeHerdr();
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
// Argument order matters: configDir is the 4th parameter, cwd the 11th (after mcpUrl).
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, "/pinned/dir", null);
@@ -24,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.*;
class ClaudeCodeLauncherTest {
private ClaudeCodeLauncher service(FakeHerdr herdr, List<String> argv, String mcpUrl) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null, null);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -59,7 +59,7 @@ class ClaudeCodeLauncherTest {
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(
new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")),
Map.of("ltms-local", new BridgedConfig.Worker(
Map.of("ltms-local", new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null)),
"ltms-local", _ -> null,
@@ -100,9 +100,9 @@ class ClaudeCodeLauncherTest {
}
private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) {
BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder",
BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null,
BridgedConfig.Profile ollama = new BridgedConfig.Profile("ollama", "http://ollama.ltms.dev", null,
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")),
@@ -143,7 +143,7 @@ class ClaudeCodeLauncherTest {
@Test
void profileConfigCwdBeatsTheCallerCwd() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder",
BridgedConfig.Profile cfg = new BridgedConfig.Profile("ltms-local", "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"w #{n}", null, "/pinned/dir", null);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -170,7 +170,7 @@ class ClaudeCodeLauncherTest {
@Test
void injectsForgeTokenAndHostWhenProfileGrantsIt() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null,
"GITEA_ACCESS_TOKEN", null); // parityOverlay null; gitHostEnv null → defaults to GITEA_HOST
@@ -192,7 +192,7 @@ class ClaudeCodeLauncherTest {
FakeHerdr herdr = new FakeHerdr();
// gitTokenEnv unset (12-arg ctor); the env would resolve a token if asked, proving the gate
// is the profile config, not a missing env var.
BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder",
BridgedConfig.Profile cfg = new BridgedConfig.Profile("ltms-local", "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("ccs"), "tab", "bridged-workers", "w #{n}",
null, null, null);
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -323,7 +323,7 @@ class ClaudeCodeLauncherTest {
@Test
void capabilitiesIncludeSelfPrWhenProfileHasGitToken() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null,
"GITEA_ACCESS_TOKEN", null);
@@ -476,8 +476,8 @@ class ClaudeCodeLauncherTest {
// --- CB-306 spawn-readiness gate -----------------------------------------------------------
private static Map<String, BridgedConfig.Worker> workerConfigMap(String profile, String mcpUrl) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
private static Map<String, BridgedConfig.Profile> workerConfigMap(String profile, String mcpUrl) {
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
profile, "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", profile), "tab", "bridged-workers",
"worker: {profile} #{n}", mcpUrl, null, null);
@@ -579,7 +579,7 @@ class ClaudeCodeLauncherTest {
@Test
void workerInheritsTheDaemonPath() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -593,7 +593,7 @@ class ClaudeCodeLauncherTest {
@Test
void profileEnvIsInjectedIntoTheWorker() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
null, Map.of("JAVA_HOME", "/opt/jdk", "PATH", "/profile/bin"), null, null);
@@ -614,7 +614,7 @@ class ClaudeCodeLauncherTest {
@Test
void profileEnvCannotOverrideGuardCheckedAnthropicVars() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
null, Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com"), null, null);
@@ -630,14 +630,14 @@ class ClaudeCodeLauncherTest {
/** A launcher for a profile identical but for its {@code model:} — the only variable here. */
private ClaudeCodeLauncher serviceWithModel(FakeHerdr herdr, String model) {
BridgedConfig.Worker cfg = profileWithModel(model);
BridgedConfig.Profile cfg = profileWithModel(model);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> null);
}
private static BridgedConfig.Worker profileWithModel(String model) {
return new BridgedConfig.Worker("sonnet", "http://gx00.gw:8000", model, null,
private static BridgedConfig.Profile profileWithModel(String model) {
return new BridgedConfig.Profile("sonnet", "http://gx00.gw:8000", model, null,
"BRIDGED_WORKER_TOKEN", List.of("ccs", "sonnet"), "tab", "bridged-workers",
"w #{n}", "http://127.0.0.1:8765/mcp", null, null);
}
@@ -679,8 +679,8 @@ class ClaudeCodeLauncherTest {
// --- CB-539: subscription-profile opt-in ----------------------------------------------------
/** A claude-code profile on the subscription: no baseUrl (by design), no off-sub endpoint. */
private static BridgedConfig.Worker subscriptionCfg(String profile, String baseUrl) {
return new BridgedConfig.Worker(
private static BridgedConfig.Profile subscriptionCfg(String profile, String baseUrl) {
return new BridgedConfig.Profile(
profile, baseUrl, "sonnet", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", profile), "tab", "bridged-workers", "w #{n}", null, null, null,
null, null, null, Map.of(), null, null, true);
@@ -691,7 +691,7 @@ class ClaudeCodeLauncherTest {
// Requirement 1: absent subscription:true ⇒ byte-identical refusal to today. A claude-code
// profile with no baseUrl and no subscription must still be refused (it would bill the sub).
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", null, "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers", "w #{n}", null, null, null);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -710,7 +710,7 @@ class ClaudeCodeLauncherTest {
// ANTHROPIC_BASE_URL nor ANTHROPIC_AUTH_TOKEN is injected even though the token env would
// resolve one if asked.
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = subscriptionCfg("sonnet", null);
BridgedConfig.Profile cfg = subscriptionCfg("sonnet", null);
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> "would-be-token").spawn();
@@ -727,7 +727,7 @@ class ClaudeCodeLauncherTest {
// Requirement 2: subscription:true + a baseUrl state opposite intents — refuse at spawn,
// naming the profile, rather than silently picking a winner.
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000");
BridgedConfig.Profile cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000");
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
@@ -744,7 +744,7 @@ class ClaudeCodeLauncherTest {
// Requirement 3: the guard keeps its teeth for every other profile — a base_url whose host is
// not on the allowlist is still refused, whether or not any subscription profile exists.
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker rogue = new BridgedConfig.Worker(
BridgedConfig.Profile rogue = new BridgedConfig.Profile(
"rogue", "http://evil.example.com:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "rogue"), "tab", "bridged-workers", "w #{n}", null, null, null);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -763,7 +763,7 @@ class ClaudeCodeLauncherTest {
// load refuses this loudly; this launcher-side strip is the belt-and-braces that makes the
// invariant hold for a profile built in code that never passed through that validation.
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"sonnet", null, "sonnet", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "sonnet"), "tab", "bridged-workers", "w #{n}", null, null, null,
null, null, null,
@@ -40,7 +40,7 @@ class CompositePeerLauncherTest {
private ClaudeCodeLauncher claudeAdapter(FakeHerdr herdr) {
// 12-arg back-compat Worker ctor → kind defaults to claude-code.
BridgedConfig.Worker claude = new BridgedConfig.Worker("claude", "http://gx00.gw:8000", "coder",
BridgedConfig.Profile claude = new BridgedConfig.Profile("claude", "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}",
null, null, null);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
@@ -48,9 +48,9 @@ class CompositePeerLauncherTest {
}
private OpenCodeLauncher opencodeAdapter(FakeHerdr herdr) {
BridgedConfig.Worker gemini = new BridgedConfig.Worker("gemini", null, "google/gemini-2.5-pro",
BridgedConfig.Profile gemini = new BridgedConfig.Profile("gemini", null, "google/gemini-2.5-pro",
null, "BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers", "w #{n}",
null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Worker.KIND_OPENCODE);
null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Profile.KIND_OPENCODE);
return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
Map.of("gemini", gemini), "gemini", _ -> "tok");
}
@@ -70,7 +70,7 @@ class CompositePeerLauncherTest {
private final Map<String, Integer> spawnCounts = new HashMap<>();
StubLauncher(String prefix, FakeHerdr herdr,
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Set<String> failProfiles) {
super(prefix, new AgentControl(herdr), new WorkspaceControl(herdr),
profiles, defaultProfile, _ -> null, 0L, System::currentTimeMillis, () -> { });
@@ -78,7 +78,7 @@ class CompositePeerLauncherTest {
}
@Override
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
protected Launch buildLaunch(BridgedConfig.Profile cfg) {
return new Launch(Map.of(), List.of());
}
@@ -114,14 +114,14 @@ class CompositePeerLauncherTest {
}
}
private static BridgedConfig.Worker stubWorker(String profile) {
return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder",
private static BridgedConfig.Profile stubWorker(String profile) {
return new BridgedConfig.Profile(profile, "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
"w #{n}", null, null, null, null, null, null, null, null, null);
}
private static BridgedConfig.Worker stubWorker(String profile, float weight, Integer maxLoad) {
return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder",
private static BridgedConfig.Profile stubWorker(String profile, float weight, Integer maxLoad) {
return new BridgedConfig.Profile(profile, "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
"w #{n}", null, null, null, null, null, null, null,
weight, maxLoad);
@@ -133,9 +133,9 @@ class CompositePeerLauncherTest {
* so a {@code Map.of} would make "which profile is tried first" a coin flip per run and any
* assertion about the first attempt intermittently false.
*/
private static Map<String, BridgedConfig.Worker> ordered(String first, BridgedConfig.Worker a,
String second, BridgedConfig.Worker b) {
Map<String, BridgedConfig.Worker> m = new LinkedHashMap<>();
private static Map<String, BridgedConfig.Profile> ordered(String first, BridgedConfig.Profile a,
String second, BridgedConfig.Profile b) {
Map<String, BridgedConfig.Profile> m = new LinkedHashMap<>();
m.put(first, a);
m.put(second, b);
return m;
@@ -292,7 +292,7 @@ class CompositePeerLauncherTest {
@Test
void weightedPolicyGatesProfileAtMaxLoad() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 1.0f, 1),
"b", stubWorker("b", 1.0f, null));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -308,7 +308,7 @@ class CompositePeerLauncherTest {
@Test
void weightedPolicyDistributesAccordingToWeightRatio() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 0.75f, null),
"b", stubWorker("b", 0.25f, null));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -328,7 +328,7 @@ class CompositePeerLauncherTest {
@Test
void failoverRetriesNextCandidateWhenProfileIsUnreachable() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a"),
"b", stubWorker("b"));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a"));
@@ -349,7 +349,7 @@ class CompositePeerLauncherTest {
@Test
void reversingDefinitionOrderReversesWhichProfileIsTriedFirst() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"b", stubWorker("b"),
"a", stubWorker("a"));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("b"));
@@ -364,7 +364,7 @@ class CompositePeerLauncherTest {
@Test
void failoverBoundedByCandidateCount() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a"),
"b", stubWorker("b"));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a", "b"));
@@ -381,7 +381,7 @@ class CompositePeerLauncherTest {
@Test
void explicitSpawnAtMaxLoadThrowsPlacementExceptionNamingProfileLiveAndCap() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 1.0f, 2),
"b", stubWorker("b"));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -399,7 +399,7 @@ class CompositePeerLauncherTest {
@Test
void explicitSpawnUnderMaxLoadStillSucceeds() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 1.0f, 2),
"b", stubWorker("b"));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -414,7 +414,7 @@ class CompositePeerLauncherTest {
@Test
void explicitSpawnWithNullMaxLoadIsNeverCapped() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 1.0f, null),
"b", stubWorker("b"));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -429,7 +429,7 @@ class CompositePeerLauncherTest {
@Test
void emptyCandidateSetThrowsClearException() {
FakeHerdr herdr = new FakeHerdr();
Map<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 1.0f, 1),
"b", stubWorker("b", 1.0f, 1));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -27,14 +27,14 @@ import static org.junit.jupiter.api.Assertions.*;
*/
class OpenCodeLauncherTest {
private static BridgedConfig.Worker opencodeCfg(String model, String mcpUrl, String gitTokenEnv) {
return new BridgedConfig.Worker("gemini", null, model, null, "BRIDGED_WORKER_TOKEN",
private static BridgedConfig.Profile opencodeCfg(String model, String mcpUrl, String gitTokenEnv) {
return new BridgedConfig.Profile("gemini", null, model, null, "BRIDGED_WORKER_TOKEN",
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_OPENCODE);
null, null, gitTokenEnv, null, BridgedConfig.Profile.KIND_OPENCODE);
}
/** Gate-disabled launcher whose per-spawn config dirs land under an inspectable temp root. */
private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Worker cfg) {
private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Profile cfg) {
return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
Map.of(cfg.profile(), cfg), cfg.profile(), k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null,
0, System::currentTimeMillis, () -> { }, configRoot, configRoot);
@@ -205,7 +205,7 @@ class OpenCodeLauncherTest {
@Test
void productionConstructorsWireThroughToTheBase() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = opencodeCfg(null, null, null);
BridgedConfig.Profile cfg = opencodeCfg(null, null, null);
// 5-arg (gate disabled) and 7-arg (gate enabled) production constructors both expose the profile.
OpenCodeLauncher disabled = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
@@ -247,10 +247,10 @@ class OpenCodeLauncherTest {
// --- CB-508: pinned OpenAI-compatible endpoint (e.g. a local vLLM) ---------------------------
/** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */
private static BridgedConfig.Worker pinnedCfg(String model, String baseUrl, String mcpUrl) {
return new BridgedConfig.Worker("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN",
private static BridgedConfig.Profile pinnedCfg(String model, String baseUrl, String mcpUrl) {
return new BridgedConfig.Profile("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN",
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
null, null, null, null, BridgedConfig.Worker.KIND_OPENCODE);
null, null, null, null, BridgedConfig.Profile.KIND_OPENCODE);
}
@Test