Stage-1 walking skeleton: bridged Java daemon (herdr client + REST + guard)
Maven/Java 25 module under bridged/. End-to-end verified against live herdr 0.7.0 (protocol 14): GET /healthz and GET /sessions serve real workspace data through the socket client. - herdr client (CB-101): Unix-socket JSON-RPC via UnixDomainSocketAddress. Two contract facts pinned by tests against the real daemon: ids MUST be strings, and herdr is one-shot per connection (connection-per-call, which also makes the client lock-free). - subscription guard: worker base_url must be on the off-subscription allowlist (gx00.gw, ollama.ltms.dev); primary env must carry no base_url. - config (CB-106): Jackson YAML + Logback; example grounded in ltms-local. - REST app (CB-104 start): injectable HerdrClient so acceptance tests run on an ephemeral port with a fake herdr, no daemon/Claude in the loop. - tests: 17 unit/acceptance (mvn test) + 3 contract (mvn test -Pcontract).
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Build output
|
||||
target/
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
# Local runtime config (copy from bridged.example.yaml)
|
||||
bridged.yaml
|
||||
|
||||
# Editor / OS
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,27 @@
|
||||
# bridged configuration (example). Copy to bridged.yaml and adjust.
|
||||
#
|
||||
# bridged is the sole gateway between primary/worker Claude sessions and herdr.
|
||||
# It is NOT a Claude process and must never carry ANTHROPIC_BASE_URL.
|
||||
|
||||
# REST + MCP listen address. Keep it on loopback — bridged is same-host in Stage-1.
|
||||
bind:
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
|
||||
# herdr Unix socket. Omit to use the client default
|
||||
# (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}).
|
||||
herdrSocket: ~/.config/herdr/herdr.sock
|
||||
|
||||
# How a worker session is spawned. Stage-1 uses the existing ccs `ltms-local`
|
||||
# profile, whose .claude.json routes to the gx00 vLLM below.
|
||||
worker:
|
||||
profile: ltms-local
|
||||
baseUrl: http://gx00.gw:8000 # the gx00 vLLM (models: coder / deepseek-v4-flash)
|
||||
model: coder
|
||||
|
||||
# Subscription boundary. A worker's base_url host MUST be one of these; the primary
|
||||
# must carry none. Grounded in ltms-local's real endpoints.
|
||||
guard:
|
||||
offSubscriptionHosts:
|
||||
- gx00.gw
|
||||
- ollama.ltms.dev
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>dev.ltms</groupId>
|
||||
<artifactId>bridged</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>bridged</name>
|
||||
<description>claude-bridge message server: sole gateway between primary/worker Claude sessions and herdr</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<mainClass>dev.ltms.bridged.Bridged</mainClass>
|
||||
|
||||
<jackson.version>2.18.2</jackson.version>
|
||||
<javalin.version>6.3.0</javalin.version>
|
||||
<slf4j.version>2.0.16</slf4j.version>
|
||||
<logback.version>1.5.15</logback.version>
|
||||
<junit.version>5.11.4</junit.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- JSON + YAML (config, herdr wire format, REST bodies) -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- REST: the testability surface. MCP tools are thin adapters over these endpoints. -->
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>${javalin.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Logging -->
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>bridged</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.0</version>
|
||||
</plugin>
|
||||
|
||||
<!-- Unit tests run by default; contract tests (live herdr) are tag-excluded. -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<configuration>
|
||||
<excludedGroups>${excludedGroups}</excludedGroups>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- Runnable fat jar: java -jar target/bridged.jar -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>${mainClass}</mainClass>
|
||||
</transformer>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- `mvn test` excludes contract tests. `mvn test -Pcontract` runs them against a live herdr. -->
|
||||
<profile>
|
||||
<id>default-excludes</id>
|
||||
<activation><activeByDefault>true</activeByDefault></activation>
|
||||
<properties><excludedGroups>contract</excludedGroups></properties>
|
||||
</profile>
|
||||
<profile>
|
||||
<id>contract</id>
|
||||
<properties><excludedGroups></excludedGroups></properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.ltms.bridged;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import dev.ltms.bridged.guard.SubscriptionGuard;
|
||||
import dev.ltms.bridged.herdr.UnixSocketHerdrClient;
|
||||
import dev.ltms.bridged.rest.BridgedApp;
|
||||
import io.javalin.Javalin;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* {@code bridged} entry point. Wires the real herdr socket client to the REST app and
|
||||
* starts listening. Before anything else it asserts its own environment is clean —
|
||||
* {@code bridged} is not a Claude process and must never carry a base_url.
|
||||
*/
|
||||
public final class Bridged {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Bridged.class);
|
||||
|
||||
public static void main(String[] args) {
|
||||
Path configPath = Path.of(args.length > 0 ? args[0] : "bridged.yaml");
|
||||
BridgedConfig cfg = BridgedConfig.load(configPath);
|
||||
|
||||
// The primary/host env that launched bridged must not be tainted.
|
||||
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
|
||||
guard.assertPrimaryClean(System.getenv());
|
||||
|
||||
Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
|
||||
? Path.of(cfg.herdrSocket())
|
||||
: UnixSocketHerdrClient.defaultSocketPath();
|
||||
|
||||
UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect(socket, new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(herdr::close));
|
||||
|
||||
Javalin app = new BridgedApp(herdr).build();
|
||||
app.start(cfg.bind().host(), cfg.bind().port());
|
||||
log.info("bridged listening on {}:{}, herdr socket {}",
|
||||
cfg.bind().host(), cfg.bind().port(), socket);
|
||||
}
|
||||
|
||||
private Bridged() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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.List;
|
||||
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 worker-spawn settings
|
||||
* @param guard subscription-boundary allowlist
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record BridgedConfig(
|
||||
Bind bind,
|
||||
String herdrSocket,
|
||||
Worker worker,
|
||||
Guard guard) {
|
||||
|
||||
@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 = 8080;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param profile ccs profile a worker is spawned under (Stage-1: {@code ltms-local})
|
||||
* @param baseUrl the off-subscription endpoint the worker's launch line sets
|
||||
* @param model model alias to request from that endpoint
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Worker(String profile, String baseUrl, String model) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
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, g);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.ltms.bridged.guard;
|
||||
|
||||
/** Thrown when the subscription boundary would be violated. Never swallow this. */
|
||||
public class GuardException extends RuntimeException {
|
||||
public GuardException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.ltms.bridged.guard;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Enforces the non-negotiable subscription boundary:
|
||||
*
|
||||
* <ul>
|
||||
* <li>A <b>worker</b> must egress to an off-subscription host — its
|
||||
* {@code ANTHROPIC_BASE_URL} host must be on the allowlist
|
||||
* (Stage-1: {@code gx00.gw}, {@code ollama.ltms.dev}).</li>
|
||||
* <li>The <b>primary</b> must never carry {@code ANTHROPIC_BASE_URL}; a set value
|
||||
* means its traffic would leave the subscription. That is a hard stop.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Both checks throw {@link GuardException} on violation. {@code bridged} calls
|
||||
* {@link #assertWorker} before spawning a worker and {@link #assertPrimaryClean}
|
||||
* against its own environment at startup.
|
||||
*/
|
||||
public final class SubscriptionGuard {
|
||||
|
||||
private final Set<String> offSubscriptionHosts;
|
||||
|
||||
public SubscriptionGuard(Set<String> offSubscriptionHosts) {
|
||||
this.offSubscriptionHosts = Set.copyOf(offSubscriptionHosts);
|
||||
}
|
||||
|
||||
/** The worker's base_url must resolve to an allowlisted off-subscription host. */
|
||||
public void assertWorker(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
throw new GuardException("worker has no ANTHROPIC_BASE_URL — refusing to spawn "
|
||||
+ "a session that would bill the subscription");
|
||||
}
|
||||
String host = hostOf(baseUrl);
|
||||
if (host == null) {
|
||||
throw new GuardException("worker ANTHROPIC_BASE_URL is not a valid URL: " + baseUrl);
|
||||
}
|
||||
if (!offSubscriptionHosts.contains(host)) {
|
||||
throw new GuardException("worker base_url host '" + host + "' is not on the "
|
||||
+ "off-subscription allowlist " + offSubscriptionHosts
|
||||
+ " — refusing to spawn");
|
||||
}
|
||||
}
|
||||
|
||||
/** The primary's environment must not contain {@code ANTHROPIC_BASE_URL}. */
|
||||
public void assertPrimaryClean(Map<String, String> env) {
|
||||
String v = env.get("ANTHROPIC_BASE_URL");
|
||||
if (v != null && !v.isBlank()) {
|
||||
throw new GuardException("primary environment is tainted: ANTHROPIC_BASE_URL="
|
||||
+ v + " — the primary must run on the subscription, never a base_url");
|
||||
}
|
||||
}
|
||||
|
||||
private static String hostOf(String baseUrl) {
|
||||
try {
|
||||
return new URI(baseUrl).getHost();
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.ltms.bridged.herdr;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Client face onto the herdr daemon (protocol 14, herdr 0.7.0).
|
||||
*
|
||||
* <p>This is the ONLY thing in {@code bridged} that speaks to herdr. Every method
|
||||
* maps to a herdr JSON-RPC call over its Unix domain socket. Requests are
|
||||
* newline-delimited JSON with a <em>string</em> id; responses carry either a
|
||||
* {@code result} object (whose {@code type} field discriminates the payload) or an
|
||||
* {@code error} object.
|
||||
*
|
||||
* <p>Higher layers ({@code bridged}'s policy brain, REST endpoints, MCP adapters)
|
||||
* depend on this interface, not on the socket. Tests substitute a fake; the
|
||||
* {@code contract}-tagged suite exercises the real implementation against a running
|
||||
* herdr to catch protocol drift.
|
||||
*/
|
||||
public interface HerdrClient extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* Invoke a herdr method and return its {@code result} node.
|
||||
*
|
||||
* @param method herdr method name, e.g. {@code "ping"}, {@code "workspace.list"}
|
||||
* @param params params object (may be {@code null} → sent as {@code {}}); serialized by Jackson
|
||||
* @return the {@code result} node of the response
|
||||
* @throws HerdrException on transport failure or an {@code error} envelope
|
||||
*/
|
||||
JsonNode call(String method, Object params) throws HerdrException;
|
||||
|
||||
/** Convenience for parameterless calls. */
|
||||
default JsonNode call(String method) throws HerdrException {
|
||||
return call(method, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.ltms.bridged.herdr;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Wire codec for herdr's newline-delimited JSON-RPC (protocol 14).
|
||||
*
|
||||
* <p>Split out from the socket so the framing rules — the ones that actually bit us
|
||||
* during the spike (id MUST be a string; response carries {@code result} or
|
||||
* {@code error}, never {@code jsonrpc}) — are unit-testable without a live daemon.
|
||||
*/
|
||||
final class HerdrCodec {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
HerdrCodec(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Build one request frame: a single JSON object terminated by {@code '\n'}. */
|
||||
byte[] encode(String id, String method, Object params) {
|
||||
ObjectNode req = mapper.createObjectNode();
|
||||
req.put("jsonrpc", "2.0");
|
||||
req.put("id", id); // string id — herdr rejects integer ids with invalid_request
|
||||
req.put("method", method);
|
||||
JsonNode p = params == null ? mapper.createObjectNode() : mapper.valueToTree(params);
|
||||
req.set("params", p);
|
||||
try {
|
||||
String line = mapper.writeValueAsString(req) + "\n";
|
||||
return line.getBytes(StandardCharsets.UTF_8);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new HerdrException("failed to encode herdr request for method " + method, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one response frame and return its {@code result} node.
|
||||
*
|
||||
* @throws HerdrException if the frame is an {@code error} envelope or is malformed
|
||||
*/
|
||||
JsonNode decodeResult(String line) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = mapper.readTree(line);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new HerdrException("malformed herdr response: " + trim(line), e);
|
||||
}
|
||||
JsonNode error = root.get("error");
|
||||
if (error != null && !error.isNull()) {
|
||||
String code = error.path("code").asText(null);
|
||||
String message = error.path("message").asText("unknown herdr error");
|
||||
throw new HerdrException("herdr error [" + code + "]: " + message, code, null);
|
||||
}
|
||||
JsonNode result = root.get("result");
|
||||
if (result == null || result.isNull()) {
|
||||
throw new HerdrException("herdr response has neither result nor error: " + trim(line));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String trim(String s) {
|
||||
String t = s.strip();
|
||||
return t.length() > 200 ? t.substring(0, 200) + "…" : t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.ltms.bridged.herdr;
|
||||
|
||||
/**
|
||||
* Raised when a herdr call fails: transport error, or an {@code error} envelope
|
||||
* returned by the daemon. {@link #code()} carries herdr's error code
|
||||
* (e.g. {@code invalid_request}) when the failure came back as a protocol error,
|
||||
* or {@code null} for transport-level failures.
|
||||
*/
|
||||
public class HerdrException extends RuntimeException {
|
||||
|
||||
private final String code;
|
||||
|
||||
public HerdrException(String message) {
|
||||
this(message, null, null);
|
||||
}
|
||||
|
||||
public HerdrException(String message, Throwable cause) {
|
||||
this(message, null, cause);
|
||||
}
|
||||
|
||||
public HerdrException(String message, String code, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/** herdr protocol error code, or {@code null} if this was a transport failure. */
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dev.ltms.bridged.herdr;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* {@link HerdrClient} over herdr's Unix domain socket using the JDK's
|
||||
* {@link UnixDomainSocketAddress} + {@link SocketChannel} (no native/JNI dependency).
|
||||
*
|
||||
* <p><b>Connection-per-call.</b> The contract test against herdr 0.7.0 established
|
||||
* that herdr serves <em>one</em> request/response per connection and then closes it —
|
||||
* a second write on the same socket gets a broken pipe. So every {@link #call} opens a
|
||||
* fresh connection, writes one frame, reads one line, and closes. A pleasant
|
||||
* consequence: with no shared socket there is no shared read state, so the client is
|
||||
* safe to call concurrently from many virtual threads with no locking.
|
||||
*
|
||||
* <p>Streaming methods ({@code events.subscribe}) keep their own long-lived connection
|
||||
* and land in a later ticket; they do not reuse this request/response path.
|
||||
*/
|
||||
public final class UnixSocketHerdrClient implements HerdrClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UnixSocketHerdrClient.class);
|
||||
|
||||
/** herdr's default socket, overridable by {@code HERDR_SOCKET_PATH}. */
|
||||
public static Path defaultSocketPath() {
|
||||
String override = System.getenv("HERDR_SOCKET_PATH");
|
||||
if (override != null && !override.isBlank()) {
|
||||
return Path.of(override);
|
||||
}
|
||||
return Path.of(System.getProperty("user.home"), ".config", "herdr", "herdr.sock");
|
||||
}
|
||||
|
||||
private final Path socketPath;
|
||||
private final HerdrCodec codec;
|
||||
private final AtomicLong ids = new AtomicLong(1);
|
||||
|
||||
private UnixSocketHerdrClient(Path socketPath, ObjectMapper mapper) {
|
||||
this.socketPath = socketPath;
|
||||
this.codec = new HerdrCodec(mapper);
|
||||
}
|
||||
|
||||
/** Client for the default socket with a fresh {@link ObjectMapper}. */
|
||||
public static UnixSocketHerdrClient connect() {
|
||||
return connect(defaultSocketPath(), new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for {@code socketPath}. Does not hold a connection open (herdr is
|
||||
* one-shot per connection); connectivity surfaces on the first {@link #call}.
|
||||
*/
|
||||
public static UnixSocketHerdrClient connect(Path socketPath, ObjectMapper mapper) {
|
||||
log.debug("herdr client bound to socket {}", socketPath);
|
||||
return new UnixSocketHerdrClient(socketPath, mapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode call(String method, Object params) {
|
||||
String id = Long.toString(ids.getAndIncrement());
|
||||
byte[] frame = codec.encode(id, method, params);
|
||||
try (SocketChannel ch = SocketChannel.open(StandardProtocolFamily.UNIX)) {
|
||||
ch.connect(UnixDomainSocketAddress.of(socketPath));
|
||||
writeFully(ch, ByteBuffer.wrap(frame));
|
||||
return codec.decodeResult(readLine(ch));
|
||||
} catch (IOException e) {
|
||||
throw new HerdrException("herdr call '" + method + "' failed at transport (socket "
|
||||
+ socketPath + ", is herdr running?)", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeFully(SocketChannel ch, ByteBuffer buf) throws IOException {
|
||||
while (buf.hasRemaining()) {
|
||||
ch.write(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read up to and including the first {@code '\n'}, returning the line without it. */
|
||||
private static String readLine(SocketChannel ch) throws IOException {
|
||||
ByteBuffer readBuf = ByteBuffer.allocate(64 * 1024);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (true) {
|
||||
int nl = indexOfNewline(sb);
|
||||
if (nl >= 0) {
|
||||
return sb.substring(0, nl);
|
||||
}
|
||||
readBuf.clear();
|
||||
int n = ch.read(readBuf);
|
||||
if (n == -1) {
|
||||
if (sb.length() > 0) {
|
||||
return sb.toString(); // herdr closed after a complete, unterminated frame
|
||||
}
|
||||
throw new IOException("herdr closed the connection with no response");
|
||||
}
|
||||
readBuf.flip();
|
||||
sb.append(StandardCharsets.UTF_8.decode(readBuf));
|
||||
}
|
||||
}
|
||||
|
||||
private static int indexOfNewline(CharSequence s) {
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
if (s.charAt(i) == '\n') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** No persistent resources to release; present for the {@link AutoCloseable} contract. */
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.ltms.bridged.rest;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import dev.ltms.bridged.herdr.HerdrClient;
|
||||
import dev.ltms.bridged.herdr.HerdrException;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The REST surface — {@code bridged}'s contract, and its testability seam. Every
|
||||
* feature is reachable here without Claude or MCP in the loop, so each is an
|
||||
* acceptance test against plain HTTP. MCP tools (later) are thin adapters over these
|
||||
* same endpoints and are validated by parity, not by re-implementing behaviour.
|
||||
*
|
||||
* <p>Built from an injected {@link HerdrClient} so tests can supply a fake and run on
|
||||
* an ephemeral port; {@code main} supplies the real Unix-socket client.
|
||||
*/
|
||||
public final class BridgedApp {
|
||||
|
||||
private final HerdrClient herdr;
|
||||
|
||||
public BridgedApp(HerdrClient herdr) {
|
||||
this.herdr = herdr;
|
||||
}
|
||||
|
||||
/** Wire routes onto a fresh, unstarted Javalin instance. Caller starts it. */
|
||||
public Javalin build() {
|
||||
Javalin app = Javalin.create(cfg -> cfg.showJavalinBanner = false);
|
||||
app.get("/healthz", this::healthz);
|
||||
app.get("/sessions", this::sessions);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Liveness + herdr reachability. 200 when herdr answers ping, 503 otherwise. */
|
||||
private void healthz(Context ctx) {
|
||||
try {
|
||||
JsonNode pong = herdr.call("ping");
|
||||
ctx.status(200).json(Map.of(
|
||||
"status", "ok",
|
||||
"herdr", Map.of(
|
||||
"version", pong.path("version").asText(""),
|
||||
"protocol", pong.path("protocol").asInt())));
|
||||
} catch (HerdrException e) {
|
||||
ctx.status(503).json(Map.of(
|
||||
"status", "degraded",
|
||||
"herdr", "unreachable",
|
||||
"detail", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions view, derived from herdr {@code workspace.list}. Stage-1 maps one
|
||||
* workspace → one session summary; later tickets enrich this with the primary/
|
||||
* worker role and the subscription-guard verdict per pane.
|
||||
*/
|
||||
private void sessions(Context ctx) {
|
||||
JsonNode result = herdr.call("workspace.list");
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (JsonNode w : result.path("workspaces")) {
|
||||
out.add(Map.of(
|
||||
"id", w.path("workspace_id").asText(""),
|
||||
"label", w.path("label").asText(""),
|
||||
"focused", w.path("focused").asBoolean(false),
|
||||
"paneCount", w.path("pane_count").asInt(),
|
||||
"agentStatus", w.path("agent_status").asText("unknown")));
|
||||
}
|
||||
ctx.status(200).json(Map.of("sessions", out));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{28} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="dev.ltms.bridged" level="DEBUG"/>
|
||||
<logger name="io.javalin" level="INFO"/>
|
||||
<logger name="org.eclipse.jetty" level="WARN"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -0,0 +1,55 @@
|
||||
package dev.ltms.bridged.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BridgedConfigTest {
|
||||
|
||||
@Test
|
||||
void loadsFullConfig(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bridged.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
herdrSocket: ~/.config/herdr/herdr.sock
|
||||
worker:
|
||||
profile: ltms-local
|
||||
baseUrl: http://gx00.gw:8000
|
||||
model: coder
|
||||
guard:
|
||||
offSubscriptionHosts:
|
||||
- gx00.gw
|
||||
- ollama.ltms.dev
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(8080, cfg.bind().port());
|
||||
assertEquals("ltms-local", cfg.worker().profile());
|
||||
assertTrue(cfg.guard().hostSet().contains("gx00.gw"));
|
||||
assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDefaultsForMissingSections(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("minimal.yaml");
|
||||
Files.writeString(f, "bind:\n host: 0.0.0.0\n port: 9000\n");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(9000, cfg.bind().port());
|
||||
assertNotNull(cfg.guard(), "guard must default to empty, never null");
|
||||
assertTrue(cfg.guard().offSubscriptionHosts().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresUnknownKeys(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("future.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\nfutureFeature:\n enabled: true\n");
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.ltms.bridged.guard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** The subscription boundary is the system's core invariant — test it hard. */
|
||||
class SubscriptionGuardTest {
|
||||
|
||||
private final SubscriptionGuard guard =
|
||||
new SubscriptionGuard(Set.of("gx00.gw", "ollama.ltms.dev"));
|
||||
|
||||
@Test
|
||||
void acceptsAllowlistedWorkerHosts() {
|
||||
assertDoesNotThrow(() -> guard.assertWorker("http://gx00.gw:8000"));
|
||||
assertDoesNotThrow(() -> guard.assertWorker("https://ollama.ltms.dev"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsHostNotOnAllowlist() {
|
||||
GuardException ex = assertThrows(GuardException.class,
|
||||
() -> guard.assertWorker("https://api.anthropic.com"));
|
||||
assertTrue(ex.getMessage().contains("api.anthropic.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingWorkerBaseUrl() {
|
||||
assertThrows(GuardException.class, () -> guard.assertWorker(null));
|
||||
assertThrows(GuardException.class, () -> guard.assertWorker(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedWorkerBaseUrl() {
|
||||
assertThrows(GuardException.class, () -> guard.assertWorker("not a url"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void primaryWithBaseUrlIsTainted() {
|
||||
GuardException ex = assertThrows(GuardException.class,
|
||||
() -> guard.assertPrimaryClean(Map.of("ANTHROPIC_BASE_URL", "http://gx00.gw:8000")));
|
||||
assertTrue(ex.getMessage().contains("tainted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanPrimaryPasses() {
|
||||
assertDoesNotThrow(() -> guard.assertPrimaryClean(Map.of("PATH", "/usr/bin")));
|
||||
assertDoesNotThrow(() -> guard.assertPrimaryClean(Map.of("ANTHROPIC_BASE_URL", "")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.ltms.bridged.herdr;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Unit tests for the herdr wire codec — the framing rules the spike pinned down. */
|
||||
class HerdrCodecTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HerdrCodec codec = new HerdrCodec(mapper);
|
||||
|
||||
@Test
|
||||
void encodesStringIdAndTrailingNewline() throws Exception {
|
||||
byte[] frame = codec.encode("7", "ping", null);
|
||||
String s = new String(frame, StandardCharsets.UTF_8);
|
||||
assertTrue(s.endsWith("\n"), "frame must be newline-terminated");
|
||||
|
||||
JsonNode node = mapper.readTree(s);
|
||||
// id MUST be a JSON string — herdr rejects integer ids with invalid_request.
|
||||
assertTrue(node.get("id").isTextual());
|
||||
assertEquals("7", node.get("id").asText());
|
||||
assertEquals("ping", node.get("method").asText());
|
||||
assertTrue(node.get("params").isObject(), "null params serialize to {}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodesParamsObject() throws Exception {
|
||||
byte[] frame = codec.encode("1", "pane.list", Map.of("workspace_id", "w2"));
|
||||
JsonNode node = mapper.readTree(new String(frame, StandardCharsets.UTF_8));
|
||||
assertEquals("w2", node.get("params").get("workspace_id").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesResultNode() {
|
||||
String line = "{\"id\":\"1\",\"result\":{\"type\":\"pong\",\"protocol\":14}}";
|
||||
JsonNode result = codec.decodeResult(line);
|
||||
assertEquals(14, result.get("protocol").asInt());
|
||||
assertEquals("pong", result.get("type").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwsOnErrorEnvelope() {
|
||||
// Exactly the shape herdr returned for an integer id during the spike.
|
||||
String line = "{\"id\":\"\",\"error\":{\"code\":\"invalid_request\","
|
||||
+ "\"message\":\"invalid request: invalid type: integer\"}}";
|
||||
HerdrException ex = assertThrows(HerdrException.class, () -> codec.decodeResult(line));
|
||||
assertEquals("invalid_request", ex.code());
|
||||
assertTrue(ex.getMessage().contains("invalid_request"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwsOnFrameWithNeitherResultNorError() {
|
||||
HerdrException ex = assertThrows(HerdrException.class,
|
||||
() -> codec.decodeResult("{\"id\":\"1\"}"));
|
||||
assertTrue(ex.getMessage().contains("neither result nor error"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.ltms.bridged.herdr;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Contract test against a REAL running herdr. Tagged {@code contract} so it is
|
||||
* excluded from {@code mvn test}; run it with {@code mvn test -Pcontract}. It fails
|
||||
* loudly if herdr drifts from the protocol {@code bridged} was built against
|
||||
* (0.7.0, protocol 14) — catching breakage that unit tests with canned frames cannot.
|
||||
*/
|
||||
@Tag("contract")
|
||||
class HerdrContractTest {
|
||||
|
||||
private Path socket() {
|
||||
return UnixSocketHerdrClient.defaultSocketPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingReturnsProtocol14() {
|
||||
assumeTrue(Files.exists(socket()), "no herdr socket at " + socket() + " — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
JsonNode pong = herdr.call("ping");
|
||||
assertEquals("pong", pong.get("type").asText());
|
||||
assertEquals(14, pong.get("protocol").asInt(),
|
||||
"bridged is built against herdr protocol 14");
|
||||
assertFalse(pong.get("version").asText().isBlank());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void workspaceListReturnsWorkspaces() {
|
||||
assumeTrue(Files.exists(socket()), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
JsonNode result = herdr.call("workspace.list");
|
||||
assertEquals("workspace_list", result.get("type").asText());
|
||||
assertTrue(result.get("workspaces").isArray());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleSequentialCallsSucceed() {
|
||||
// herdr is one-shot per connection: a persistent socket breaks on the 2nd call.
|
||||
// The connection-per-call client must let many sequential calls through — this
|
||||
// is the exact scenario that failed the first end-to-end smoke test.
|
||||
assumeTrue(Files.exists(socket()), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
assertEquals("pong", herdr.call("ping").get("type").asText());
|
||||
assertEquals("workspace_list", herdr.call("workspace.list").get("type").asText());
|
||||
assertEquals("pong", herdr.call("ping").get("type").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package dev.ltms.bridged.rest;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.ltms.bridged.herdr.HerdrClient;
|
||||
import dev.ltms.bridged.herdr.HerdrException;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* REST acceptance tests — the feature contract exercised over plain HTTP with a fake
|
||||
* herdr, no live daemon and no Claude in the loop. This is the surface later MCP
|
||||
* tools must match by parity.
|
||||
*/
|
||||
class BridgedAppTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private Javalin app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
/** Fake herdr returning canned frames captured from the real 0.7.0 daemon. */
|
||||
private HerdrClient fakeHerdr(boolean healthy) {
|
||||
return new HerdrClient() {
|
||||
@Override
|
||||
public JsonNode call(String method, Object params) {
|
||||
if (!healthy) throw new HerdrException("herdr unreachable (fake)");
|
||||
try {
|
||||
return switch (method) {
|
||||
case "ping" -> mapper.readTree(
|
||||
"{\"type\":\"pong\",\"version\":\"0.7.0\",\"protocol\":14}");
|
||||
case "workspace.list" -> mapper.readTree("""
|
||||
{"type":"workspace_list","workspaces":[
|
||||
{"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"},
|
||||
{"workspace_id":"w2","label":"ltms","focused":false,"pane_count":5,"agent_status":"done"}]}""");
|
||||
default -> throw new HerdrException("unexpected method " + method);
|
||||
};
|
||||
} catch (Exception e) {
|
||||
throw new HerdrException("fake decode failed", e);
|
||||
}
|
||||
}
|
||||
@Override public void close() { }
|
||||
};
|
||||
}
|
||||
|
||||
private int startWith(HerdrClient herdr) {
|
||||
app = new BridgedApp(herdr).build().start("127.0.0.1", 0);
|
||||
return app.port();
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(int port, String path) throws Exception {
|
||||
return http.send(
|
||||
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthzOkWhenHerdrAnswers() throws Exception {
|
||||
int port = startWith(fakeHerdr(true));
|
||||
HttpResponse<String> res = get(port, "/healthz");
|
||||
assertEquals(200, res.statusCode());
|
||||
JsonNode body = mapper.readTree(res.body());
|
||||
assertEquals("ok", body.get("status").asText());
|
||||
assertEquals(14, body.get("herdr").get("protocol").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthzDegradedWhenHerdrDown() throws Exception {
|
||||
int port = startWith(fakeHerdr(false));
|
||||
HttpResponse<String> res = get(port, "/healthz");
|
||||
assertEquals(503, res.statusCode());
|
||||
assertEquals("degraded", mapper.readTree(res.body()).get("status").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionsMapsWorkspaceList() throws Exception {
|
||||
int port = startWith(fakeHerdr(true));
|
||||
HttpResponse<String> res = get(port, "/sessions");
|
||||
assertEquals(200, res.statusCode());
|
||||
JsonNode sessions = mapper.readTree(res.body()).get("sessions");
|
||||
assertEquals(2, sessions.size());
|
||||
assertEquals("w1", sessions.get(0).get("id").asText());
|
||||
assertEquals("done", sessions.get(1).get("agentStatus").asText());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user