97ecc7136e
Opt-in isolated worktree so parallel implementers don't stomp the shared tree, hydrated to config parity so a worker differs from the primary only in LLM provider. - Worktrees seam (interface) behind SessionManager; GitWorktrees shells git via ProcessBuilder (non-zero exit -> WorktreeException), FakeWorktrees for tests. No live git in unit tests. - acquire() 5-arg overload provisions add -> overlayParity -> spawn(cwd=wt) -> register, unwinding the worktree on any failure before registration. 4-arg overload and shared-tree behavior unchanged (backward compatible). - release() removes the checkout but never deletes the branch (it holds the worker's commits + PR, CB-302). - overlayParity copies local config (.mcp.json, settings.local.json, .env/ .envrc) into the worktree; tracked ones get --skip-worktree so a worker can never stage the parity overlay. - WorkerSession gains nullable worktree/branch; BridgedConfig.Worker gains parityOverlay (default list) + top-level worktreeRoot. - bridge_spawn / POST /workers gain an optional worktree(+ticket) arg; the worker view includes worktree/branch only when non-null. Verify fixes on the delegated impl: strip trailing dashes in slug() (^-+|-+$, was ^-+|^-+$); make FakeWorktrees.add a pure fn of the branch (nonce already unique); MCP worktreeRequest treats blank/"false" string as no-worktree, matching the REST builder. 162 tests, 0 failures.
189 lines
8.9 KiB
Java
189 lines
8.9 KiB
Java
package dev.ltms.bridged.config;
|
|
|
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
|
|
|
import java.io.IOException;
|
|
import java.io.UncheckedIOException;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
|
|
/**
|
|
* {@code bridged} configuration, loaded from a YAML file (see
|
|
* {@code bridged.example.yaml}). Unknown keys are ignored so config can grow ahead
|
|
* of the code.
|
|
*
|
|
* @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 guard subscription-boundary allowlist
|
|
* @param worktreeRoot nullable root directory for provisioned worktrees; defaults to a sibling
|
|
* of the repo root
|
|
*/
|
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
public record BridgedConfig(
|
|
Bind bind,
|
|
String herdrSocket,
|
|
Worker worker,
|
|
Map<String, Worker> workers,
|
|
String defaultWorker,
|
|
Guard guard,
|
|
String worktreeRoot) {
|
|
|
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
public record Bind(String host, int port) {
|
|
public Bind {
|
|
if (host == null || host.isBlank()) host = "127.0.0.1";
|
|
if (port <= 0) port = 8765;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param profile ccs profile a worker is spawned under (Stage-1: {@code ltms-local})
|
|
* @param baseUrl the off-subscription endpoint injected as {@code ANTHROPIC_BASE_URL}
|
|
* @param model model alias, injected as {@code ANTHROPIC_MODEL} (may be {@code null})
|
|
* @param configDir {@code CLAUDE_CONFIG_DIR} so the worker inherits the profile's
|
|
* skills/MCP/hooks (may be {@code null})
|
|
* @param tokenEnv name of the host env var holding the worker's auth token; its value
|
|
* is injected as {@code ANTHROPIC_AUTH_TOKEN} (never stored in config)
|
|
* @param argv launch command; defaults to {@code ["claude"]}
|
|
* @param placement where a worker lands: {@code "tab"} (default — its own tab in the
|
|
* worker space) or {@code "pane"} (legacy — split the focused tab)
|
|
* @param workspace label of the dedicated worker space; found-or-created on first
|
|
* spawn (default {@code "bridged-workers"}). A future per-session
|
|
* layout is just a distinct label here — the shared space is default.
|
|
* @param tabLabel template for a worker tab's label; {@code {profile}}/{@code {model}}
|
|
* and {@code {n}} (per-worker number, to keep sibling tabs distinct)
|
|
* are substituted (default {@code "worker: {profile} #{n}"})
|
|
* @param mcpUrl bridge MCP URL to provision into the worker's {@code configDir} so it
|
|
* can call {@code bridge_reply} ({@code null}/blank → no provisioning; the
|
|
* worker won't reply, only the fallback/timeout resolves the send)
|
|
* @param cwd fixed working directory for this profile's workers (CB-112 "told otherwise");
|
|
* {@code null}/blank → inherit the primary's cwd, else the daemon's
|
|
* @param parityOverlay repo-relative paths copied primary→worktree for config parity; null/empty
|
|
* defaults to a sensible set of local config files
|
|
*/
|
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
public record Worker(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) {
|
|
public Worker {
|
|
argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv);
|
|
tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv;
|
|
placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase();
|
|
workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace;
|
|
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? "worker: {profile} #{n}" : tabLabel;
|
|
parityOverlay = (parityOverlay == null || parityOverlay.isEmpty())
|
|
? List.of(".mcp.json", ".claude/settings.local.json", ".env", ".envrc")
|
|
: List.copyOf(parityOverlay);
|
|
}
|
|
|
|
/** 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,
|
|
mcpUrl, cwd, parityOverlay);
|
|
}
|
|
|
|
/** True when workers should land in their own tab in the worker space. */
|
|
public boolean tabPlacement() {
|
|
return "tab".equals(placement);
|
|
}
|
|
|
|
/** True when the bridge MCP should be mounted into a spawned worker (via launch flags). */
|
|
public boolean hasMcp() {
|
|
return mcpUrl != null && !mcpUrl.isBlank();
|
|
}
|
|
|
|
/**
|
|
* Render {@link #tabLabel} for the {@code n}-th worker (substitutes
|
|
* {@code {profile}}/{@code {model}}/{@code {n}}), so sibling worker tabs are distinct.
|
|
*/
|
|
public String renderTabLabel(long n) {
|
|
return tabLabel
|
|
.replace("{profile}", profile == null ? "" : profile)
|
|
.replace("{model}", model == null ? "" : model)
|
|
.replace("{n}", Long.toString(n));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscription boundary. Only these hosts may back a worker's
|
|
* {@code ANTHROPIC_BASE_URL}; the primary must carry none.
|
|
*
|
|
* @param offSubscriptionHosts hostnames allowed for worker base_urls
|
|
*/
|
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
public record Guard(List<String> offSubscriptionHosts) {
|
|
public Guard {
|
|
offSubscriptionHosts = offSubscriptionHosts == null ? List.of() : List.copyOf(offSubscriptionHosts);
|
|
}
|
|
|
|
public Set<String> hostSet() {
|
|
return Set.copyOf(offSubscriptionHosts);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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));
|
|
return Map.copyOf(out);
|
|
}
|
|
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();
|
|
}
|
|
|
|
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
|
|
|
|
/** Load and validate config from {@code path}. */
|
|
public static BridgedConfig load(Path path) {
|
|
try {
|
|
BridgedConfig cfg = YAML.readValue(Files.readString(path), BridgedConfig.class);
|
|
return cfg.withDefaults();
|
|
} catch (IOException e) {
|
|
throw new UncheckedIOException("cannot read bridged config at " + path, e);
|
|
}
|
|
}
|
|
|
|
/** Fill in nested defaults so callers never see nulls for structural fields. */
|
|
public BridgedConfig withDefaults() {
|
|
Bind b = bind != null ? bind : new Bind(null, 0);
|
|
Guard g = guard != null ? guard : new Guard(List.of());
|
|
return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot);
|
|
}
|
|
}
|