CB-557: fleet role pools — role is the config key, and the tab label says it
Four top-level keys (leaders:, members:, leadScan:, defaultProfile:) become one
`fleet:` block, and a member's role becomes the map key that contains it rather
than a `role:` field inside it.
Why the key and not a field: a misspelled `role: architct` used to produce a
member with no contract, which nothing rejected. A misspelled pool name declares
nothing, which is a shape the loader can see.
`fleet.architects/developers/reviewers` are pools of profiles a role MAY run on.
That replaces the single global `defaultProfile:`, so an unqualified spawn now
resolves its profile from the pool of the role it asked for. Role and profile
stay orthogonal: a reviewer may run on the same profile as the dev it reviews,
and one profile may appear in several pools.
Tab labels are role-first — `dev: sonnet #4`. The template lives on `fleet:`
because a profile cannot know the role of the member launched on it; a profile
may still override it. The `{n}` counter is scoped per role+profile, so a dev
and a reviewer on one profile each start at #1. Making {role} the first field
also turns the lead/member namespace check into a structural guarantee: roles
are a closed enum, so only hand-written templates can still collide with a lead
tabPrefix.
Removed keys are hard errors that name their successor. `defaultProfile:` has no
single successor key, so its message explains the new model instead of pointing
at a key that does not exist.
Map order is kept with LinkedHashMap, deliberately not Map.copyOf — the latter
salts iteration order per JVM run, which would destroy the YAML definition order
that `placement: fixed` selects on.
Not yet wired: SessionManager still hands the launchers one effectiveDefault-
Profile, so pools are not enforced at spawn time yet, and placement still ranges
over all profiles.
595 tests pass.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
package dev.ltms.bridged.auth;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import dev.ltms.bridged.peer.MemberRole;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -22,23 +24,52 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
class MemberRegistryTest {
|
||||
|
||||
private static final Map<String, BridgedConfig.Member> SLOTS = Map.of(
|
||||
"lead-designer", new BridgedConfig.Member("architect", "sonnet"),
|
||||
"reviewer", new BridgedConfig.Member("architect", "gx10"));
|
||||
/**
|
||||
* Slot keys are qualified by role (CB-557): a bare name is unique only within its pool, so
|
||||
* {@code sonnet} can be both a developer and a reviewer, while a terminal binds to exactly one.
|
||||
*/
|
||||
private static final String DESIGNER = "architect:lead-designer";
|
||||
private static final String REVIEWER = "architect:code-reviewer";
|
||||
|
||||
private final MemberRegistry registry = new MemberRegistry(SLOTS);
|
||||
private static BridgedConfig.Fleet fleetWith(Map<String, BridgedConfig.Slot> architects) {
|
||||
return new BridgedConfig.Fleet(Map.of(), architects, Map.of(), Map.of(), null);
|
||||
}
|
||||
|
||||
private static Map<String, BridgedConfig.Slot> architects() {
|
||||
Map<String, BridgedConfig.Slot> pool = new LinkedHashMap<>();
|
||||
pool.put("lead-designer", new BridgedConfig.Slot("sonnet"));
|
||||
pool.put("code-reviewer", new BridgedConfig.Slot("gx10"));
|
||||
return pool;
|
||||
}
|
||||
|
||||
private final MemberRegistry registry = new MemberRegistry(fleetWith(architects()));
|
||||
|
||||
@Test
|
||||
void exposesTheConfiguredSlots() {
|
||||
assertEquals(SLOTS.keySet(), registry.slots().keySet());
|
||||
assertTrue(registry.isSlot("reviewer"));
|
||||
void exposesTheConfiguredSlotsQualifiedByRole() {
|
||||
assertEquals(Set.of(DESIGNER, REVIEWER), registry.slots().keySet());
|
||||
assertTrue(registry.isSlot(REVIEWER));
|
||||
assertFalse(registry.isSlot("nope"));
|
||||
assertFalse(registry.isSlot("lead-designer"),
|
||||
"the bare name is not the key — it is unique only inside its pool");
|
||||
}
|
||||
|
||||
/** The case the pool shape exists for: one profile serving two roles is not a duplicate. */
|
||||
@Test
|
||||
void oneProfileMayServeTwoRolesUnderTheSameSlotName() {
|
||||
Map<String, BridgedConfig.Slot> devs = Map.of("sonnet", new BridgedConfig.Slot("sonnet"));
|
||||
Map<String, BridgedConfig.Slot> revs = Map.of("sonnet", new BridgedConfig.Slot("sonnet"));
|
||||
MemberRegistry r = new MemberRegistry(
|
||||
new BridgedConfig.Fleet(Map.of(), Map.of(), devs, revs, null));
|
||||
|
||||
assertEquals(Set.of("dev:sonnet", "reviewer:sonnet"), r.slots().keySet());
|
||||
assertEquals(MemberRole.DEV, r.roleForSlot("dev:sonnet"));
|
||||
assertEquals(MemberRole.REVIEWER, r.roleForSlot("reviewer:sonnet"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSpawnLifecycleReadsTheProfileBackFromASlot() {
|
||||
assertEquals("sonnet", registry.profileForSlot("lead-designer"));
|
||||
assertEquals("gx10", registry.profileForSlot("reviewer"));
|
||||
assertEquals("sonnet", registry.profileForSlot(DESIGNER));
|
||||
assertEquals("gx10", registry.profileForSlot(REVIEWER));
|
||||
assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile");
|
||||
}
|
||||
|
||||
@@ -54,9 +85,9 @@ class MemberRegistryTest {
|
||||
|
||||
@Test
|
||||
void bindResolvesTheTerminalToTheSlot() {
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
assertEquals("lead-designer", registry.slotForTerminal("term_design"));
|
||||
assertEquals(Map.of("term_design", "lead-designer"), registry.snapshot());
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
assertEquals(DESIGNER, registry.slotForTerminal("term_design"));
|
||||
assertEquals(Map.of("term_design", DESIGNER), registry.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,27 +99,27 @@ class MemberRegistryTest {
|
||||
|
||||
@Test
|
||||
void bindRefusesATerminalInTwoSlots() {
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
assertFalse(registry.bind("reviewer", "term_design"),
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
assertFalse(registry.bind(REVIEWER, "term_design"),
|
||||
"a terminal may occupy at most one slot");
|
||||
assertEquals("lead-designer", registry.slotForTerminal("term_design"),
|
||||
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
|
||||
"the first binding survives the refused second");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindRefusesASlotWithTwoTerminals() {
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
assertFalse(registry.bind("lead-designer", "term_other"),
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
assertFalse(registry.bind(DESIGNER, "term_other"),
|
||||
"a slot may host at most one terminal");
|
||||
assertEquals("lead-designer", registry.slotForTerminal("term_design"),
|
||||
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
|
||||
"the first binding survives the refused second");
|
||||
assertNull(registry.slotForTerminal("term_other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebindingTheSamePairIsAnIdempotentNoOp() {
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
assertTrue(registry.bind("lead-designer", "term_design"),
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"),
|
||||
"the same terminal → slot is harmless to repeat");
|
||||
assertEquals(1, registry.snapshot().size());
|
||||
}
|
||||
@@ -97,8 +128,8 @@ class MemberRegistryTest {
|
||||
|
||||
@Test
|
||||
void unbindRemovesTheExactBinding() {
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
assertTrue(registry.unbind("lead-designer", "term_design"));
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
assertTrue(registry.unbind(DESIGNER, "term_design"));
|
||||
assertNull(registry.slotForTerminal("term_design"));
|
||||
assertTrue(registry.snapshot().isEmpty());
|
||||
}
|
||||
@@ -106,32 +137,32 @@ class MemberRegistryTest {
|
||||
@Test
|
||||
void aStaleUnbindDoesNotRemoveAReplacement() {
|
||||
// Bind, tear down, and stand the slot back up with a NEW terminal.
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
registry.unbind("lead-designer", "term_design");
|
||||
assertTrue(registry.bind("lead-designer", "term_new"));
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
registry.unbind(DESIGNER, "term_design");
|
||||
assertTrue(registry.bind(DESIGNER, "term_new"));
|
||||
|
||||
// A late unbind naming the OLD terminal must not remove the replacement binding.
|
||||
assertFalse(registry.unbind("lead-designer", "term_design"));
|
||||
assertEquals("lead-designer", registry.slotForTerminal("term_new"),
|
||||
assertFalse(registry.unbind(DESIGNER, "term_design"));
|
||||
assertEquals(DESIGNER, registry.slotForTerminal("term_new"),
|
||||
"the replacement terminal stays bound");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStaleUnbindForATerminalThatMovedSlotsDoesNothing() {
|
||||
// term_design starts in lead-designer, is torn down, and stands back up in a FREE slot.
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
registry.unbind("lead-designer", "term_design");
|
||||
assertTrue(registry.bind("reviewer", "term_design"));
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
registry.unbind(DESIGNER, "term_design");
|
||||
assertTrue(registry.bind(REVIEWER, "term_design"));
|
||||
|
||||
// Unbinding against the slot it no longer occupies is refused; the new binding is intact.
|
||||
assertFalse(registry.unbind("lead-designer", "term_design"),
|
||||
assertFalse(registry.unbind(DESIGNER, "term_design"),
|
||||
"the old slot must not unbind a terminal that moved elsewhere");
|
||||
assertEquals("reviewer", registry.slotForTerminal("term_design"));
|
||||
assertEquals(REVIEWER, registry.slotForTerminal("term_design"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unbindOfNothingIsAFalseNoOp() {
|
||||
assertFalse(registry.unbind("lead-designer", "term_design"),
|
||||
assertFalse(registry.unbind(DESIGNER, "term_design"),
|
||||
"nothing was bound, so nothing is removed");
|
||||
}
|
||||
|
||||
@@ -139,14 +170,14 @@ class MemberRegistryTest {
|
||||
|
||||
@Test
|
||||
void theSnapshotIsAnImmutableCopyNotAliveState() {
|
||||
assertTrue(registry.bind("lead-designer", "term_design"));
|
||||
assertTrue(registry.bind(DESIGNER, "term_design"));
|
||||
Map<String, String> snap = registry.snapshot();
|
||||
|
||||
assertThrows(UnsupportedOperationException.class, () -> snap.put("x", "y"),
|
||||
"a handed-out snapshot cannot be mutated in place");
|
||||
|
||||
// Later binds must not leak into an earlier snapshot.
|
||||
assertTrue(registry.bind("reviewer", "term_review"));
|
||||
assertTrue(registry.bind(REVIEWER, "term_review"));
|
||||
assertFalse(snap.containsKey("term_review"),
|
||||
"a snapshot is a point-in-time copy, not a live view");
|
||||
}
|
||||
@@ -164,7 +195,7 @@ class MemberRegistryTest {
|
||||
final String term = "term_" + i; // every thread races for the SAME slot
|
||||
results.add(pool.submit(() -> {
|
||||
go.await();
|
||||
return registry.bind("lead-designer", term);
|
||||
return registry.bind(DESIGNER, term);
|
||||
}));
|
||||
}
|
||||
go.countDown();
|
||||
@@ -191,7 +222,7 @@ class MemberRegistryTest {
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
List<Future<String>> results = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
final String slot = (i % 2 == 0) ? "lead-designer" : "reviewer"; // all race for ONE terminal
|
||||
final String slot = (i % 2 == 0) ? DESIGNER : REVIEWER; // all race for ONE terminal
|
||||
results.add(pool.submit(() -> {
|
||||
go.await();
|
||||
return registry.bind(slot, "shared_term")
|
||||
@@ -228,11 +259,11 @@ class MemberRegistryTest {
|
||||
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
|
||||
@Test
|
||||
void theSlotSnapshotIsFixedByConstruction() {
|
||||
Map<String, BridgedConfig.Member> mutable = new HashMap<>(SLOTS);
|
||||
MemberRegistry r = new MemberRegistry(mutable);
|
||||
Map<String, BridgedConfig.Slot> mutable = architects();
|
||||
MemberRegistry r = new MemberRegistry(fleetWith(mutable));
|
||||
|
||||
mutable.put("hijack", new BridgedConfig.Member("architect", "gx10"));
|
||||
mutable.put("hijack", new BridgedConfig.Slot("gx10"));
|
||||
|
||||
assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state");
|
||||
assertFalse(r.isSlot("architect:hijack"), "a handed-over map is not offered as live state");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.ltms.bridged.config;
|
||||
|
||||
import dev.ltms.bridged.auth.MemberRegistry;
|
||||
import dev.ltms.bridged.peer.MemberRole;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
@@ -63,13 +64,12 @@ class BridgedConfigTest {
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
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 {
|
||||
void loadsMultipleProfilesAndPicksADevDefaultFromTheirPool(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("multi.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
@@ -79,7 +79,12 @@ class BridgedConfigTest {
|
||||
ollama:
|
||||
baseUrl: http://ollama.ltms.dev
|
||||
argv: ["ccs", "ollama"]
|
||||
defaultProfile: gx10
|
||||
fleet:
|
||||
developers:
|
||||
gx10:
|
||||
profile: gx10
|
||||
ollama:
|
||||
profile: ollama
|
||||
guard:
|
||||
offSubscriptionHosts: [gx10.gw, ollama.ltms.dev]
|
||||
""");
|
||||
@@ -91,7 +96,10 @@ class BridgedConfigTest {
|
||||
assertEquals(java.util.List.of("gx10", "ollama"),
|
||||
java.util.List.copyOf(cfg.profiles().keySet()),
|
||||
"profiles must preserve YAML definition order");
|
||||
assertEquals("gx10", cfg.defaultProfile());
|
||||
assertEquals(List.of("gx10", "ollama"), cfg.candidateProfiles(MemberRole.DEV),
|
||||
"the dev pool supplies the candidates, in definition order");
|
||||
assertEquals("gx10", cfg.effectiveDefaultProfile(),
|
||||
"the fixed policy answers with the pool's first entry");
|
||||
assertEquals("ollama", cfg.profiles().get("ollama").profile(), "profile defaults to its map key");
|
||||
assertEquals("http://gx10.gw:8000", cfg.profiles().get("gx10").baseUrl());
|
||||
}
|
||||
@@ -124,7 +132,6 @@ class BridgedConfigTest {
|
||||
port: 8080
|
||||
herdrSocket: /tmp/s
|
||||
profiles: {}
|
||||
defaultProfile: a
|
||||
guard: {}
|
||||
worktreeRoot: /tmp
|
||||
lifecycle: {}
|
||||
@@ -132,9 +139,7 @@ class BridgedConfigTest {
|
||||
spawnReadyPollMs: 1
|
||||
broker: {}
|
||||
primary: {}
|
||||
leaders: {}
|
||||
members: {}
|
||||
leadScan: {}
|
||||
fleet: {}
|
||||
leadHeartbeat: {}
|
||||
placement: fixed
|
||||
auth: {}
|
||||
@@ -150,38 +155,93 @@ class BridgedConfigTest {
|
||||
// ── CB-531: lead discovery by tab label ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void leadScanIsOffUnlessTheBlockIsPresent(@TempDir Path dir) throws Exception {
|
||||
void leadScanIsOffUnlessALeadIsConfigured(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-scan.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\n");
|
||||
|
||||
assertNull(BridgedConfig.load(f).leadScan(),
|
||||
assertTrue(BridgedConfig.load(f).fleet().leaders().isEmpty(),
|
||||
"turning this on widens who resolves as PRIMARY — upgrading the daemon must not do that");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leadScanDefaultsItsFieldsWhenTheBlockIsPresentButBare(@TempDir Path dir) throws Exception {
|
||||
void aLeadDefaultsItsScanFieldsWhenPresentButBare(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bare-scan.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\nleadScan: {}\n");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
terminal: term_opus
|
||||
""");
|
||||
|
||||
BridgedConfig.LeadScan scan = BridgedConfig.load(f).leadScan();
|
||||
assertEquals("lead:", scan.tabPrefix());
|
||||
assertEquals(10, scan.intervalSeconds());
|
||||
BridgedConfig.Leader lead = BridgedConfig.load(f).fleet().leaders().get("opus");
|
||||
assertEquals("lead:", lead.tabPrefix());
|
||||
assertEquals(10, lead.scanIntervalSeconds());
|
||||
assertEquals(1, lead.instances(), "one of a lead is the assumption worth defaulting to");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leadScanReadsAnExplicitPrefixAndInterval(@TempDir Path dir) throws Exception {
|
||||
void aLeadReadsAnExplicitPrefixAndInterval(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("scan.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
leadScan:
|
||||
tabPrefix: "drive:"
|
||||
intervalSeconds: 30
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
terminal: term_opus
|
||||
tabPrefix: "drive:"
|
||||
scanIntervalSeconds: 30
|
||||
""");
|
||||
|
||||
BridgedConfig.LeadScan scan = BridgedConfig.load(f).leadScan();
|
||||
assertEquals("drive:", scan.tabPrefix());
|
||||
assertEquals(30, scan.intervalSeconds());
|
||||
BridgedConfig.Leader lead = BridgedConfig.load(f).fleet().leaders().get("opus");
|
||||
assertEquals("drive:", lead.tabPrefix());
|
||||
assertEquals(30, lead.scanIntervalSeconds());
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane no longer has to exist before the daemon does (CB-557): a lead naming a profile may
|
||||
* be launched, while one that names only a terminal is recognised and never created.
|
||||
*/
|
||||
@Test
|
||||
void aLeadIsCreatableOnlyWhenItNamesAProfile(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("creatable.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
profiles:
|
||||
opus:
|
||||
subscription: true
|
||||
fleet:
|
||||
leaders:
|
||||
launched:
|
||||
profile: opus
|
||||
pinned:
|
||||
terminal: term_opus
|
||||
""");
|
||||
|
||||
var leaders = BridgedConfig.load(f).fleet().leaders();
|
||||
assertTrue(leaders.get("launched").isCreatable());
|
||||
assertFalse(leaders.get("pinned").isCreatable(),
|
||||
"no profile to launch on ⇒ recognise-only, the pre-CB-557 behaviour");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLeadThatCanBeNeitherFoundNorCreatedRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("useless-lead.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
leaders:
|
||||
ghost:
|
||||
tabPrefix: "lead:"
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
|
||||
}
|
||||
|
||||
// ── CB-551: the idle-lead heartbeat ─────────────────────────────────────────────────────────
|
||||
@@ -234,7 +294,8 @@ class BridgedConfigTest {
|
||||
* Overlap the two and every worker it spawns is read back as a lead.
|
||||
*/
|
||||
@Test
|
||||
void aLeadPrefixThatAWorkerTabLabelAlsoMatchesRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
void aLeadPrefixThatAProfileTabLabelOverrideAlsoMatchesRefusesToStart(@TempDir Path dir)
|
||||
throws Exception {
|
||||
Path f = dir.resolve("collide.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -242,17 +303,45 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
gx10:
|
||||
tabLabel: "lead: {profile} #{n}"
|
||||
leadScan:
|
||||
tabPrefix: "lead:"
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
terminal: term_opus
|
||||
tabPrefix: "lead:"
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateLeadScan);
|
||||
IllegalStateException e =
|
||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||
}
|
||||
|
||||
/** A bad fleet-wide template promotes every member, not one profile — so it is checked too. */
|
||||
@Test
|
||||
void theDefaultWorkerTabLabelDoesNotCollideWithTheDefaultLeadPrefix(@TempDir Path dir) throws Exception {
|
||||
void aFleetTabLabelThatMatchesALeadPrefixRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("collide-template.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
tabLabel: "lead: {role} {profile}"
|
||||
leaders:
|
||||
opus:
|
||||
terminal: term_opus
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e =
|
||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of making role the label's first field: {@code {role}} comes from a closed enum, so
|
||||
* a generated label cannot begin with {@code "lead:"} however the fleet is configured.
|
||||
*/
|
||||
@Test
|
||||
void theDefaultTabLabelCannotCollideWithTheDefaultLeadPrefix(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("ok.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -260,14 +349,22 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
leadScan: {}
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
terminal: term_opus
|
||||
""");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadScan());
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadTabPrefixes());
|
||||
for (MemberRole role : MemberRole.values()) {
|
||||
assertFalse(BridgedConfig.Fleet.DEFAULT_TAB_LABEL
|
||||
.replace("{role}", role.wireName()).startsWith("lead:"),
|
||||
"no role renders a label that reads as a lead");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCollisionGuardIsANoOpWhenScanningIsOff(@TempDir Path dir) throws Exception {
|
||||
void theCollisionGuardIsANoOpWhenNoLeadIsConfigured(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("off.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -277,7 +374,7 @@ class BridgedConfigTest {
|
||||
tabLabel: "lead: {profile}"
|
||||
""");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadScan(),
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadTabPrefixes(),
|
||||
"a label that collides with a convention nobody reads is not a problem");
|
||||
}
|
||||
|
||||
@@ -289,21 +386,23 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
leaders:
|
||||
opus-5.0:
|
||||
terminal: term_opus
|
||||
kind: claude
|
||||
gpt-sol-5.6:
|
||||
terminal: term_sol
|
||||
kind: opencode
|
||||
model: openai/gpt-5.6-terra
|
||||
fleet:
|
||||
leaders:
|
||||
opus-5.0:
|
||||
terminal: term_opus
|
||||
kind: claude
|
||||
gpt-sol-5.6:
|
||||
terminal: term_sol
|
||||
kind: opencode
|
||||
model: openai/gpt-5.6-terra
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
var leaders = cfg.fleet().leaders();
|
||||
|
||||
assertEquals(Set.of("opus-5.0", "gpt-sol-5.6"), cfg.leaders().keySet());
|
||||
assertEquals("opencode", cfg.leaders().get("gpt-sol-5.6").kind());
|
||||
assertEquals("openai/gpt-5.6-terra", cfg.leaders().get("gpt-sol-5.6").model());
|
||||
assertEquals(Set.of("opus-5.0", "gpt-sol-5.6"), leaders.keySet());
|
||||
assertEquals("opencode", leaders.get("gpt-sol-5.6").kind());
|
||||
assertEquals("openai/gpt-5.6-terra", leaders.get("gpt-sol-5.6").model());
|
||||
// The whole point: BOTH panes resolve as leads, so neither is demoted to worker.
|
||||
assertEquals(Map.of("term_opus", "opus-5.0", "term_sol", "gpt-sol-5.6"),
|
||||
cfg.leaderTerminals());
|
||||
@@ -326,9 +425,10 @@ class BridgedConfigTest {
|
||||
port: 8080
|
||||
primary:
|
||||
terminal: term_shared
|
||||
leaders:
|
||||
opus-5.0:
|
||||
terminal: term_shared
|
||||
fleet:
|
||||
leaders:
|
||||
opus-5.0:
|
||||
terminal: term_shared
|
||||
""");
|
||||
|
||||
assertEquals(Map.of("term_shared", "opus-5.0"), BridgedConfig.load(f).leaderTerminals(),
|
||||
@@ -343,9 +443,10 @@ class BridgedConfigTest {
|
||||
port: 8080
|
||||
primary:
|
||||
terminal: term_pinned
|
||||
leaders:
|
||||
gpt-sol-5.6:
|
||||
terminal: term_sol
|
||||
fleet:
|
||||
leaders:
|
||||
gpt-sol-5.6:
|
||||
terminal: term_sol
|
||||
""");
|
||||
|
||||
assertEquals(Map.of("term_pinned", "primary", "term_sol", "gpt-sol-5.6"),
|
||||
@@ -367,11 +468,12 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
leaders:
|
||||
sketch:
|
||||
kind: opencode
|
||||
real:
|
||||
terminal: term_real
|
||||
fleet:
|
||||
leaders:
|
||||
sketch:
|
||||
kind: opencode
|
||||
real:
|
||||
terminal: term_real
|
||||
""");
|
||||
|
||||
assertEquals(Map.of("term_real", "real"), BridgedConfig.load(f).leaderTerminals());
|
||||
@@ -380,7 +482,7 @@ class BridgedConfigTest {
|
||||
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void architectsBlockDeclaresSlotsByNameAndProfileOnly(@TempDir Path dir) throws Exception {
|
||||
void aRolePoolDeclaresSlotsByNameAndProfileOnly(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("architects.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -388,28 +490,81 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
second-opinion:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(Set.of("lead-designer", "reviewer"), cfg.members().keySet(),
|
||||
"slot names are the keys — gateway-local unique by construction");
|
||||
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
|
||||
"each slot carries its strong-model profile reference");
|
||||
assertEquals("sonnet", cfg.members().get("reviewer").profile());
|
||||
var pool = cfg.fleet().pool(MemberRole.ARCHITECT);
|
||||
assertEquals(Set.of("lead-designer", "second-opinion"), pool.keySet(),
|
||||
"slot names are the keys — unique within their pool by construction");
|
||||
assertEquals("sonnet", pool.get("lead-designer").profile(),
|
||||
"each slot carries its backend reference");
|
||||
assertEquals("sonnet", pool.get("second-opinion").profile());
|
||||
}
|
||||
|
||||
/**
|
||||
* The role is the containing key now (CB-557), so it cannot be misspelled into a member with no
|
||||
* contract. A pool name that is not a role is simply not a pool.
|
||||
*/
|
||||
@Test
|
||||
void theRoleIsTheContainingKeyNotAField(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("role-by-key.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
fleet:
|
||||
developers:
|
||||
a:
|
||||
profile: sonnet
|
||||
reviewers:
|
||||
b:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.DEV));
|
||||
assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.REVIEWER));
|
||||
assertTrue(cfg.fleet().profilesFor(MemberRole.ARCHITECT).isEmpty());
|
||||
assertEquals(List.of(MemberRole.DEV, MemberRole.REVIEWER), cfg.fleet().rolesConfigured());
|
||||
}
|
||||
|
||||
/** The case the two axes exist for: one backend, two roles, and neither is a duplicate. */
|
||||
@Test
|
||||
void oneProfileMayServeSeveralRoles(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("shared.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
fleet:
|
||||
developers:
|
||||
sonnet:
|
||||
profile: sonnet
|
||||
reviewers:
|
||||
sonnet:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertDoesNotThrow(cfg::validateMembers);
|
||||
assertEquals("sonnet", cfg.defaultProfileFor(MemberRole.DEV));
|
||||
assertEquals("sonnet", cfg.defaultProfileFor(MemberRole.REVIEWER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectCarriesNoConfigTerminalSoNothingIsRecognisedYet(@TempDir Path dir) throws Exception {
|
||||
// The corrected CB-548 premise: config declares slots (name + profile) only. A `terminal:`
|
||||
// key left over from the earlier premise is ignored — an architect is NOT recognised from
|
||||
// config the way a lead is, so it binds nothing at startup and resolves no architect.
|
||||
void aSlotCarriesNoConfigTerminalSoNothingIsRecognisedYet(@TempDir Path dir) throws Exception {
|
||||
// Config declares slots (name + profile) only. A `terminal:` key is ignored — a member is
|
||||
// NOT recognised from config the way a lead is, so it binds nothing at startup.
|
||||
Path f = dir.resolve("arch-stale-terminal.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -417,35 +572,38 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
|
||||
assertEquals("sonnet", cfg.fleet().pool(MemberRole.ARCHITECT).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.
|
||||
MemberRegistry r = new MemberRegistry(cfg.members());
|
||||
MemberRegistry r = new MemberRegistry(cfg.fleet());
|
||||
assertTrue(r.snapshot().isEmpty());
|
||||
assertNull(r.slotForTerminal("term_design"),
|
||||
"a config terminal must not resolve an architect — slots start idle");
|
||||
"a config terminal must not resolve a member — slots start idle");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noArchitectsBlockLeavesNothingConfigured(@TempDir Path dir) throws Exception {
|
||||
void noFleetBlockLeavesEveryPoolEmpty(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-arch.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\n");
|
||||
|
||||
assertNull(BridgedConfig.load(f).members(),
|
||||
"no members: block ⇒ no architect identity, exactly as before CB-548");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertNotNull(cfg.fleet(), "an absent block is an empty fleet, not a null one");
|
||||
assertTrue(cfg.fleet().rolesConfigured().isEmpty(),
|
||||
"no fleet: block ⇒ no member identity, exactly as before CB-548");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception {
|
||||
// Even a single unqualified worker profile can back an architect slot — the reference is
|
||||
// by name, not by position, so an explicit name is required.
|
||||
void aSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception {
|
||||
// Even a single unqualified profile must be named explicitly — the reference is by name,
|
||||
// not by position.
|
||||
Path f = dir.resolve("arch-single.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -453,15 +611,16 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: ltms-local
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: ltms-local
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertDoesNotThrow(cfg::validateMembers);
|
||||
assertEquals("ltms-local", cfg.members().get("lead-designer").profile());
|
||||
assertEquals("ltms-local",
|
||||
cfg.fleet().pool(MemberRole.ARCHITECT).get("lead-designer").profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -473,10 +632,10 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
@@ -494,10 +653,10 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: ""
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: ""
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
@@ -516,13 +675,13 @@ class BridgedConfigTest {
|
||||
baseUrl: http://gx10.gw:8000
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
role: architect
|
||||
profile: gx10
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
reviewers:
|
||||
second-pair-of-eyes:
|
||||
profile: gx10
|
||||
""");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers());
|
||||
@@ -537,7 +696,7 @@ class BridgedConfigTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateArchitectSlotNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
|
||||
void duplicateSlotNamesInOnePoolAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("arch-dup.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -545,26 +704,52 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
IllegalStateException e =
|
||||
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 member"),
|
||||
"the refusal says the slot name is duplicated");
|
||||
assertTrue(e.getMessage().contains("fleet.architects"),
|
||||
"the refusal names the pool the duplicate is in, was: " + e.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same name in two different pools is the role × profile matrix, not a mistake — only a
|
||||
* repeat <em>within</em> one pool loses an entry.
|
||||
*/
|
||||
@Test
|
||||
void theSameSlotNameInTwoPoolsIsNotADuplicate(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("cross-pool.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
fleet:
|
||||
developers:
|
||||
sonnet:
|
||||
profile: sonnet
|
||||
reviewers:
|
||||
sonnet:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
|
||||
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.DEV).keySet());
|
||||
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateKeysOutsideArchitectsAreUnaffected(@TempDir Path dir) throws Exception {
|
||||
// The duplicate check is scoped to the architects block — a duplicate elsewhere is not this
|
||||
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
||||
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
||||
// guard's concern and must not change parsing of the rest of the config.
|
||||
Path f = dir.resolve("dup-other.yaml");
|
||||
Files.writeString(f, """
|
||||
@@ -576,47 +761,47 @@ class BridgedConfigTest {
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
""");
|
||||
// Last-wins for a non-architect duplicate is untouched: only the architects block is walked.
|
||||
// Last-wins for a non-pool duplicate is untouched: only the fleet pools are walked.
|
||||
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).profiles().keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNestedArchitectsFieldDoesNotSuppressRealDuplicateDetection(@TempDir Path dir) throws Exception {
|
||||
// A field ALSO named `architects` nested under another block carries its own duplicate and
|
||||
// sits BEFORE the real top-level block. Only the top-level block is ever inspected: the
|
||||
// refusal must name the real slot (lead-designer), not the nested one (nested-slot).
|
||||
void aNestedFleetFieldDoesNotSuppressRealDuplicateDetection(@TempDir Path dir) throws Exception {
|
||||
// A field ALSO named `fleet` nested under another block carries its own duplicate and sits
|
||||
// BEFORE the real top-level block. Only the top-level block is ever inspected: the refusal
|
||||
// must name the real slot (lead-designer), not the nested one (nested-slot).
|
||||
Path f = dir.resolve("nested-arch.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
members:
|
||||
nested-slot:
|
||||
k: v
|
||||
nested-slot:
|
||||
k: v
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
fleet:
|
||||
architects:
|
||||
nested-slot:
|
||||
k: v
|
||||
nested-slot:
|
||||
k: v
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
IllegalStateException e =
|
||||
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 member"),
|
||||
"the refusal says the slot name is duplicated");
|
||||
assertFalse(e.getMessage().contains("nested-slot"),
|
||||
"the nested block must not be inspected, was: " + e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedDuplicateFieldsInsideASlotAreNotDuplicateSlotNames(@TempDir Path dir) throws Exception {
|
||||
// A duplicated field nested inside one slot's own value (here inside an ignored `extra:`
|
||||
// sub-block) is not a duplicate SLOT name — it must not be rejected as one. Only the direct
|
||||
// child keys of the architects mapping are slot names; whatever is deeper is the slot's
|
||||
// business and must not masquerade as a duplicate slot.
|
||||
// child keys of a pool mapping are slot names; whatever is deeper is the slot's business and
|
||||
// must not masquerade as a duplicate slot.
|
||||
Path f = dir.resolve("nested-dup-inside-slot.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
@@ -624,20 +809,21 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
extra:
|
||||
a: 1
|
||||
a: 1
|
||||
fleet:
|
||||
architects:
|
||||
lead-designer:
|
||||
profile: sonnet
|
||||
extra:
|
||||
a: 1
|
||||
a: 1
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
|
||||
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.members().keySet());
|
||||
assertEquals("sonnet", cfg.members().get("lead-designer").profile());
|
||||
var pool = cfg.fleet().pool(MemberRole.ARCHITECT);
|
||||
assertEquals(Set.of("lead-designer"), pool.keySet());
|
||||
assertEquals("sonnet", pool.get("lead-designer").profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -853,7 +1039,7 @@ class BridgedConfigTest {
|
||||
BridgedConfig cfg = BridgedConfig.load(example);
|
||||
assertEquals(8765, cfg.bind().port(), "example binds the documented default port");
|
||||
assertTrue(cfg.profiles().containsKey("gx10"), "example documents the gx10 profile");
|
||||
assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves");
|
||||
assertEquals("gx10", cfg.effectiveDefaultProfile(), "example's dev pool resolves");
|
||||
assertTrue(cfg.guard().hostSet().contains("gx01.gw"),
|
||||
"every example profile's base_url host must be in the example allowlist");
|
||||
}
|
||||
@@ -1095,8 +1281,8 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
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("'architects' is now 'fleet.architects'"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("'defaultWorker' is now a role pool"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("'workers' is now 'profiles'"), e.getMessage());
|
||||
}
|
||||
|
||||
@@ -1109,20 +1295,21 @@ class BridgedConfigTest {
|
||||
baseUrl: http://gx00.gw:8000
|
||||
opus:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
members:
|
||||
architect-1:
|
||||
role: architect
|
||||
profile: opus
|
||||
reviewer-1:
|
||||
role: reviewer
|
||||
profile: gx10
|
||||
fleet:
|
||||
architects:
|
||||
architect-1:
|
||||
profile: opus
|
||||
reviewers:
|
||||
reviewer-1:
|
||||
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());
|
||||
assertEquals("opus", cfg.fleet().pool(MemberRole.ARCHITECT).get("architect-1").profile());
|
||||
assertEquals("gx10", cfg.fleet().pool(MemberRole.REVIEWER).get("reviewer-1").profile());
|
||||
assertEquals(List.of(MemberRole.ARCHITECT, MemberRole.REVIEWER),
|
||||
cfg.fleet().rolesConfigured(), "the pool a slot sits in IS its role");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1136,56 +1323,77 @@ class BridgedConfigTest {
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
members:
|
||||
dev-1:
|
||||
role: dev
|
||||
profile: gx10
|
||||
reviewer-1:
|
||||
role: reviewer
|
||||
profile: gx10
|
||||
fleet:
|
||||
developers:
|
||||
dev-1:
|
||||
profile: gx10
|
||||
reviewers:
|
||||
reviewer-1:
|
||||
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());
|
||||
assertEquals(cfg.fleet().pool(MemberRole.DEV).get("dev-1").profile(),
|
||||
cfg.fleet().pool(MemberRole.REVIEWER).get("reviewer-1").profile(),
|
||||
"one backend, two roles — the axes are independent");
|
||||
assertEquals("gx10", cfg.defaultProfileFor(MemberRole.DEV));
|
||||
assertEquals("gx10", cfg.defaultProfileFor(MemberRole.REVIEWER));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the pool shape bought over the old {@code role:} field. A misspelled role used to parse
|
||||
* into a member with no contract, so it needed catching by name. Now it is a pool name nobody
|
||||
* reads: the slot simply does not exist, and no member can run under a role that is not one.
|
||||
*/
|
||||
@Test
|
||||
void aMemberSlotWithAnUnknownRoleIsRejectedAndListsTheValidRoles(@TempDir Path dir) throws Exception {
|
||||
void aMisspelledPoolNameDeclaresNoMembersRatherThanRolelessOnes(@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
|
||||
fleet:
|
||||
archtiects:
|
||||
slot-1:
|
||||
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());
|
||||
assertDoesNotThrow(cfg::validateMembers);
|
||||
assertTrue(cfg.fleet().rolesConfigured().isEmpty(),
|
||||
"a pool name that is not a role declares nothing at all");
|
||||
assertNull(MemberRole.fromConfigKey("archtiects"));
|
||||
}
|
||||
|
||||
/** Every role's pool key must round-trip, or a correctly-spelled block would be dropped. */
|
||||
@Test
|
||||
void everyRolePoolKeyRoundTrips() {
|
||||
for (MemberRole role : MemberRole.values()) {
|
||||
assertEquals(role, MemberRole.fromConfigKey(role.configKey()),
|
||||
role + " must be readable back from the key it is written under");
|
||||
}
|
||||
assertNull(MemberRole.fromConfigKey("leaders"), "a lead is not a member role");
|
||||
assertNull(MemberRole.fromConfigKey("tabLabel"), "a non-pool fleet key is not a role");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMemberSlotWithNoRoleIsRejected(@TempDir Path dir) throws Exception {
|
||||
void aSlotWithNoProfileIsRejectedAndNamesItsPool(@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
|
||||
fleet:
|
||||
developers:
|
||||
slot-1: {}
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||
assertTrue(e.getMessage().contains("has no role:"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("fleet.developers.slot-1"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("has no profile:"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1221,7 +1429,9 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
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");
|
||||
assertTrue(cfg.fleet().profilesFor(MemberRole.DEV).isEmpty(), "no dev pool is configured");
|
||||
assertEquals("gx10", cfg.effectiveDefaultProfile(),
|
||||
"with no pool to choose from, every configured profile is a candidate and the "
|
||||
+ "first one wins");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.AgentControl;
|
||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||
import dev.ltms.bridged.peer.Capability;
|
||||
import dev.ltms.bridged.peer.MemberRole;
|
||||
import dev.ltms.bridged.peer.PeerHandle;
|
||||
import dev.ltms.bridged.peer.PeerUnreachableException;
|
||||
import dev.ltms.bridged.peer.SpawnRequest;
|
||||
@@ -782,4 +783,79 @@ class ClaudeCodeLauncherTest {
|
||||
assertEquals("/opt/jdk", env.get("JAVA_HOME"),
|
||||
"only the Anthropic binding keys are stripped; the rest of env: still applies");
|
||||
}
|
||||
|
||||
// ── CB-557: role-aware tab labels ─────────────────────────────────────────────────────────
|
||||
|
||||
/** The {@code label} of every {@code tab.rename}, in call order. */
|
||||
private List<String> tabLabels(FakeHerdr herdr) {
|
||||
return herdr.calls.stream()
|
||||
.filter(c -> c.method().equals("tab.rename"))
|
||||
.map(c -> (String) ((Map<?, ?>) c.params()).get("label"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** A profile with no {@code tabLabel:} of its own — the fleet template decides. */
|
||||
private ClaudeCodeLauncher labelService(FakeHerdr herdr, String fleetTemplate) {
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"sonnet", "http://gx00.gw:8000", "sonnet", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", null, null, null, null);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
_ -> null, 0, 0L, fleetTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The knob must reach the rename call. It was inert once — {@code HerdrPeerLauncher} accepted a
|
||||
* template while {@code Bridged} passed none, and the label stayed right only because the
|
||||
* fallback happened to match. Pin the wiring, not the coincidence.
|
||||
*/
|
||||
@Test
|
||||
void theFleetTemplateNamesTheRoleTheMemberWasSpawnedFor() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
ClaudeCodeLauncher svc = labelService(herdr, "{role}: {profile} #{n}");
|
||||
|
||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
|
||||
|
||||
assertEquals(List.of("reviewer: sonnet #1"), tabLabels(herdr));
|
||||
}
|
||||
|
||||
/** The counter is per role+profile, so a dev and a reviewer on one profile both start at #1. */
|
||||
@Test
|
||||
void theCounterRunsPerRoleAndProfileNotPerFleet() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
ClaudeCodeLauncher svc = labelService(herdr, "{role}: {profile} #{n}");
|
||||
|
||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
|
||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
|
||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
|
||||
|
||||
assertEquals(List.of("dev: sonnet #1", "reviewer: sonnet #1", "dev: sonnet #2"),
|
||||
tabLabels(herdr));
|
||||
}
|
||||
|
||||
/** No fleet template configured ⇒ the built-in default, still role-first. */
|
||||
@Test
|
||||
void aBlankFleetTemplateFallsBackToTheRoleFirstDefault() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
labelService(herdr, null).spawn(
|
||||
new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT));
|
||||
|
||||
assertEquals(List.of("architect: sonnet #1"), tabLabels(herdr));
|
||||
assertEquals("{role}: {profile} #{n}", BridgedConfig.Fleet.DEFAULT_TAB_LABEL);
|
||||
}
|
||||
|
||||
/** A profile that wants its own label still outranks the fleet template. */
|
||||
@Test
|
||||
void aProfileTabLabelOverridesTheFleetTemplate() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"sonnet", "http://gx00.gw:8000", "sonnet", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "pinned {profile}", null, null, null);
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
_ -> null, 0, 0L, "{role}: {profile} #{n}")
|
||||
.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
|
||||
|
||||
assertEquals(List.of("pinned sonnet"), tabLabels(herdr));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user