4b48d2d921
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.
270 lines
12 KiB
Java
270 lines
12 KiB
Java
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.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;
|
|
import java.util.concurrent.Future;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* CB-548 — the architect-slot registry: the config snapshot of slot → profile, and the live
|
|
* terminal → slot bindings it owns. The role a binding produces is asserted in
|
|
* {@link CallerResolverTest}; this pins the registry object itself — its invariants and their
|
|
* thread-safety.
|
|
*/
|
|
class MemberRegistryTest {
|
|
|
|
/**
|
|
* 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 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 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(DESIGNER));
|
|
assertEquals("gx10", registry.profileForSlot(REVIEWER));
|
|
assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile");
|
|
}
|
|
|
|
@Test
|
|
void startsEmptySoNoTerminalResolvesToAnArchitect() {
|
|
assertTrue(registry.snapshot().isEmpty());
|
|
assertNull(registry.slotForTerminal("term_design"),
|
|
"config declares no architect terminal — nothing is recognised until a bind");
|
|
assertNull(registry.slotForTerminal(null), "no terminal ⇒ no slot");
|
|
}
|
|
|
|
// ── bind ──────────────────────────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
void bindResolvesTheTerminalToTheSlot() {
|
|
assertTrue(registry.bind(DESIGNER, "term_design"));
|
|
assertEquals(DESIGNER, registry.slotForTerminal("term_design"));
|
|
assertEquals(Map.of("term_design", DESIGNER), registry.snapshot());
|
|
}
|
|
|
|
@Test
|
|
void bindRefusesAnUnknownSlot() {
|
|
assertFalse(registry.bind("nope", "term_x"),
|
|
"a slot that is not configured must be refused — bind is not a way to invent one");
|
|
assertNull(registry.slotForTerminal("term_x"));
|
|
}
|
|
|
|
@Test
|
|
void bindRefusesATerminalInTwoSlots() {
|
|
assertTrue(registry.bind(DESIGNER, "term_design"));
|
|
assertFalse(registry.bind(REVIEWER, "term_design"),
|
|
"a terminal may occupy at most one slot");
|
|
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
|
|
"the first binding survives the refused second");
|
|
}
|
|
|
|
@Test
|
|
void bindRefusesASlotWithTwoTerminals() {
|
|
assertTrue(registry.bind(DESIGNER, "term_design"));
|
|
assertFalse(registry.bind(DESIGNER, "term_other"),
|
|
"a slot may host at most one terminal");
|
|
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
|
|
"the first binding survives the refused second");
|
|
assertNull(registry.slotForTerminal("term_other"));
|
|
}
|
|
|
|
@Test
|
|
void rebindingTheSamePairIsAnIdempotentNoOp() {
|
|
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());
|
|
}
|
|
|
|
// ── unbind ────────────────────────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
void unbindRemovesTheExactBinding() {
|
|
assertTrue(registry.bind(DESIGNER, "term_design"));
|
|
assertTrue(registry.unbind(DESIGNER, "term_design"));
|
|
assertNull(registry.slotForTerminal("term_design"));
|
|
assertTrue(registry.snapshot().isEmpty());
|
|
}
|
|
|
|
@Test
|
|
void aStaleUnbindDoesNotRemoveAReplacement() {
|
|
// Bind, tear down, and stand the slot back up with a NEW terminal.
|
|
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(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(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(DESIGNER, "term_design"),
|
|
"the old slot must not unbind a terminal that moved elsewhere");
|
|
assertEquals(REVIEWER, registry.slotForTerminal("term_design"));
|
|
}
|
|
|
|
@Test
|
|
void unbindOfNothingIsAFalseNoOp() {
|
|
assertFalse(registry.unbind(DESIGNER, "term_design"),
|
|
"nothing was bound, so nothing is removed");
|
|
}
|
|
|
|
// ── snapshot ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
void theSnapshotIsAnImmutableCopyNotAliveState() {
|
|
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"));
|
|
assertFalse(snap.containsKey("term_review"),
|
|
"a snapshot is a point-in-time copy, not a live view");
|
|
}
|
|
|
|
// ── concurrency (CB-548 invariants hold under contention) ─────────────────────────────────
|
|
|
|
@Test
|
|
void concurrentBindsNeverGiveASlotTwoTerminals() throws Exception {
|
|
int n = 16;
|
|
ExecutorService pool = Executors.newFixedThreadPool(n);
|
|
try {
|
|
CountDownLatch go = new CountDownLatch(1);
|
|
List<Future<Boolean>> results = new ArrayList<>();
|
|
for (int i = 0; i < n; i++) {
|
|
final String term = "term_" + i; // every thread races for the SAME slot
|
|
results.add(pool.submit(() -> {
|
|
go.await();
|
|
return registry.bind(DESIGNER, term);
|
|
}));
|
|
}
|
|
go.countDown();
|
|
|
|
int won = 0;
|
|
for (Future<Boolean> r : results) {
|
|
if (r.get()) {
|
|
won++;
|
|
}
|
|
}
|
|
assertEquals(1, won, "exactly one terminal may win the sole slot, got " + won);
|
|
assertEquals(1, registry.snapshot().size(),
|
|
"the slot hosts at most one terminal after the race");
|
|
} finally {
|
|
pool.shutdownNow();
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void concurrentBindsNeverPutOneTerminalInTwoSlots() throws Exception {
|
|
int n = 16;
|
|
ExecutorService pool = Executors.newFixedThreadPool(n);
|
|
try {
|
|
CountDownLatch go = new CountDownLatch(1);
|
|
List<Future<String>> results = new ArrayList<>();
|
|
for (int i = 0; i < n; i++) {
|
|
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")
|
|
? registry.slotForTerminal("shared_term") : null;
|
|
}));
|
|
}
|
|
go.countDown();
|
|
|
|
// Rebinding the same terminal to the same slot is a harmless idempotent true, so count
|
|
// winners is not the assertion — agreement is: every thread that reported success must
|
|
// have seen the terminal in the SAME slot, never in two at once.
|
|
String bound = null;
|
|
boolean conflict = false;
|
|
for (Future<String> r : results) {
|
|
String s = r.get();
|
|
if (s != null) {
|
|
if (bound == null) {
|
|
bound = s;
|
|
} else if (!bound.equals(s)) {
|
|
conflict = true;
|
|
}
|
|
}
|
|
}
|
|
assertFalse(conflict, "a terminal was observed in two slots at once");
|
|
assertNotNull(bound, "at least one thread bound the terminal");
|
|
assertEquals(1, registry.snapshot().size(),
|
|
"the terminal occupies exactly one slot in the final snapshot");
|
|
assertEquals(bound, registry.slotForTerminal("shared_term"));
|
|
} finally {
|
|
pool.shutdownNow();
|
|
}
|
|
}
|
|
|
|
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
|
|
@Test
|
|
void theSlotSnapshotIsFixedByConstruction() {
|
|
Map<String, BridgedConfig.Slot> mutable = architects();
|
|
MemberRegistry r = new MemberRegistry(fleetWith(mutable));
|
|
|
|
mutable.put("hijack", new BridgedConfig.Slot("gx10"));
|
|
|
|
assertFalse(r.isSlot("architect:hijack"), "a handed-over map is not offered as live state");
|
|
}
|
|
}
|