This commit is contained in:
@@ -669,6 +669,15 @@ fleet:
|
|||||||
# kind: opencode
|
# kind: opencode
|
||||||
# model: openai/gpt-5.6-terra
|
# model: openai/gpt-5.6-terra
|
||||||
|
|
||||||
|
# A collaborator tab, keyed by name (fleetd #669). This block is parsed and validated today;
|
||||||
|
# nothing yet recognises or addresses the tab it names. Recognise-only, like a profile-less
|
||||||
|
# `leaders:` entry above: there is no `profile:`, no `instances:` and no `kind:`. `tab:` is
|
||||||
|
# REQUIRED and is the only field identity depends on, matched case-insensitively — the same
|
||||||
|
# GET-THE-VALUE-RIGHT warning above the `leaders:` block applies here too.
|
||||||
|
# collaborators:
|
||||||
|
# reviewer-alex:
|
||||||
|
# tab: "collab: alex"
|
||||||
|
|
||||||
# architects:
|
# architects:
|
||||||
# architect-1:
|
# architect-1:
|
||||||
# profile: opus # a strong model, on the operator's subscription
|
# profile: opus # a strong model, on the operator's subscription
|
||||||
|
|||||||
@@ -1176,6 +1176,25 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab fleetd recognises as a collaborator, keyed by name (fleetd #669).
|
||||||
|
*
|
||||||
|
* <p>Recognise-only: there is no {@code profile}, no {@code instances} and no {@code kind}.
|
||||||
|
* Nothing here ever launches a pane.
|
||||||
|
*
|
||||||
|
* <p>{@code tabPrefix} is absent. Identity is matched on the exact {@code tab} alone.
|
||||||
|
*
|
||||||
|
* @param tab the exact tab label hosting this collaborator, matched case-insensitively; the
|
||||||
|
* only field identity depends on. Required — an entry with no {@code tab} can
|
||||||
|
* never be discovered.
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Collaborator(String tab) {
|
||||||
|
public Collaborator {
|
||||||
|
tab = (tab == null || tab.isBlank()) ? null : tab.strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One entry of a {@code fleet:} role pool — a role paired with the backend it runs on.
|
* One entry of a {@code fleet:} role pool — a role paired with the backend it runs on.
|
||||||
*
|
*
|
||||||
@@ -1213,15 +1232,18 @@ public record FleetConfig(
|
|||||||
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
|
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
|
||||||
* unqualified spawn names a role, and the role's pool supplies the candidates.
|
* unqualified spawn names a role, and the role's pool supplies the candidates.
|
||||||
*
|
*
|
||||||
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
|
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
|
||||||
* @param architects profiles the {@code architect} role may run on
|
* @param architects profiles the {@code architect} role may run on
|
||||||
* @param developers profiles the {@code dev} role may run on
|
* @param developers profiles the {@code dev} role may run on
|
||||||
* @param hunters profiles the {@code hunter} role may run on
|
* @param hunters profiles the {@code hunter} role may run on
|
||||||
* @param reviewers profiles the {@code reviewer} role may run on
|
* @param reviewers profiles the {@code reviewer} role may run on
|
||||||
* @param charters optional launch-charter text keyed by singular role wire name
|
* @param charters optional launch-charter text keyed by singular role wire name
|
||||||
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
|
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
|
||||||
* {@code {model}} and {@code {n}} (a per role+profile counter) are
|
* {@code {model}} and {@code {n}} (a per role+profile counter) are
|
||||||
* substituted. Default {@link #DEFAULT_TAB_LABEL}
|
* substituted. Default {@link #DEFAULT_TAB_LABEL}
|
||||||
|
* @param collaborators tabs fleetd recognises as collaborators (fleetd #669), keyed by name.
|
||||||
|
* Recognise-only, exactly like a {@code profile}-less {@link Leader}:
|
||||||
|
* nothing here is ever auto-launched.
|
||||||
*/
|
*/
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Fleet(Map<String, Leader> leaders,
|
public record Fleet(Map<String, Leader> leaders,
|
||||||
@@ -1230,7 +1252,8 @@ public record FleetConfig(
|
|||||||
Map<String, Slot> hunters,
|
Map<String, Slot> hunters,
|
||||||
Map<String, Slot> reviewers,
|
Map<String, Slot> reviewers,
|
||||||
Map<String, String> charters,
|
Map<String, String> charters,
|
||||||
String tabLabel) {
|
String tabLabel,
|
||||||
|
Map<String, Collaborator> collaborators) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role first, so the tab bar identifies the member's fleet role.
|
* Role first, so the tab bar identifies the member's fleet role.
|
||||||
@@ -1245,26 +1268,30 @@ public record FleetConfig(
|
|||||||
reviewers = unmodifiableOrEmpty(reviewers);
|
reviewers = unmodifiableOrEmpty(reviewers);
|
||||||
charters = unmodifiableOrEmpty(charters);
|
charters = unmodifiableOrEmpty(charters);
|
||||||
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
|
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
|
||||||
|
collaborators = unmodifiableOrEmpty(collaborators);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fleet with no configured launch charters — the shape every deployment had before
|
* A fleet with no configured launch charters and no collaborators — the shape every
|
||||||
* CB-566, and what most tests want.
|
* deployment had before CB-566, and what most tests want.
|
||||||
*
|
*
|
||||||
* <p>Kept deliberately, even though an overload that drops a new field is normally the
|
* <p>Kept deliberately, even though an overload that drops a new field is normally the
|
||||||
* shape to avoid. It is safe here because nothing <em>reads</em> a charter through a
|
* shape to avoid. It is safe here because nothing <em>reads</em> a charter through a
|
||||||
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
|
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
|
||||||
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
|
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
|
||||||
|
* {@code collaborators} is dropped the same way and for the same reason: no caller of
|
||||||
|
* this overload has ever needed to set it, so it defaults to empty here exactly as the
|
||||||
|
* canonical constructor would default an absent YAML key.
|
||||||
*/
|
*/
|
||||||
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
||||||
Map<String, Slot> developers, Map<String, Slot> reviewers,
|
Map<String, Slot> developers, Map<String, Slot> reviewers,
|
||||||
Map<String, String> charters, String tabLabel) {
|
Map<String, String> charters, String tabLabel) {
|
||||||
this(leaders, architects, developers, null, reviewers, charters, tabLabel);
|
this(leaders, architects, developers, null, reviewers, charters, tabLabel, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
||||||
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
|
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
|
||||||
this(leaders, architects, developers, null, reviewers, null, tabLabel);
|
this(leaders, architects, developers, null, reviewers, null, tabLabel, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1910,7 +1937,7 @@ public record FleetConfig(
|
|||||||
|
|
||||||
/** The {@code fleet:} child blocks whose direct children are slot names. */
|
/** The {@code fleet:} child blocks whose direct children are slot names. */
|
||||||
private static final Set<String> FLEET_POOL_KEYS =
|
private static final Set<String> FLEET_POOL_KEYS =
|
||||||
Set.of("leaders", "architects", "developers", "hunters", "reviewers");
|
Set.of("leaders", "architects", "developers", "hunters", "reviewers", "collaborators");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
|
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
|
||||||
@@ -1920,7 +1947,7 @@ public record FleetConfig(
|
|||||||
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
|
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
|
||||||
* default, so duplicates are caught here, at parse time, before the map is built.
|
* default, so duplicates are caught here, at parse time, before the map is built.
|
||||||
*
|
*
|
||||||
* <p>Only the five pools <em>directly under the top-level {@code fleet:}</em> are considered,
|
* <p>Only the six pools <em>directly under the top-level {@code fleet:}</em> are considered,
|
||||||
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
|
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
|
||||||
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
|
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
|
||||||
*
|
*
|
||||||
@@ -2679,17 +2706,21 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a member tab-label template that could render as a configured lead tab or match a
|
* Reject a member tab-label template that could render as a configured lead or collaborator
|
||||||
* lead-tab naming convention, and reject two {@code fleet.leaders} entries that share one exact
|
* tab or match a lead-tab naming convention, and reject two {@code fleet.leaders} or
|
||||||
* tab.
|
* {@code fleet.collaborators} entries — across either registry — that share one exact tab.
|
||||||
|
*
|
||||||
|
* <p>{@code fleet.collaborators} has no {@code tabPrefix}: identity is matched on the exact
|
||||||
|
* {@code tab} alone, so only the exact-render check applies there, not the prefix check.
|
||||||
*
|
*
|
||||||
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
|
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
|
||||||
* can render as a configured lead tab or match a lead-tab prefix,
|
* can render as a configured lead or collaborator tab or match a
|
||||||
* or when two {@code fleet.leaders} entries carry the same exact
|
* lead-tab prefix, or when two entries — of either registry, or
|
||||||
* {@code tab} (case-insensitively)
|
* one of each — carry the same exact {@code tab}
|
||||||
|
* (case-insensitively)
|
||||||
*/
|
*/
|
||||||
public void validateLeadTabPrefixes() {
|
public void validateLeadTabPrefixes() {
|
||||||
if (fleet == null || fleet.leaders().isEmpty()) {
|
if (fleet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> bad = new ArrayList<>();
|
List<String> bad = new ArrayList<>();
|
||||||
@@ -2722,11 +2753,32 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
fleet.collaborators().forEach((collabName, collaborator) -> {
|
||||||
|
if (collaborator == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String tab = collaborator.tab();
|
||||||
|
if (templateCanRenderAs(fleet.tabLabel(), tab)) {
|
||||||
|
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
|
||||||
|
+ "collaborator '" + collabName + "' (\"" + tab + "\")");
|
||||||
|
}
|
||||||
|
profiles().entrySet().stream()
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.sorted()
|
||||||
|
.forEach(p -> {
|
||||||
|
String label = profiles().get(p).tabLabel();
|
||||||
|
if (templateCanRenderAs(label, tab)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which can render as the tab of collaborator '"
|
||||||
|
+ collabName + "' (\"" + tab + "\")");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
if (!bad.isEmpty()) {
|
if (!bad.isEmpty()) {
|
||||||
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
|
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
|
||||||
+ ". Every member labelled that way would be read back as a lead and granted "
|
+ ". Every member labelled that way would be read back as a lead or "
|
||||||
+ "spawn/stop/send on the whole fleet. Change one of the two so member tabs "
|
+ "collaborator and granted that identity's authority. Change one of the two "
|
||||||
+ "and lead tabs cannot be confused.");
|
+ "so member tabs cannot be confused with a lead's or collaborator's tab.");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> collisions = new ArrayList<>();
|
List<String> collisions = new ArrayList<>();
|
||||||
@@ -2749,13 +2801,49 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
List<String> collabNames = fleet.collaborators().keySet().stream().sorted().toList();
|
||||||
|
for (int i = 0; i < collabNames.size(); i++) {
|
||||||
|
String nameA = collabNames.get(i);
|
||||||
|
Collaborator a = fleet.collaborators().get(nameA);
|
||||||
|
if (a == null || a.tab() == null || a.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int j = i + 1; j < collabNames.size(); j++) {
|
||||||
|
String nameB = collabNames.get(j);
|
||||||
|
Collaborator b = fleet.collaborators().get(nameB);
|
||||||
|
if (b == null || b.tab() == null || b.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.tab().equalsIgnoreCase(b.tab())) {
|
||||||
|
collisions.add("collaborator '" + nameA + "' and collaborator '" + nameB
|
||||||
|
+ "' both use tab \"" + a.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String leadName : leadNames) {
|
||||||
|
Leader lead = fleet.leaders().get(leadName);
|
||||||
|
if (lead == null || lead.tab() == null || lead.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String collabName : collabNames) {
|
||||||
|
Collaborator collaborator = fleet.collaborators().get(collabName);
|
||||||
|
if (collaborator == null || collaborator.tab() == null
|
||||||
|
|| collaborator.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lead.tab().equalsIgnoreCase(collaborator.tab())) {
|
||||||
|
collisions.add("lead '" + leadName + "' and collaborator '" + collabName
|
||||||
|
+ "' both use tab \"" + lead.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (collisions.isEmpty()) {
|
if (collisions.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
|
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
|
||||||
+ ". Tab identity is matched exactly, so only one of two leads sharing a tab can "
|
+ ". Tab identity is matched exactly, so only one of two entries sharing a tab can "
|
||||||
+ "ever be found — the other is silently unreachable. Give each lead its own "
|
+ "ever be found — the other is silently unreachable. Give each lead and "
|
||||||
+ "exact tab.");
|
+ "collaborator its own exact tab.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean templateCanRenderAs(String template, String tab) {
|
private static boolean templateCanRenderAs(String template, String tab) {
|
||||||
@@ -2785,25 +2873,28 @@ public record FleetConfig(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
|
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
|
||||||
* entry names a {@code tab}. A pane-placed member lands inside the focused tab rather than its
|
* or {@code fleet.collaborators} entry names a {@code tab}. A pane-placed member lands inside
|
||||||
* own, so it can land inside a lead's own labelled tab. {@link
|
* the focused tab rather than its own, so it can land inside a lead's or collaborator's own
|
||||||
* dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead purely by that tab's label — it does
|
* labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead or collaborator
|
||||||
* not exclude the member space — so a member that ends up there would be read back as the lead
|
* purely by that tab's label — it does not exclude the member space — so a member that ends up
|
||||||
* and granted spawn/stop/send on the whole fleet.
|
* there would be read back as that lead or collaborator and granted that identity's authority.
|
||||||
*
|
*
|
||||||
* <p>Only a leader with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
|
* <p>Only an entry 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.
|
* 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
|
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while any
|
||||||
* {@code fleet.leaders} entry names a non-blank {@code tab}
|
* {@code fleet.leaders} or {@code fleet.collaborators} entry
|
||||||
|
* names a non-blank {@code tab}
|
||||||
*/
|
*/
|
||||||
public void validatePanePlacementAgainstLeadTabs() {
|
public void validatePanePlacementAgainstLeadTabs() {
|
||||||
if (fleet == null || fleet.leaders().isEmpty()) {
|
if (fleet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean anyLeaderHasTab = fleet.leaders().values().stream()
|
boolean anyLeaderHasTab = fleet.leaders().values().stream()
|
||||||
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
|
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
|
||||||
if (!anyLeaderHasTab) {
|
boolean anyCollaboratorHasTab = fleet.collaborators().values().stream()
|
||||||
|
.anyMatch(c -> c != null && c.tab() != null && !c.tab().isBlank());
|
||||||
|
if (!anyLeaderHasTab && !anyCollaboratorHasTab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> bad = new ArrayList<>();
|
List<String> bad = new ArrayList<>();
|
||||||
@@ -2816,10 +2907,11 @@ public record FleetConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("refusing to start: profile(s) " + bad
|
throw new IllegalStateException("refusing to start: profile(s) " + bad
|
||||||
+ " use placement: pane while fleet.leaders names a tab. A pane-placed member can "
|
+ " use placement: pane while fleet.leaders or fleet.collaborators names a tab. A "
|
||||||
+ "land inside a lead's labelled tab and be read back as the lead, granted "
|
+ "pane-placed member can land inside that labelled tab and be read back as the "
|
||||||
+ "spawn/stop/send on the whole fleet. Set placement: tab for each named profile, "
|
+ "lead or collaborator, granted that identity's authority. Set placement: tab for "
|
||||||
+ "or remove the tab from every fleet.leaders entry.");
|
+ "each named profile, or remove the tab from every fleet.leaders and "
|
||||||
|
+ "fleet.collaborators entry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2926,8 +3018,14 @@ public record FleetConfig(
|
|||||||
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
|
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
|
||||||
* already rejects a duplicated slot name at parse time, before the map collapses.
|
* already rejects a duplicated slot name at parse time, before the map collapses.
|
||||||
*
|
*
|
||||||
* @throws IllegalStateException when a slot names no profile or an unknown one, or when a lead
|
* <p>Also rejects a {@code fleet.collaborators} entry with no (or a blank) {@code tab}. A
|
||||||
* can be neither found nor created, naming the offending entry
|
* {@code profile}-less lead is still useful recognise-only — {@code tab} is the only field
|
||||||
|
* that matters to it either way. A collaborator carries no other field at all, so a blank
|
||||||
|
* {@code tab} leaves nothing for the entry to mean.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException when a slot names no profile or an unknown one, when a lead
|
||||||
|
* can be neither found nor created, or when a collaborator names
|
||||||
|
* no tab, naming the offending entry
|
||||||
*/
|
*/
|
||||||
public void validateMembers() {
|
public void validateMembers() {
|
||||||
if (fleet == null) {
|
if (fleet == null) {
|
||||||
@@ -2965,6 +3063,16 @@ public record FleetConfig(
|
|||||||
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
|
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
fleet.collaborators().forEach((name, collaborator) -> {
|
||||||
|
if (collaborator == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (collaborator.tab() == null || collaborator.tab().isBlank()) {
|
||||||
|
bad.add("fleet.collaborators." + name + " has no tab: — a collaborator is "
|
||||||
|
+ "recognised purely by its tab, and carries no other field, so every "
|
||||||
|
+ "entry must name one.");
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!bad.isEmpty()) {
|
if (!bad.isEmpty()) {
|
||||||
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
|
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1000,6 +1000,273 @@ class FleetConfigTest {
|
|||||||
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
|
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #669: the collaborators registry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code Fleet} is {@code @JsonIgnoreProperties(ignoreUnknown = true)}, so a config naming
|
||||||
|
* {@code fleet.collaborators.<name>.tab} loads with no exception whether or not the key is
|
||||||
|
* ever read into the object model. Asserting only "no exception" would pass both before and
|
||||||
|
* after the real fix, so this asserts the parsed value is actually reachable from the loaded
|
||||||
|
* {@code FleetConfig} — the one thing a vacuous "no exception" test cannot tell apart.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void collaboratorsBlockIsActuallyParsedNotSilentlyDropped(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborators.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
assertEquals("collab: alex", cfg.fleet().collaborators().get("reviewer-alex").tab());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A collaborator carries no field other than {@code tab}, so a blank one is meaningless. */
|
||||||
|
@Test
|
||||||
|
void aCollaboratorWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("useless-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
ghost: {}
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||||
|
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
|
||||||
|
assertTrue(e.getMessage().contains("tab:"), "the message must say what is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #aCollaboratorWithNoTabRefusesToStart}: a named tab loads cleanly. */
|
||||||
|
@Test
|
||||||
|
void aCollaboratorWithATabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("named-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: the fleet-wide {@code tabLabel} template can render as a collaborator tab, the
|
||||||
|
* same hazard {@link #aFleetTabLabelTemplateThatCanRenderAsALeadTabRefusesToStart} covers on
|
||||||
|
* the lead side. Drives the fleet-wide branch directly, with no profile override involved.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aFleetTabLabelTemplateThatCanRenderAsACollaboratorTabRefusesToStart(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("collide-template-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
tabLabel: "al{profile}"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "alpha"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a member tabLabel that can render as a configured collaborator tab is the same
|
||||||
|
* hazard as the lead case above — a member labelled that way is read back as the collaborator.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aProfileTabLabelOverrideMatchingACollaboratorTabRefusesToStart(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("collide-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
tabLabel: "collab-tab"
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab-tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
assertTrue(e.getMessage().contains("collab-tab"), "the message must name the offending label");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control: a profile tabLabel that cannot render as the collaborator tab is allowed. */
|
||||||
|
@Test
|
||||||
|
void aProfileTabLabelThatCannotRenderAsACollaboratorTabIsAllowed(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("ok-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
tabLabel: "worker-{profile}"
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab-tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fleetd #669: identity is matched on a collaborator's exact tab, so two sharing one are unreachable. */
|
||||||
|
@Test
|
||||||
|
void twoCollaboratorsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("shared-collaborator-tab.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "shared tab"
|
||||||
|
sam:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name one offending collaborator");
|
||||||
|
assertTrue(e.getMessage().contains("sam"), "the message must name the other offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #twoCollaboratorsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void twoCollaboratorsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("distinct-collaborator-tabs.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "alex tab"
|
||||||
|
sam:
|
||||||
|
tab: "sam tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a collaborator tab equal to a lead tab crosses a privilege boundary — the worst
|
||||||
|
* of the three new collisions, since only one of the two identities is ever found.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorTabEqualToALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("lead-collaborator-collision.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "shared tab"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("opus"), "the message must name the offending lead");
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #aCollaboratorTabEqualToALeadTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void aLeadAndACollaboratorWithDistinctTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("lead-collaborator-ok.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "lead tab"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a pane-placed member can land in a collaborator's labelled tab exactly as it
|
||||||
|
* can land in a lead's — {@code validatePanePlacementAgainstLeadTabs()} must fire even when
|
||||||
|
* {@code fleet.leaders} is empty, which is the early-return the brief flagged as the bug.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aPanePlacedProfileWithACollaboratorTabRefusesToStartEvenWithNoLeaders(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("pane-hazard-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
placement: pane
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||||||
|
cfg::validatePanePlacementAgainstLeadTabs);
|
||||||
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control: a pane-placed profile with no lead or collaborator tab configured is allowed. */
|
||||||
|
@Test
|
||||||
|
void aPanePlacedProfileWithNoLeaderOrCollaboratorTabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("pane-no-tab-at-all.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
placement: pane
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex: {}
|
||||||
|
""");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> FleetConfig.load(f).validatePanePlacementAgainstLeadTabs(),
|
||||||
|
"a collaborator with no tab feeds nothing into the scanner, so pane placement is safe");
|
||||||
|
}
|
||||||
|
|
||||||
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1272,6 +1539,60 @@ class FleetConfigTest {
|
|||||||
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A duplicated name in {@code fleet.collaborators} is refused at parse time, like any other
|
||||||
|
* {@code fleet:} pool. See {@link #duplicateSlotNamesInOnePoolAreRejectedAtParseTime}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void duplicateCollaboratorNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborator-dup.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex, second"
|
||||||
|
""");
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
|
||||||
|
assertTrue(e.getMessage().contains("alex"),
|
||||||
|
"the refusal names the duplicated entry, was: " + e.getMessage());
|
||||||
|
assertTrue(e.getMessage().contains("fleet.collaborators"),
|
||||||
|
"the refusal names the pool the duplicate is in, was: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control for {@link #duplicateCollaboratorNamesAreRejectedAtParseTime}: the same name reused
|
||||||
|
* across the collaborators registry and a member role pool is the role × profile matrix doing
|
||||||
|
* its job in the other pool, not a mistake — only a repeat within one pool loses an entry.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theSameNameInCollaboratorsAndAnotherPoolIsNotADuplicate(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborator-cross-pool.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
sonnet:
|
||||||
|
baseUrl: http://gx10.gw:8000
|
||||||
|
fleet:
|
||||||
|
architects:
|
||||||
|
alex:
|
||||||
|
profile: sonnet
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig cfg = assertDoesNotThrow(() -> FleetConfig.load(f));
|
||||||
|
assertEquals(Set.of("alex"), cfg.fleet().pool(MemberRole.ARCHITECT).keySet());
|
||||||
|
assertEquals(Set.of("alex"), cfg.fleet().collaborators().keySet());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
||||||
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
||||||
|
|||||||
Reference in New Issue
Block a user