diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java index 4e0afa1a..5827df74 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -2956,27 +2956,29 @@ public record FleetConfig( } /** - * Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders} - * or {@code fleet.collaborators} entry names a {@code tab}. A pane-placed member lands inside - * the focused tab rather than its own, so it can land inside a lead's or collaborator's own - * labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead or collaborator - * purely by that tab's label — it does not exclude the member space — so a member that ends up - * there, while its pane carries no entry in the spawned-member roster, is read back as that - * lead or collaborator and granted that identity's authority. + * Reject a profile that places its members by {@code "pane"} while {@code fleet.leaders} has + * any entry, or any {@code fleet.collaborators} entry names a {@code tab}. A pane-placed + * member lands inside the focused tab rather than its own, so it can land inside a lead's or + * collaborator's own labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a + * lead or collaborator purely by that tab's label — it does not exclude the member space — so + * a member that ends up there, while its pane carries no entry in the spawned-member roster, + * is read back as that lead or collaborator and granted that identity's authority. * - *

Only an entry with a non-blank {@code tab} is in scope: one with no {@code tab} feeds + *

Every {@code fleet.leaders} entry is in scope regardless of its own {@code tab} field: + * {@link Leader#acceptedLabels()} always includes {@link Leader#LEAD_TAB_LABEL}. Only a + * collaborator with a non-blank {@code tab} is in scope: one with no {@code tab} feeds * nothing into {@link dev.ltms.fleet.herdr.LeadTabScanner}, so it creates no hazard here. * - * @throws IllegalStateException when any {@code profiles:} entry is pane-placed while any - * {@code fleet.leaders} or {@code fleet.collaborators} entry - * names a non-blank {@code tab} + * @throws IllegalStateException when any {@code profiles:} entry is pane-placed while + * {@code fleet.leaders} is non-empty, or any + * {@code fleet.collaborators} entry names a non-blank + * {@code tab} */ public void validatePanePlacementAgainstLeadTabs() { if (fleet == null) { return; } - boolean anyLeaderHasTab = fleet.leaders().values().stream() - .anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank()); + boolean anyLeaderHasTab = !fleet.leaders().isEmpty(); boolean anyCollaboratorHasTab = fleet.collaborators().values().stream() .anyMatch(c -> c != null && c.tab() != null && !c.tab().isBlank()); if (!anyLeaderHasTab && !anyCollaboratorHasTab) { @@ -2996,8 +2998,9 @@ public record FleetConfig( + "pane-placed member can land inside that labelled tab, and while its pane " + "carries no entry in the spawned-member roster, it is read back as the lead or " + "collaborator and granted that identity's authority. Set placement: tab for " - + "each named profile, or remove the tab from every fleet.leaders and " - + "fleet.collaborators entry."); + + "each named profile — the only fix when a lead triggered this, since a lead's " + + "tab label is fixed regardless of its own tab: field. A collaborator's tab can " + + "still be removed instead."); } /** diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java index 6bc4216c..fdfc1d88 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -923,8 +923,12 @@ class FleetConfigTest { assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile"); } + /** + * A lead is found by its fixed tab label regardless of its own {@code tab} field, so a + * {@code fleet.leaders} entry with no {@code tab} must still arm the guard. + */ @Test - void aPanePlacedProfileWithNoLeadTabIsAllowed(@TempDir Path dir) throws Exception { + void aPanePlacedProfileWithNoLeadTabRefusesToStart(@TempDir Path dir) throws Exception { Path f = dir.resolve("pane-no-tab.yaml"); Files.writeString(f, """ bind: @@ -937,9 +941,59 @@ class FleetConfigTest { opus: profile: gx10 """); + FleetConfig cfg = FleetConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, + cfg::validatePanePlacementAgainstLeadTabs); + assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile"); + assertTrue(e.getMessage().contains("the only fix when a lead triggered this"), + "the message must say placement: tab is the only fix for a lead"); + assertFalse(e.getMessage().contains("remove the tab from every fleet.leaders"), + "the message must not send the operator in a circle by advising a tab: removal"); + } + + /** + * {@code placement:} is optional, and {@link FleetConfig.Profile}'s own compact constructor + * defaults an absent or blank value to {@code "tab"}, so a profile naming no placement at all + * is tab-placed and the guard must not fire for it. + */ + @Test + void aProfileWithNoPlacementKeyDefaultsToTabPlacementAndIsAllowed(@TempDir Path dir) + throws Exception { + Path f = dir.resolve("no-placement-key.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + gx10: {} + fleet: + leaders: + opus: + profile: gx10 + """); + FleetConfig cfg = FleetConfig.load(f); + + assertTrue(cfg.profiles().get("gx10").tabPlacement(), + "Profile's compact constructor defaults an absent placement to \"tab\""); + assertDoesNotThrow(cfg::validatePanePlacementAgainstLeadTabs, + "a profile with no placement: key is tab-placed, not pane-placed"); + } + + /** Control: no {@code fleet.leaders} entry and no collaborator tab still starts fine. */ + @Test + void aPanePlacedProfileWithAnEmptyFleetBlockIsAllowed(@TempDir Path dir) throws Exception { + Path f = dir.resolve("pane-empty-fleet.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + gx10: + placement: pane + fleet: {} + """); assertDoesNotThrow(() -> FleetConfig.load(f).validatePanePlacementAgainstLeadTabs(), - "a leader with no tab feeds nothing into the scanner, so pane placement is safe"); + "no fleet.leaders entry and no collaborator tab means pane placement is safe"); } @Test