233 lines
9.5 KiB
Java
233 lines
9.5 KiB
Java
package dev.ltms.bridged.auth;
|
|
|
|
import dev.ltms.bridged.config.BridgedConfig;
|
|
import dev.ltms.bridged.peer.MemberRole;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* The architect-slot registry (CB-548): every gateway-local architect name and the strong-model
|
|
* profile it points at, plus the <em>live</em> bindings from a live architect's herdr terminal to
|
|
* its slot.
|
|
*
|
|
* <p>Two halves, split by who owns each:
|
|
* <ul>
|
|
* <li><b>slots</b> — configured once, keyed by the gateway-local unique name; each carries the
|
|
* {@code profile} reference the spawn lifecycle reads when it stands the slot up. A read-only
|
|
* snapshot taken at construction.</li>
|
|
* <li><b>terminal bindings</b> — owned by this registry and initially <em>empty</em>. Config
|
|
* declares no architect terminal, so at startup every slot is idle and nothing resolves to an
|
|
* architect; a session only becomes one when the spawn lifecycle {@linkplain #bind(String,
|
|
* String) binds} its terminal to a slot. {@link CallerResolver} reads this through
|
|
* {@link #snapshot()} to turn a pane into an {@link Role#ARCHITECT}.</li>
|
|
* </ul>
|
|
*
|
|
* <p>Spawning/lifecycle is deliberately a separate unit: this class only owns the bindings and
|
|
* exposes the map the resolver resolves against plus the profile lookup lifecycle will call.
|
|
* Nothing here creates or manages an architect session.
|
|
*/
|
|
public final class MemberRegistry implements MemberLifecycle {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(MemberRegistry.class);
|
|
|
|
/**
|
|
* One flattened {@code fleet:} entry.
|
|
*
|
|
* <p>Flattened because a slot name is unique only <em>within</em> its pool — {@code sonnet} may
|
|
* legitimately be both a developer and a reviewer — while a terminal binds to exactly one thing.
|
|
* The qualified {@link #key()} is what that binding uses.
|
|
*
|
|
* @param name the slot's key inside its pool
|
|
* @param role the pool it came from
|
|
* @param profile the backend it runs on
|
|
*/
|
|
public record Entry(String name, MemberRole role, String profile) {
|
|
/** {@code "architect:opus"} — unique across pools, unlike {@link #name()}. */
|
|
public String key() {
|
|
return role.wireName() + ":" + name;
|
|
}
|
|
}
|
|
|
|
private final Map<String, Entry> slots;
|
|
/** Live {@code terminal_id → qualified slot key}; guarded by {@code this}. */
|
|
private final Map<String, String> terminalToSlot = new HashMap<>();
|
|
|
|
/** Flatten every role pool in {@code fleet} into one registry. Leaders are not members. */
|
|
public MemberRegistry(BridgedConfig.Fleet fleet) {
|
|
Map<String, Entry> flat = new LinkedHashMap<>();
|
|
if (fleet != null) {
|
|
for (MemberRole role : MemberRole.values()) {
|
|
fleet.pool(role).forEach((name, slot) -> {
|
|
if (slot != null) {
|
|
Entry e = new Entry(name, role, slot.profile());
|
|
flat.put(e.key(), e);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
this.slots = Collections.unmodifiableMap(flat);
|
|
}
|
|
|
|
/** The configured slots, keyed by qualified {@link Entry#key()}. Unmodifiable snapshot. */
|
|
public Map<String, Entry> slots() {
|
|
return slots;
|
|
}
|
|
|
|
/** The slots belonging to {@code role}, in definition order. */
|
|
public Map<String, Entry> slotsFor(MemberRole role) {
|
|
Map<String, Entry> out = new LinkedHashMap<>();
|
|
slots.forEach((key, e) -> {
|
|
if (e.role() == role) {
|
|
out.put(key, e);
|
|
}
|
|
});
|
|
return Collections.unmodifiableMap(out);
|
|
}
|
|
|
|
/**
|
|
* An immutable copy of the live {@code terminal_id → slot name} bindings.
|
|
*
|
|
* <p>Passed to {@link CallerResolver} as the source of architect identity, and what
|
|
* {@code bridge_whoami}/the roster will read to say which slot a pane hosts. Empty until the
|
|
* spawn lifecycle binds a slot.
|
|
*/
|
|
public Map<String, String> snapshot() {
|
|
synchronized (terminalToSlot) {
|
|
return Map.copyOf(terminalToSlot);
|
|
}
|
|
}
|
|
|
|
/** The slot a live terminal is bound to, or {@code null} if it is not an architect slot. */
|
|
public String slotForTerminal(String terminal) {
|
|
if (terminal == null) {
|
|
return null;
|
|
}
|
|
synchronized (terminalToSlot) {
|
|
return terminalToSlot.get(terminal);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The strong-model profile a slot runs under — what the spawn lifecycle reads.
|
|
*
|
|
* @return the slot's configured {@code profile}, or {@code null} if the slot is unknown or
|
|
* declares none
|
|
*/
|
|
public String profileForSlot(String slotName) {
|
|
Entry e = slots.get(slotName);
|
|
return (e == null || e.profile() == null) ? null : e.profile();
|
|
}
|
|
|
|
/** The role a qualified slot key belongs to, or {@code null} when the key is unknown. */
|
|
public MemberRole roleForSlot(String slotName) {
|
|
Entry e = slots.get(slotName);
|
|
return e == null ? null : e.role();
|
|
}
|
|
|
|
/** The unqualified configured name for a slot, or {@code null} if it is unknown. */
|
|
public String nameForSlot(String slotName) {
|
|
Entry e = slots.get(slotName);
|
|
return e == null ? null : e.name();
|
|
}
|
|
|
|
/** True when {@code slotName} is a configured architect slot. */
|
|
public boolean isSlot(String slotName) {
|
|
return slots.containsKey(slotName);
|
|
}
|
|
|
|
/**
|
|
* Bind {@code terminal} to {@code slot} (CB-548).
|
|
*
|
|
* <p>The spawn lifecycle calls this when it stands a slot up. The bind is atomic and preserves
|
|
* the two cardinality invariants: a terminal may occupy at most one slot, and a slot may host at
|
|
* most one terminal. Binding the same terminal to the same slot again is a harmless no-op.
|
|
*
|
|
* @param slot a configured slot name, or the bind is refused
|
|
* @param terminal the pane that will act as this architect
|
|
* @return {@code true} if the binding is now {@code terminal → slot}; {@code false} if it was
|
|
* refused — an unknown slot, a terminal already bound to a different slot, or a slot
|
|
* already hosting a different terminal
|
|
*/
|
|
public boolean bind(String slot, String terminal) {
|
|
if (slot == null || terminal == null || terminal.isBlank()) {
|
|
return false;
|
|
}
|
|
synchronized (terminalToSlot) {
|
|
if (!isSlot(slot)) {
|
|
return false; // unknown slot — nothing to bind to
|
|
}
|
|
String existingSlot = terminalToSlot.get(terminal);
|
|
if (existingSlot != null) {
|
|
return slot.equals(existingSlot); // already this slot (idempotent) or a different one
|
|
}
|
|
if (terminalToSlot.containsValue(slot)) {
|
|
return false; // slot already hosts a terminal — no second one
|
|
}
|
|
terminalToSlot.put(terminal, slot);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare-safe unbind of {@code expectedTerminal} from {@code slot} (CB-548).
|
|
*
|
|
* <p>The spawn lifecycle calls this when it tears a slot down. Only the exact binding
|
|
* {@code expectedTerminal → slot} is removed; if that terminal was since rebound to a different
|
|
* slot (or the slot to a different terminal), the call is a no-op returning {@code false} — a
|
|
* stale unbind must never remove a replacement.
|
|
*
|
|
* @param slot the slot the caller believes the terminal is bound to
|
|
* @param expectedTerminal the terminal it expects to be bound there
|
|
* @return {@code true} if {@code expectedTerminal → slot} was removed; {@code false} if nothing
|
|
* was (no such binding, or the binding had already moved)
|
|
*/
|
|
public boolean unbind(String slot, String expectedTerminal) {
|
|
if (slot == null || expectedTerminal == null) {
|
|
return false;
|
|
}
|
|
synchronized (terminalToSlot) {
|
|
String current = terminalToSlot.get(expectedTerminal);
|
|
if (current == null || !slot.equals(current)) {
|
|
return false; // absent, or a replacement/moved binding — leave it in place
|
|
}
|
|
terminalToSlot.remove(expectedTerminal);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bind only architect sessions to a free slot with the resolved profile.
|
|
*
|
|
* <p>The role check is lifecycle policy. {@link CallerResolver} repeats it when resolving a
|
|
* binding, so a later lifecycle regression cannot turn a worker into an architect.
|
|
*/
|
|
@Override
|
|
public void acquired(MemberRole role, String profile, String terminal) {
|
|
if (role != MemberRole.ARCHITECT || terminal == null || terminal.isBlank()) {
|
|
return;
|
|
}
|
|
// slotsFor preserves definition order, so duplicate-profile slots use the first free one.
|
|
for (Entry entry : slotsFor(MemberRole.ARCHITECT).values()) {
|
|
if (Objects.equals(profile, entry.profile()) && bind(entry.key(), terminal)) {
|
|
return;
|
|
}
|
|
}
|
|
log.info("member slot: no free architect slot for profile={}; session remains a worker", profile);
|
|
}
|
|
|
|
/** Unbind a released terminal using the compare-safe registry operation. */
|
|
@Override
|
|
public void released(String terminal) {
|
|
String slot = slotForTerminal(terminal);
|
|
if (slot != null) {
|
|
unbind(slot, terminal);
|
|
}
|
|
}
|
|
}
|