Merge CB-557: member taxonomy in config
This commit is contained in:
@@ -160,7 +160,7 @@ herdrSocket: ~/.config/herdr/herdr.sock
|
||||
# entry cannot repoint a worker past the SubscriptionGuard — which is checked
|
||||
# against `baseUrl` alone.
|
||||
# Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously.
|
||||
workers:
|
||||
profiles:
|
||||
gx10: # ccs profile name (NOT a hostname)
|
||||
kind: claude-code # which adapter spawns this profile (default; may omit)
|
||||
baseUrl: http://gx01.gw:8000 # the vLLM host this profile targets (gx00.gw / gx01.gw)
|
||||
@@ -239,7 +239,30 @@ workers:
|
||||
# How an unqualified spawn chooses a profile: fixed (default, reproduces pre-CB-518 behaviour),
|
||||
# round-robin, or weighted. Omitting this key is a strict no-op for existing configs.
|
||||
placement: weighted
|
||||
defaultWorker: gx10
|
||||
defaultProfile: gx10
|
||||
|
||||
# Named member slots. A member is anything a lead spawns. Every member has two independent
|
||||
# attributes:
|
||||
# role — which contract: architect, dev or reviewer. It picks the launch charter, the role
|
||||
# file, the playbook skill and the authz row.
|
||||
# profile — which backend: one of the `profiles:` keys above (model, CLI adapter, cost).
|
||||
# They vary on their own. A reviewer may run on the same profile as the dev whose diff it reads,
|
||||
# which is why the two cannot be one field.
|
||||
#
|
||||
# Only members that need a STABLE IDENTITY are declared here, because a lead addresses the same
|
||||
# slot across many tickets. Architects are such members. A dev or a reviewer is anonymous and
|
||||
# short-lived — the lead spawns it per task and tears it down after — so it never appears here.
|
||||
#
|
||||
# A slot is declared, not recognised: config gives no terminal, so every slot is idle at boot and
|
||||
# nothing resolves to it until the spawn lifecycle binds a live terminal. Nothing here spawns one.
|
||||
# members:
|
||||
# architect-1:
|
||||
# role: architect
|
||||
# profile: opus # a strong model, on the operator's subscription
|
||||
# architect-2:
|
||||
# role: architect
|
||||
# profile: sol # a different vendor on purpose — two architects that share a
|
||||
# # model share its blind spots
|
||||
|
||||
# Subscription boundary. A worker's base_url host MUST be one of these; the primary
|
||||
# must carry none. Every profile above must have its host listed here.
|
||||
|
||||
@@ -14,7 +14,7 @@ import dev.ltms.bridged.inject.Injector;
|
||||
import dev.ltms.bridged.inject.StatusPoller;
|
||||
import dev.ltms.bridged.inject.TurnListener;
|
||||
import dev.ltms.bridged.inject.WorkerPresence;
|
||||
import dev.ltms.bridged.auth.ArchitectRegistry;
|
||||
import dev.ltms.bridged.auth.MemberRegistry;
|
||||
import dev.ltms.bridged.auth.CallerResolver;
|
||||
import dev.ltms.bridged.mcp.BridgeMcp;
|
||||
import dev.ltms.bridged.mcp.ConnectionIdentity;
|
||||
@@ -94,7 +94,7 @@ public final class Bridged {
|
||||
cfg.validateSubscriptionProfiles();
|
||||
// CB-548: every architect slot must name a configured workers: profile — the strong-model
|
||||
// backend the future spawn lifecycle would read. A stale reference dies here, not later.
|
||||
cfg.validateArchitects();
|
||||
cfg.validateMembers();
|
||||
|
||||
Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
|
||||
? Path.of(cfg.herdrSocket())
|
||||
@@ -107,9 +107,9 @@ public final class Bridged {
|
||||
// CB-402: one adapter per configured peer kind, fronted by a composite router. A profile's
|
||||
// `kind:` selects its adapter — claude-code (the default) and opencode partition the profile
|
||||
// set — and the composite dispatches each SPI call to the adapter that owns the profile/pane.
|
||||
Map<String, BridgedConfig.Worker> claudeProfiles = new LinkedHashMap<>();
|
||||
Map<String, BridgedConfig.Worker> opencodeProfiles = new LinkedHashMap<>();
|
||||
cfg.workerProfiles().forEach((name, w) -> {
|
||||
Map<String, BridgedConfig.Profile> claudeProfiles = new LinkedHashMap<>();
|
||||
Map<String, BridgedConfig.Profile> opencodeProfiles = new LinkedHashMap<>();
|
||||
cfg.profiles().forEach((name, w) -> {
|
||||
if (w.isOpenCode()) {
|
||||
opencodeProfiles.put(name, w);
|
||||
} else {
|
||||
@@ -122,19 +122,19 @@ public final class Bridged {
|
||||
// unless opencode is the only kind configured.
|
||||
if (!claudeProfiles.isEmpty() || opencodeProfiles.isEmpty()) {
|
||||
adapters.add(new ClaudeCodeLauncher(agents, spaces, guard,
|
||||
claudeProfiles, cfg.defaultProfile(), System::getenv,
|
||||
claudeProfiles, cfg.effectiveDefaultProfile(), System::getenv,
|
||||
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs()));
|
||||
}
|
||||
if (!opencodeProfiles.isEmpty()) {
|
||||
adapters.add(new OpenCodeLauncher(agents, spaces,
|
||||
opencodeProfiles, cfg.defaultProfile(), System::getenv,
|
||||
opencodeProfiles, cfg.effectiveDefaultProfile(), System::getenv,
|
||||
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs()));
|
||||
}
|
||||
AtomicReference<Function<String, Integer>> liveCountRef = new AtomicReference<>(_ -> 0);
|
||||
PeerLauncher workers = new CompositePeerLauncher(
|
||||
adapters,
|
||||
cfg.defaultProfile(),
|
||||
cfg.workerProfiles(),
|
||||
cfg.effectiveDefaultProfile(),
|
||||
cfg.profiles(),
|
||||
PlacementPolicies.fromName(cfg.placement()),
|
||||
profileName -> liveCountRef.get().apply(profileName));
|
||||
// CB-504: under supervision (launchd/systemd) bridged can start before herdr's socket
|
||||
@@ -191,8 +191,8 @@ public final class Bridged {
|
||||
final Supplier<Map<String, String>> leads;
|
||||
if (cfg.leadScan() != null) {
|
||||
var scan = cfg.leadScan();
|
||||
Set<String> workerSpaces = cfg.workerProfiles().values().stream()
|
||||
.map(BridgedConfig.Worker::workspace)
|
||||
Set<String> workerSpaces = cfg.profiles().values().stream()
|
||||
.map(BridgedConfig.Profile::workspace)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
leads = new LeadTabScanner(herdr, scan.tabPrefix(), workerSpaces, leadTerminals,
|
||||
@@ -209,12 +209,12 @@ public final class Bridged {
|
||||
// pane resolves to an architect until the later spawn lifecycle binds one. The registry is
|
||||
// what CallerResolver resolves against and what that lifecycle will read profiles from;
|
||||
// nothing here spawns a slot.
|
||||
ArchitectRegistry architects = new ArchitectRegistry(
|
||||
cfg.architects() == null ? Map.of() : cfg.architects());
|
||||
if (!architects.slots().isEmpty()) {
|
||||
MemberRegistry members = new MemberRegistry(
|
||||
cfg.members() == null ? Map.of() : cfg.members());
|
||||
if (!members.slots().isEmpty()) {
|
||||
log.info("architect slots: {} configured {} — none bound yet (a slot is idle until the "
|
||||
+ "spawn lifecycle binds a live terminal to it)",
|
||||
architects.slots().size(), architects.slots().keySet());
|
||||
members.slots().size(), members.slots().keySet());
|
||||
}
|
||||
|
||||
// Status-gated injector (CB-103): the single writer into workers, fed by a poller.
|
||||
@@ -343,13 +343,13 @@ public final class Bridged {
|
||||
throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv()
|
||||
+ " is unset or empty — export it before starting bridged");
|
||||
}
|
||||
callers = CallerResolver.withLeadsAndArchitects(identity, true, token, leads,
|
||||
architects::snapshot);
|
||||
callers = CallerResolver.withLeadsAndMembers(identity, true, token, leads,
|
||||
members::snapshot);
|
||||
log.info("auth: token mode (bearer required for non-worker callers, env {})",
|
||||
cfg.auth().tokenEnv());
|
||||
} else {
|
||||
callers = CallerResolver.withLeadsAndArchitects(identity, false, null, leads,
|
||||
architects::snapshot);
|
||||
callers = CallerResolver.withLeadsAndMembers(identity, false, null, leads,
|
||||
members::snapshot);
|
||||
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ public final class CallerResolver {
|
||||
* {@link #pinnedTo}: too many {@code Map}/{@code Supplier} combinations to make {@code null}
|
||||
* unambiguous.
|
||||
*/
|
||||
public static CallerResolver withLeadsAndArchitects(ConnectionIdentity identity,
|
||||
public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity,
|
||||
boolean tokenMode, String token,
|
||||
Supplier<Map<String, String>> leadTerminals,
|
||||
Supplier<Map<String, String>> architectTerminals) {
|
||||
@@ -183,7 +183,7 @@ public final class CallerResolver {
|
||||
* here but would not <em>resolve</em> (or the reverse) cannot drift apart. Live for the same
|
||||
* reason as {@link #leads()}.
|
||||
*/
|
||||
public Map<String, String> architects() {
|
||||
public Map<String, String> members() {
|
||||
return architectTerminals.get();
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -26,18 +26,18 @@ import java.util.Map;
|
||||
* 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 ArchitectRegistry {
|
||||
public final class MemberRegistry {
|
||||
|
||||
private final Map<String, BridgedConfig.Architect> slots;
|
||||
private final Map<String, BridgedConfig.Member> slots;
|
||||
/** Live {@code terminal_id → slot name}; guarded by {@code this}. */
|
||||
private final Map<String, String> terminalToSlot = new HashMap<>();
|
||||
|
||||
public ArchitectRegistry(Map<String, BridgedConfig.Architect> slots) {
|
||||
public MemberRegistry(Map<String, BridgedConfig.Member> slots) {
|
||||
this.slots = slots == null ? Map.of() : Map.copyOf(slots);
|
||||
}
|
||||
|
||||
/** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
|
||||
public Map<String, BridgedConfig.Architect> slots() {
|
||||
public Map<String, BridgedConfig.Member> slots() {
|
||||
return slots;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ public final class ArchitectRegistry {
|
||||
* declares none
|
||||
*/
|
||||
public String profileForSlot(String slotName) {
|
||||
BridgedConfig.Architect a = slots.get(slotName);
|
||||
BridgedConfig.Member a = slots.get(slotName);
|
||||
return (a == null || a.profile() == null) ? null : a.profile();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import dev.ltms.bridged.peer.MemberRole;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -27,10 +28,14 @@ import java.util.Set;
|
||||
*
|
||||
* @param bind REST/MCP listen host:port
|
||||
* @param herdrSocket path to herdr's Unix socket ({@code null} → client default)
|
||||
* @param worker single worker profile (legacy; superseded by {@code workers})
|
||||
* @param workers named worker profiles, keyed by profile name (multi-backend fleet)
|
||||
* @param defaultWorker which {@code workers} key a no-argument spawn uses ({@code null} → the
|
||||
* single {@code worker}, or the sole/first profile)
|
||||
* @param profiles named backend profiles, keyed by profile name (multi-backend fleet). A
|
||||
* profile answers <em>which backend</em> — model, CLI adapter, credentials,
|
||||
* cost. It says nothing about what the member spawned on it is for; that is
|
||||
* the member's {@code role}. Each value's {@code profile} field is defaulted
|
||||
* to its key at construction, so this map is always normalized
|
||||
* @param defaultProfile which {@code profiles} key a no-argument spawn uses ({@code null} → the
|
||||
* sole/first profile). See {@link #effectiveDefaultProfile()} for the
|
||||
* resolved value
|
||||
* @param guard subscription-boundary allowlist
|
||||
* @param worktreeRoot nullable root directory for provisioned worktrees; defaults to a sibling
|
||||
* of the repo root
|
||||
@@ -46,16 +51,18 @@ import java.util.Set;
|
||||
* @param leaders named panes that orchestrate rather than are orchestrated (CB-530), keyed by
|
||||
* lead name; supersedes the singular {@code primary} pin, which stays honoured.
|
||||
* See {@link #leaderTerminals()} for how the two merge
|
||||
* @param architects CB-548 architect slots, keyed by gateway-local unique slot name; each points
|
||||
* at a strong-model profile the future spawn lifecycle reads back. An architect
|
||||
* is <em>not</em> recognised like a lead: config declares the slots only, and a
|
||||
* live session becomes an architect when the spawn lifecycle binds its terminal
|
||||
* to a slot. Nothing here spawns a slot.
|
||||
* @param members named member slots, keyed by gateway-local unique slot name; each pairs a
|
||||
* {@code role} with the {@code profile} it runs on. Only members that need a
|
||||
* stable identity are declared here — architects, today. A {@code dev} or
|
||||
* {@code reviewer} is anonymous and short-lived, so the lead spawns it per
|
||||
* task and it never appears in config. A slot is <em>not</em> recognised like
|
||||
* a lead: config declares it only, and a live session becomes that member when
|
||||
* the spawn lifecycle binds its terminal to the slot. Nothing here spawns one.
|
||||
* @param leadScan opt-in discovery of leads by tab label (CB-531); {@code null} ⇒ no scanning,
|
||||
* and only {@code leaders:}/{@code primary:} name a lead
|
||||
* @param leadHeartbeat opt-in idle-lead heartbeat (CB-551); {@code null} ⇒ off, and an upgraded
|
||||
* daemon never nudges an idle lead on its own initiative
|
||||
* @param placement how to choose a worker profile for an unqualified spawn:
|
||||
* @param placement how to choose a profile for an unqualified spawn:
|
||||
* {@code fixed} (default), {@code round-robin}, or {@code weighted}
|
||||
* @param auth API authentication mode ({@code null} → {@code loopback-trust}, the
|
||||
* historical behaviour), CB-501
|
||||
@@ -64,9 +71,8 @@ import java.util.Set;
|
||||
public record BridgedConfig(
|
||||
Bind bind,
|
||||
String herdrSocket,
|
||||
Worker worker,
|
||||
Map<String, Worker> workers,
|
||||
String defaultWorker,
|
||||
Map<String, Profile> profiles,
|
||||
String defaultProfile,
|
||||
Guard guard,
|
||||
String worktreeRoot,
|
||||
Lifecycle lifecycle,
|
||||
@@ -75,12 +81,36 @@ public record BridgedConfig(
|
||||
Broker broker,
|
||||
Primary primary,
|
||||
Map<String, Leader> leaders,
|
||||
Map<String, Architect> architects,
|
||||
Map<String, Member> members,
|
||||
LeadScan leadScan,
|
||||
LeadHeartbeat leadHeartbeat,
|
||||
String placement,
|
||||
Auth auth) {
|
||||
|
||||
/**
|
||||
* Normalize {@code profiles} once, at construction, so every reader sees the same map.
|
||||
*
|
||||
* <p>Each value's {@code profile} field is defaulted to its map key. This used to happen in a
|
||||
* derived {@code workerProfiles()} accessor, which meant a caller that read the raw map got
|
||||
* un-defaulted values — two spellings of the same thing, and a real source of bugs. Doing it
|
||||
* here leaves one spelling.
|
||||
*
|
||||
* <p>Deliberately NOT {@code Map.copyOf}: its iteration order is salted per JVM run, which
|
||||
* would discard the YAML definition order. Placement tie-breaks on candidate order (see
|
||||
* {@code WeightedRoundRobinPolicy}), so losing it makes equal-weight placement
|
||||
* non-reproducible across restarts. Unmodifiable-wrap instead of copy-and-scramble.
|
||||
*/
|
||||
public BridgedConfig {
|
||||
if (profiles != null && !profiles.isEmpty()) {
|
||||
Map<String, Profile> normalized = new LinkedHashMap<>();
|
||||
profiles.forEach((name, p) -> normalized.put(name,
|
||||
(p.profile() == null || p.profile().isBlank()) ? p.withProfile(name) : p));
|
||||
profiles = Collections.unmodifiableMap(normalized);
|
||||
} else {
|
||||
profiles = Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Bind(String host, int port) {
|
||||
public Bind {
|
||||
@@ -144,7 +174,7 @@ public record BridgedConfig(
|
||||
* config load (CB-542): on the subscription path no guard would vet it.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Worker(String profile, String baseUrl, String model,
|
||||
public record Profile(String profile, String baseUrl, String model,
|
||||
String configDir, String tokenEnv, List<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd,
|
||||
@@ -161,7 +191,7 @@ public record BridgedConfig(
|
||||
/** Peer kind spawned by the opencode adapter (CB-402). */
|
||||
public static final String KIND_OPENCODE = "opencode";
|
||||
|
||||
public Worker {
|
||||
public Profile {
|
||||
// A claude-code worker defaults its launch command to `claude`; other kinds carry their own
|
||||
// argv (e.g. `opencode`) and must not inherit the Claude binary — so only default when unset
|
||||
// AND this is the claude-code kind.
|
||||
@@ -195,7 +225,7 @@ public record BridgedConfig(
|
||||
* granted no PR-create token (push over SSH is unaffected). Keeps pre-CB-302 call sites
|
||||
* (and any {@code workers:} YAML that omits the git keys) working unchanged.
|
||||
*/
|
||||
public Worker(String profile, String baseUrl, String model,
|
||||
public Profile(String profile, String baseUrl, String model,
|
||||
String configDir, String tokenEnv, List<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd, List<String> parityOverlay) {
|
||||
@@ -207,7 +237,7 @@ public record BridgedConfig(
|
||||
* Backward-compatible constructor with the CB-302 git-forge fields but no explicit peer
|
||||
* {@code kind} — defaults to {@link #KIND_CLAUDE_CODE}. Keeps pre-CB-402 call sites working.
|
||||
*/
|
||||
public Worker(String profile, String baseUrl, String model,
|
||||
public Profile(String profile, String baseUrl, String model,
|
||||
String configDir, String tokenEnv, List<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv) {
|
||||
@@ -219,7 +249,7 @@ public record BridgedConfig(
|
||||
* Backward-compatible constructor without the CB-511 {@code env:} passthrough — the worker
|
||||
* gets the daemon's PATH and nothing else. Keeps pre-CB-511 call sites working.
|
||||
*/
|
||||
public Worker(String profile, String baseUrl, String model,
|
||||
public Profile(String profile, String baseUrl, String model,
|
||||
String configDir, String tokenEnv, List<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
|
||||
@@ -229,8 +259,8 @@ public record BridgedConfig(
|
||||
}
|
||||
|
||||
/** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */
|
||||
public Worker withProfile(String p) {
|
||||
return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
public Profile withProfile(String p) {
|
||||
return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription);
|
||||
}
|
||||
|
||||
@@ -258,7 +288,7 @@ public record BridgedConfig(
|
||||
* the off-subscription boundary (the default). Keeps pre-CB-539 call sites (and any YAML
|
||||
* that omits the flag) compiling and behaving identically.
|
||||
*/
|
||||
public Worker(String profile, String baseUrl, String model,
|
||||
public Profile(String profile, String baseUrl, String model,
|
||||
String configDir, String tokenEnv, List<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
|
||||
@@ -392,26 +422,32 @@ public record BridgedConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of the CB-548 {@code architects:} registry — a gateway-local named slot that points
|
||||
* at a strong-model profile.
|
||||
* One entry of the {@code members:} registry — a gateway-local named slot that pairs a role
|
||||
* with the profile it runs on.
|
||||
*
|
||||
* <p>A slot is <em>declared</em>, not recognised: config names the slot and the profile it runs,
|
||||
* and nothing else. Unlike a lead (which config pins by herdr {@code terminal_id} and is
|
||||
* recognised at startup), an architect slot is idle at boot — config supplies no terminal, so no
|
||||
* session resolves to one until the spawn lifecycle binds a live terminal to the slot. The
|
||||
* stable name + profile pair is the only config-time identity; live identity is defined purely
|
||||
* by the runtime {@link dev.ltms.bridged.auth.ArchitectRegistry} binding.
|
||||
* <p><b>Role and profile are separate axes.</b> The {@code role} answers <em>which contract</em>
|
||||
* — it picks the launch charter, the role file, the playbook skill and the authz row. The
|
||||
* {@code profile} answers <em>which backend</em> — model, CLI adapter, credentials, cost. They
|
||||
* vary independently: a {@code reviewer} may run on the very same profile as the {@code dev}
|
||||
* whose diff it reviews, and that case is what proves the two are not one axis.
|
||||
*
|
||||
* <p>Why a {@code profile} reference: an architect is meant to run a strong model, and the slot
|
||||
* records which {@code workers:} profile that is — the value the future spawn lifecycle reads.
|
||||
* It must name a configured profile, enforced by {@link #validateArchitects()} (a stale or
|
||||
* typo'd reference fails at startup rather than silently spawning the wrong backend later).
|
||||
* <p>Only members that need a <em>stable identity</em> are declared here. Architects are, because
|
||||
* a lead addresses the same pair of them across many tickets. A {@code dev} or {@code reviewer}
|
||||
* is anonymous and fungible — spawned per task, torn down after — so it never appears in config.
|
||||
*
|
||||
* @param profile the name of the strong-model {@code workers:} profile this slot runs;
|
||||
* required and validated against {@link #workerProfiles()}
|
||||
* <p>A slot is <em>declared</em>, not recognised: config names the slot, its role and its
|
||||
* profile, and nothing else. Unlike a lead (which config pins by herdr {@code terminal_id} and
|
||||
* is recognised at startup), a member slot is idle at boot — config supplies no terminal, so no
|
||||
* session resolves to one until the spawn lifecycle binds a live terminal to the slot.
|
||||
*
|
||||
* @param role the contract this slot runs under — {@code architect}, {@code dev} or
|
||||
* {@code reviewer}; required, and validated against {@link MemberRole}
|
||||
* @param profile the name of the {@code profiles:} entry this slot runs on; required and
|
||||
* validated against {@link #profiles()} (a stale or typo'd reference fails at
|
||||
* startup rather than silently spawning the wrong backend later)
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Architect(String profile) {
|
||||
public record Member(String role, String profile) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,7 +545,7 @@ public record BridgedConfig(
|
||||
* API authentication (CB-501). Governs how a caller that is <em>not</em> an on-host worker
|
||||
* pane proves it is the primary.
|
||||
*
|
||||
* <p>Worker identity never depends on this block: a loopback peer PID that maps to a herdr
|
||||
* <p>Member identity never depends on this block: a loopback peer PID that maps to a herdr
|
||||
* pane is unforgeable and is always honoured (see
|
||||
* {@link dev.ltms.bridged.mcp.ConnectionIdentity}). This only decides what happens for
|
||||
* <em>everyone else</em>.
|
||||
@@ -560,41 +596,17 @@ public record BridgedConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective worker profiles, keyed by profile name. Prefers the {@code workers} map (each
|
||||
* value's {@code profile} defaulted to its key); falls back to the legacy singular {@code worker}
|
||||
* (keyed by its own profile). Empty if neither is configured.
|
||||
* The profile a no-argument spawn uses: {@code defaultProfile} if set, else the sole/first
|
||||
* configured profile, else {@code null}.
|
||||
*
|
||||
* <p>Named {@code effective…} because the record component {@code defaultProfile()} returns the
|
||||
* raw config value, which may be {@code null}. This is the resolved one.
|
||||
*/
|
||||
public Map<String, Worker> workerProfiles() {
|
||||
if (workers != null && !workers.isEmpty()) {
|
||||
Map<String, Worker> out = new LinkedHashMap<>();
|
||||
workers.forEach((name, w) -> out.put(name,
|
||||
(w.profile() == null || w.profile().isBlank()) ? w.withProfile(name) : w));
|
||||
// Deliberately NOT Map.copyOf: its iteration order is salted per JVM run, which would
|
||||
// discard the YAML definition order built above. Placement tie-breaks on candidate
|
||||
// order (see WeightedRoundRobinPolicy), so losing it makes equal-weight placement
|
||||
// non-reproducible across restarts. Unmodifiable-wrap instead of copy-and-scramble.
|
||||
return Collections.unmodifiableMap(out);
|
||||
public String effectiveDefaultProfile() {
|
||||
if (defaultProfile != null && !defaultProfile.isBlank()) {
|
||||
return defaultProfile;
|
||||
}
|
||||
if (worker != null) {
|
||||
String name = (worker.profile() == null || worker.profile().isBlank()) ? "default" : worker.profile();
|
||||
return Map.of(name, worker);
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a no-argument spawn uses: {@code defaultWorker} if set, else the legacy single
|
||||
* {@code worker}'s profile, else the sole/first configured profile, else {@code null}.
|
||||
*/
|
||||
public String defaultProfile() {
|
||||
if (defaultWorker != null && !defaultWorker.isBlank()) {
|
||||
return defaultWorker;
|
||||
}
|
||||
if (worker != null && worker.profile() != null && !worker.profile().isBlank()) {
|
||||
return worker.profile();
|
||||
}
|
||||
Map<String, Worker> p = workerProfiles();
|
||||
return p.isEmpty() ? null : p.keySet().iterator().next();
|
||||
return profiles.isEmpty() ? null : profiles.keySet().iterator().next();
|
||||
}
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BridgedConfig.class);
|
||||
@@ -606,16 +618,17 @@ public record BridgedConfig(
|
||||
* {@link #warnUnknownTopLevelKeys}. Keep in step with the record components.
|
||||
*/
|
||||
private static final Set<String> KNOWN_TOP_LEVEL_KEYS = Set.of(
|
||||
"bind", "herdrSocket", "worker", "workers", "defaultWorker", "guard", "worktreeRoot",
|
||||
"bind", "herdrSocket", "profiles", "defaultProfile", "guard", "worktreeRoot",
|
||||
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "leaders",
|
||||
"architects", "leadScan", "leadHeartbeat", "placement", "auth");
|
||||
"members", "leadScan", "leadHeartbeat", "placement", "auth");
|
||||
|
||||
/** Load and validate config from {@code path}. */
|
||||
public static BridgedConfig load(Path path) {
|
||||
try {
|
||||
String yaml = Files.readString(path);
|
||||
rejectRenamedTopLevelKeys(yaml);
|
||||
warnUnknownTopLevelKeys(yaml, path);
|
||||
rejectDuplicateArchitectSlots(yaml);
|
||||
rejectDuplicateMemberSlots(yaml);
|
||||
BridgedConfig cfg = YAML.readValue(yaml, BridgedConfig.class);
|
||||
return cfg.withDefaults();
|
||||
} catch (IOException e) {
|
||||
@@ -624,37 +637,37 @@ public record BridgedConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an {@code architects:} registry whose slot names repeat (CB-548).
|
||||
* Reject an {@code members:} registry whose slot names repeat (CB-548).
|
||||
*
|
||||
* <p>The registry is a {@code Map} keyed by slot name, so by the time it is read duplicate keys
|
||||
* have already collapsed last-wins — a duplicated slot name would silently drop one slot and the
|
||||
* 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. Only the
|
||||
* <em>top-level</em> {@code architects:} block is considered, and only its direct child keys (the
|
||||
* slot names) — a nested field elsewhere, even one also named {@code architects:}, is ignored, so
|
||||
* <em>top-level</em> {@code members:} block is considered, and only its direct child keys (the
|
||||
* slot names) — a nested field elsewhere, even one also named {@code members:}, is ignored, so
|
||||
* parsing of the rest of the config is unaffected.
|
||||
*
|
||||
* @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it
|
||||
* @throws IllegalStateException when two {@code members:} entries share a slot name, naming it
|
||||
*/
|
||||
static void rejectDuplicateArchitectSlots(String yaml) {
|
||||
static void rejectDuplicateMemberSlots(String yaml) {
|
||||
try (JsonParser p = YAML.createParser(yaml)) {
|
||||
if (p.nextToken() != JsonToken.START_OBJECT) {
|
||||
return; // not a mapping at top level — readValue reports the malformed file
|
||||
}
|
||||
// Scan the TOP-LEVEL mapping only. Every other field's value (however deep, including
|
||||
// any nested field also literally named "architects") is consumed whole by skipValue, so
|
||||
// the loop below can only ever see the top-level field names — a nested `architects:` can
|
||||
// any nested field also literally named "members") is consumed whole by skipValue, so
|
||||
// the loop below can only ever see the top-level field names — a nested `members:` can
|
||||
// neither suppress the real block nor be misread as one.
|
||||
JsonToken t;
|
||||
while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) {
|
||||
if (t == JsonToken.FIELD_NAME) {
|
||||
String name = p.getCurrentName();
|
||||
JsonToken value = p.nextToken();
|
||||
if ("architects".equals(name)) {
|
||||
if ("members".equals(name)) {
|
||||
if (value == JsonToken.START_OBJECT) {
|
||||
rejectDuplicateChildSlotKeys(p);
|
||||
}
|
||||
return; // the single top-level architects block is handled; nothing more to check
|
||||
return; // the single top-level members block is handled; nothing more to check
|
||||
}
|
||||
skipValue(p, value);
|
||||
}
|
||||
@@ -665,14 +678,14 @@ public record BridgedConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a duplicated <em>direct child</em> key of the (already-positioned) {@code architects:}
|
||||
* Reject a duplicated <em>direct child</em> key of the (already-positioned) {@code members:}
|
||||
* mapping — i.e. a duplicated {@code slot name}.
|
||||
*
|
||||
* <p>Each slot's value is consumed whole by {@link #skipValue}, so a duplicated field <em>inside</em>
|
||||
* a slot (e.g. two {@code profile:} keys, or a duplicate nested {@code architects:}) is never seen
|
||||
* a slot (e.g. two {@code profile:} keys, or a duplicate nested {@code members:}) is never seen
|
||||
* here and cannot masquerade as a duplicated slot name.
|
||||
*
|
||||
* @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it
|
||||
* @throws IllegalStateException when two {@code members:} entries share a slot name, naming it
|
||||
*/
|
||||
private static void rejectDuplicateChildSlotKeys(JsonParser p) throws IOException {
|
||||
Set<String> seen = new HashSet<>();
|
||||
@@ -680,7 +693,7 @@ public record BridgedConfig(
|
||||
while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) {
|
||||
if (t == JsonToken.FIELD_NAME) {
|
||||
if (!seen.add(p.getCurrentName())) {
|
||||
throw new IllegalStateException("refusing to start: duplicate architect slot name '"
|
||||
throw new IllegalStateException("refusing to start: duplicate member slot name '"
|
||||
+ p.getCurrentName() + "' — slot names must be unique; a later entry would "
|
||||
+ "silently overwrite the earlier one");
|
||||
}
|
||||
@@ -692,7 +705,7 @@ public record BridgedConfig(
|
||||
/**
|
||||
* Consume the whole value that starts at {@code start}, including every nested structure, and
|
||||
* leave the parser positioned just past it. Used so depth is handled structurally rather than by
|
||||
* a heuristic — a nested field is never interpreted as a top-level {@code architects:}.
|
||||
* a heuristic — a nested field is never interpreted as a top-level {@code members:}.
|
||||
*/
|
||||
private static void skipValue(JsonParser p, JsonToken start) throws IOException {
|
||||
switch (start) {
|
||||
@@ -749,6 +762,54 @@ public record BridgedConfig(
|
||||
* @return empty when everything is known, or when {@code yaml} is not a mapping at all (a
|
||||
* malformed file is {@code readValue}'s error to report, not this method's)
|
||||
*/
|
||||
/**
|
||||
* Top-level keys renamed by the member taxonomy, mapped old → new.
|
||||
*
|
||||
* <p>These get a hard error rather than the usual unknown-key WARN. The reason is the failure
|
||||
* they would otherwise cause: a config still saying {@code workers:} would load, warn once, and
|
||||
* then run with <em>zero</em> profiles — so every spawn fails later with a message that points
|
||||
* nowhere near the real cause. Naming the new key at startup turns a confusing runtime failure
|
||||
* into a one-line fix.
|
||||
*
|
||||
* <p>We are in active development, so the old spellings are not accepted as aliases. Accepting
|
||||
* both would leave two names for one thing in every config and doc, which is the cost the
|
||||
* rename was meant to remove.
|
||||
*/
|
||||
private static final Map<String, String> RENAMED_TOP_LEVEL_KEYS = Map.of(
|
||||
"workers", "profiles",
|
||||
"worker", "profiles",
|
||||
"defaultWorker", "defaultProfile",
|
||||
"architects", "members");
|
||||
|
||||
/**
|
||||
* Reject a config that still uses a pre-rename top-level key, naming its replacement.
|
||||
*
|
||||
* @param yaml the raw config text
|
||||
* @throws IllegalStateException when any renamed key is present
|
||||
*/
|
||||
static void rejectRenamedTopLevelKeys(String yaml) {
|
||||
Map<?, ?> raw;
|
||||
try {
|
||||
raw = YAML.readValue(yaml, Map.class);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return; // a malformed file is reported by the real parse, not here
|
||||
}
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
List<String> bad = raw.keySet().stream()
|
||||
.map(String::valueOf)
|
||||
.filter(RENAMED_TOP_LEVEL_KEYS::containsKey)
|
||||
.sorted()
|
||||
.map(k -> "'" + k + "' is now '" + RENAMED_TOP_LEVEL_KEYS.get(k) + "'")
|
||||
.toList();
|
||||
if (!bad.isEmpty()) {
|
||||
throw new IllegalStateException("refusing to start: this config uses renamed top-level "
|
||||
+ "keys — " + String.join("; ", bad)
|
||||
+ ". A profile says which backend to run; a member says which role runs on it.");
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> unknownTopLevelKeys(String yaml) {
|
||||
Map<?, ?> raw;
|
||||
try {
|
||||
@@ -782,9 +843,9 @@ public record BridgedConfig(
|
||||
// every config that never mentioned it.
|
||||
// leadHeartbeat is left as-is for the same reason (CB-551): null is "off", and LeadHeartbeat's
|
||||
// own compact constructor defaults the fields of a block that IS present.
|
||||
// architects is left as-is: null is "none configured", and Architect's fields have no
|
||||
// members is left as-is: null is "none configured", and Member's fields have no
|
||||
// defaults to fill. Defaulting it here would change nothing, so leave the call natural.
|
||||
return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, architects, leadScan, leadHeartbeat, placementOrDefault, a);
|
||||
return new BridgedConfig(b, herdrSocket, profiles, defaultProfile, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, members, leadScan, leadHeartbeat, placementOrDefault, a);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -833,7 +894,7 @@ public record BridgedConfig(
|
||||
return;
|
||||
}
|
||||
String prefix = leadScan.tabPrefix();
|
||||
List<String> clashing = workerProfiles().entrySet().stream()
|
||||
List<String> clashing = profiles().entrySet().stream()
|
||||
.filter(e -> e.getValue().tabLabel() != null
|
||||
&& e.getValue().tabLabel().strip()
|
||||
.regionMatches(true, 0, prefix, 0, prefix.length()))
|
||||
@@ -871,7 +932,7 @@ public record BridgedConfig(
|
||||
*/
|
||||
public void validateSubscriptionProfiles() {
|
||||
List<String> bad = new java.util.ArrayList<>();
|
||||
workerProfiles().forEach((name, w) -> {
|
||||
profiles().forEach((name, w) -> {
|
||||
if (w.isSubscription() && w.envCarriesAnthropicBinding()) {
|
||||
List<String> keys = w.env().keySet().stream()
|
||||
.filter(k -> k.equals("ANTHROPIC_BASE_URL") || k.equals("ANTHROPIC_AUTH_TOKEN"))
|
||||
@@ -890,40 +951,48 @@ public record BridgedConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an architect slot whose profile reference does not resolve (CB-548).
|
||||
* Reject a member slot whose {@code role} or {@code profile} does not resolve.
|
||||
*
|
||||
* <p>An architect's {@code profile} is the strong-model {@code workers:} profile the future
|
||||
* spawn lifecycle will read to stand the slot up. A reference that names no configured profile
|
||||
* is a typo or a stale config — and unlike a worker spawn (which fails loudly at its call site
|
||||
* when it cannot resolve), an architect slot fails only when something later tries to use it.
|
||||
* This config class <em>does</em> have access to {@link #workerProfiles()}, so the reference
|
||||
* is validated at startup and the mistake is named then, not discovered months later by a
|
||||
* spawn that quietly has no backend to use.
|
||||
* <p>A slot's {@code profile} is the {@code profiles:} entry the spawn lifecycle reads to stand
|
||||
* the slot up. A reference that names no configured profile is a typo or a stale config — and
|
||||
* unlike a spawn (which fails loudly at its call site when it cannot resolve), a slot fails only
|
||||
* when something later tries to use it. Validating at startup names the mistake then, rather
|
||||
* than leaving it to be discovered months later by a spawn that quietly has no backend.
|
||||
*
|
||||
* <p>The {@code role} is checked the same way and for the same reason: a typo'd role would
|
||||
* otherwise pick no charter at all, and the member would run with no contract.
|
||||
*
|
||||
* <p>Slot-name uniqueness needs no check here: the registry is a {@code Map} keyed by name, 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.
|
||||
*
|
||||
* @throws IllegalStateException when any architect slot is missing or names an unknown profile,
|
||||
* naming the slot and the offending reference
|
||||
* @throws IllegalStateException when any member slot is missing or names an unknown role or
|
||||
* profile, naming the slot and the offending reference
|
||||
*/
|
||||
public void validateArchitects() {
|
||||
if (architects == null) {
|
||||
public void validateMembers() {
|
||||
if (members == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Worker> profiles = workerProfiles();
|
||||
List<String> bad = new java.util.ArrayList<>();
|
||||
architects.forEach((name, arch) -> {
|
||||
if (arch == null || arch.profile() == null || arch.profile().isBlank()) {
|
||||
bad.add("architect slot '" + name + "' has no profile: — give it the name of a "
|
||||
+ "workers: profile (the strong-model backend it runs).");
|
||||
return;
|
||||
}
|
||||
if (!profiles.containsKey(arch.profile())) {
|
||||
bad.add("architect slot '" + name + "' references profile '" + arch.profile()
|
||||
+ "', which is not a configured workers: profile (have: " + profiles.keySet()
|
||||
members.forEach((name, m) -> {
|
||||
if (m == null || m.profile() == null || m.profile().isBlank()) {
|
||||
bad.add("member slot '" + name + "' has no profile: — give it the name of a "
|
||||
+ "profiles: entry (the backend it runs on).");
|
||||
} else if (!profiles.containsKey(m.profile())) {
|
||||
bad.add("member slot '" + name + "' references profile '" + m.profile()
|
||||
+ "', which is not a configured profiles: entry (have: " + profiles.keySet()
|
||||
+ ").");
|
||||
}
|
||||
if (m == null || m.role() == null || m.role().isBlank()) {
|
||||
bad.add("member slot '" + name + "' has no role: — give it one of architect, dev, "
|
||||
+ "reviewer.");
|
||||
} else {
|
||||
try {
|
||||
MemberRole.parse(m.role());
|
||||
} catch (IllegalArgumentException e) {
|
||||
bad.add("member slot '" + name + "': " + e.getMessage() + ".");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!bad.isEmpty()) {
|
||||
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package dev.ltms.bridged.peer;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* What a member is <em>for</em> — the contract it runs under.
|
||||
*
|
||||
* <p>A member is anything a lead spawns. Every member carries two independent attributes:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>role</b> (this enum) — <em>which contract</em>: the launch charter it is given, the role
|
||||
* file it reads, the playbook skill it loads, and its authorization row.</li>
|
||||
* <li><b>profile</b> (a {@code profiles:} key) — <em>which backend</em>: model, CLI adapter,
|
||||
* credentials, cost.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>These are separate axes on purpose. A {@code REVIEWER} may run on the same profile as the
|
||||
* {@code DEV} whose diff it reviews — same backend, different contract. That case is what proves
|
||||
* role and profile cannot be collapsed into one field.
|
||||
*
|
||||
* <p>A lead is deliberately <em>not</em> a role here. A lead is not spawned: it is a pre-existing,
|
||||
* human-facing session that config recognises. Only spawned peers have a member role.
|
||||
*/
|
||||
public enum MemberRole {
|
||||
|
||||
/**
|
||||
* Refines a ticket before anyone implements it: scope, acceptance criteria, risks, unit split.
|
||||
*
|
||||
* <p>Reads the repo and writes analysis. Never commits code and never opens a pull request —
|
||||
* an architect that starts implementing has stopped doing the job that makes it useful.
|
||||
*
|
||||
* <p>Architects are the one member kind declared in config, because a lead addresses the same
|
||||
* slots across many tickets and needs a stable name for them.
|
||||
*/
|
||||
ARCHITECT,
|
||||
|
||||
/**
|
||||
* Implements one unit of work: provisions a worktree, writes the code, commits, pushes, and
|
||||
* opens its own pull request.
|
||||
*
|
||||
* <p>Never merges. The lead is the gate, and a member that merged its own work would remove the
|
||||
* only independent check in the flow.
|
||||
*/
|
||||
DEV,
|
||||
|
||||
/**
|
||||
* Reviews a diff it did not write and reports one structured finding.
|
||||
*
|
||||
* <p>Never commits, never merges, and is never the member that wrote the scope under review.
|
||||
* Briefed from the diff rather than from the implementer's rationale, because that rationale
|
||||
* carries the same blind spot that produced the bug.
|
||||
*/
|
||||
REVIEWER;
|
||||
|
||||
/** The lowercase spelling used in config and on the wire ({@code architect}, {@code dev}, …). */
|
||||
public String wireName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a config/wire spelling, case-insensitively.
|
||||
*
|
||||
* @param s the spelling to parse
|
||||
* @return the matching role
|
||||
* @throws IllegalArgumentException when {@code s} is null, blank, or not a known role — the
|
||||
* message lists the valid spellings, because a typo'd role in
|
||||
* config should fail at startup with the fix in the error
|
||||
*/
|
||||
public static MemberRole parse(String s) {
|
||||
if (s != null && !s.isBlank()) {
|
||||
String t = s.trim().toLowerCase(Locale.ROOT);
|
||||
for (MemberRole r : values()) {
|
||||
if (r.wireName().equals(t)) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
StringBuilder valid = new StringBuilder();
|
||||
for (MemberRole r : values()) {
|
||||
if (!valid.isEmpty()) {
|
||||
valid.append(", ");
|
||||
}
|
||||
valid.append(r.wireName());
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
"unknown member role '" + s + "'; valid roles are: " + valid);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* existing deployments and tests keep the legacy non-blocking spawn semantics.
|
||||
*/
|
||||
public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env) {
|
||||
this(agents, spaces, guard, profiles, defaultProfile, env, 0,
|
||||
System::currentTimeMillis, () -> sleepUninterruptibly(300));
|
||||
@@ -76,7 +76,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* until the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses.
|
||||
*/
|
||||
public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs, long spawnReadyPollMs) {
|
||||
this(agents, spaces, guard, profiles, defaultProfile, env,
|
||||
@@ -102,7 +102,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* (poll ms) is accepted for API symmetry but otherwise unused here
|
||||
*/
|
||||
public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs,
|
||||
LongSupplier nowMillis, Runnable sleeper) {
|
||||
@@ -118,7 +118,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* the session-aware form with no name and no resume id.
|
||||
*/
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
||||
protected Launch buildLaunch(BridgedConfig.Profile cfg) {
|
||||
return buildLaunch(cfg, null, null);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* applied here — see {@link #applySessionIdentity}.
|
||||
*/
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg, String sessionName, String resumeSessionId) {
|
||||
protected Launch buildLaunch(BridgedConfig.Profile cfg, String sessionName, String resumeSessionId) {
|
||||
// CB-539: a profile may deliberately opt into the subscription (subscription: true) when no
|
||||
// off-subscription endpoint exists for it — e.g. `sonnet` on `ccs`. That profile gets no
|
||||
// ANTHROPIC_BASE_URL/AUTH_TOKEN (there is nothing to point them at) and the guard's base_url
|
||||
@@ -219,7 +219,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* touches the profile's config; both are pure command-line flags. This inline-flag mount is
|
||||
* Claude Code specific — other adapters mount MCP and instructions their own way.
|
||||
*/
|
||||
private List<String> argvWithBridge(BridgedConfig.Worker cfg) {
|
||||
private List<String> argvWithBridge(BridgedConfig.Profile cfg) {
|
||||
if (!cfg.hasMcp()) {
|
||||
return cfg.argv();
|
||||
}
|
||||
@@ -249,7 +249,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
* {@code gx10} does) are untouched — this adds nothing when there is nothing to add. This is
|
||||
* the {@code kind: claude} counterpart of the opencode adapter's {@code -m provider/model}.
|
||||
*/
|
||||
private static List<String> argvWithModel(List<String> argv, BridgedConfig.Worker cfg) {
|
||||
private static List<String> argvWithModel(List<String> argv, BridgedConfig.Profile cfg) {
|
||||
if (cfg.model() == null || cfg.model().isBlank()) {
|
||||
return argv;
|
||||
}
|
||||
@@ -302,7 +302,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
|
||||
/** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */
|
||||
private boolean hasGitTokenProfile() {
|
||||
return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken);
|
||||
return profileConfigs().stream().anyMatch(BridgedConfig.Profile::hasGitToken);
|
||||
}
|
||||
|
||||
// --- CB-117 reap predicate (Claude prefix), kept for direct unit testing -------------------
|
||||
|
||||
@@ -64,7 +64,7 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
/** paneId → the delegate that spawned it, so {@link #stop} tears down through the right adapter. */
|
||||
private final Map<String, HerdrPeerLauncher> spawnedBy = new ConcurrentHashMap<>();
|
||||
|
||||
private final Map<String, BridgedConfig.Worker> profileConfigs;
|
||||
private final Map<String, BridgedConfig.Profile> profileConfigs;
|
||||
private final PlacementPolicy placementPolicy;
|
||||
private final Function<String, Integer> liveCount;
|
||||
|
||||
@@ -93,7 +93,7 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
*/
|
||||
public CompositePeerLauncher(List<HerdrPeerLauncher> delegates,
|
||||
String defaultProfile,
|
||||
Map<String, BridgedConfig.Worker> profileConfigs,
|
||||
Map<String, BridgedConfig.Profile> profileConfigs,
|
||||
PlacementPolicy placementPolicy,
|
||||
Function<String, Integer> liveCount) {
|
||||
if (delegates.isEmpty()) {
|
||||
@@ -141,7 +141,7 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
String requestedProfile = req.profileName();
|
||||
if (requestedProfile != null && !requestedProfile.isBlank()) {
|
||||
// An explicit profile bypasses the placement policy, but not the capacity cap: maxLoad
|
||||
// is documented as an unconditional limit on this profile (BridgedConfig.Worker), and
|
||||
// is documented as an unconditional limit on this profile (BridgedConfig.Profile), and
|
||||
// the charter makes explicit-profile spawns the normal path — so skipping the check
|
||||
// here would leave the cap dead config in real operation.
|
||||
HerdrPeerLauncher d = route(requestedProfile);
|
||||
@@ -198,7 +198,7 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
/**
|
||||
* Refuse an explicit-profile spawn when the profile is at its {@code maxLoad} cap.
|
||||
*
|
||||
* <p>maxLoad is a documented, unconditional capacity limit (see {@code BridgedConfig.Worker#maxLoad}),
|
||||
* <p>maxLoad is a documented, unconditional capacity limit (see {@code BridgedConfig.Profile#maxLoad}),
|
||||
* and the charter makes explicit-profile spawns the normal path — so enforcing it only in placement
|
||||
* ({@link dev.ltms.bridged.placement.PlacementPolicyUtil}) would leave the cap dead config on every
|
||||
* call that names a profile. Same rule as placement: {@code live >= cap} is at capacity.
|
||||
@@ -219,7 +219,7 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
private void enforceMaxLoad(String profile) {
|
||||
// Absent config, or a config whose maxLoad normalized to null (non-positive ⇒ unlimited at
|
||||
// load), means no cap — never cap what wasn't configured.
|
||||
BridgedConfig.Worker cfg = profileConfigs.get(profile);
|
||||
BridgedConfig.Profile cfg = profileConfigs.get(profile);
|
||||
Integer cap = (cfg == null) ? null : cfg.maxLoad();
|
||||
if (cap == null) {
|
||||
return;
|
||||
@@ -234,8 +234,8 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
/** Build the candidate list from the configured profiles, in definition order. */
|
||||
private List<PlacementCandidate> candidates() {
|
||||
List<PlacementCandidate> out = new ArrayList<>();
|
||||
for (Map.Entry<String, BridgedConfig.Worker> e : profileConfigs.entrySet()) {
|
||||
BridgedConfig.Worker w = e.getValue();
|
||||
for (Map.Entry<String, BridgedConfig.Profile> e : profileConfigs.entrySet()) {
|
||||
BridgedConfig.Profile w = e.getValue();
|
||||
out.add(new PlacementCandidate(e.getKey(), null, w.weight(), w.maxLoad()));
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -42,7 +42,7 @@ import java.util.regex.Pattern;
|
||||
* <li>{@code namePrefix} (constructor arg) — the label prefix ({@code claude}, {@code opencode})
|
||||
* that drives both unique naming and the orphan-reap pattern, so each adapter reaps only its
|
||||
* own kind of pane and never another's.</li>
|
||||
* <li>{@link #buildLaunch(BridgedConfig.Worker)} — the peer-specific env map + argv, including any
|
||||
* <li>{@link #buildLaunch(BridgedConfig.Profile)} — the peer-specific env map + argv, including any
|
||||
* subscription/guard check, MCP mount, and instruction injection. The base never sees how the
|
||||
* peer is configured; it only places and starts the returned {@link Launch}.</li>
|
||||
* </ul>
|
||||
@@ -69,7 +69,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
private final String namePrefix; // label prefix: naming + reap scheme
|
||||
private final AgentControl agents;
|
||||
private final WorkspaceControl spaces;
|
||||
private final Map<String, BridgedConfig.Worker> profiles; // profile name → spawn settings
|
||||
private final Map<String, BridgedConfig.Profile> profiles; // profile name → spawn settings
|
||||
private final String defaultProfile; // profile a no-arg spawn uses (nullable)
|
||||
|
||||
/** Host env lookup (injectable for tests); adapters read it in {@link #buildLaunch}. */
|
||||
@@ -106,7 +106,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* interval is baked into this hook, so the base needs no poll field
|
||||
*/
|
||||
protected HerdrPeerLauncher(String namePrefix, AgentControl agents, WorkspaceControl spaces,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs,
|
||||
LongSupplier nowMillis, Runnable sleeper) {
|
||||
@@ -128,10 +128,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* Any subscription/guard check, MCP mount, and instruction injection happen here. The env map
|
||||
* and argv are adapter-private; the base only places and starts what is returned.
|
||||
*/
|
||||
protected abstract Launch buildLaunch(BridgedConfig.Worker cfg);
|
||||
protected abstract Launch buildLaunch(BridgedConfig.Profile cfg);
|
||||
|
||||
/**
|
||||
* Session-aware variant of {@link #buildLaunch(BridgedConfig.Worker)} (CB-547a). Default
|
||||
* Session-aware variant of {@link #buildLaunch(BridgedConfig.Profile)} (CB-547a). Default
|
||||
* discards the session identity and delegates to the profile-only form, so an adapter that
|
||||
* carries no durable peer session (opencode, say) inherits byte-identical behaviour and needs
|
||||
* no change. An adapter that does (Claude Code) overrides this to mint/resume the id and to
|
||||
@@ -141,7 +141,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* @param sessionName the bridge's logical session name, or null/blank for launcher-derived
|
||||
* @param resumeSessionId the peer's own prior session id to resume, or null/blank for fresh
|
||||
*/
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg, String sessionName, String resumeSessionId) {
|
||||
protected Launch buildLaunch(BridgedConfig.Profile cfg, String sessionName, String resumeSessionId) {
|
||||
return buildLaunch(cfg);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
if (name == null || name.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
BridgedConfig.Worker cfg = profiles.get(name);
|
||||
BridgedConfig.Profile cfg = profiles.get(name);
|
||||
return cfg == null ? List.of() : cfg.parityOverlay();
|
||||
}
|
||||
|
||||
@@ -204,18 +204,18 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
}
|
||||
|
||||
/** The configured profiles, for adapter capability decisions (e.g. any git-token grant). */
|
||||
protected Collection<BridgedConfig.Worker> profileConfigs() {
|
||||
protected Collection<BridgedConfig.Profile> profileConfigs() {
|
||||
return profiles.values();
|
||||
}
|
||||
|
||||
/** Resolve {@code profileName} (null/blank → default) to its config, or throw with the options. */
|
||||
protected BridgedConfig.Worker requireProfile(String profileName) {
|
||||
protected BridgedConfig.Profile requireProfile(String profileName) {
|
||||
String name = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName;
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("no default worker profile is configured — "
|
||||
+ "pass a profile; configured: " + profiles.keySet());
|
||||
}
|
||||
BridgedConfig.Worker cfg = profiles.get(name);
|
||||
BridgedConfig.Profile cfg = profiles.get(name);
|
||||
if (cfg == null) {
|
||||
throw new IllegalArgumentException("unknown worker profile '" + name
|
||||
+ "' — configured: " + profiles.keySet());
|
||||
@@ -242,13 +242,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
|
||||
/**
|
||||
* Spawn a peer with session identity (CB-547a). {@code sessionName} and {@code resumeSessionId}
|
||||
* are threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Worker,
|
||||
* are threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Profile,
|
||||
* String, String)}, and the launch's resolved agent-session id is returned alongside the agent
|
||||
* so the caller can put it on the {@link PeerHandle}.
|
||||
*/
|
||||
protected Spawned spawnInternal(String profileName, String requestedCwd, String callerCwd,
|
||||
String sessionName, String resumeSessionId) {
|
||||
BridgedConfig.Worker cfg = requireProfile(profileName);
|
||||
BridgedConfig.Profile cfg = requireProfile(profileName);
|
||||
Launch launch = buildLaunch(cfg, sessionName, resumeSessionId);
|
||||
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
|
||||
Agent agent = cfg.tabPlacement()
|
||||
@@ -304,7 +304,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* guaranteed last resort so a pathological environment with an unset {@code user.dir} still
|
||||
* honours the "never assume {@code $HOME}" contract rather than letting herdr default the pane.
|
||||
*/
|
||||
private static String resolveCwd(String requestedCwd, BridgedConfig.Worker cfg, String callerCwd) {
|
||||
private static String resolveCwd(String requestedCwd, BridgedConfig.Profile cfg, String callerCwd) {
|
||||
return firstNonBlank(requestedCwd, cfg.cwd(), callerCwd, System.getProperty("user.dir"), ".");
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
}
|
||||
|
||||
/** Dedicated worker space → own tab (carrying cwd+env) → start the peer into the seed pane. */
|
||||
private Agent spawnInTab(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
|
||||
private Agent spawnInTab(BridgedConfig.Profile cfg, Map<String, String> workerEnv,
|
||||
List<String> argv, String cwd) {
|
||||
Workspace space = spaces.ensureWorkspace(cfg.workspace());
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), cwd, workerEnv);
|
||||
@@ -364,7 +364,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
}
|
||||
|
||||
/** Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. */
|
||||
private Agent spawnAsPane(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
|
||||
private Agent spawnAsPane(BridgedConfig.Profile cfg, Map<String, String> workerEnv,
|
||||
List<String> argv, String cwd) {
|
||||
log.info("spawning {} (pane placement) profile={} cwd={} argv={}",
|
||||
namePrefix, cfg.profile(), cwd, argv);
|
||||
@@ -391,7 +391,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* backstop for the astronomically unlikely nonce+seq clash; the name is a label only — herdr
|
||||
* detects kind and status from terminal output, not from it.
|
||||
*/
|
||||
private Started startUniquelyNamed(BridgedConfig.Worker cfg, List<String> argv, String paneId) {
|
||||
private Started startUniquelyNamed(BridgedConfig.Profile cfg, List<String> argv, String paneId) {
|
||||
// Protocol 19 resolves the executable from the agent kind (== namePrefix here), so
|
||||
// argv[0] — the configured executable — is dropped and only the extra args are passed.
|
||||
List<String> args = argv.isEmpty() ? argv : argv.subList(1, argv.size());
|
||||
@@ -549,7 +549,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
|
||||
/** Whether any configured profile places peers in their own tab (so tabs may need cleanup). */
|
||||
private boolean usesTabPlacement() {
|
||||
return profiles.values().stream().anyMatch(BridgedConfig.Worker::tabPlacement);
|
||||
return profiles.values().stream().anyMatch(BridgedConfig.Profile::tabPlacement);
|
||||
}
|
||||
|
||||
/** True when a herdr error means the target is already gone (safe to treat as done). */
|
||||
@@ -608,7 +608,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* {@code GITEA_HOST}. Push over SSH is unaffected; the only incremental grant is PR-create.
|
||||
* Peer-neutral, so every herdr adapter reuses it unchanged.
|
||||
*/
|
||||
protected void applyGitToken(Map<String, String> workerEnv, BridgedConfig.Worker cfg) {
|
||||
protected void applyGitToken(Map<String, String> workerEnv, BridgedConfig.Profile cfg) {
|
||||
if (!cfg.hasGitToken()) {
|
||||
return;
|
||||
}
|
||||
@@ -637,7 +637,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* dev.ltms.bridged.guard.SubscriptionGuard}, which is checked against the profile's
|
||||
* {@code baseUrl} and nothing else.
|
||||
*/
|
||||
protected Map<String, String> baseEnv(BridgedConfig.Worker cfg) {
|
||||
protected Map<String, String> baseEnv(BridgedConfig.Profile cfg) {
|
||||
Map<String, String> workerEnv = new LinkedHashMap<>();
|
||||
String path = env.apply("PATH");
|
||||
if (path != null && !path.isBlank()) {
|
||||
|
||||
@@ -97,7 +97,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* matches the legacy non-blocking spawn semantics. Config dirs are created under the JVM temp dir.
|
||||
*/
|
||||
public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env) {
|
||||
this(agents, spaces, profiles, defaultProfile, env, 0,
|
||||
System::currentTimeMillis, () -> sleepUninterruptibly(300),
|
||||
@@ -109,7 +109,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses.
|
||||
*/
|
||||
public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs, long spawnReadyPollMs) {
|
||||
this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs,
|
||||
@@ -137,7 +137,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* {@link OpenCodeSessionDiscovery})
|
||||
*/
|
||||
public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs,
|
||||
LongSupplier nowMillis, Runnable sleeper,
|
||||
@@ -167,7 +167,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* with {@code -m}.
|
||||
*/
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
||||
protected Launch buildLaunch(BridgedConfig.Profile cfg) {
|
||||
Map<String, String> workerEnv = baseEnv(cfg);
|
||||
// A config file is needed for the bridge MCP mount, for a pinned endpoint (CB-508), or both.
|
||||
if (cfg.hasMcp() || hasCustomProvider(cfg)) {
|
||||
@@ -187,7 +187,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* primary's Anthropic subscription, and an opencode process has no Anthropic credential path
|
||||
* at all. Pointing it at a local vLLM cannot leak the subscription.
|
||||
*/
|
||||
private static boolean hasCustomProvider(BridgedConfig.Worker cfg) {
|
||||
private static boolean hasCustomProvider(BridgedConfig.Profile cfg) {
|
||||
return cfg.baseUrl() != null && !cfg.baseUrl().isBlank();
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* own git worktree on its own branch, is off-subscription, and cannot merge — the lead is the
|
||||
* gate.
|
||||
*/
|
||||
private List<String> argvWithAuto(BridgedConfig.Worker cfg) {
|
||||
private List<String> argvWithAuto(BridgedConfig.Profile cfg) {
|
||||
List<String> argv = mutableArgv(cfg.argv());
|
||||
argv.add("--auto");
|
||||
return argv;
|
||||
@@ -226,7 +226,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
}
|
||||
|
||||
/** The launch argv plus, when a model is configured, the opencode {@code -m provider/model} flag. */
|
||||
private List<String> argvWithModel(List<String> argv, BridgedConfig.Worker cfg) {
|
||||
private List<String> argvWithModel(List<String> argv, BridgedConfig.Profile cfg) {
|
||||
if (cfg.model() != null && !cfg.model().isBlank()) {
|
||||
argv.add("-m");
|
||||
argv.add(cfg.model());
|
||||
@@ -240,7 +240,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* {@code OPENCODE_CONFIG}. The dir is unique per spawn so concurrent workers never race on it;
|
||||
* it is best-effort cleaned on JVM exit (worker config is disposable — regenerated every spawn).
|
||||
*/
|
||||
private Path writeConfig(BridgedConfig.Worker cfg) {
|
||||
private Path writeConfig(BridgedConfig.Profile cfg) {
|
||||
try {
|
||||
Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-");
|
||||
dir.toFile().deleteOnExit();
|
||||
@@ -297,7 +297,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* <p>The provider id comes from the {@code provider/model} selector in {@code model:}, so one
|
||||
* field drives both the declaration and the {@code -m} flag and they cannot drift apart.
|
||||
*/
|
||||
private void addCustomProvider(ObjectNode root, BridgedConfig.Worker cfg) {
|
||||
private void addCustomProvider(ObjectNode root, BridgedConfig.Profile cfg) {
|
||||
String[] parts = splitModelSelector(cfg);
|
||||
String providerId = parts[0];
|
||||
String modelId = parts[1];
|
||||
@@ -320,7 +320,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* needs both, so a bare model name is rejected loudly rather than silently falling back to the
|
||||
* default gateway — a worker quietly talking to the wrong endpoint is the failure this avoids.
|
||||
*/
|
||||
private static String[] splitModelSelector(BridgedConfig.Worker cfg) {
|
||||
private static String[] splitModelSelector(BridgedConfig.Profile cfg) {
|
||||
String model = cfg.model();
|
||||
int slash = model == null ? -1 : model.indexOf('/');
|
||||
if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) {
|
||||
@@ -459,7 +459,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
|
||||
/** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */
|
||||
private boolean hasGitTokenProfile() {
|
||||
return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken);
|
||||
return profileConfigs().stream().anyMatch(BridgedConfig.Profile::hasGitToken);
|
||||
}
|
||||
|
||||
// --- CB-117 reap predicate (opencode prefix), kept for direct unit testing -----------------
|
||||
|
||||
@@ -302,7 +302,7 @@ class CallerResolverTest {
|
||||
|
||||
@Test
|
||||
void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() {
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||
Map::of, () -> Map.of("term_a", "lead-designer"))
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
@@ -315,7 +315,7 @@ class CallerResolverTest {
|
||||
|
||||
@Test
|
||||
void anArchitectNeedsNoTokenEvenInTokenMode() {
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), true, "s3cret",
|
||||
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), true, "s3cret",
|
||||
Map::of, () -> Map.of("term_a", "lead-designer"))
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
@@ -326,7 +326,7 @@ class CallerResolverTest {
|
||||
@Test
|
||||
void anUnboundPaneStillResolvesAsAWorker() {
|
||||
Map<String, String> arch = Map.of("term_elsewhere", "reviewer");
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||
Map::of, () -> arch).resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.WORKER, p.role());
|
||||
@@ -336,7 +336,7 @@ class CallerResolverTest {
|
||||
/** CB-548 precedence: lead > architect > worker, so a pane named in BOTH is still a lead. */
|
||||
@Test
|
||||
void aLeadWinsOverAnArchitectBindingForTheSamePane() {
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||
() -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer"))
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
@@ -350,7 +350,7 @@ class CallerResolverTest {
|
||||
@Test
|
||||
void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() {
|
||||
Map<String, String> live = new java.util.HashMap<>();
|
||||
CallerResolver r = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||
Map::of, () -> live);
|
||||
|
||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
||||
@@ -358,7 +358,7 @@ class CallerResolverTest {
|
||||
live.put("term_a", "lead-designer"); // the later lifecycle binds the slot
|
||||
|
||||
assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role());
|
||||
assertEquals("lead-designer", r.architects().get("term_a"));
|
||||
assertEquals("lead-designer", r.members().get("term_a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -380,7 +380,7 @@ class CallerResolverTest {
|
||||
/** An architect acts only as its own pane — the same ownsSession rule as a worker or lead. */
|
||||
@Test
|
||||
void anArchitectOwnsItsOwnPaneAndNoOther() {
|
||||
Principal arch = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Principal arch = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||
Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertTrue(arch.ownsSession("term_a"));
|
||||
|
||||
+8
-8
@@ -20,13 +20,13 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
* {@link CallerResolverTest}; this pins the registry object itself — its invariants and their
|
||||
* thread-safety.
|
||||
*/
|
||||
class ArchitectRegistryTest {
|
||||
class MemberRegistryTest {
|
||||
|
||||
private static final Map<String, BridgedConfig.Architect> SLOTS = Map.of(
|
||||
"lead-designer", new BridgedConfig.Architect("sonnet"),
|
||||
"reviewer", new BridgedConfig.Architect("gx10"));
|
||||
private static final Map<String, BridgedConfig.Member> SLOTS = Map.of(
|
||||
"lead-designer", new BridgedConfig.Member("architect", "sonnet"),
|
||||
"reviewer", new BridgedConfig.Member("architect", "gx10"));
|
||||
|
||||
private final ArchitectRegistry registry = new ArchitectRegistry(SLOTS);
|
||||
private final MemberRegistry registry = new MemberRegistry(SLOTS);
|
||||
|
||||
@Test
|
||||
void exposesTheConfiguredSlots() {
|
||||
@@ -228,10 +228,10 @@ class ArchitectRegistryTest {
|
||||
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
|
||||
@Test
|
||||
void theSlotSnapshotIsFixedByConstruction() {
|
||||
Map<String, BridgedConfig.Architect> mutable = new HashMap<>(SLOTS);
|
||||
ArchitectRegistry r = new ArchitectRegistry(mutable);
|
||||
Map<String, BridgedConfig.Member> mutable = new HashMap<>(SLOTS);
|
||||
MemberRegistry r = new MemberRegistry(mutable);
|
||||
|
||||
mutable.put("hijack", new BridgedConfig.Architect("gx10"));
|
||||
mutable.put("hijack", new BridgedConfig.Member("architect", "gx10"));
|
||||
|
||||
assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package dev.ltms.bridged.config;
|
||||
|
||||
import dev.ltms.bridged.auth.ArchitectRegistry;
|
||||
import dev.ltms.bridged.auth.MemberRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
@@ -22,10 +22,10 @@ class BridgedConfigTest {
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
herdrSocket: ~/.config/herdr/herdr.sock
|
||||
worker:
|
||||
profile: ltms-local
|
||||
baseUrl: http://gx00.gw:8000
|
||||
model: coder
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
model: coder
|
||||
guard:
|
||||
offSubscriptionHosts:
|
||||
- gx00.gw
|
||||
@@ -34,7 +34,8 @@ class BridgedConfigTest {
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(8080, cfg.bind().port());
|
||||
assertEquals("ltms-local", cfg.worker().profile());
|
||||
// The profile field is defaulted to the map key by the compact constructor.
|
||||
assertEquals("ltms-local", cfg.profiles().get("ltms-local").profile());
|
||||
assertTrue(cfg.guard().hostSet().contains("gx00.gw"));
|
||||
assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev"));
|
||||
}
|
||||
@@ -52,45 +53,47 @@ class BridgedConfigTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleWorkerBecomesAOneEntryProfileMapWithItselfAsDefault(@TempDir Path dir) throws Exception {
|
||||
void aSoleProfileIsTheDefaultWithoutBeingNamed(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("single.yaml");
|
||||
Files.writeString(f, """
|
||||
worker:
|
||||
profile: ltms-local
|
||||
baseUrl: http://gx00.gw:8000
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(Set.of("ltms-local"), cfg.workerProfiles().keySet(), "legacy worker → one profile");
|
||||
assertEquals("ltms-local", cfg.defaultProfile());
|
||||
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 {
|
||||
Path f = dir.resolve("multi.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
argv: ["ccs", "gx10"]
|
||||
ollama:
|
||||
baseUrl: http://ollama.ltms.dev
|
||||
argv: ["ccs", "ollama"]
|
||||
defaultWorker: gx10
|
||||
defaultProfile: gx10
|
||||
guard:
|
||||
offSubscriptionHosts: [gx10.gw, ollama.ltms.dev]
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(Set.of("gx10", "ollama"), cfg.workerProfiles().keySet());
|
||||
assertEquals(Set.of("gx10", "ollama"), cfg.profiles().keySet());
|
||||
// Order, not just membership: placement breaks an exact-weight tie on definition order, so a
|
||||
// hash-ordered map here would make equal-weight placement differ from one restart to the next.
|
||||
assertEquals(java.util.List.of("gx10", "ollama"),
|
||||
java.util.List.copyOf(cfg.workerProfiles().keySet()),
|
||||
"workerProfiles must preserve YAML definition order");
|
||||
java.util.List.copyOf(cfg.profiles().keySet()),
|
||||
"profiles must preserve YAML definition order");
|
||||
assertEquals("gx10", cfg.defaultProfile());
|
||||
assertEquals("ollama", cfg.workerProfiles().get("ollama").profile(), "profile defaults to its map key");
|
||||
assertEquals("http://gx10.gw:8000", cfg.workerProfiles().get("gx10").baseUrl());
|
||||
assertEquals("ollama", cfg.profiles().get("ollama").profile(), "profile defaults to its map key");
|
||||
assertEquals("http://gx10.gw:8000", cfg.profiles().get("gx10").baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,8 +123,8 @@ class BridgedConfigTest {
|
||||
bind:
|
||||
port: 8080
|
||||
herdrSocket: /tmp/s
|
||||
workers: {}
|
||||
defaultWorker: a
|
||||
profiles: {}
|
||||
defaultProfile: a
|
||||
guard: {}
|
||||
worktreeRoot: /tmp
|
||||
lifecycle: {}
|
||||
@@ -130,7 +133,7 @@ class BridgedConfigTest {
|
||||
broker: {}
|
||||
primary: {}
|
||||
leaders: {}
|
||||
architects: {}
|
||||
members: {}
|
||||
leadScan: {}
|
||||
leadHeartbeat: {}
|
||||
placement: fixed
|
||||
@@ -236,7 +239,7 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
tabLabel: "lead: {profile} #{n}"
|
||||
leadScan:
|
||||
@@ -254,7 +257,7 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
leadScan: {}
|
||||
@@ -269,7 +272,7 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
tabLabel: "lead: {profile}"
|
||||
""");
|
||||
@@ -382,22 +385,24 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(Set.of("lead-designer", "reviewer"), cfg.architects().keySet(),
|
||||
assertEquals(Set.of("lead-designer", "reviewer"), cfg.members().keySet(),
|
||||
"slot names are the keys — gateway-local unique by construction");
|
||||
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
|
||||
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
|
||||
"each slot carries its strong-model profile reference");
|
||||
assertEquals("sonnet", cfg.architects().get("reviewer").profile());
|
||||
assertEquals("sonnet", cfg.members().get("reviewer").profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -409,20 +414,20 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
|
||||
assertEquals("sonnet", cfg.members().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.
|
||||
ArchitectRegistry r = new ArchitectRegistry(cfg.architects());
|
||||
MemberRegistry r = new MemberRegistry(cfg.members());
|
||||
assertTrue(r.snapshot().isEmpty());
|
||||
assertNull(r.slotForTerminal("term_design"),
|
||||
"a config terminal must not resolve an architect — slots start idle");
|
||||
@@ -433,8 +438,8 @@ class BridgedConfigTest {
|
||||
Path f = dir.resolve("no-arch.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\n");
|
||||
|
||||
assertNull(BridgedConfig.load(f).architects(),
|
||||
"no architects: block ⇒ no architect identity, exactly as before CB-548");
|
||||
assertNull(BridgedConfig.load(f).members(),
|
||||
"no members: block ⇒ no architect identity, exactly as before CB-548");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -445,17 +450,18 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
worker:
|
||||
profile: ltms-local
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: ltms-local
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertDoesNotThrow(cfg::validateArchitects);
|
||||
assertEquals("ltms-local", cfg.architects().get("lead-designer").profile());
|
||||
assertDoesNotThrow(cfg::validateMembers);
|
||||
assertEquals("ltms-local", cfg.members().get("lead-designer").profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -464,16 +470,17 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
|
||||
assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile");
|
||||
}
|
||||
@@ -484,38 +491,41 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: ""
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidArchitectRegistryPassesValidation(@TempDir Path dir) throws Exception {
|
||||
void aValidMemberRegistryPassesValidation(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("arch-ok.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
role: architect
|
||||
profile: gx10
|
||||
""");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -523,7 +533,7 @@ class BridgedConfigTest {
|
||||
Path f = dir.resolve("no-arch.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\n");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -532,13 +542,15 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
@@ -546,7 +558,7 @@ class BridgedConfigTest {
|
||||
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 architect"),
|
||||
assertTrue(e.getMessage().contains("duplicate member"),
|
||||
"the refusal says the slot name is duplicated");
|
||||
}
|
||||
|
||||
@@ -558,14 +570,14 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
""");
|
||||
// Last-wins for a non-architect duplicate is untouched: only the architects block is walked.
|
||||
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).workerProfiles().keySet());
|
||||
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).profiles().keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -577,15 +589,17 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
architects:
|
||||
members:
|
||||
nested-slot:
|
||||
k: v
|
||||
nested-slot:
|
||||
k: v
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
@@ -593,7 +607,7 @@ class BridgedConfigTest {
|
||||
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 architect"),
|
||||
assertTrue(e.getMessage().contains("duplicate member"),
|
||||
"the refusal says the slot name is duplicated");
|
||||
}
|
||||
|
||||
@@ -607,11 +621,12 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
members:
|
||||
lead-designer:
|
||||
role: architect
|
||||
profile: sonnet
|
||||
extra:
|
||||
a: 1
|
||||
@@ -619,10 +634,10 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
|
||||
assertDoesNotThrow(cfg::validateArchitects,
|
||||
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.architects().keySet());
|
||||
assertEquals("sonnet", cfg.architects().get("lead-designer").profile());
|
||||
assertEquals(Set.of("lead-designer"), cfg.members().keySet());
|
||||
assertEquals("sonnet", cfg.members().get("lead-designer").profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -701,14 +716,14 @@ class BridgedConfigTest {
|
||||
void workerKindDefaultsToClaudeCodeWhenOmitted(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("kind-absent.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
argv: ["ccs", "gx10"]
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(BridgedConfig.Worker.KIND_CLAUDE_CODE, cfg.workerProfiles().get("gx10").kind(),
|
||||
assertEquals(BridgedConfig.Profile.KIND_CLAUDE_CODE, cfg.profiles().get("gx10").kind(),
|
||||
"a worker with no kind: is a claude-code worker (backward compatible)");
|
||||
}
|
||||
|
||||
@@ -716,7 +731,7 @@ class BridgedConfigTest {
|
||||
void opencodeKindIsNormalizedToLowerCase(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("kind-opencode.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
gemini:
|
||||
kind: OpenCode
|
||||
model: google/gemini-2.5-pro
|
||||
@@ -724,7 +739,7 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(BridgedConfig.Worker.KIND_OPENCODE, cfg.workerProfiles().get("gemini").kind(),
|
||||
assertEquals(BridgedConfig.Profile.KIND_OPENCODE, cfg.profiles().get("gemini").kind(),
|
||||
"kind is normalised to lower-case so YAML casing does not matter");
|
||||
}
|
||||
|
||||
@@ -732,7 +747,7 @@ class BridgedConfigTest {
|
||||
void kindPredicatesReflectTheResolvedKind(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("kind-predicates.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
claude:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
gemini:
|
||||
@@ -741,8 +756,8 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
BridgedConfig.Worker claude = cfg.workerProfiles().get("claude");
|
||||
BridgedConfig.Worker gemini = cfg.workerProfiles().get("gemini");
|
||||
BridgedConfig.Profile claude = cfg.profiles().get("claude");
|
||||
BridgedConfig.Profile gemini = cfg.profiles().get("gemini");
|
||||
assertTrue(claude.isClaudeCode(), "the default-kind worker is claude-code");
|
||||
assertFalse(claude.isOpenCode(), "a claude-code worker is not opencode");
|
||||
assertTrue(gemini.isOpenCode(), "the kind: opencode worker is opencode");
|
||||
@@ -753,7 +768,7 @@ class BridgedConfigTest {
|
||||
void argvDefaultsToTheKindBinaryWhenUnset(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("kind-argv.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
claude:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
gemini:
|
||||
@@ -762,9 +777,9 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(java.util.List.of("claude"), cfg.workerProfiles().get("claude").argv(),
|
||||
assertEquals(java.util.List.of("claude"), cfg.profiles().get("claude").argv(),
|
||||
"a claude-code worker with no argv defaults to the claude binary");
|
||||
assertEquals(java.util.List.of("opencode"), cfg.workerProfiles().get("gemini").argv(),
|
||||
assertEquals(java.util.List.of("opencode"), cfg.profiles().get("gemini").argv(),
|
||||
"an opencode worker with no argv defaults to the opencode binary, never claude");
|
||||
}
|
||||
|
||||
@@ -837,7 +852,7 @@ class BridgedConfigTest {
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(example);
|
||||
assertEquals(8765, cfg.bind().port(), "example binds the documented default port");
|
||||
assertTrue(cfg.workerProfiles().containsKey("gx10"), "example documents the gx10 profile");
|
||||
assertTrue(cfg.profiles().containsKey("gx10"), "example documents the gx10 profile");
|
||||
assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves");
|
||||
assertTrue(cfg.guard().hostSet().contains("gx01.gw"),
|
||||
"every example profile's base_url host must be in the example allowlist");
|
||||
@@ -858,7 +873,7 @@ class BridgedConfigTest {
|
||||
spawnReadyTimeoutMs: 25000
|
||||
spawnReadyPollMs: 400
|
||||
worktreeRoot: /tmp/bridged-worktrees
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
kind: claude-code
|
||||
baseUrl: http://gx01.gw:8000
|
||||
@@ -892,7 +907,7 @@ class BridgedConfigTest {
|
||||
assertEquals(400, cfg.spawnReadyPollMs(), "spawnReadyPollMs is camelCase, not snake_case");
|
||||
assertEquals("/tmp/bridged-worktrees", cfg.worktreeRoot());
|
||||
|
||||
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
|
||||
BridgedConfig.Profile w = cfg.profiles().get("gx10");
|
||||
assertEquals("/tmp/ccs/gx10", w.configDir());
|
||||
assertEquals("/tmp/repo", w.cwd());
|
||||
assertEquals(java.util.List.of(".mcp.json", ".env"), w.parityOverlay());
|
||||
@@ -921,7 +936,7 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
""");
|
||||
@@ -936,13 +951,13 @@ class BridgedConfigTest {
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
|
||||
BridgedConfig.Profile w = cfg.profiles().get("gx10");
|
||||
assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0");
|
||||
assertNull(w.maxLoad(), "absent maxLoad defaults to unlimited (null)");
|
||||
}
|
||||
@@ -951,7 +966,7 @@ class BridgedConfigTest {
|
||||
void subscriptionFlagBindsAndDefaultsFalse(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("subscription.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
subscription: true
|
||||
argv: ["ccs", "sonnet"]
|
||||
@@ -960,9 +975,9 @@ class BridgedConfigTest {
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertTrue(cfg.workerProfiles().get("sonnet").isSubscription(),
|
||||
assertTrue(cfg.profiles().get("sonnet").isSubscription(),
|
||||
"subscription: true binds as an explicit opt-in");
|
||||
assertFalse(cfg.workerProfiles().get("opted").isSubscription(),
|
||||
assertFalse(cfg.profiles().get("opted").isSubscription(),
|
||||
"a profile without the key stays off-subscription (the default)");
|
||||
}
|
||||
|
||||
@@ -972,7 +987,7 @@ class BridgedConfigTest {
|
||||
void aSubscriptionProfileWithAnthropicBaseUrlInEnvIsRejected(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("baseUrl.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
subscription: true
|
||||
argv: ["ccs", "sonnet"]
|
||||
@@ -992,7 +1007,7 @@ class BridgedConfigTest {
|
||||
void aSubscriptionProfileWithAnthropicAuthTokenInEnvIsRejected(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("authToken.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
subscription: true
|
||||
argv: ["ccs", "sonnet"]
|
||||
@@ -1012,7 +1027,7 @@ class BridgedConfigTest {
|
||||
void aSubscriptionProfileWithACleanEnvPassesValidation(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("clean.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
sonnet:
|
||||
subscription: true
|
||||
argv: ["ccs", "sonnet"]
|
||||
@@ -1031,7 +1046,7 @@ class BridgedConfigTest {
|
||||
// plain profile's env: is still overwritten by the launcher's guard-checked value (CB-511).
|
||||
Path f = dir.resolve("nonsub.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
env:
|
||||
@@ -1042,4 +1057,171 @@ class BridgedConfigTest {
|
||||
assertDoesNotThrow(cfg::validateSubscriptionProfiles,
|
||||
"only subscription:true profiles are checked — off-subscription ones keep the baseUrl guard");
|
||||
}
|
||||
|
||||
// --- member taxonomy (CB-557) ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A pre-rename config must fail loudly, not load empty.
|
||||
*
|
||||
* <p>This is the whole point of the hard error. Without it, {@code workers:} would be an unknown
|
||||
* top-level key: the load would warn once and then run with zero profiles, so the first spawn
|
||||
* fails with a message that points nowhere near the real cause.
|
||||
*/
|
||||
@Test
|
||||
void aConfigStillUsingTheOldWorkersKeyIsRejectedAndNamesTheNewKey(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
""");
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
|
||||
assertTrue(e.getMessage().contains("'workers' is now 'profiles'"),
|
||||
"the error must name the replacement key, not just say the key is wrong: " + e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRenamedTopLevelKeyIsReportedAtOnce(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
workers:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
defaultWorker: gx10
|
||||
architects:
|
||||
architect-1:
|
||||
profile: gx10
|
||||
""");
|
||||
|
||||
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("'workers' is now 'profiles'"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMemberSlotCarriesBothItsRoleAndItsProfile(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
opus:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
members:
|
||||
architect-1:
|
||||
role: architect
|
||||
profile: opus
|
||||
reviewer-1:
|
||||
role: reviewer
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Role and profile are separate axes: two members may share a backend and still differ in what
|
||||
* they are allowed to do. This is the case that stops the two collapsing into one field.
|
||||
*/
|
||||
@Test
|
||||
void twoMembersWithDifferentRolesMayShareOneProfile(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
members:
|
||||
dev-1:
|
||||
role: dev
|
||||
profile: gx10
|
||||
reviewer-1:
|
||||
role: reviewer
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMemberSlotWithAnUnknownRoleIsRejectedAndListsTheValidRoles(@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
|
||||
""");
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMemberSlotWithNoRoleIsRejected(@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
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||
assertTrue(e.getMessage().contains("has no role:"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theProfilesMapIsNormalizedOnceAtConstruction(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
terra:
|
||||
profile: explicitly-set
|
||||
baseUrl: http://gx01.gw:8000
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals("gx10", cfg.profiles().get("gx10").profile(),
|
||||
"a profile that names no profile: field is defaulted to its map key");
|
||||
assertEquals("explicitly-set", cfg.profiles().get("terra").profile(),
|
||||
"an explicit profile: field is left alone");
|
||||
assertEquals(List.of("gx10", "terra"), List.copyOf(cfg.profiles().keySet()),
|
||||
"YAML definition order survives — placement tie-breaks on it");
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectiveDefaultProfileFallsBackToTheFirstConfiguredProfile(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
gx10:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
terra:
|
||||
baseUrl: http://gx01.gw:8000
|
||||
""");
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class BridgeMcpAuthzTest {
|
||||
|
||||
/** A fully wired BridgeMcp on fakes — constructing it is itself part of what is under test. */
|
||||
private BridgeMcp mcp(boolean enforce) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
||||
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr),
|
||||
|
||||
@@ -52,7 +52,7 @@ class BridgeMcpTest {
|
||||
}
|
||||
|
||||
private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set<String> allow) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
||||
return new ClaudeCodeLauncher(new AgentControl(h), new WorkspaceControl(h),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.ltms.bridged.peer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MemberRoleTest {
|
||||
|
||||
@Test
|
||||
void theThreeRolesAreArchitectDevAndReviewer() {
|
||||
assertEquals(3, MemberRole.values().length,
|
||||
"a new role changes the charter, the role file, the skill and the authz row — "
|
||||
+ "adding one is a deliberate act, so this count is meant to fail first");
|
||||
assertEquals("architect", MemberRole.ARCHITECT.wireName());
|
||||
assertEquals("dev", MemberRole.DEV.wireName());
|
||||
assertEquals("reviewer", MemberRole.REVIEWER.wireName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseAcceptsTheWireSpelling() {
|
||||
for (MemberRole r : MemberRole.values()) {
|
||||
assertSame(r, MemberRole.parse(r.wireName()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseIsCaseInsensitiveAndTrimsSurroundingSpace() {
|
||||
assertSame(MemberRole.ARCHITECT, MemberRole.parse("Architect"));
|
||||
assertSame(MemberRole.DEV, MemberRole.parse(" DEV "));
|
||||
assertSame(MemberRole.REVIEWER, MemberRole.parse("ReViEwEr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRejectsAnUnknownRoleAndListsTheValidOnes() {
|
||||
IllegalArgumentException e =
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("archtiect"));
|
||||
assertTrue(e.getMessage().contains("archtiect"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("architect, dev, reviewer"),
|
||||
"a typo in config should be fixable from the message alone: " + e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRejectsNullAndBlank() {
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(null));
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(""));
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* A lead is not a member role. A lead is not spawned — it is a pre-existing session that config
|
||||
* recognises — so it has no launch charter to pick and never appears in a {@code members:} slot.
|
||||
*/
|
||||
@Test
|
||||
void leadIsNotAMemberRole() {
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("lead"));
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("primary"));
|
||||
}
|
||||
|
||||
/** "worker" is the name this taxonomy replaced; it must not quietly keep working. */
|
||||
@Test
|
||||
void workerIsNoLongerARole() {
|
||||
assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("worker"));
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class BridgedAppAuthTest {
|
||||
*/
|
||||
private int start(long pid, boolean tokenMode, String token) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile wcfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
@@ -206,7 +206,7 @@ class BridgedAppAuthTest {
|
||||
// The 29 pre-existing acceptance tests rely on this: no auth fixture, no enforcement.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile wcfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
||||
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
|
||||
|
||||
@@ -62,7 +62,7 @@ class BridgedAppTest {
|
||||
}
|
||||
|
||||
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> allow, String placement, Worktrees worktrees) {
|
||||
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile wcfg = new BridgedConfig.Profile(
|
||||
"ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
placement, "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
|
||||
@@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class SessionManagerTest {
|
||||
|
||||
private SessionManager sessionManager(FakeHerdr herdr) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, null, null);
|
||||
@@ -44,7 +44,7 @@ class SessionManagerTest {
|
||||
|
||||
private SessionManager sessionManager(FakeHerdr herdr, LongSupplier clock, int contextCap,
|
||||
boolean clearAfterTurn) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, null, null);
|
||||
@@ -432,7 +432,7 @@ class SessionManagerTest {
|
||||
long[] clock = {0};
|
||||
|
||||
// Gate-enabled launcher (1 ms timeout + no-op sleeper that advances clock past deadline)
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, null, null);
|
||||
|
||||
@@ -29,7 +29,7 @@ class SessionReaperTest {
|
||||
|
||||
private static ClaudeCodeLauncher launcher() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, null, null);
|
||||
|
||||
@@ -23,7 +23,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class WorktreeSessionManagerTest {
|
||||
|
||||
private static ClaudeCodeLauncher workerService(FakeHerdr herdr) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, null, null);
|
||||
@@ -245,7 +245,7 @@ class WorktreeSessionManagerTest {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||
// Argument order matters: configDir is the 4th parameter, cwd the 11th (after mcpUrl).
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, "/pinned/dir", null);
|
||||
|
||||
@@ -24,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class ClaudeCodeLauncherTest {
|
||||
|
||||
private ClaudeCodeLauncher service(FakeHerdr herdr, List<String> argv, String mcpUrl) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null, null);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -59,7 +59,7 @@ class ClaudeCodeLauncherTest {
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(
|
||||
new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")),
|
||||
Map.of("ltms-local", new BridgedConfig.Worker(
|
||||
Map.of("ltms-local", new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null)),
|
||||
"ltms-local", _ -> null,
|
||||
@@ -100,9 +100,9 @@ class ClaudeCodeLauncherTest {
|
||||
}
|
||||
|
||||
private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) {
|
||||
BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder",
|
||||
BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null,
|
||||
BridgedConfig.Profile ollama = new BridgedConfig.Profile("ollama", "http://ollama.ltms.dev", null,
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")),
|
||||
@@ -143,7 +143,7 @@ class ClaudeCodeLauncherTest {
|
||||
@Test
|
||||
void profileConfigCwdBeatsTheCallerCwd() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder",
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile("ltms-local", "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"w #{n}", null, "/pinned/dir", null);
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -170,7 +170,7 @@ class ClaudeCodeLauncherTest {
|
||||
@Test
|
||||
void injectsForgeTokenAndHostWhenProfileGrantsIt() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null,
|
||||
"GITEA_ACCESS_TOKEN", null); // parityOverlay null; gitHostEnv null → defaults to GITEA_HOST
|
||||
@@ -192,7 +192,7 @@ class ClaudeCodeLauncherTest {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
// gitTokenEnv unset (12-arg ctor); the env would resolve a token if asked, proving the gate
|
||||
// is the profile config, not a missing env var.
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder",
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile("ltms-local", "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("ccs"), "tab", "bridged-workers", "w #{n}",
|
||||
null, null, null);
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -323,7 +323,7 @@ class ClaudeCodeLauncherTest {
|
||||
@Test
|
||||
void capabilitiesIncludeSelfPrWhenProfileHasGitToken() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null,
|
||||
"GITEA_ACCESS_TOKEN", null);
|
||||
@@ -476,8 +476,8 @@ class ClaudeCodeLauncherTest {
|
||||
|
||||
// --- CB-306 spawn-readiness gate -----------------------------------------------------------
|
||||
|
||||
private static Map<String, BridgedConfig.Worker> workerConfigMap(String profile, String mcpUrl) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
private static Map<String, BridgedConfig.Profile> workerConfigMap(String profile, String mcpUrl) {
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
profile, "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", profile), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", mcpUrl, null, null);
|
||||
@@ -579,7 +579,7 @@ class ClaudeCodeLauncherTest {
|
||||
@Test
|
||||
void workerInheritsTheDaemonPath() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -593,7 +593,7 @@ class ClaudeCodeLauncherTest {
|
||||
@Test
|
||||
void profileEnvIsInjectedIntoTheWorker() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
|
||||
null, Map.of("JAVA_HOME", "/opt/jdk", "PATH", "/profile/bin"), null, null);
|
||||
@@ -614,7 +614,7 @@ class ClaudeCodeLauncherTest {
|
||||
@Test
|
||||
void profileEnvCannotOverrideGuardCheckedAnthropicVars() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
|
||||
null, Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com"), null, null);
|
||||
@@ -630,14 +630,14 @@ class ClaudeCodeLauncherTest {
|
||||
|
||||
/** A launcher for a profile identical but for its {@code model:} — the only variable here. */
|
||||
private ClaudeCodeLauncher serviceWithModel(FakeHerdr herdr, String model) {
|
||||
BridgedConfig.Worker cfg = profileWithModel(model);
|
||||
BridgedConfig.Profile cfg = profileWithModel(model);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
_ -> null);
|
||||
}
|
||||
|
||||
private static BridgedConfig.Worker profileWithModel(String model) {
|
||||
return new BridgedConfig.Worker("sonnet", "http://gx00.gw:8000", model, null,
|
||||
private static BridgedConfig.Profile profileWithModel(String model) {
|
||||
return new BridgedConfig.Profile("sonnet", "http://gx00.gw:8000", model, null,
|
||||
"BRIDGED_WORKER_TOKEN", List.of("ccs", "sonnet"), "tab", "bridged-workers",
|
||||
"w #{n}", "http://127.0.0.1:8765/mcp", null, null);
|
||||
}
|
||||
@@ -679,8 +679,8 @@ class ClaudeCodeLauncherTest {
|
||||
// --- CB-539: subscription-profile opt-in ----------------------------------------------------
|
||||
|
||||
/** A claude-code profile on the subscription: no baseUrl (by design), no off-sub endpoint. */
|
||||
private static BridgedConfig.Worker subscriptionCfg(String profile, String baseUrl) {
|
||||
return new BridgedConfig.Worker(
|
||||
private static BridgedConfig.Profile subscriptionCfg(String profile, String baseUrl) {
|
||||
return new BridgedConfig.Profile(
|
||||
profile, baseUrl, "sonnet", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", profile), "tab", "bridged-workers", "w #{n}", null, null, null,
|
||||
null, null, null, Map.of(), null, null, true);
|
||||
@@ -691,7 +691,7 @@ class ClaudeCodeLauncherTest {
|
||||
// Requirement 1: absent subscription:true ⇒ byte-identical refusal to today. A claude-code
|
||||
// profile with no baseUrl and no subscription must still be refused (it would bill the sub).
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"ltms-local", null, "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -710,7 +710,7 @@ class ClaudeCodeLauncherTest {
|
||||
// ANTHROPIC_BASE_URL nor ANTHROPIC_AUTH_TOKEN is injected even though the token env would
|
||||
// resolve one if asked.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = subscriptionCfg("sonnet", null);
|
||||
BridgedConfig.Profile cfg = subscriptionCfg("sonnet", null);
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
_ -> "would-be-token").spawn();
|
||||
@@ -727,7 +727,7 @@ class ClaudeCodeLauncherTest {
|
||||
// Requirement 2: subscription:true + a baseUrl state opposite intents — refuse at spawn,
|
||||
// naming the profile, rather than silently picking a winner.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000");
|
||||
BridgedConfig.Profile cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000");
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||
|
||||
@@ -744,7 +744,7 @@ class ClaudeCodeLauncherTest {
|
||||
// Requirement 3: the guard keeps its teeth for every other profile — a base_url whose host is
|
||||
// not on the allowlist is still refused, whether or not any subscription profile exists.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker rogue = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile rogue = new BridgedConfig.Profile(
|
||||
"rogue", "http://evil.example.com:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "rogue"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -763,7 +763,7 @@ class ClaudeCodeLauncherTest {
|
||||
// load refuses this loudly; this launcher-side strip is the belt-and-braces that makes the
|
||||
// invariant hold for a profile built in code that never passed through that validation.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||
"sonnet", null, "sonnet", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "sonnet"), "tab", "bridged-workers", "w #{n}", null, null, null,
|
||||
null, null, null,
|
||||
|
||||
@@ -40,7 +40,7 @@ class CompositePeerLauncherTest {
|
||||
|
||||
private ClaudeCodeLauncher claudeAdapter(FakeHerdr herdr) {
|
||||
// 12-arg back-compat Worker ctor → kind defaults to claude-code.
|
||||
BridgedConfig.Worker claude = new BridgedConfig.Worker("claude", "http://gx00.gw:8000", "coder",
|
||||
BridgedConfig.Profile claude = new BridgedConfig.Profile("claude", "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}",
|
||||
null, null, null);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
@@ -48,9 +48,9 @@ class CompositePeerLauncherTest {
|
||||
}
|
||||
|
||||
private OpenCodeLauncher opencodeAdapter(FakeHerdr herdr) {
|
||||
BridgedConfig.Worker gemini = new BridgedConfig.Worker("gemini", null, "google/gemini-2.5-pro",
|
||||
BridgedConfig.Profile gemini = new BridgedConfig.Profile("gemini", null, "google/gemini-2.5-pro",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers", "w #{n}",
|
||||
null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Worker.KIND_OPENCODE);
|
||||
null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Profile.KIND_OPENCODE);
|
||||
return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
Map.of("gemini", gemini), "gemini", _ -> "tok");
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class CompositePeerLauncherTest {
|
||||
private final Map<String, Integer> spawnCounts = new HashMap<>();
|
||||
|
||||
StubLauncher(String prefix, FakeHerdr herdr,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
|
||||
Set<String> failProfiles) {
|
||||
super(prefix, new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
profiles, defaultProfile, _ -> null, 0L, System::currentTimeMillis, () -> { });
|
||||
@@ -78,7 +78,7 @@ class CompositePeerLauncherTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
||||
protected Launch buildLaunch(BridgedConfig.Profile cfg) {
|
||||
return new Launch(Map.of(), List.of());
|
||||
}
|
||||
|
||||
@@ -114,14 +114,14 @@ class CompositePeerLauncherTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static BridgedConfig.Worker stubWorker(String profile) {
|
||||
return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder",
|
||||
private static BridgedConfig.Profile stubWorker(String profile) {
|
||||
return new BridgedConfig.Profile(profile, "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
|
||||
"w #{n}", null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static BridgedConfig.Worker stubWorker(String profile, float weight, Integer maxLoad) {
|
||||
return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder",
|
||||
private static BridgedConfig.Profile stubWorker(String profile, float weight, Integer maxLoad) {
|
||||
return new BridgedConfig.Profile(profile, "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
|
||||
"w #{n}", null, null, null, null, null, null, null,
|
||||
weight, maxLoad);
|
||||
@@ -133,9 +133,9 @@ class CompositePeerLauncherTest {
|
||||
* so a {@code Map.of} would make "which profile is tried first" a coin flip per run and any
|
||||
* assertion about the first attempt intermittently false.
|
||||
*/
|
||||
private static Map<String, BridgedConfig.Worker> ordered(String first, BridgedConfig.Worker a,
|
||||
String second, BridgedConfig.Worker b) {
|
||||
Map<String, BridgedConfig.Worker> m = new LinkedHashMap<>();
|
||||
private static Map<String, BridgedConfig.Profile> ordered(String first, BridgedConfig.Profile a,
|
||||
String second, BridgedConfig.Profile b) {
|
||||
Map<String, BridgedConfig.Profile> m = new LinkedHashMap<>();
|
||||
m.put(first, a);
|
||||
m.put(second, b);
|
||||
return m;
|
||||
@@ -292,7 +292,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void weightedPolicyGatesProfileAtMaxLoad() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, 1),
|
||||
"b", stubWorker("b", 1.0f, null));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
@@ -308,7 +308,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void weightedPolicyDistributesAccordingToWeightRatio() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a", 0.75f, null),
|
||||
"b", stubWorker("b", 0.25f, null));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
@@ -328,7 +328,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void failoverRetriesNextCandidateWhenProfileIsUnreachable() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a"),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a"));
|
||||
@@ -349,7 +349,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void reversingDefinitionOrderReversesWhichProfileIsTriedFirst() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"b", stubWorker("b"),
|
||||
"a", stubWorker("a"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("b"));
|
||||
@@ -364,7 +364,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void failoverBoundedByCandidateCount() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a"),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a", "b"));
|
||||
@@ -381,7 +381,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void explicitSpawnAtMaxLoadThrowsPlacementExceptionNamingProfileLiveAndCap() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, 2),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
@@ -399,7 +399,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void explicitSpawnUnderMaxLoadStillSucceeds() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, 2),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
@@ -414,7 +414,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void explicitSpawnWithNullMaxLoadIsNeverCapped() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, null),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
@@ -429,7 +429,7 @@ class CompositePeerLauncherTest {
|
||||
@Test
|
||||
void emptyCandidateSetThrowsClearException() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
Map<String, BridgedConfig.Profile> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, 1),
|
||||
"b", stubWorker("b", 1.0f, 1));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
|
||||
@@ -27,14 +27,14 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
class OpenCodeLauncherTest {
|
||||
|
||||
private static BridgedConfig.Worker opencodeCfg(String model, String mcpUrl, String gitTokenEnv) {
|
||||
return new BridgedConfig.Worker("gemini", null, model, null, "BRIDGED_WORKER_TOKEN",
|
||||
private static BridgedConfig.Profile opencodeCfg(String model, String mcpUrl, String gitTokenEnv) {
|
||||
return new BridgedConfig.Profile("gemini", null, model, null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
|
||||
null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_OPENCODE);
|
||||
null, null, gitTokenEnv, null, BridgedConfig.Profile.KIND_OPENCODE);
|
||||
}
|
||||
|
||||
/** Gate-disabled launcher whose per-spawn config dirs land under an inspectable temp root. */
|
||||
private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Worker cfg) {
|
||||
private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Profile cfg) {
|
||||
return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
Map.of(cfg.profile(), cfg), cfg.profile(), k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null,
|
||||
0, System::currentTimeMillis, () -> { }, configRoot, configRoot);
|
||||
@@ -205,7 +205,7 @@ class OpenCodeLauncherTest {
|
||||
@Test
|
||||
void productionConstructorsWireThroughToTheBase() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker cfg = opencodeCfg(null, null, null);
|
||||
BridgedConfig.Profile cfg = opencodeCfg(null, null, null);
|
||||
// 5-arg (gate disabled) and 7-arg (gate enabled) production constructors both expose the profile.
|
||||
OpenCodeLauncher disabled = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||
@@ -247,10 +247,10 @@ class OpenCodeLauncherTest {
|
||||
// --- CB-508: pinned OpenAI-compatible endpoint (e.g. a local vLLM) ---------------------------
|
||||
|
||||
/** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */
|
||||
private static BridgedConfig.Worker pinnedCfg(String model, String baseUrl, String mcpUrl) {
|
||||
return new BridgedConfig.Worker("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN",
|
||||
private static BridgedConfig.Profile pinnedCfg(String model, String baseUrl, String mcpUrl) {
|
||||
return new BridgedConfig.Profile("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
|
||||
null, null, null, null, BridgedConfig.Worker.KIND_OPENCODE);
|
||||
null, null, null, null, BridgedConfig.Profile.KIND_OPENCODE);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user