connect() {
return sessionFuture;
}
+ /**
+ * Blocking variant of {@link #connect()}: sends {@code session.hello} and waits for {@code
+ * session.welcome} (§6.2).
+ *
+ * @param timeout maximum time to wait for the handshake to complete
+ * @return the negotiated session
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ * @throws TimeoutException if no {@code session.welcome} arrives within {@code timeout}
+ * @throws ArcpException if the runtime rejects the handshake with a protocol error (e.g. {@code
+ * RESUME_WINDOW_EXPIRED} for a stale resume token, §6.3)
+ */
public Session connect(Duration timeout)
throws InterruptedException, TimeoutException, ArcpException {
try {
@@ -170,6 +192,12 @@ public Session connect(Duration timeout)
}
}
+ /**
+ * Blocking submit without trace context; see {@link #submit(JobSubmit, TraceId)}.
+ *
+ * @param submit the {@code job.submit} payload (§7)
+ * @return a handle to the accepted job
+ */
public JobHandle submit(JobSubmit submit) {
return submit(submit, null);
}
@@ -181,6 +209,10 @@ public JobHandle submit(JobSubmit submit) {
* Must not be called from a dispatch/result callback (i.e. the transport inbound thread);
* doing so would deadlock because the acknowledgement is delivered by that same thread. Such a
* call fails fast with {@link IllegalStateException}.
+ *
+ * @param submit the {@code job.submit} payload (§7)
+ * @param traceId W3C trace context to stamp on the envelope (§11), or {@code null} for none
+ * @return a handle to the accepted job
*/
public JobHandle submit(JobSubmit submit, @Nullable TraceId traceId) {
if (Boolean.TRUE.equals(inDispatch.get())) {
@@ -207,11 +239,24 @@ public JobHandle submit(JobSubmit submit, @Nullable TraceId traceId) {
}
}
- /** Non-blocking submit. Completes with the {@link JobHandle} on {@code job.accepted} (#106). */
+ /**
+ * Non-blocking submit. Completes with the {@link JobHandle} on {@code job.accepted} (#106).
+ *
+ * @param submit the {@code job.submit} payload (§7)
+ * @return a future completing when the runtime accepts (or rejects) the job
+ */
public CompletableFuture submitAsync(JobSubmit submit) {
return submitAsync(submit, null);
}
+ /**
+ * Non-blocking submit with trace context. Completes with the {@link JobHandle} on {@code
+ * job.accepted}, or exceptionally on rejection (#106).
+ *
+ * @param submit the {@code job.submit} payload (§7)
+ * @param traceId W3C trace context to stamp on the envelope (§11), or {@code null} for none
+ * @return a future completing when the runtime accepts (or rejects) the job
+ */
public CompletableFuture submitAsync(JobSubmit submit, @Nullable TraceId traceId) {
Outstanding o = new Outstanding();
MessageId requestId = MessageId.generate();
@@ -230,14 +275,32 @@ public CompletableFuture submitAsync(JobSubmit submit, @Nullable Trac
return o.handleFuture;
}
+ /**
+ * Lists the first page of jobs visible to this session via {@code session.list_jobs} (§6.6).
+ *
+ * @param filter status/agent/creation-time filter, or {@code null} for all visible jobs
+ * @return the first page of job summaries
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ * @throws TimeoutException if the runtime does not answer with {@code session.jobs} in time
+ * @throws ArcpException if the runtime rejects the request with a protocol error
+ */
public Page listJobs(@Nullable JobFilter filter)
throws InterruptedException, TimeoutException, ArcpException {
return listJobs(filter, null, null);
}
/**
- * List jobs with optional pagination. Supply {@code cursor} from the previous {@link Page} to
- * continue, or {@code null} to fetch the first page. {@code limit} caps the page size.
+ * Lists jobs with optional pagination via {@code session.list_jobs} (§6.6). Supply {@code cursor}
+ * from the previous {@link Page} to continue, or {@code null} to fetch the first page. {@code
+ * limit} caps the page size.
+ *
+ * @param filter status/agent/creation-time filter, or {@code null} for all visible jobs
+ * @param limit maximum number of jobs per page, or {@code null} for the runtime default
+ * @param cursor {@code next_cursor} from the previous page, or {@code null} for the first page
+ * @return one page of job summaries plus the continuation cursor, if any
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ * @throws TimeoutException if the runtime does not answer with {@code session.jobs} in time
+ * @throws ArcpException if the runtime rejects the request with a protocol error
*/
public Page listJobs(
@Nullable JobFilter filter, @Nullable Integer limit, @Nullable String cursor)
@@ -267,6 +330,15 @@ public Page listJobs(
}
}
+ /**
+ * Attaches to a job's event stream via {@code job.subscribe} (§7.6), e.g. one submitted in a
+ * different session. The first call for a {@code jobId} sends the subscribe message; subsequent
+ * calls share the same publisher and the original options.
+ *
+ * @param jobId the job to observe
+ * @param options live-only or history-replaying subscription, per {@link SubscribeOptions}
+ * @return a hot publisher of decoded {@code job.event} bodies for the subscribed job
+ */
public Flow.Publisher subscribe(JobId jobId, SubscribeOptions options) {
java.util.concurrent.atomic.AtomicBoolean inserted =
new java.util.concurrent.atomic.AtomicBoolean(false);
@@ -291,6 +363,8 @@ public Flow.Publisher subscribe(JobId jobId, SubscribeOptions options
/**
* Locally unsubscribe from job events and notify the runtime via {@code job.unsubscribe}. Closes
* the local {@link Flow.Publisher} so any downstream subscribers see {@code onComplete}.
+ *
+ * @param jobId the job whose subscription to cancel (§7.6)
*/
public void unsubscribe(JobId jobId) {
SubmissionPublisher pub = liveSubscribers.remove(jobId);
@@ -310,6 +384,12 @@ public void unsubscribe(JobId jobId) {
}
}
+ /**
+ * Sends an explicit {@code session.ack} acknowledging processed events (§6.5). Only needed when
+ * auto-ack is disabled via {@link Builder#autoAck(boolean)}.
+ *
+ * @param lastProcessedSeq highest {@code event_seq} the application has fully processed
+ */
public void ack(long lastProcessedSeq) {
send(Message.Type.SESSION_ACK, new SessionAck(lastProcessedSeq), sessionId, null, null, null);
}
@@ -350,12 +430,21 @@ public void close() {
}
}
- /** Returns the highest event sequence number seen from the server, or -1 if none. */
+ /**
+ * Returns the highest event sequence number seen from the server, or -1 if none. Useful as the
+ * {@code last_event_seq} when resuming (§6.3).
+ *
+ * @return the highest observed {@code event_seq}, or -1 before any sequenced event arrives
+ */
public long lastSeenSeq() {
return lastSeenSeq.get();
}
- /** Returns the active session after {@link #connect()} completes. */
+ /**
+ * Returns the active session after {@link #connect()} completes.
+ *
+ * @return the negotiated session snapshot
+ */
public Session session() {
Session current = session;
if (current == null) {
@@ -751,6 +840,10 @@ public void cancel() {
}
}
+ /**
+ * Fluent builder for {@link ArcpClient}. Obtain via {@link ArcpClient#builder(Transport)}; every
+ * setter returns this builder so calls can be chained, ending in {@link #build()}.
+ */
public static final class Builder {
private final Transport transport;
private @Nullable ObjectMapper mapper;
@@ -768,53 +861,117 @@ public static final class Builder {
this.transport = transport;
}
+ /**
+ * Overrides the Jackson mapper used for wire encoding; defaults to {@link ArcpMapper#shared()}.
+ *
+ * @param m mapper configured for ARCP wire I/O
+ * @return this builder
+ */
public Builder mapper(ObjectMapper m) {
this.mapper = m;
return this;
}
+ /**
+ * Sets the client name and version advertised in {@code session.hello} (§6.2).
+ *
+ * @param name client implementation name, e.g. {@code examplectl}
+ * @param version client implementation version, e.g. {@code 0.4.1}
+ * @return this builder
+ */
public Builder client(String name, String version) {
this.info = new ClientInfo(name, version);
return this;
}
+ /**
+ * Sets the authentication presented in {@code session.hello} (§6.1); defaults to anonymous.
+ *
+ * @param a authentication payload, e.g. {@link Auth#bearer(String)}
+ * @return this builder
+ */
public Builder auth(Auth a) {
this.auth = a;
return this;
}
+ /**
+ * Shorthand for {@code auth(Auth.bearer(token))}: authenticates with a bearer token (§6.1).
+ *
+ * @param token bearer token sent in {@code session.hello.payload.auth.token}
+ * @return this builder
+ */
public Builder bearer(String token) {
this.auth = Auth.bearer(token);
return this;
}
+ /**
+ * Restricts the features requested in {@code session.hello} (§6.2); defaults to all features.
+ * The effective set is the intersection with what the runtime grants in {@code
+ * session.welcome}.
+ *
+ * @param features features to request; {@code null} or empty requests none
+ * @return this builder
+ */
public Builder features(Set features) {
this.features = safeFeatureCopy(features);
return this;
}
+ /**
+ * Enables or disables periodic automatic {@code session.ack} emission (§6.5); enabled by
+ * default. When disabled, the application must call {@link ArcpClient#ack(long)} itself.
+ *
+ * @param enabled whether the client acks processed events automatically
+ * @return this builder
+ */
public Builder autoAck(boolean enabled) {
this.autoAck = enabled;
return this;
}
+ /**
+ * Sets the period between automatic {@code session.ack} emissions (§6.5); defaults to 200ms.
+ *
+ * @param interval delay between ack ticks when auto-ack is enabled
+ * @return this builder
+ */
public Builder ackInterval(Duration interval) {
this.ackInterval = interval;
return this;
}
- /** Maximum time blocking {@link #submit(JobSubmit)} waits for {@code job.accepted} (#106). */
+ /**
+ * Maximum time blocking {@link #submit(JobSubmit)} waits for {@code job.accepted} (#106).
+ *
+ * @param timeout submit timeout; defaults to 30 seconds
+ * @return this builder
+ */
public Builder submitTimeout(Duration timeout) {
this.submitTimeout = timeout;
return this;
}
+ /**
+ * Supplies an external scheduler for ack ticks and the heartbeat watchdog. The client does not
+ * shut down a supplied scheduler on {@link ArcpClient#close()}; by default it creates and owns
+ * a single-threaded daemon scheduler.
+ *
+ * @param s scheduler to run the client's periodic tasks on
+ * @return this builder
+ */
public Builder scheduler(ScheduledExecutorService s) {
this.scheduler = s;
return this;
}
- /** Resume a prior session by supplying the token received in {@link Session#resumeToken()}. */
+ /**
+ * Resume a prior session by supplying the token received in {@link Session#resumeToken()}.
+ *
+ * @param token resume token presented in {@code session.resume} (§6.3)
+ * @return this builder
+ */
public Builder resumeToken(String token) {
this.resumeToken = token;
return this;
@@ -823,22 +980,50 @@ public Builder resumeToken(String token) {
/**
* Resume from a known event sequence number (§6.3). Used together with {@link
* #resumeToken(String)} to re-subscribe to events the client may have missed.
+ *
+ * @param seq the {@code last_event_seq} the client has already processed
+ * @return this builder
*/
public Builder lastEventSeq(long seq) {
this.lastEventSeq = seq;
return this;
}
+ /**
+ * Builds the client. The client does not connect until {@link ArcpClient#connect()} is called.
+ *
+ * @return a new {@link ArcpClient} over this builder's transport and settings
+ */
public ArcpClient build() {
return new ArcpClient(this);
}
}
- /** Construct a job submit payload conveniently. */
+ /**
+ * Construct a job submit payload conveniently, with no lease request, constraints, idempotency
+ * key, or runtime cap (§7).
+ *
+ * @param agent agent reference, optionally versioned, e.g. {@code code-refactor@2.0.0}
+ * @param input agent-defined input document carried in {@code job.submit.payload.input}
+ * @return a minimal {@code job.submit} payload for {@link #submit(JobSubmit)}
+ */
public static JobSubmit jobSubmit(String agent, JsonNode input) {
return new JobSubmit(AgentRef.parse(agent), input, null, null, null, null);
}
+ /**
+ * Construct a fully specified job submit payload (§7), validating that any {@code expires_at}
+ * lease constraint lies in the future.
+ *
+ * @param agent agent reference, optionally versioned, e.g. {@code code-refactor@2.0.0}
+ * @param input agent-defined input document carried in {@code job.submit.payload.input}
+ * @param lease requested capability lease ({@code lease_request}), or {@code null} for none
+ * @param constraints lease constraints such as {@code expires_at}, or {@code null} for none
+ * @param idempotencyKey key making resubmission return the same {@code job.accepted} (§7.2), or
+ * {@code null} to disable idempotency
+ * @param maxRuntimeSec maximum job runtime in seconds, or {@code null} for no cap
+ * @return a {@code job.submit} payload for {@link #submit(JobSubmit)}
+ */
public static JobSubmit jobSubmit(
String agent,
JsonNode input,
diff --git a/arcp-client/src/main/java/dev/arcp/client/JobHandle.java b/arcp-client/src/main/java/dev/arcp/client/JobHandle.java
index 3b2db32..ea33c8e 100644
--- a/arcp-client/src/main/java/dev/arcp/client/JobHandle.java
+++ b/arcp-client/src/main/java/dev/arcp/client/JobHandle.java
@@ -14,21 +14,55 @@
/** Client-side handle to one submitted job. */
public interface JobHandle {
+ /**
+ * Returns the runtime-assigned job identifier from {@code job.accepted}.
+ *
+ * @return the job id
+ */
JobId jobId();
+ /**
+ * Returns the fully resolved agent reference the runtime selected for this job, e.g. {@code
+ * code-refactor@2.0.0} (§7.5).
+ *
+ * @return the resolved {@code agent} value echoed on {@code job.accepted}
+ */
String resolvedAgent();
+ /**
+ * Returns the {@code job.accepted} payload for this job, carrying the effective lease,
+ * constraints, and initial budget counters (§7.1).
+ *
+ * @return the acceptance message as received from the runtime
+ */
JobAccepted accepted();
+ /**
+ * Returns the provisioned credentials attached to {@code job.accepted}, if any (§9.8).
+ *
+ * @return the credentials list, or {@link Optional#empty()} when none were provisioned
+ */
default Optional> credentials() {
return Optional.ofNullable(accepted().credentials());
}
- /** Hot publisher of {@link EventBody} for this job's {@code job.event} stream. */
+ /**
+ * Hot publisher of {@link EventBody} for this job's {@code job.event} stream.
+ *
+ * @return a publisher that replays buffered events to late subscribers and then streams live
+ */
Flow.Publisher events();
- /** Completes with {@link JobResult} on success or fails with {@link ArcpException}. */
+ /**
+ * Completes with {@link JobResult} on success or fails with {@link ArcpException}.
+ *
+ * @return a future tracking the job's terminal outcome
+ */
CompletableFuture result();
+ /**
+ * Requests cancellation by sending {@code job.cancel} (§7.4). The terminal {@code job.error} with
+ * code {@code CANCELLED} that follows completes {@link #result()} exceptionally.
+ */
void cancel();
}
diff --git a/arcp-client/src/main/java/dev/arcp/client/Page.java b/arcp-client/src/main/java/dev/arcp/client/Page.java
index 3ae89c5..d1ebdc3 100644
--- a/arcp-client/src/main/java/dev/arcp/client/Page.java
+++ b/arcp-client/src/main/java/dev/arcp/client/Page.java
@@ -4,15 +4,35 @@
import java.util.List;
import org.jspecify.annotations.Nullable;
+/**
+ * One page of a cursor-paginated listing, as returned by {@link ArcpClient#listJobs} from a {@code
+ * session.jobs} response (§6.6).
+ *
+ * @param the element type, e.g. {@link JobSummary} for job listings
+ * @param items the elements of this page; defensively copied, never {@code null}
+ * @param nextCursor the {@code next_cursor} to pass to the next listing call, or {@code null} if
+ * this is the final page
+ */
public record Page(List items, @Nullable String nextCursor) {
+ /** Canonical constructor; copies {@code items} into an immutable view ({@code null} = empty). */
public Page {
items = items == null ? List.of() : List.copyOf(items);
}
+ /**
+ * Returns an empty job-listing page with no continuation cursor.
+ *
+ * @return a page with no items and a {@code null} cursor
+ */
public static Page empty() {
return new Page<>(List.of(), null);
}
+ /**
+ * Returns whether another page can be fetched.
+ *
+ * @return {@code true} if {@link #nextCursor()} is non-null and more results remain
+ */
public boolean hasNext() {
return nextCursor != null;
}
diff --git a/arcp-client/src/main/java/dev/arcp/client/ResultStream.java b/arcp-client/src/main/java/dev/arcp/client/ResultStream.java
index 9604dd3..b8e878c 100644
--- a/arcp-client/src/main/java/dev/arcp/client/ResultStream.java
+++ b/arcp-client/src/main/java/dev/arcp/client/ResultStream.java
@@ -17,19 +17,38 @@
*/
public final class ResultStream {
+ /** Raised when chunks remain pending after the terminal chunk ({@code more=false}, §8.4). */
public static final class OutOfOrderChunkException extends RuntimeException {
+ /**
+ * Creates the exception with a description of the ordering violation.
+ *
+ * @param message detail of the violation, including the orphaned {@code chunk_seq} values
+ */
public OutOfOrderChunkException(String message) {
super(message);
}
}
+ /** Raised when a {@code chunk_seq} that was already flushed or buffered arrives again (§8.4). */
public static final class DuplicateChunkException extends RuntimeException {
+ /**
+ * Creates the exception for the duplicated sequence number.
+ *
+ * @param chunkSeq the {@code chunk_seq} that was delivered more than once
+ */
public DuplicateChunkException(long chunkSeq) {
super("duplicate chunk_seq " + chunkSeq);
}
}
+ /** Raised when a chunk's {@code encoding} differs from the first chunk's encoding (§8.4). */
public static final class EncodingMismatchException extends RuntimeException {
+ /**
+ * Creates the exception describing the encoding switch.
+ *
+ * @param first the encoding declared by the first chunk
+ * @param later the conflicting encoding declared by a later chunk
+ */
public EncodingMismatchException(String first, String later) {
super("encoding switched mid-stream: " + first + " -> " + later);
}
@@ -48,16 +67,35 @@ private ResultStream(@Nullable ResultId resultId, OutputStream sink) {
this.sink = sink;
}
- /** In-memory sink; {@link #bytes()} returns the assembled output. */
+ /**
+ * In-memory sink; {@link #bytes()} returns the assembled output.
+ *
+ * @param resultId expected {@code result_id}, or {@code null} to accept chunks for any result
+ * @return a stream that assembles chunks into an in-memory buffer
+ */
public static ResultStream toMemory(@Nullable ResultId resultId) {
return new ResultStream(resultId, new ByteArrayOutputStream());
}
- /** Stream chunks directly to an arbitrary {@link OutputStream}. */
+ /**
+ * Stream chunks directly to an arbitrary {@link OutputStream}.
+ *
+ * @param resultId expected {@code result_id}, or {@code null} to accept chunks for any result
+ * @param sink destination for decoded chunk bytes; flushed when the terminal chunk lands
+ * @return a stream that writes assembled output to {@code sink}
+ */
public static ResultStream toSink(@Nullable ResultId resultId, OutputStream sink) {
return new ResultStream(resultId, sink);
}
+ /**
+ * Accepts one {@code result_chunk} event (§8.4), buffering it until its predecessors arrive and
+ * then flushing in {@code chunk_seq} order. The terminal chunk ({@code more=false}) completes the
+ * stream and flushes the sink.
+ *
+ * @param chunk the chunk to ingest; its {@code result_id} must match this stream's, if set
+ * @throws IOException if writing the decoded bytes to the sink fails
+ */
public synchronized void accept(ResultChunkEvent chunk) throws IOException {
if (closed) {
throw new IllegalStateException("ResultStream already closed");
@@ -108,15 +146,29 @@ private static byte[] decode(ResultChunkEvent chunk) {
};
}
+ /**
+ * Returns whether the terminal chunk ({@code more=false}) has been flushed.
+ *
+ * @return {@code true} once the stream is complete and no further chunks are accepted
+ */
public synchronized boolean isComplete() {
return closed;
}
+ /**
+ * Returns the number of decoded bytes flushed to the sink so far.
+ *
+ * @return the running count of bytes written, excluding still-pending out-of-order chunks
+ */
public synchronized long bytesWritten() {
return bytesWritten;
}
- /** Assembled bytes from an in-memory stream; throws if a non-memory sink is in use. */
+ /**
+ * Assembled bytes from an in-memory stream; throws if a non-memory sink is in use.
+ *
+ * @return the bytes flushed so far to the in-memory buffer created by {@link #toMemory}
+ */
public synchronized byte[] bytes() {
if (!(sink instanceof ByteArrayOutputStream baos)) {
throw new IllegalStateException("ResultStream sink is not in-memory");
diff --git a/arcp-client/src/main/java/dev/arcp/client/Session.java b/arcp-client/src/main/java/dev/arcp/client/Session.java
index 8bf1336..64e43fa 100644
--- a/arcp-client/src/main/java/dev/arcp/client/Session.java
+++ b/arcp-client/src/main/java/dev/arcp/client/Session.java
@@ -9,8 +9,19 @@
import org.jspecify.annotations.Nullable;
/**
- * Snapshot of a successfully handshaken ARCP session. Collection components are defensively copied
- * into immutable views so callers cannot mutate the session after construction.
+ * Snapshot of a successfully handshaken ARCP session, built from the {@code session.welcome}
+ * payload (§6.2). Collection components are defensively copied into immutable views so callers
+ * cannot mutate the session after construction.
+ *
+ * @param sessionId the runtime-assigned session identifier echoed on every envelope
+ * @param negotiatedFeatures the effective feature set, i.e. the intersection of the features
+ * requested in {@code session.hello} and those granted in {@code session.welcome} (§6.2)
+ * @param resumeToken token presented in {@code session.resume} to reattach after a transport drop
+ * (§6.3), or {@code null} if the runtime did not offer resumption
+ * @param heartbeatInterval the negotiated {@code heartbeat_interval_sec} (§6.4) as a {@link
+ * Duration}, or {@code null} if heartbeats were not negotiated
+ * @param availableAgents the runtime's agent inventory advertised in {@code session.welcome},
+ * including version information (§6.2)
*/
public record Session(
SessionId sessionId,
@@ -19,6 +30,10 @@ public record Session(
@Nullable Duration heartbeatInterval,
List availableAgents) {
+ /**
+ * Canonical constructor; copies {@code negotiatedFeatures} and {@code availableAgents} into
+ * immutable views.
+ */
public Session {
negotiatedFeatures = Set.copyOf(negotiatedFeatures);
availableAgents = List.copyOf(availableAgents);
diff --git a/arcp-client/src/main/java/dev/arcp/client/SubscribeOptions.java b/arcp-client/src/main/java/dev/arcp/client/SubscribeOptions.java
index 0a43871..2c1bd28 100644
--- a/arcp-client/src/main/java/dev/arcp/client/SubscribeOptions.java
+++ b/arcp-client/src/main/java/dev/arcp/client/SubscribeOptions.java
@@ -1,10 +1,32 @@
package dev.arcp.client;
+/**
+ * Options for {@link ArcpClient#subscribe(dev.arcp.core.ids.JobId, SubscribeOptions)} controlling
+ * whether a {@code job.subscribe} request replays buffered event history (§7.6).
+ *
+ * @param history whether to request replay of buffered events; when {@code false} the subscriber
+ * only sees events emitted after the subscription is acknowledged
+ * @param fromEventSeq when {@code history} is {@code true}, the runtime replays buffered events
+ * with {@code seq > fromEventSeq} before resuming live streaming; ignored otherwise
+ */
public record SubscribeOptions(boolean history, long fromEventSeq) {
+ /**
+ * Returns options for a live-only subscription: no history replay, only events emitted after the
+ * subscription is acknowledged (§7.6).
+ *
+ * @return options with {@code history} disabled
+ */
public static SubscribeOptions live() {
return new SubscribeOptions(false, 0);
}
+ /**
+ * Returns options requesting replay of buffered history before live streaming resumes (§7.6).
+ *
+ * @param fromEventSeq sequence number to replay from; the runtime replays buffered events with
+ * {@code seq > fromEventSeq}, bounded by the same buffer window that governs resume
+ * @return options with {@code history} enabled starting at {@code fromEventSeq}
+ */
public static SubscribeOptions withHistory(long fromEventSeq) {
return new SubscribeOptions(true, fromEventSeq);
}
diff --git a/arcp-client/src/main/java/dev/arcp/client/WebSocketTransport.java b/arcp-client/src/main/java/dev/arcp/client/WebSocketTransport.java
index 82345cb..c6434d4 100644
--- a/arcp-client/src/main/java/dev/arcp/client/WebSocketTransport.java
+++ b/arcp-client/src/main/java/dev/arcp/client/WebSocketTransport.java
@@ -25,9 +25,8 @@
import org.slf4j.LoggerFactory;
/**
- * Client-side ARCP {@link Transport} backed by the JDK {@link java.net.http.HttpClient.WebSocket}.
- * JSON envelopes ride as text frames; multi-part text frames are reassembled per {@code last}
- * delivery.
+ * Client-side ARCP {@link Transport} backed by the JDK {@link java.net.http.WebSocket}. JSON
+ * envelopes ride as text frames; multi-part text frames are reassembled per {@code last} delivery.
*/
public final class WebSocketTransport implements Transport {
@@ -52,11 +51,31 @@ void attachSocket(WebSocket socket) {
this.socket = socket;
}
- /** Open a WebSocket connection to {@code uri} and return a connected transport. */
+ /**
+ * Opens a WebSocket connection to {@code uri} with no extra headers, the shared {@link
+ * ArcpMapper}, and a 10-second connect timeout.
+ *
+ * @param uri the {@code ws://} or {@code wss://} endpoint of the ARCP runtime
+ * @return a connected transport ready for {@link #send} and {@link #incoming()}
+ * @throws InterruptedException if the calling thread is interrupted while awaiting the WebSocket
+ * handshake
+ */
public static WebSocketTransport connect(URI uri) throws InterruptedException {
return connect(uri, Map.of(), ArcpMapper.shared(), Duration.ofSeconds(10));
}
+ /**
+ * Opens a WebSocket connection to {@code uri} and returns a connected transport. The transport
+ * owns the underlying {@link HttpClient}; {@link #close()} releases it.
+ *
+ * @param uri the {@code ws://} or {@code wss://} endpoint of the ARCP runtime
+ * @param headers extra HTTP headers sent with the upgrade request (e.g. authentication)
+ * @param mapper Jackson mapper used to encode and decode {@link Envelope} frames
+ * @param timeout maximum time to wait for the WebSocket handshake to complete
+ * @return a connected transport ready for {@link #send} and {@link #incoming()}
+ * @throws InterruptedException if the calling thread is interrupted while awaiting the WebSocket
+ * handshake
+ */
public static WebSocketTransport connect(
URI uri, Map headers, ObjectMapper mapper, Duration timeout)
throws InterruptedException {
diff --git a/arcp-client/src/test/java/dev/arcp/client/coverage/ClientLifecycleCoverageTest.java b/arcp-client/src/test/java/dev/arcp/client/coverage/ClientLifecycleCoverageTest.java
new file mode 100644
index 0000000..0094fe2
--- /dev/null
+++ b/arcp-client/src/test/java/dev/arcp/client/coverage/ClientLifecycleCoverageTest.java
@@ -0,0 +1,580 @@
+package dev.arcp.client.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import dev.arcp.client.ArcpClient;
+import dev.arcp.client.JobHandle;
+import dev.arcp.client.Page;
+import dev.arcp.client.ResultStream;
+import dev.arcp.client.Session;
+import dev.arcp.client.SubscribeOptions;
+import dev.arcp.core.capabilities.Capabilities;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.error.ErrorCode;
+import dev.arcp.core.error.PermissionDeniedException;
+import dev.arcp.core.events.EventBody;
+import dev.arcp.core.events.Events;
+import dev.arcp.core.events.LogEvent;
+import dev.arcp.core.events.ResultChunkEvent;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.ResultId;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.messages.JobError;
+import dev.arcp.core.messages.JobEvent;
+import dev.arcp.core.messages.JobFilter;
+import dev.arcp.core.messages.JobSummary;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.RuntimeInfo;
+import dev.arcp.core.messages.SessionAck;
+import dev.arcp.core.messages.SessionHello;
+import dev.arcp.core.messages.SessionJobs;
+import dev.arcp.core.messages.SessionWelcome;
+import dev.arcp.core.wire.Envelope;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Flow;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Branch coverage for {@link ArcpClient} lifecycle paths: blocking submit guards and timeouts,
+ * list_jobs pagination and failure modes, subscription bookkeeping, close idempotency, and the
+ * ack/heartbeat maintenance tasks (invoked directly so no test ever sleeps through an interval).
+ */
+class ClientLifecycleCoverageTest {
+
+ private static ObjectNode obj() {
+ return JsonNodeFactory.instance.objectNode();
+ }
+
+ private static SessionWelcome welcome(Set features) {
+ return new SessionWelcome(
+ new RuntimeInfo("rt", "1.0.0"),
+ null,
+ null,
+ null,
+ new Capabilities(List.of("json"), features, null));
+ }
+
+ private static ArcpClient connect(FakeTransport fake, ArcpClient.Builder builder)
+ throws Exception {
+ ArcpClient client = builder.build();
+ CompletableFuture future = client.connect();
+ fake.awaitSent("session.hello");
+ fake.deliver(Message.Type.SESSION_WELCOME, welcome(EnumSet.of(Feature.ACK)));
+ future.get(2, TimeUnit.SECONDS);
+ return client;
+ }
+
+ private static ArcpClient connect(FakeTransport fake) throws Exception {
+ return connect(fake, ArcpClient.builder(fake).ackInterval(Duration.ofHours(1)));
+ }
+
+ @Test
+ void blockingSubmitFromDispatchThreadFailsFast() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ AtomicReference guardTrip = new AtomicReference<>();
+ CompletableFuture pending =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ pending.thenAccept(
+ handle -> {
+ try {
+ client.submit(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ } catch (Throwable t) {
+ guardTrip.set(t);
+ }
+ });
+ fake.awaitSent("job.submit");
+ // Delivered on the test thread, so the callback runs inside dispatch.
+ fake.deliver(
+ Message.Type.JOB_ACCEPTED,
+ new dev.arcp.core.messages.JobAccepted(
+ JobId.of("job_guard"),
+ "echo@1.0.0",
+ dev.arcp.core.lease.Lease.empty(),
+ null,
+ null,
+ null,
+ Instant.now(),
+ null));
+ assertThat(guardTrip.get())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must not be called from an event/result callback");
+ }
+ }
+
+ @Test
+ void blockingSubmitTimesOutAndPrunesOnlyItsPendingEntry() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client =
+ connect(
+ fake,
+ ArcpClient.builder(fake)
+ .ackInterval(Duration.ofHours(1))
+ .submitTimeout(Duration.ofMillis(150)));
+ // A second in-flight submit ensures the timeout prune inspects a non-matching entry too.
+ CompletableFuture other =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ assertThatThrownBy(() -> client.submit(ArcpClient.jobSubmit("echo@1.0.0", obj())))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("submit timed out")
+ .hasCauseInstanceOf(TimeoutException.class);
+ assertThat(other).isNotDone();
+ client.close();
+ }
+
+ @Test
+ void blockingSubmitSurfacesInterruption() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ AtomicReference thrown = new AtomicReference<>();
+ Thread submitter =
+ new Thread(
+ () -> {
+ try {
+ client.submit(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ } catch (Throwable t) {
+ thrown.set(t);
+ }
+ });
+ submitter.start();
+ fake.awaitSent("job.submit");
+ submitter.interrupt();
+ submitter.join(TimeUnit.SECONDS.toMillis(5));
+ assertThat(thrown.get())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("submit interrupted");
+ }
+ }
+
+ @Test
+ void blockingSubmitWrapsRejectionCause() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ AtomicReference thrown = new AtomicReference<>();
+ Thread submitter =
+ new Thread(
+ () -> {
+ try {
+ client.submit(ArcpClient.jobSubmit("echo@1.0.0", obj()), null);
+ } catch (Throwable t) {
+ thrown.set(t);
+ }
+ });
+ submitter.start();
+ Envelope submitEnv = fake.awaitSent("job.submit");
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.PERMISSION_DENIED, "nope", false, null),
+ null,
+ null,
+ submitEnv.id());
+ submitter.join(TimeUnit.SECONDS.toMillis(5));
+ assertThat(thrown.get())
+ .isInstanceOf(CompletionException.class)
+ .hasCauseInstanceOf(PermissionDeniedException.class);
+ }
+ }
+
+ @Test
+ void listJobsPaginatesAndIgnoresUnknownResponses() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ // A session.jobs response nobody asked for is dropped.
+ fake.deliver(
+ Message.Type.SESSION_JOBS, new SessionJobs(MessageId.generate(), List.of(), null));
+
+ AtomicReference outcome = new AtomicReference<>();
+ CountDownLatch finished = new CountDownLatch(1);
+ Thread lister =
+ new Thread(
+ () -> {
+ try {
+ outcome.set(client.listJobs(JobFilter.all(), 10, null));
+ } catch (Exception e) {
+ outcome.set(e);
+ } finally {
+ finished.countDown();
+ }
+ });
+ lister.start();
+ Envelope request = fake.awaitSent("session.list_jobs");
+ JobSummary summary =
+ new JobSummary(
+ JobId.of("job_1"), "echo@1.0.0", "running", null, null, Instant.now(), null, 3L);
+ fake.deliver(
+ Message.Type.SESSION_JOBS, new SessionJobs(request.id(), List.of(summary), "cursor-2"));
+ assertThat(finished.await(5, TimeUnit.SECONDS)).isTrue();
+ @SuppressWarnings("unchecked")
+ Page page = (Page) outcome.get();
+ assertThat(page.items()).hasSize(1);
+ assertThat(page.hasNext()).isTrue();
+ assertThat(page.nextCursor()).isEqualTo("cursor-2");
+
+ // The filter-only overload returns the terminal page.
+ AtomicReference secondOutcome = new AtomicReference<>();
+ CountDownLatch secondFinished = new CountDownLatch(1);
+ Thread secondLister =
+ new Thread(
+ () -> {
+ try {
+ secondOutcome.set(client.listJobs(null));
+ } catch (Exception e) {
+ secondOutcome.set(e);
+ } finally {
+ secondFinished.countDown();
+ }
+ });
+ secondLister.start();
+ Envelope secondRequest = fake.awaitSent("session.list_jobs");
+ fake.deliver(Message.Type.SESSION_JOBS, new SessionJobs(secondRequest.id(), List.of(), null));
+ assertThat(secondFinished.await(5, TimeUnit.SECONDS)).isTrue();
+ @SuppressWarnings("unchecked")
+ Page lastPage = (Page) secondOutcome.get();
+ assertThat(lastPage.hasNext()).isFalse();
+ }
+ }
+
+ @Test
+ void listJobsSurfacesCorrelatedArcpError() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ AtomicReference outcome = new AtomicReference<>();
+ CountDownLatch finished = new CountDownLatch(1);
+ Thread lister =
+ new Thread(
+ () -> {
+ try {
+ outcome.set(client.listJobs(null));
+ } catch (Exception e) {
+ outcome.set(e);
+ } finally {
+ finished.countDown();
+ }
+ });
+ lister.start();
+ Envelope request = fake.awaitSent("session.list_jobs");
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.PERMISSION_DENIED, "denied", false, null),
+ null,
+ null,
+ request.id());
+ assertThat(finished.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(outcome.get()).isInstanceOf(PermissionDeniedException.class);
+ }
+ }
+
+ @Test
+ void listJobsSurfacesInterruption() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ AtomicReference outcome = new AtomicReference<>();
+ CountDownLatch finished = new CountDownLatch(1);
+ Thread lister =
+ new Thread(
+ () -> {
+ try {
+ outcome.set(client.listJobs(null));
+ } catch (Exception e) {
+ outcome.set(e);
+ } finally {
+ finished.countDown();
+ }
+ });
+ lister.start();
+ fake.awaitSent("session.list_jobs");
+ lister.interrupt();
+ assertThat(finished.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(outcome.get()).isInstanceOf(InterruptedException.class);
+ }
+ }
+
+ @Test
+ void subscriptionBookkeepingAcrossResubscribeUnsubscribeAndClose() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = connect(fake);
+ JobId watched = JobId.of("job_watched");
+
+ Flow.Publisher publisher =
+ client.subscribe(watched, SubscribeOptions.withHistory(42L));
+ Envelope subscribeEnv = fake.awaitSent("job.subscribe");
+ dev.arcp.core.messages.JobSubscribe subscribePayload =
+ FakeTransport.MAPPER.convertValue(
+ subscribeEnv.payload(), dev.arcp.core.messages.JobSubscribe.class);
+ assertThat(subscribePayload.fromEventSeq()).isEqualTo(42L);
+ assertThat(subscribePayload.history()).isTrue();
+
+ // Re-subscribing the same job reuses the existing publisher without a new wire message.
+ assertThat(client.subscribe(watched, SubscribeOptions.live())).isSameAs(publisher);
+ assertThat(fake.sent.poll(100, TimeUnit.MILLISECONDS)).isNull();
+
+ // A live subscription omits from_event_seq.
+ JobId liveJob = JobId.of("job_live_only");
+ client.subscribe(liveJob, SubscribeOptions.live());
+ Envelope liveEnv = fake.awaitSent("job.subscribe");
+ dev.arcp.core.messages.JobSubscribe livePayload =
+ FakeTransport.MAPPER.convertValue(
+ liveEnv.payload(), dev.arcp.core.messages.JobSubscribe.class);
+ assertThat(livePayload.fromEventSeq()).isNull();
+ assertThat(livePayload.history()).isFalse();
+
+ // Unsubscribe closes the local publisher and notifies the runtime.
+ CountDownLatch completed = new CountDownLatch(1);
+ publisher.subscribe(completionLatchSubscriber(completed));
+ client.unsubscribe(watched);
+ assertThat(fake.awaitSent("job.unsubscribe").type()).isEqualTo("job.unsubscribe");
+ assertThat(completed.await(5, TimeUnit.SECONDS)).isTrue();
+ // A second unsubscribe has no local publisher/executor left but still notifies the runtime.
+ client.unsubscribe(watched);
+ assertThat(fake.awaitSent("job.unsubscribe")).isNotNull();
+
+ // Failures while notifying the runtime are swallowed.
+ JobId flaky = JobId.of("job_flaky");
+ client.subscribe(flaky, SubscribeOptions.live());
+ fake.awaitSent("job.subscribe");
+ fake.failSends = true;
+ assertThatCode(() -> client.unsubscribe(flaky)).doesNotThrowAnyException();
+ fake.failSends = false;
+
+ // After close() the runtime is no longer notified.
+ JobId postClose = JobId.of("job_post_close");
+ client.subscribe(postClose, SubscribeOptions.live());
+ fake.awaitSent("job.subscribe");
+ client.close();
+ fake.awaitSent("session.close");
+ assertThatCode(() -> client.unsubscribe(postClose)).doesNotThrowAnyException();
+ assertThat(fake.sent.poll(100, TimeUnit.MILLISECONDS)).isNull();
+ }
+
+ @Test
+ void closeIsIdempotentAndSurvivesDeadTransport() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = connect(fake);
+ fake.failSends = true; // session.close cannot be sent
+ fake.throwOnClose = true; // transport.close() blows up
+ assertThatCode(client::close).doesNotThrowAnyException();
+ assertThatCode(client::close).doesNotThrowAnyException(); // second close returns immediately
+ }
+
+ @Test
+ void externallyOwnedSchedulerIsNotShutDownOnClose() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ try {
+ ArcpClient client =
+ connect(
+ fake, ArcpClient.builder(fake).scheduler(scheduler).ackInterval(Duration.ofHours(1)));
+ client.close();
+ assertThat(scheduler.isShutdown()).isFalse();
+ } finally {
+ scheduler.shutdownNow();
+ }
+ }
+
+ @Test
+ void builderOptionsFlowIntoHello() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client =
+ ArcpClient.builder(fake)
+ .mapper(FakeTransport.MAPPER)
+ .client("coverage-client", "0.0.1")
+ .auth(dev.arcp.core.auth.Auth.anonymous())
+ .bearer("hunter2")
+ .features(null)
+ .features(EnumSet.noneOf(Feature.class))
+ .features(EnumSet.of(Feature.ACK))
+ .autoAck(true)
+ .ackInterval(Duration.ofHours(1))
+ .submitTimeout(Duration.ofSeconds(30))
+ .build();
+ client.connect();
+ Envelope hello = fake.awaitSent("session.hello");
+ SessionHello payload = FakeTransport.MAPPER.convertValue(hello.payload(), SessionHello.class);
+ assertThat(payload.client().name()).isEqualTo("coverage-client");
+ assertThat(payload.auth().scheme()).isEqualTo("bearer");
+ assertThat(payload.capabilities().features()).containsExactly(Feature.ACK);
+ assertThat(payload.resumeToken()).isNull();
+ client.close();
+ }
+
+ @Test
+ void explicitAndAutomaticAcksTrackProgress() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake)) {
+ client.ack(3);
+ Envelope explicitAck = fake.awaitSent("session.ack");
+ assertThat(
+ FakeTransport.MAPPER
+ .convertValue(explicitAck.payload(), SessionAck.class)
+ .lastProcessedSeq())
+ .isEqualTo(3);
+
+ // Drive maybeAck() directly: progress -> ack, no progress -> silence, send failure -> warn.
+ fake.deliver(
+ Message.Type.JOB_EVENT,
+ new JobEvent(
+ "log", Instant.now(), Events.encode(FakeTransport.MAPPER, new LogEvent("info", "x"))),
+ JobId.of("job_seq"),
+ 7L,
+ MessageId.generate());
+ Method maybeAck = ArcpClient.class.getDeclaredMethod("maybeAck");
+ maybeAck.setAccessible(true);
+ maybeAck.invoke(client);
+ Envelope autoAck = fake.awaitSent("session.ack");
+ assertThat(
+ FakeTransport.MAPPER
+ .convertValue(autoAck.payload(), SessionAck.class)
+ .lastProcessedSeq())
+ .isEqualTo(7);
+ maybeAck.invoke(client);
+ assertThat(fake.sent.poll(100, TimeUnit.MILLISECONDS)).isNull();
+
+ fake.deliver(
+ Message.Type.JOB_EVENT,
+ new JobEvent(
+ "log", Instant.now(), Events.encode(FakeTransport.MAPPER, new LogEvent("info", "y"))),
+ JobId.of("job_seq"),
+ 9L,
+ MessageId.generate());
+ fake.failSends = true;
+ assertThatCode(() -> maybeAck.invoke(client)).doesNotThrowAnyException();
+ fake.failSends = false;
+ }
+ }
+
+ @Test
+ void autoAckSchedulerEmitsAckAfterProgress() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = connect(fake, ArcpClient.builder(fake).ackInterval(Duration.ofMillis(25)));
+ fake.deliver(
+ Message.Type.JOB_EVENT,
+ new JobEvent(
+ "log", Instant.now(), Events.encode(FakeTransport.MAPPER, new LogEvent("info", "x"))),
+ JobId.of("job_seq"),
+ 11L,
+ MessageId.generate());
+ Envelope ack = fake.awaitSent("session.ack");
+ assertThat(
+ FakeTransport.MAPPER.convertValue(ack.payload(), SessionAck.class).lastProcessedSeq())
+ .isEqualTo(11);
+ client.close();
+ }
+
+ @Test
+ void heartbeatWatchdogClosesSessionOnLoss() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = connect(fake);
+ CompletableFuture pending =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+
+ Method watch = ArcpClient.class.getDeclaredMethod("watchHeartbeat", long.class);
+ watch.setAccessible(true);
+ // Recent inbound traffic: nothing happens.
+ watch.invoke(client, Long.MAX_VALUE / 4);
+ assertThat(pending).isNotDone();
+ // Two missed intervals: every in-flight future fails and the client closes.
+ watch.invoke(client, -1L);
+ assertThat(pending.isCompletedExceptionally()).isTrue();
+ assertThatThrownBy(pending::join).hasRootCauseMessage("heartbeat lost");
+ // The watchdog already closed the client; another close is a no-op.
+ assertThatCode(client::close).doesNotThrowAnyException();
+ }
+
+ @Test
+ void jobSubmitFactoryValidatesExpiry() {
+ assertThatThrownBy(
+ () ->
+ ArcpClient.jobSubmit(
+ "echo",
+ obj(),
+ null,
+ LeaseConstraints.of(Instant.now().minusSeconds(60)),
+ null,
+ null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("expires_at must be in the future");
+ assertThatCode(
+ () ->
+ ArcpClient.jobSubmit(
+ "echo",
+ obj(),
+ null,
+ LeaseConstraints.of(Instant.now().plusSeconds(3600)),
+ "idem-1",
+ 30))
+ .doesNotThrowAnyException();
+ assertThatCode(
+ () -> ArcpClient.jobSubmit("echo", obj(), null, LeaseConstraints.none(), null, null))
+ .doesNotThrowAnyException();
+ assertThatCode(() -> ArcpClient.jobSubmit("echo", obj(), null, null, null, null))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void pageDefensiveCopiesAndCursorSemantics() {
+ Page empty = Page.empty();
+ assertThat(empty.hasNext()).isFalse();
+ assertThat(empty.items()).isEmpty();
+ Page nullItems = new Page<>(null, "more");
+ assertThat(nullItems.items()).isEmpty();
+ assertThat(nullItems.hasNext()).isTrue();
+ }
+
+ @Test
+ void resultStreamEnforcesResultIdMatch() throws Exception {
+ ResultStream bound = ResultStream.toMemory(ResultId.of("r1"));
+ assertThatThrownBy(
+ () ->
+ bound.accept(
+ new ResultChunkEvent(ResultId.of("r2"), 0, "x", ResultChunkEvent.UTF8, false)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("wrong result_id");
+ bound.accept(new ResultChunkEvent(ResultId.of("r1"), 0, "ok", ResultChunkEvent.UTF8, false));
+ assertThat(bound.isComplete()).isTrue();
+ assertThat(new String(bound.bytes(), java.nio.charset.StandardCharsets.UTF_8)).isEqualTo("ok");
+
+ ResultStream unbound = ResultStream.toMemory(null);
+ unbound.accept(new ResultChunkEvent(ResultId.of("any"), 0, "hi", ResultChunkEvent.UTF8, false));
+ assertThat(new String(unbound.bytes(), java.nio.charset.StandardCharsets.UTF_8))
+ .isEqualTo("hi");
+ }
+
+ private static Flow.Subscriber completionLatchSubscriber(CountDownLatch onComplete) {
+ return new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(EventBody item) {}
+
+ @Override
+ public void onError(Throwable throwable) {}
+
+ @Override
+ public void onComplete() {
+ onComplete.countDown();
+ }
+ };
+ }
+}
diff --git a/arcp-client/src/test/java/dev/arcp/client/coverage/ClientProtocolCoverageTest.java b/arcp-client/src/test/java/dev/arcp/client/coverage/ClientProtocolCoverageTest.java
new file mode 100644
index 0000000..3c7e2c4
--- /dev/null
+++ b/arcp-client/src/test/java/dev/arcp/client/coverage/ClientProtocolCoverageTest.java
@@ -0,0 +1,642 @@
+package dev.arcp.client.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import dev.arcp.client.ArcpClient;
+import dev.arcp.client.JobHandle;
+import dev.arcp.client.Session;
+import dev.arcp.client.SubscribeOptions;
+import dev.arcp.core.agents.AgentRef;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.capabilities.AgentDescriptor;
+import dev.arcp.core.capabilities.Capabilities;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.credentials.Credential;
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.core.credentials.CredentialScheme;
+import dev.arcp.core.error.BudgetExhaustedException;
+import dev.arcp.core.error.ErrorCode;
+import dev.arcp.core.events.EventBody;
+import dev.arcp.core.events.Events;
+import dev.arcp.core.events.LogEvent;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.messages.ClientInfo;
+import dev.arcp.core.messages.JobAccepted;
+import dev.arcp.core.messages.JobCancel;
+import dev.arcp.core.messages.JobCancelled;
+import dev.arcp.core.messages.JobError;
+import dev.arcp.core.messages.JobEvent;
+import dev.arcp.core.messages.JobResult;
+import dev.arcp.core.messages.JobSubmit;
+import dev.arcp.core.messages.JobSubscribe;
+import dev.arcp.core.messages.JobSubscribed;
+import dev.arcp.core.messages.JobUnsubscribe;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.RuntimeInfo;
+import dev.arcp.core.messages.SessionAck;
+import dev.arcp.core.messages.SessionBye;
+import dev.arcp.core.messages.SessionClosed;
+import dev.arcp.core.messages.SessionHello;
+import dev.arcp.core.messages.SessionListJobs;
+import dev.arcp.core.messages.SessionPing;
+import dev.arcp.core.messages.SessionPong;
+import dev.arcp.core.messages.SessionWelcome;
+import dev.arcp.core.wire.Envelope;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Branch coverage for {@link ArcpClient} message dispatch and error correlation, driven over a
+ * synchronous {@link FakeTransport} so every inbound envelope is fully processed before the test
+ * proceeds.
+ */
+class ClientProtocolCoverageTest {
+
+ private static ObjectNode obj() {
+ return JsonNodeFactory.instance.objectNode();
+ }
+
+ private static SessionWelcome welcome(
+ Set features, Integer heartbeatSec, List agents) {
+ return new SessionWelcome(
+ new RuntimeInfo("rt", "1.0.0"),
+ "resume-1",
+ 60,
+ heartbeatSec,
+ new Capabilities(List.of("json"), features, agents));
+ }
+
+ private static ArcpClient connect(FakeTransport fake, SessionWelcome welcome) throws Exception {
+ ArcpClient client = ArcpClient.builder(fake).ackInterval(Duration.ofHours(1)).build();
+ CompletableFuture future = client.connect();
+ fake.awaitSent("session.hello");
+ fake.deliver(Message.Type.SESSION_WELCOME, welcome);
+ future.get(2, TimeUnit.SECONDS);
+ return client;
+ }
+
+ private static JobAccepted accepted(JobId jobId, List credentials) {
+ return new JobAccepted(
+ jobId, "echo@1.0.0", Lease.empty(), null, null, credentials, Instant.now(), null);
+ }
+
+ private static Credential credential(String id, CredentialScheme scheme) {
+ return new Credential(
+ CredentialId.of(id), scheme, "secret", "https://api.example.com", null, null);
+ }
+
+ private static JobHandle submitAccepted(FakeTransport fake, ArcpClient client, JobId jobId)
+ throws Exception {
+ CompletableFuture pending =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.deliver(Message.Type.JOB_ACCEPTED, accepted(jobId, null));
+ return pending.get(2, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void welcomeWithoutOptionalFeaturesNegotiatesBareSession() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ Session session = client.session();
+ assertThat(session.negotiatedFeatures()).isEmpty();
+ assertThat(session.heartbeatInterval()).isNull();
+ assertThat(session.availableAgents()).isEmpty();
+ assertThat(session.resumeToken()).isEqualTo("resume-1");
+ assertThat(client.lastSeenSeq()).isEqualTo(-1);
+ }
+ }
+
+ @Test
+ void welcomeWithHeartbeatAckAndAgentsSchedulesWatchers() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ SessionWelcome full =
+ welcome(
+ EnumSet.of(Feature.HEARTBEAT, Feature.ACK),
+ 1,
+ List.of(new AgentDescriptor("echo", List.of("1.0.0"), "1.0.0")));
+ try (ArcpClient client = connect(fake, full)) {
+ assertThat(client.session().heartbeatInterval()).isEqualTo(Duration.ofSeconds(1));
+ assertThat(client.session().availableAgents()).extracting("name").contains("echo");
+ }
+ }
+
+ @Test
+ void welcomeWithIntervalButNoHeartbeatFeatureSkipsWatchdog() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), 1, null))) {
+ assertThat(client.session().heartbeatInterval()).isEqualTo(Duration.ofSeconds(1));
+ assertThat(client.session().negotiatedFeatures()).doesNotContain(Feature.HEARTBEAT);
+ }
+ }
+
+ @Test
+ void autoAckDisabledSkipsAckScheduling() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = ArcpClient.builder(fake).autoAck(false).build();
+ CompletableFuture future = client.connect();
+ fake.awaitSent("session.hello");
+ fake.deliver(Message.Type.SESSION_WELCOME, welcome(EnumSet.of(Feature.ACK), null, null));
+ assertThat(future.get(2, TimeUnit.SECONDS).negotiatedFeatures()).contains(Feature.ACK);
+ client.close();
+ }
+
+ @Test
+ void sessionAccessorBeforeWelcomeThrows() {
+ ArcpClient client = ArcpClient.builder(new FakeTransport()).build();
+ assertThatThrownBy(client::session)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("not connected");
+ client.close();
+ }
+
+ @Test
+ void pingIsAnsweredWithPongCarryingNonce() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ fake.deliver(Message.Type.SESSION_PING, new SessionPing("nonce-1", Instant.now()));
+ Envelope pong = fake.awaitSent("session.pong");
+ SessionPong payload = FakeTransport.MAPPER.convertValue(pong.payload(), SessionPong.class);
+ assertThat(payload.pingNonce()).isEqualTo("nonce-1");
+ }
+ }
+
+ @Test
+ void clientIgnoresRuntimeBoundAndUnknownMessages() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ JobId jobId = JobId.of("job_ignored");
+ fake.deliver(Message.Type.SESSION_CLOSED, new SessionClosed("done"));
+ fake.deliver(Message.Type.SESSION_PONG, new SessionPong("n", Instant.now()));
+ fake.deliver(Message.Type.SESSION_ACK, new SessionAck(1));
+ fake.deliver(
+ Message.Type.SESSION_HELLO,
+ new SessionHello(new ClientInfo("c", "1"), Auth.anonymous(), null, null, null));
+ fake.deliver(Message.Type.SESSION_BYE, new SessionBye("bye"));
+ fake.deliver(Message.Type.SESSION_LIST_JOBS, new SessionListJobs(null, null, null));
+ fake.deliver(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(AgentRef.parse("echo"), obj(), null, null, null, null));
+ fake.deliver(Message.Type.JOB_CANCEL, new JobCancel("nah"));
+ fake.deliver(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(jobId, null, false));
+ fake.deliver(
+ Message.Type.JOB_SUBSCRIBED,
+ new JobSubscribed(jobId, "running", "echo", null, null, null, 0, false));
+ fake.deliver(Message.Type.JOB_UNSUBSCRIBE, new JobUnsubscribe(jobId));
+ fake.deliver(
+ FakeTransport.envelope(
+ Message.Type.JOB_CANCELLED,
+ new JobCancelled("user asked"),
+ jobId,
+ null,
+ MessageId.generate()));
+
+ // Legacy `session.bye` alias decodes through the same dispatch arm.
+ fake.deliver(
+ new Envelope(
+ Envelope.VERSION,
+ MessageId.generate(),
+ "session.bye",
+ FakeTransport.SESSION,
+ null,
+ null,
+ null,
+ FakeTransport.MAPPER.valueToTree(new SessionBye("legacy"))));
+ // Unknown type and undecodable payload both log-and-drop.
+ fake.deliver(
+ new Envelope(
+ Envelope.VERSION,
+ MessageId.generate(),
+ "totally.unknown",
+ null,
+ null,
+ null,
+ null,
+ obj()));
+ fake.deliver(
+ new Envelope(
+ Envelope.VERSION, MessageId.generate(), "job.event", null, null, null, null, obj()));
+
+ // The client survives all of the above.
+ fake.deliver(Message.Type.SESSION_PING, new SessionPing("still-alive", Instant.now()));
+ assertThat(fake.awaitSent("session.pong")).isNotNull();
+ }
+ }
+
+ @Test
+ void dispatchSwallowsHandlerExceptions() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ fake.failSends = true;
+ fake.deliver(Message.Type.SESSION_PING, new SessionPing("boom", Instant.now()));
+ fake.failSends = false;
+ fake.deliver(Message.Type.SESSION_PING, new SessionPing("ok", Instant.now()));
+ assertThat(fake.awaitSent("session.pong")).isNotNull();
+ }
+ }
+
+ @Test
+ void lastSeenSeqTracksMonotonicMaximum() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ JobEvent event =
+ new JobEvent(
+ "log", Instant.now(), Events.encode(FakeTransport.MAPPER, new LogEvent("info", "x")));
+ fake.deliver(
+ Message.Type.JOB_EVENT, event, JobId.of("job_unknown"), 5L, MessageId.generate());
+ fake.deliver(
+ Message.Type.JOB_EVENT, event, JobId.of("job_unknown"), 3L, MessageId.generate());
+ assertThat(client.lastSeenSeq()).isEqualTo(5);
+
+ // Events and results without a job id are dropped before dispatching to subscribers.
+ fake.deliver(Message.Type.JOB_EVENT, event, null, null, MessageId.generate());
+ fake.deliver(
+ Message.Type.JOB_RESULT,
+ new JobResult(JobResult.SUCCESS, null, null, obj(), null),
+ null,
+ null,
+ MessageId.generate());
+ // A result for an unknown job is ignored too.
+ fake.deliver(
+ Message.Type.JOB_RESULT,
+ new JobResult(JobResult.SUCCESS, null, null, obj(), null),
+ JobId.of("job_unknown"),
+ null,
+ MessageId.generate());
+ assertThat(client.lastSeenSeq()).isEqualTo(5);
+ }
+ }
+
+ @Test
+ void credentialFilteringDropsUnknownSchemes() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ // Mixed list: the unknown scheme is dropped, the bearer survives.
+ CompletableFuture mixed =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.deliver(
+ Message.Type.JOB_ACCEPTED,
+ accepted(
+ JobId.of("job_mixed"),
+ List.of(
+ credential("c1", CredentialScheme.BEARER),
+ credential("c2", CredentialScheme.UNKNOWN))));
+ JobHandle mixedHandle = mixed.get(2, TimeUnit.SECONDS);
+ assertThat(mixedHandle.credentials()).isPresent();
+ assertThat(mixedHandle.credentials().orElseThrow())
+ .extracting(Credential::scheme)
+ .containsExactly(CredentialScheme.BEARER);
+
+ // All unknown: the credential list collapses to absent.
+ CompletableFuture allUnknown =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.deliver(
+ Message.Type.JOB_ACCEPTED,
+ accepted(JobId.of("job_unknowns"), List.of(credential("c3", CredentialScheme.UNKNOWN))));
+ assertThat(allUnknown.get(2, TimeUnit.SECONDS).credentials()).isEmpty();
+
+ // All recognized: fast path returns the acceptance untouched.
+ CompletableFuture recognized =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.deliver(
+ Message.Type.JOB_ACCEPTED,
+ accepted(JobId.of("job_known"), List.of(credential("c4", CredentialScheme.BEARER))));
+ assertThat(recognized.get(2, TimeUnit.SECONDS).credentials().orElseThrow()).hasSize(1);
+
+ // Empty and absent credential lists short-circuit the filter.
+ CompletableFuture empty =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.deliver(Message.Type.JOB_ACCEPTED, accepted(JobId.of("job_empty"), List.of()));
+ assertThat(empty.get(2, TimeUnit.SECONDS).credentials().orElseThrow()).isEmpty();
+
+ CompletableFuture absent =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.deliver(Message.Type.JOB_ACCEPTED, accepted(JobId.of("job_absent"), null));
+ JobHandle absentHandle = absent.get(2, TimeUnit.SECONDS);
+ assertThat(absentHandle.credentials()).isEmpty();
+ assertThat(absentHandle.resolvedAgent()).isEqualTo("echo@1.0.0");
+ assertThat(absentHandle.jobId()).isEqualTo(JobId.of("job_absent"));
+ assertThat(absentHandle.accepted().agent()).isEqualTo("echo@1.0.0");
+
+ // job.accepted with no pending submit is dropped.
+ fake.deliver(Message.Type.JOB_ACCEPTED, accepted(JobId.of("job_spurious"), null));
+ assertThat(fake.sent.poll(100, TimeUnit.MILLISECONDS)).isNull();
+ }
+ }
+
+ @Test
+ void cancelSendsJobCancelForAcceptedJob() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ JobHandle handle = submitAccepted(fake, client, JobId.of("job_cancel"));
+ handle.cancel();
+ Envelope cancel = fake.awaitSent("job.cancel");
+ assertThat(cancel.jobId()).isEqualTo(JobId.of("job_cancel"));
+ }
+ }
+
+ @Test
+ void jobfulErrorFailsResultAndUnknownJobErrorIsIgnored() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ JobHandle handle = submitAccepted(fake, client, JobId.of("job_err"));
+ // A live subscriber on the failing job must observe the terminal error too.
+ CountDownLatch liveErrored = new CountDownLatch(1);
+ client
+ .subscribe(JobId.of("job_err"), SubscribeOptions.live())
+ .subscribe(errorLatchSubscriber(liveErrored));
+ fake.awaitSent("job.subscribe");
+ // An error for some other job leaves this handle untouched.
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.INTERNAL_ERROR, "other", true, null),
+ JobId.of("job_other"),
+ null,
+ MessageId.generate());
+ assertThat(handle.result()).isNotDone();
+ // The terminal error for the known job fails the result future.
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.BUDGET_EXHAUSTED, "broke", false, null),
+ JobId.of("job_err"),
+ null,
+ MessageId.generate());
+ assertThatThrownBy(() -> handle.result().get(2, TimeUnit.SECONDS))
+ .isInstanceOf(ExecutionException.class)
+ .hasCauseInstanceOf(BudgetExhaustedException.class);
+ assertThat(liveErrored.await(5, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
+ @Test
+ void topLevelErrorCorrelatesToPendingSubmitById() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ CompletableFuture first =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ Envelope firstSubmit = fake.awaitSent("job.submit");
+ CompletableFuture second =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ Envelope secondSubmit = fake.awaitSent("job.submit");
+
+ // Failing the SECOND request exercises the non-head scan of pending submits.
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.BUDGET_EXHAUSTED, "no funds", false, null),
+ null,
+ null,
+ secondSubmit.id());
+ assertThatThrownBy(() -> second.get(2, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(BudgetExhaustedException.class);
+ assertThat(first).isNotDone();
+
+ // The first submit still completes when its acceptance arrives.
+ fake.deliver(Message.Type.JOB_ACCEPTED, accepted(JobId.of("job_first"), null));
+ assertThat(first.get(2, TimeUnit.SECONDS).jobId()).isEqualTo(JobId.of("job_first"));
+ }
+ }
+
+ @Test
+ void uncorrelatedTopLevelErrorAfterWelcomeIsDropped() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.INTERNAL_ERROR, "stray", true, null),
+ null,
+ null,
+ MessageId.generate());
+ fake.deliver(Message.Type.SESSION_PING, new SessionPing("post-stray", Instant.now()));
+ assertThat(fake.awaitSent("session.pong")).isNotNull();
+ }
+ }
+
+ @Test
+ void topLevelErrorBeforeWelcomeRejectsHandshake() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = ArcpClient.builder(fake).resumeToken("expired").lastEventSeq(17).build();
+ AtomicReference thrown = new AtomicReference<>();
+ Thread connector =
+ new Thread(
+ () -> {
+ try {
+ client.connect(Duration.ofSeconds(5));
+ } catch (Exception e) {
+ thrown.set(e);
+ }
+ });
+ connector.start();
+ Envelope hello = fake.awaitSent("session.hello");
+ SessionHello payload = FakeTransport.MAPPER.convertValue(hello.payload(), SessionHello.class);
+ assertThat(payload.resumeToken()).isEqualTo("expired");
+ assertThat(payload.lastEventSeq()).isEqualTo(17);
+ fake.deliver(
+ Message.Type.JOB_ERROR,
+ new JobError(JobError.ERROR, ErrorCode.RESUME_WINDOW_EXPIRED, "expired", false, null),
+ null,
+ null,
+ MessageId.generate());
+ connector.join(TimeUnit.SECONDS.toMillis(5));
+ assertThat(thrown.get())
+ .isInstanceOf(dev.arcp.core.error.ResumeWindowExpiredException.class)
+ .hasMessageContaining("expired");
+ client.close();
+ }
+
+ @Test
+ void transportCompletionBeforeWelcomeFailsConnect() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = ArcpClient.builder(fake).build();
+ AtomicReference thrown = new AtomicReference<>();
+ Thread connector =
+ new Thread(
+ () -> {
+ try {
+ client.connect(Duration.ofSeconds(5));
+ } catch (Exception e) {
+ thrown.set(e);
+ }
+ });
+ connector.start();
+ fake.awaitSent("session.hello");
+ fake.completeInbound();
+ connector.join(TimeUnit.SECONDS.toMillis(5));
+ assertThat(thrown.get())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("connect failed");
+ assertThat(thrown.get().getCause()).hasMessageContaining("transport closed before welcome");
+ client.close();
+ }
+
+ @Test
+ void transportCompletionAfterWelcomeFailsEverythingInFlight() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = connect(fake, welcome(Set.of(), null, null));
+
+ JobHandle live = submitAccepted(fake, client, JobId.of("job_live"));
+ JobHandle done = submitAccepted(fake, client, JobId.of("job_done"));
+ done.result().complete(new JobResult(JobResult.SUCCESS, null, null, obj(), null));
+
+ CompletableFuture pendingSubmit =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+
+ AtomicReference listOutcome = new AtomicReference<>();
+ CountDownLatch listDone = new CountDownLatch(1);
+ Thread lister =
+ new Thread(
+ () -> {
+ try {
+ listOutcome.set(client.listJobs(null));
+ } catch (Exception e) {
+ listOutcome.set(e);
+ } finally {
+ listDone.countDown();
+ }
+ });
+ lister.start();
+ fake.awaitSent("session.list_jobs");
+
+ CountDownLatch subscriberError = new CountDownLatch(1);
+ client
+ .subscribe(JobId.of("job_watched"), SubscribeOptions.live())
+ .subscribe(errorLatchSubscriber(subscriberError));
+ fake.awaitSent("job.subscribe");
+
+ fake.completeInbound();
+
+ assertThat(pendingSubmit.isCompletedExceptionally()).isTrue();
+ assertThat(live.result().isCompletedExceptionally()).isTrue();
+ assertThat(done.result().get()).isNotNull();
+ assertThat(listDone.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(listOutcome.get())
+ .isInstanceOf(IllegalStateException.class)
+ .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.THROWABLE)
+ .hasMessageContaining("list_jobs failed");
+ assertThat(subscriberError.await(5, TimeUnit.SECONDS)).isTrue();
+ client.close();
+ }
+
+ @Test
+ void transportErrorFailsPendingSubmit() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ ArcpClient client = connect(fake, welcome(Set.of(), null, null));
+ CompletableFuture pending =
+ client.submitAsync(ArcpClient.jobSubmit("echo@1.0.0", obj()));
+ fake.awaitSent("job.submit");
+ fake.errorInbound(new RuntimeException("link down"));
+ assertThatThrownBy(() -> pending.get(2, TimeUnit.SECONDS)).hasRootCauseMessage("link down");
+ client.close();
+ }
+
+ private static Flow.Subscriber errorLatchSubscriber(CountDownLatch onError) {
+ return new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(EventBody item) {}
+
+ @Override
+ public void onError(Throwable throwable) {
+ onError.countDown();
+ }
+
+ @Override
+ public void onComplete() {}
+ };
+ }
+
+ @Test
+ void eventsAreFannedOutToHandleAndLiveSubscribers() throws Exception {
+ FakeTransport fake = new FakeTransport();
+ try (ArcpClient client = connect(fake, welcome(Set.of(), null, null))) {
+ JobId jobId = JobId.of("job_fan");
+ JobHandle handle = submitAccepted(fake, client, jobId);
+
+ CopyOnWriteArrayList handleLogs = new CopyOnWriteArrayList<>();
+ CountDownLatch handleSaw = new CountDownLatch(1);
+ handle.events().subscribe(collectingSubscriber(handleLogs, handleSaw));
+
+ CopyOnWriteArrayList liveLogs = new CopyOnWriteArrayList<>();
+ CountDownLatch liveSaw = new CountDownLatch(1);
+ client
+ .subscribe(jobId, SubscribeOptions.withHistory(0L))
+ .subscribe(collectingSubscriber(liveLogs, liveSaw));
+ Envelope sub = fake.awaitSent("job.subscribe");
+ JobSubscribe subscribePayload =
+ FakeTransport.MAPPER.convertValue(sub.payload(), JobSubscribe.class);
+ assertThat(subscribePayload.fromEventSeq()).isEqualTo(0L);
+
+ fake.deliver(
+ Message.Type.JOB_EVENT,
+ new JobEvent(
+ "log",
+ Instant.now(),
+ Events.encode(FakeTransport.MAPPER, new LogEvent("info", "hi"))),
+ jobId,
+ 1L,
+ MessageId.generate());
+ assertThat(handleSaw.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(liveSaw.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(handleLogs).containsExactly("hi");
+ assertThat(liveLogs).containsExactly("hi");
+
+ fake.deliver(
+ Message.Type.JOB_RESULT,
+ new JobResult(JobResult.SUCCESS, null, null, obj(), null),
+ jobId,
+ 2L,
+ MessageId.generate());
+ assertThat(handle.result().get(2, TimeUnit.SECONDS).finalStatus())
+ .isEqualTo(JobResult.SUCCESS);
+ }
+ }
+
+ private static Flow.Subscriber collectingSubscriber(
+ CopyOnWriteArrayList sink, CountDownLatch first) {
+ return new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(EventBody item) {
+ if (item instanceof LogEvent log) {
+ sink.add(log.message());
+ }
+ first.countDown();
+ }
+
+ @Override
+ public void onError(Throwable throwable) {}
+
+ @Override
+ public void onComplete() {}
+ };
+ }
+}
diff --git a/arcp-client/src/test/java/dev/arcp/client/coverage/FakeTransport.java b/arcp-client/src/test/java/dev/arcp/client/coverage/FakeTransport.java
new file mode 100644
index 0000000..9914a20
--- /dev/null
+++ b/arcp-client/src/test/java/dev/arcp/client/coverage/FakeTransport.java
@@ -0,0 +1,123 @@
+package dev.arcp.client.coverage;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.SessionId;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.Messages;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Flow;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Deterministic test transport: outbound envelopes are captured in a queue and inbound envelopes
+ * are delivered synchronously on the calling thread, so every client dispatch completes before
+ * {@link #deliver} returns. This makes branch-precise assertions possible without races.
+ */
+final class FakeTransport implements Transport {
+
+ static final ObjectMapper MAPPER = ArcpMapper.shared();
+ static final SessionId SESSION = SessionId.of("sess_fake");
+
+ final BlockingQueue sent = new LinkedBlockingQueue<>();
+ volatile boolean failSends;
+ volatile boolean throwOnClose;
+ private volatile Flow.@Nullable Subscriber super Envelope> subscriber;
+
+ @Override
+ public void send(Envelope envelope) {
+ if (failSends) {
+ throw new IllegalStateException("send failed (test)");
+ }
+ sent.add(envelope);
+ }
+
+ @Override
+ public Flow.Publisher incoming() {
+ return sub -> {
+ subscriber = sub;
+ sub.onSubscribe(
+ new Flow.Subscription() {
+ @Override
+ public void request(long n) {}
+
+ @Override
+ public void cancel() {}
+ });
+ };
+ }
+
+ @Override
+ public void close() {
+ if (throwOnClose) {
+ throw new IllegalStateException("close failed (test)");
+ }
+ }
+
+ void deliver(Envelope envelope) {
+ requireSubscriber().onNext(envelope);
+ }
+
+ void deliver(Message.Type type, Message payload) {
+ deliver(envelope(type, payload, null, null, MessageId.generate()));
+ }
+
+ void deliver(
+ Message.Type type,
+ Message payload,
+ @Nullable JobId jobId,
+ @Nullable Long eventSeq,
+ MessageId id) {
+ deliver(envelope(type, payload, jobId, eventSeq, id));
+ }
+
+ void completeInbound() {
+ requireSubscriber().onComplete();
+ }
+
+ void errorInbound(Throwable error) {
+ requireSubscriber().onError(error);
+ }
+
+ Envelope awaitSent(String type) throws InterruptedException {
+ Envelope env = sent.poll(3, TimeUnit.SECONDS);
+ if (env == null) {
+ throw new AssertionError("expected a " + type + " envelope but none was sent");
+ }
+ if (!env.type().equals(type)) {
+ throw new AssertionError("expected " + type + " but client sent " + env.type());
+ }
+ return env;
+ }
+
+ static Envelope envelope(
+ Message.Type type,
+ Message payload,
+ @Nullable JobId jobId,
+ @Nullable Long eventSeq,
+ MessageId id) {
+ return new Envelope(
+ Envelope.VERSION,
+ id,
+ type.wire(),
+ SESSION,
+ null,
+ jobId,
+ eventSeq,
+ Messages.encodePayload(MAPPER, payload));
+ }
+
+ private Flow.Subscriber super Envelope> requireSubscriber() {
+ Flow.Subscriber super Envelope> sub = subscriber;
+ if (sub == null) {
+ throw new AssertionError("client has not subscribed; call connect() first");
+ }
+ return sub;
+ }
+}
diff --git a/arcp-client/src/test/java/dev/arcp/client/coverage/ReplayingPublisherCoverageTest.java b/arcp-client/src/test/java/dev/arcp/client/coverage/ReplayingPublisherCoverageTest.java
new file mode 100644
index 0000000..f4875c4
--- /dev/null
+++ b/arcp-client/src/test/java/dev/arcp/client/coverage/ReplayingPublisherCoverageTest.java
@@ -0,0 +1,264 @@
+package dev.arcp.client.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Flow;
+import java.util.concurrent.SubmissionPublisher;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Branch coverage for the package-private {@code ReplayingPublisher}: replay-then-live handoff,
+ * cancellation during snapshot replay and pending-drain, close idempotency, and error forwarding.
+ * The class is reached via reflection because these tests live outside {@code dev.arcp.client}.
+ */
+class ReplayingPublisherCoverageTest {
+
+ /** Reflection facade over one ReplayingPublisher<Object> instance. */
+ private static final class Harness {
+ final Object instance;
+ private final Method submitMethod;
+
+ Harness() throws Exception {
+ Class> cls = Class.forName("dev.arcp.client.ReplayingPublisher");
+ Constructor> ctor = cls.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ instance = ctor.newInstance();
+ submitMethod = cls.getDeclaredMethod("submit", Object.class);
+ submitMethod.setAccessible(true);
+ }
+
+ @SuppressWarnings("unchecked")
+ Flow.Publisher publisher() {
+ return (Flow.Publisher) instance;
+ }
+
+ void submit(Object item) throws Exception {
+ submitMethod.invoke(instance, item);
+ }
+
+ void close() throws Exception {
+ ((AutoCloseable) instance).close();
+ }
+
+ @SuppressWarnings("unchecked")
+ SubmissionPublisher live() throws Exception {
+ Field field = instance.getClass().getDeclaredField("live");
+ field.setAccessible(true);
+ return (SubmissionPublisher) field.get(instance);
+ }
+
+ ExecutorService liveExecutor() throws Exception {
+ Field field = instance.getClass().getDeclaredField("liveExecutor");
+ field.setAccessible(true);
+ return (ExecutorService) field.get(instance);
+ }
+
+ /** Close, then wait for the forwarder tasks to finish so their branches are executed. */
+ void closeAndDrain() throws Exception {
+ close();
+ assertThat(liveExecutor().awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
+ private static class Collector implements Flow.Subscriber {
+ final CopyOnWriteArrayList received = new CopyOnWriteArrayList<>();
+ final CountDownLatch completed = new CountDownLatch(1);
+ final CountDownLatch errored = new CountDownLatch(1);
+ volatile Flow.Subscription subscription;
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ this.subscription = subscription;
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Object item) {
+ received.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ errored.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ completed.countDown();
+ }
+ }
+
+ @Test
+ void replayThenLiveHandoffPreservesOrderAndCompletes() throws Exception {
+ Harness harness = new Harness();
+ harness.submit("buffered-1");
+ harness.submit("buffered-2");
+ Collector collector = new Collector();
+ harness.publisher().subscribe(collector);
+ // Replay happens synchronously inside subscribe().
+ assertThat(collector.received).containsExactly("buffered-1", "buffered-2");
+ harness.submit("live-1");
+ org.awaitility.Awaitility.await()
+ .atMost(3, TimeUnit.SECONDS)
+ .until(() -> collector.received.size() == 3);
+ assertThat(collector.received).containsExactly("buffered-1", "buffered-2", "live-1");
+ harness.closeAndDrain();
+ assertThat(collector.completed.await(3, TimeUnit.SECONDS)).isTrue();
+ }
+
+ @Test
+ void closeIsIdempotentAndLateSubscribersStillGetReplayThenComplete() throws Exception {
+ Harness harness = new Harness();
+ harness.submit("only");
+ harness.closeAndDrain();
+ harness.close(); // second close hits the already-closed fast path
+ Collector late = new Collector();
+ harness.publisher().subscribe(late);
+ // wasClosed path: replay and completion are delivered synchronously.
+ assertThat(late.received).containsExactly("only");
+ assertThat(late.completed.await(1, TimeUnit.SECONDS)).isTrue();
+ }
+
+ @Test
+ void cancellingDuringSnapshotReplayStopsDelivery() throws Exception {
+ Harness harness = new Harness();
+ harness.submit("a");
+ harness.submit("b");
+ harness.submit("c");
+ Collector cancelling =
+ new Collector() {
+ @Override
+ public void onNext(Object item) {
+ super.onNext(item);
+ subscription.cancel();
+ }
+ };
+ harness.publisher().subscribe(cancelling);
+ assertThat(cancelling.received).containsExactly("a");
+ harness.closeAndDrain();
+ // A cancelled downstream must not observe completion.
+ assertThat(cancelling.completed.await(150, TimeUnit.MILLISECONDS)).isFalse();
+ }
+
+ @Test
+ void liveItemsDuringReplayAreBufferedAndDrainedInOrder() throws Exception {
+ int liveCount = 1500; // > SubmissionPublisher buffer (1024): guarantees pending-drain traffic
+ Harness harness = new Harness();
+ harness.submit("seed");
+ CountDownLatch seedSeen = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Collector collector =
+ new Collector() {
+ @Override
+ public void onNext(Object item) {
+ super.onNext(item);
+ if ("seed".equals(item)) {
+ seedSeen.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ };
+ Thread subscribing = new Thread(() -> harness.publisher().subscribe(collector));
+ subscribing.start();
+ assertThat(seedSeen.await(3, TimeUnit.SECONDS)).isTrue();
+ // While replay is parked inside onNext("seed"), live submissions overflow the forwarder's
+ // buffer, forcing it to consume into the pending deque (replayDone is still false).
+ for (int i = 0; i < liveCount; i++) {
+ harness.submit("live-" + i);
+ }
+ release.countDown();
+ subscribing.join(TimeUnit.SECONDS.toMillis(10));
+ assertThat(subscribing.isAlive()).isFalse();
+ org.awaitility.Awaitility.await()
+ .atMost(5, TimeUnit.SECONDS)
+ .until(() -> collector.received.size() == liveCount + 1);
+ // Order is preserved across replay -> pending drain -> direct live delivery.
+ assertThat(collector.received.get(0)).isEqualTo("seed");
+ for (int i = 0; i < liveCount; i++) {
+ assertThat(collector.received.get(i + 1)).isEqualTo("live-" + i);
+ }
+ harness.closeAndDrain();
+ assertThat(collector.completed.await(3, TimeUnit.SECONDS)).isTrue();
+ }
+
+ @Test
+ void cancellingDuringPendingDrainStopsDeliveryAndSuppressesCompletion() throws Exception {
+ int liveCount = 1500;
+ int cancelAt = 100; // well inside the guaranteed-pending range (>= 476 buffered entries)
+ Harness harness = new Harness();
+ harness.submit("seed");
+ CountDownLatch seedSeen = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger delivered = new AtomicInteger();
+ Collector collector =
+ new Collector() {
+ @Override
+ public void onNext(Object item) {
+ super.onNext(item);
+ int count = delivered.incrementAndGet();
+ if ("seed".equals(item)) {
+ seedSeen.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ } else if (count == cancelAt) {
+ subscription.cancel();
+ }
+ }
+ };
+ Thread subscribing = new Thread(() -> harness.publisher().subscribe(collector));
+ subscribing.start();
+ assertThat(seedSeen.await(3, TimeUnit.SECONDS)).isTrue();
+ for (int i = 0; i < liveCount; i++) {
+ harness.submit("live-" + i);
+ }
+ release.countDown();
+ subscribing.join(TimeUnit.SECONDS.toMillis(10));
+ assertThat(subscribing.isAlive()).isFalse();
+ // The drain loop stops at the cancellation point; later live items hit the forwarder's
+ // cancelled guard instead of the downstream.
+ assertThat(collector.received).hasSize(cancelAt);
+ harness.submit("post-cancel");
+ harness.closeAndDrain();
+ assertThat(collector.received).hasSize(cancelAt);
+ assertThat(collector.completed.await(150, TimeUnit.MILLISECONDS)).isFalse();
+ }
+
+ @Test
+ void errorsForwardToActiveSubscribersButNotCancelledOnes() throws Exception {
+ Harness harness = new Harness();
+ Collector active = new Collector();
+ Collector cancelled =
+ new Collector() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ super.onSubscribe(subscription);
+ subscription.cancel();
+ }
+ };
+ harness.publisher().subscribe(active);
+ harness.publisher().subscribe(cancelled);
+ harness.live().closeExceptionally(new IllegalStateException("upstream torn down"));
+ assertThat(active.errored.await(3, TimeUnit.SECONDS)).isTrue();
+ ExecutorService executor = harness.liveExecutor();
+ executor.shutdown();
+ assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(cancelled.errored.getCount()).isEqualTo(1);
+ assertThat(List.copyOf(cancelled.received)).isEmpty();
+ }
+}
diff --git a/arcp-client/src/test/java/dev/arcp/client/coverage/WebSocketTransportCoverageTest.java b/arcp-client/src/test/java/dev/arcp/client/coverage/WebSocketTransportCoverageTest.java
new file mode 100644
index 0000000..ff3f645
--- /dev/null
+++ b/arcp-client/src/test/java/dev/arcp/client/coverage/WebSocketTransportCoverageTest.java
@@ -0,0 +1,524 @@
+package dev.arcp.client.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.client.WebSocketTransport;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.Constructor;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises {@link WebSocketTransport#connect} against a minimal in-process WebSocket server so the
+ * real JDK handshake, frame, and close paths run: connect success (with and without headers),
+ * connect refusal, handshake timeout, server-initiated close, abrupt connection loss, and
+ * send-after-close failures.
+ */
+class WebSocketTransportCoverageTest {
+
+ private static final String REPLY_JSON =
+ "{\"arcp\":\"1.1\",\"id\":\"m_server\",\"type\":\"session.pong\",\"payload\":{}}";
+
+ private enum Mode {
+ /** Handshake, push one text frame, then answer the client's close frame. */
+ NORMAL,
+ /** Handshake, push one text frame, then send a server-initiated close frame. */
+ SERVER_CLOSE,
+ /** Handshake, push one text frame, then drop the TCP connection without a close frame. */
+ ABORT,
+ /** Accept the TCP connection but never answer the HTTP upgrade. */
+ STALL
+ }
+
+ /** Tiny single-connection RFC 6455 server: enough framing for these tests, nothing more. */
+ private static final class MiniWsServer implements AutoCloseable {
+ private final ServerSocket server;
+ private final Thread thread;
+ private final Mode mode;
+ final CompletableFuture handshake = new CompletableFuture<>();
+ final CompletableFuture firstTextFrame = new CompletableFuture<>();
+
+ MiniWsServer(Mode mode) throws IOException {
+ this.mode = mode;
+ this.server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
+ this.thread = new Thread(this::run, "mini-ws-server");
+ this.thread.start();
+ }
+
+ URI uri() {
+ return URI.create("ws://127.0.0.1:" + server.getLocalPort() + "/arcp");
+ }
+
+ private void run() {
+ try (Socket socket = server.accept()) {
+ socket.setTcpNoDelay(true);
+ InputStream in = socket.getInputStream();
+ OutputStream out = socket.getOutputStream();
+ String head = readHead(in);
+ handshake.complete(head);
+ if (mode == Mode.STALL) {
+ // Stay silent until the client's 250ms handshake timeout has long fired, then answer
+ // with a non-101 response. WebSocketTransport's timeout path calls HttpClient.close(),
+ // which blocks until the abandoned handshake operation completes, so the operation must
+ // finish (an EOF instead would make the JDK retry the GET against this one-shot server
+ // and hang forever). The 3s margin dwarfs the client timeout.
+ Thread.sleep(3000);
+ out.write(
+ ("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.ISO_8859_1));
+ out.flush();
+ return;
+ }
+ out.write(handshakeResponse(head).getBytes(StandardCharsets.ISO_8859_1));
+ out.flush();
+ // Read frames until the scripted exit for this mode. The reply text frame is only sent
+ // once the client's first frame arrives, so tests can subscribe to the inbound publisher
+ // before any envelope is published (SubmissionPublisher drops items with no subscribers).
+ while (true) {
+ int[] frame = readFrameHeader(in);
+ if (frame == null) {
+ return; // peer vanished
+ }
+ int opcode = frame[0];
+ byte[] payload = readFramePayload(in, frame[1] == 1, frame[2]);
+ if (opcode == 1) {
+ firstTextFrame.complete(new String(payload, StandardCharsets.UTF_8));
+ writeTextFrame(out, REPLY_JSON);
+ if (mode == Mode.SERVER_CLOSE) {
+ writeCloseFrame(out);
+ continue; // wait for the client's close echo, then fall through to EOF
+ }
+ if (mode == Mode.ABORT) {
+ // RST instead of FIN: an abrupt reset is unambiguously an error on the client side.
+ socket.setSoLinger(true, 0);
+ return;
+ }
+ } else if (opcode == 8) {
+ writeCloseFrame(out);
+ return;
+ }
+ }
+ } catch (IOException | RuntimeException ignored) {
+ // Test sockets tear down unceremoniously by design.
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static String readHead(InputStream in) throws IOException {
+ StringBuilder head = new StringBuilder();
+ int c;
+ while ((c = in.read()) >= 0) {
+ head.append((char) c);
+ if (head.length() >= 4 && head.substring(head.length() - 4).equals("\r\n\r\n")) {
+ return head.toString();
+ }
+ }
+ throw new IOException("connection closed during handshake");
+ }
+
+ private static String handshakeResponse(String head) {
+ String key = null;
+ for (String line : head.split("\r\n")) {
+ int colon = line.indexOf(':');
+ if (colon > 0 && line.substring(0, colon).equalsIgnoreCase("Sec-WebSocket-Key")) {
+ key = line.substring(colon + 1).trim();
+ }
+ }
+ try {
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ String accept =
+ Base64.getEncoder()
+ .encodeToString(
+ sha1.digest(
+ (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
+ .getBytes(StandardCharsets.ISO_8859_1)));
+ return "HTTP/1.1 101 Switching Protocols\r\n"
+ + "Upgrade: websocket\r\n"
+ + "Connection: Upgrade\r\n"
+ + "Sec-WebSocket-Accept: "
+ + accept
+ + "\r\n\r\n";
+ } catch (java.security.NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /** Returns {opcode, maskedFlag, payloadLength} or null on EOF. */
+ private static int[] readFrameHeader(InputStream in) throws IOException {
+ int b0 = in.read();
+ if (b0 < 0) {
+ return null;
+ }
+ int b1 = in.read();
+ if (b1 < 0) {
+ return null;
+ }
+ int masked = (b1 & 0x80) != 0 ? 1 : 0;
+ int len = b1 & 0x7F;
+ if (len == 126) {
+ len = (in.read() << 8) | in.read();
+ } else if (len == 127) {
+ long longLen = 0;
+ for (int i = 0; i < 8; i++) {
+ longLen = (longLen << 8) | in.read();
+ }
+ len = (int) longLen;
+ }
+ return new int[] {b0 & 0x0F, masked, len};
+ }
+
+ private static byte[] readFramePayload(InputStream in, boolean masked, int len)
+ throws IOException {
+ byte[] mask = new byte[4];
+ if (masked) {
+ readFully(in, mask);
+ }
+ byte[] payload = new byte[len];
+ readFully(in, payload);
+ if (masked) {
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] ^= mask[i & 3];
+ }
+ }
+ return payload;
+ }
+
+ private static void readFully(InputStream in, byte[] buffer) throws IOException {
+ int off = 0;
+ while (off < buffer.length) {
+ int n = in.read(buffer, off, buffer.length - off);
+ if (n < 0) {
+ throw new IOException("EOF mid-frame");
+ }
+ off += n;
+ }
+ }
+
+ private static void writeTextFrame(OutputStream out, String text) throws IOException {
+ byte[] payload = text.getBytes(StandardCharsets.UTF_8);
+ out.write(0x81);
+ if (payload.length < 126) {
+ out.write(payload.length);
+ } else {
+ out.write(126);
+ out.write((payload.length >>> 8) & 0xFF);
+ out.write(payload.length & 0xFF);
+ }
+ out.write(payload);
+ out.flush();
+ }
+
+ private static void writeCloseFrame(OutputStream out) throws IOException {
+ out.write(new byte[] {(byte) 0x88, 0x02, 0x03, (byte) 0xE8}); // 1000 normal closure
+ out.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ server.close();
+ try {
+ thread.join(TimeUnit.SECONDS.toMillis(5));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private static Envelope pingEnvelope() {
+ return new Envelope(
+ Envelope.VERSION,
+ MessageId.of("m_client"),
+ "session.ping",
+ null,
+ null,
+ null,
+ null,
+ JsonNodeFactory.instance.objectNode());
+ }
+
+ @Test
+ void connectWithHeadersExchangesFramesAndRejectsSendAfterClose() throws Exception {
+ try (MiniWsServer server = new MiniWsServer(Mode.NORMAL)) {
+ WebSocketTransport transport =
+ WebSocketTransport.connect(
+ server.uri(),
+ Map.of("X-Coverage", "yes"),
+ ArcpMapper.shared(),
+ Duration.ofSeconds(5));
+ try {
+ assertThat(server.handshake.get(3, TimeUnit.SECONDS)).contains("X-Coverage: yes");
+ BlockingQueue received = new LinkedBlockingQueue<>();
+ transport.incoming().subscribe(queueingSubscriber(received, null, null));
+ transport.send(pingEnvelope());
+ assertThat(server.firstTextFrame.get(3, TimeUnit.SECONDS)).contains("\"session.ping\"");
+ Envelope inbound = received.poll(5, TimeUnit.SECONDS);
+ assertThat(inbound).isNotNull();
+ assertThat(inbound.id()).isEqualTo(MessageId.of("m_server"));
+ } finally {
+ transport.close();
+ }
+ assertThatThrownBy(() -> transport.send(pingEnvelope()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("send failed");
+ }
+ }
+
+ @Test
+ void connectWithoutHeadersCompletesOnServerInitiatedClose() throws Exception {
+ try (MiniWsServer server = new MiniWsServer(Mode.SERVER_CLOSE)) {
+ WebSocketTransport transport = WebSocketTransport.connect(server.uri());
+ CountDownLatch completed = new CountDownLatch(1);
+ transport
+ .incoming()
+ .subscribe(queueingSubscriber(new LinkedBlockingQueue<>(), completed, null));
+ transport.send(pingEnvelope());
+ assertThat(server.firstTextFrame.get(3, TimeUnit.SECONDS)).isNotNull();
+ assertThat(completed.await(5, TimeUnit.SECONDS)).isTrue();
+ transport.close();
+ }
+ }
+
+ @Test
+ void abruptConnectionLossSurfacesAsInboundError() throws Exception {
+ try (MiniWsServer server = new MiniWsServer(Mode.ABORT)) {
+ WebSocketTransport transport =
+ WebSocketTransport.connect(
+ server.uri(), Map.of(), ArcpMapper.shared(), Duration.ofSeconds(5));
+ CountDownLatch errored = new CountDownLatch(1);
+ transport
+ .incoming()
+ .subscribe(queueingSubscriber(new LinkedBlockingQueue<>(), null, errored));
+ transport.send(pingEnvelope());
+ assertThat(errored.await(5, TimeUnit.SECONDS)).isTrue();
+ transport.close(); // close after the socket already died: sendClose failure is swallowed
+ }
+ }
+
+ @Test
+ void connectionRefusedYieldsIllegalState() throws Exception {
+ int unboundPort;
+ try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ unboundPort = probe.getLocalPort();
+ }
+ URI dead = URI.create("ws://127.0.0.1:" + unboundPort + "/arcp");
+ assertThatThrownBy(
+ () ->
+ WebSocketTransport.connect(
+ dead, Map.of(), ArcpMapper.shared(), Duration.ofSeconds(5)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("connect failed");
+ }
+
+ @Test
+ void handshakeStallYieldsTimeout() throws Exception {
+ try (MiniWsServer server = new MiniWsServer(Mode.STALL)) {
+ assertThatThrownBy(
+ () ->
+ WebSocketTransport.connect(
+ server.uri(), Map.of(), ArcpMapper.shared(), Duration.ofMillis(250)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("timed out");
+ }
+ }
+
+ @Test
+ void sendBeforeAttachAndCloseWithoutSocketAreSafe() throws Exception {
+ Constructor constructor =
+ WebSocketTransport.class.getDeclaredConstructor(
+ com.fasterxml.jackson.databind.ObjectMapper.class, java.net.http.HttpClient.class);
+ constructor.setAccessible(true);
+ WebSocketTransport detached = constructor.newInstance(ArcpMapper.shared(), null);
+ assertThatThrownBy(() -> detached.send(pingEnvelope()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("before socket attached");
+ detached.close(); // no socket, no owned client: every optional close branch is skipped
+ }
+
+ @Test
+ void sendWrapsSerializationFailuresAsUncheckedIo() throws Exception {
+ com.fasterxml.jackson.databind.ObjectMapper failingMapper =
+ new com.fasterxml.jackson.databind.ObjectMapper();
+ com.fasterxml.jackson.databind.module.SimpleModule module =
+ new com.fasterxml.jackson.databind.module.SimpleModule();
+ module.addSerializer(
+ Envelope.class,
+ new com.fasterxml.jackson.databind.ser.std.StdSerializer<>(Envelope.class) {
+ @Override
+ public void serialize(
+ Envelope value,
+ com.fasterxml.jackson.core.JsonGenerator gen,
+ com.fasterxml.jackson.databind.SerializerProvider provider)
+ throws java.io.IOException {
+ throw new com.fasterxml.jackson.databind.JsonMappingException(
+ gen, "cannot serialize (test)");
+ }
+ });
+ failingMapper.registerModule(module);
+ WebSocketTransport transport = detachedTransport(failingMapper);
+ attach(transport, new ScriptableWebSocket(CompletableFuture.completedFuture(null)));
+ assertThatThrownBy(() -> transport.send(pingEnvelope()))
+ .isInstanceOf(java.io.UncheckedIOException.class);
+ }
+
+ @Test
+ void sendAndCloseSurfaceInterruptionWithoutSwallowingTheFlag() throws Exception {
+ WebSocketTransport transport = detachedTransport(ArcpMapper.shared());
+ attach(transport, new ScriptableWebSocket(new CompletableFuture<>()));
+ Thread.currentThread().interrupt();
+ try {
+ assertThatThrownBy(() -> transport.send(pingEnvelope()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("interrupted while sending");
+ assertThat(Thread.currentThread().isInterrupted()).isTrue();
+ } finally {
+ Thread.interrupted(); // clear before the next phase
+ }
+
+ // close(): an interrupted sendClose wait is swallowed but re-flags the thread.
+ WebSocketTransport closing = detachedTransport(ArcpMapper.shared());
+ attach(closing, new ScriptableWebSocket(new CompletableFuture<>()));
+ Thread.currentThread().interrupt();
+ try {
+ closing.close();
+ assertThat(Thread.currentThread().isInterrupted()).isTrue();
+ } finally {
+ Thread.interrupted();
+ }
+
+ // close(): a failed sendClose is best-effort and ignored.
+ WebSocketTransport failingClose = detachedTransport(ArcpMapper.shared());
+ attach(
+ failingClose,
+ new ScriptableWebSocket(
+ CompletableFuture.failedFuture(new java.io.IOException("close rejected"))));
+ failingClose.close();
+ }
+
+ private static WebSocketTransport detachedTransport(
+ com.fasterxml.jackson.databind.ObjectMapper mapper) throws Exception {
+ Constructor constructor =
+ WebSocketTransport.class.getDeclaredConstructor(
+ com.fasterxml.jackson.databind.ObjectMapper.class, java.net.http.HttpClient.class);
+ constructor.setAccessible(true);
+ return constructor.newInstance(mapper, null);
+ }
+
+ private static void attach(WebSocketTransport transport, java.net.http.WebSocket socket)
+ throws Exception {
+ java.lang.reflect.Method method =
+ WebSocketTransport.class.getDeclaredMethod("attachSocket", java.net.http.WebSocket.class);
+ method.setAccessible(true);
+ method.invoke(transport, socket);
+ }
+
+ /** WebSocket stub whose send/close futures are scripted by the test. */
+ private static final class ScriptableWebSocket implements java.net.http.WebSocket {
+ private final CompletableFuture outcome;
+
+ ScriptableWebSocket(CompletableFuture outcome) {
+ this.outcome = outcome;
+ }
+
+ private CompletableFuture scripted() {
+ return outcome.thenApply(v -> (java.net.http.WebSocket) this);
+ }
+
+ @Override
+ public CompletableFuture sendText(CharSequence data, boolean last) {
+ return scripted();
+ }
+
+ @Override
+ public CompletableFuture sendBinary(
+ java.nio.ByteBuffer data, boolean last) {
+ return scripted();
+ }
+
+ @Override
+ public CompletableFuture sendPing(java.nio.ByteBuffer message) {
+ return scripted();
+ }
+
+ @Override
+ public CompletableFuture sendPong(java.nio.ByteBuffer message) {
+ return scripted();
+ }
+
+ @Override
+ public CompletableFuture sendClose(int statusCode, String reason) {
+ return scripted();
+ }
+
+ @Override
+ public void request(long n) {}
+
+ @Override
+ public String getSubprotocol() {
+ return "";
+ }
+
+ @Override
+ public boolean isOutputClosed() {
+ return false;
+ }
+
+ @Override
+ public boolean isInputClosed() {
+ return false;
+ }
+
+ @Override
+ public void abort() {}
+ }
+
+ private static Flow.Subscriber queueingSubscriber(
+ BlockingQueue queue, CountDownLatch onComplete, CountDownLatch onError) {
+ return new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ queue.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ if (onError != null) {
+ onError.countDown();
+ }
+ }
+
+ @Override
+ public void onComplete() {
+ if (onComplete != null) {
+ onComplete.countDown();
+ }
+ }
+ };
+ }
+}
diff --git a/arcp-core/pom.xml b/arcp-core/pom.xml
index f736c7e..7a5b54a 100644
--- a/arcp-core/pom.xml
+++ b/arcp-core/pom.xml
@@ -11,6 +11,10 @@
arcp-core
+
+
+ false
+
arcp-core
ARCP core wire types, capabilities, and errors.
diff --git a/arcp-core/src/main/java/dev/arcp/core/Version.java b/arcp-core/src/main/java/dev/arcp/core/Version.java
index 351f9c0..ffcfd89 100644
--- a/arcp-core/src/main/java/dev/arcp/core/Version.java
+++ b/arcp-core/src/main/java/dev/arcp/core/Version.java
@@ -1,7 +1,11 @@
package dev.arcp.core;
+/** Protocol and SDK version constants. */
public final class Version {
+ /** ARCP protocol version implemented by this SDK, carried in the envelope {@code arcp} field. */
public static final String PROTOCOL = "1.1";
+
+ /** Version of this ARCP Java SDK distribution. */
public static final String SDK = "1.0.0";
private Version() {}
diff --git a/arcp-core/src/main/java/dev/arcp/core/agents/AgentRef.java b/arcp-core/src/main/java/dev/arcp/core/agents/AgentRef.java
index dea707a..054e209 100644
--- a/arcp-core/src/main/java/dev/arcp/core/agents/AgentRef.java
+++ b/arcp-core/src/main/java/dev/arcp/core/agents/AgentRef.java
@@ -16,12 +16,20 @@
* name ::= [a-z0-9][a-z0-9._-]*
* version ::= [a-zA-Z0-9.+_-]+
*
+ *
+ * @param name the agent name, lower-case alphanumeric with {@code .}, {@code _}, {@code -}
+ * @param version the pinned agent version, or {@code null} to resolve the runtime default
*/
public record AgentRef(String name, @Nullable String version) {
private static final Pattern NAME = Pattern.compile("[a-z0-9][a-z0-9._-]*");
private static final Pattern VERSION = Pattern.compile("[a-zA-Z0-9.+_\\-]+");
+ /**
+ * Validates both components against the §7.5 grammar.
+ *
+ * @throws IllegalArgumentException if the name or version is invalid
+ */
public AgentRef {
Objects.requireNonNull(name, "name");
if (!NAME.matcher(name).matches()) {
@@ -32,10 +40,22 @@ public record AgentRef(String name, @Nullable String version) {
}
}
+ /**
+ * Returns the version as an {@link Optional}.
+ *
+ * @return the pinned version, or empty when the reference resolves to the runtime default
+ */
public Optional versionOpt() {
return Optional.ofNullable(version);
}
+ /**
+ * Parses the wire form {@code name} or {@code name@version} into an {@link AgentRef} (§7.5).
+ *
+ * @param raw the wire string to parse
+ * @return the parsed reference
+ * @throws IllegalArgumentException if the name or version violates the §7.5 grammar
+ */
@JsonCreator
public static AgentRef parse(String raw) {
Objects.requireNonNull(raw, "raw");
@@ -46,6 +66,11 @@ public static AgentRef parse(String raw) {
return new AgentRef(raw.substring(0, at), raw.substring(at + 1));
}
+ /**
+ * Returns the canonical wire form: {@code name} when unversioned, otherwise {@code name@version}.
+ *
+ * @return the wire string
+ */
@JsonValue
public String wire() {
return version == null ? name : name + "@" + version;
diff --git a/arcp-core/src/main/java/dev/arcp/core/auth/Auth.java b/arcp-core/src/main/java/dev/arcp/core/auth/Auth.java
index 07d00f1..6beb7ff 100644
--- a/arcp-core/src/main/java/dev/arcp/core/auth/Auth.java
+++ b/arcp-core/src/main/java/dev/arcp/core/auth/Auth.java
@@ -5,12 +5,21 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
-/** §6.1 authentication block. v1 supports {@code bearer} and {@code anonymous}. */
+/**
+ * §6.1 authentication block. v1 supports {@code bearer} and {@code anonymous}.
+ *
+ * @param scheme the authentication scheme, {@link #BEARER} or {@link #ANONYMOUS}
+ * @param token the bearer token, or {@code null} for {@code anonymous}
+ */
public record Auth(String scheme, @Nullable String token) {
+ /** Wire value of the bearer-token scheme ({@code "bearer"}). */
public static final String BEARER = "bearer";
+
+ /** Wire value of the unauthenticated scheme ({@code "anonymous"}). */
public static final String ANONYMOUS = "anonymous";
+ /** Canonical constructor requiring a non-null scheme. */
@JsonCreator
public Auth(
@JsonProperty("scheme") String scheme, @JsonProperty("token") @Nullable String token) {
@@ -18,10 +27,21 @@ public Auth(
this.token = token;
}
+ /**
+ * Creates a bearer-token auth block carried in {@code session.hello.payload.auth} (§6.1).
+ *
+ * @param token the bearer token
+ * @return an auth block with scheme {@code bearer}
+ */
public static Auth bearer(String token) {
return new Auth(BEARER, Objects.requireNonNull(token, "token"));
}
+ /**
+ * Creates an anonymous auth block carrying no credential material.
+ *
+ * @return an auth block with scheme {@code anonymous}
+ */
public static Auth anonymous() {
return new Auth(ANONYMOUS, null);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/auth/BearerVerifier.java b/arcp-core/src/main/java/dev/arcp/core/auth/BearerVerifier.java
index e644f39..fb81b6f 100644
--- a/arcp-core/src/main/java/dev/arcp/core/auth/BearerVerifier.java
+++ b/arcp-core/src/main/java/dev/arcp/core/auth/BearerVerifier.java
@@ -9,12 +9,23 @@
/** SPI for verifying §6.1 bearer tokens at the handshake seam. */
@FunctionalInterface
public interface BearerVerifier {
+ /**
+ * Verifies a bearer token presented in {@code session.hello.payload.auth.token}.
+ *
+ * @param token the presented bearer token, possibly {@code null}
+ * @return the authenticated principal
+ * @throws UnauthenticatedException if the token is missing or invalid
+ */
Principal verify(String token) throws UnauthenticatedException;
/**
* Static-token verifier that compares the supplied bearer token to {@code expected} in
* constant-time using {@link MessageDigest#isEqual(byte[], byte[])}. Suitable for production use
* with static credentials.
+ *
+ * @param expected the token value to require
+ * @param principal the principal returned on a successful match
+ * @return a verifier accepting exactly {@code expected}
*/
static BearerVerifier staticToken(String expected, Principal principal) {
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
@@ -32,9 +43,11 @@ static BearerVerifier staticToken(String expected, Principal principal) {
}
/**
- * Accept any non-empty token, returning a principal derived from a SHA-256 digest of the token
+ * Accepts any non-empty token, returning a principal derived from a SHA-256 digest of the token
* bytes (first 16 bytes hex-encoded). Avoids the principal-collision risk of using {@code
* String#hashCode}.
+ *
+ * @return a verifier accepting any non-empty token
*/
static BearerVerifier acceptAny() {
return token -> {
diff --git a/arcp-core/src/main/java/dev/arcp/core/auth/Principal.java b/arcp-core/src/main/java/dev/arcp/core/auth/Principal.java
index e5e41f4..8d36ae1 100644
--- a/arcp-core/src/main/java/dev/arcp/core/auth/Principal.java
+++ b/arcp-core/src/main/java/dev/arcp/core/auth/Principal.java
@@ -2,7 +2,14 @@
import java.util.Objects;
+/**
+ * Authenticated identity established by §6.1 verification. Job visibility (§6.6) and subscription
+ * authorization (§7.6) are scoped per principal.
+ *
+ * @param id opaque stable identifier of the authenticated caller
+ */
public record Principal(String id) {
+ /** Canonical constructor requiring a non-null id. */
public Principal {
Objects.requireNonNull(id, "id");
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/capabilities/AgentDescriptor.java b/arcp-core/src/main/java/dev/arcp/core/capabilities/AgentDescriptor.java
index 3076f62..f319db9 100644
--- a/arcp-core/src/main/java/dev/arcp/core/capabilities/AgentDescriptor.java
+++ b/arcp-core/src/main/java/dev/arcp/core/capabilities/AgentDescriptor.java
@@ -7,10 +7,21 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * Agent inventory entry advertised in {@code session.welcome} capabilities (§6.2). Lists the
+ * versions registered for one agent name and, optionally, the version that a bare {@code name}
+ * submission resolves to per §7.5.
+ *
+ * @param name the agent name
+ * @param versions the registered versions, possibly empty
+ * @param defaultVersion the wire {@code default} version used for unversioned submissions, or
+ * {@code null} when the runtime advertises none
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AgentDescriptor(
String name, List versions, @Nullable @JsonProperty("default") String defaultVersion) {
+ /** Canonical constructor; a null {@code versions} list becomes an empty list. */
@JsonCreator
public AgentDescriptor(
@JsonProperty("name") String name,
diff --git a/arcp-core/src/main/java/dev/arcp/core/capabilities/Capabilities.java b/arcp-core/src/main/java/dev/arcp/core/capabilities/Capabilities.java
index 2e42371..3743958 100644
--- a/arcp-core/src/main/java/dev/arcp/core/capabilities/Capabilities.java
+++ b/arcp-core/src/main/java/dev/arcp/core/capabilities/Capabilities.java
@@ -13,16 +13,27 @@
/**
* Client/runtime capability advertisement carried on session.hello and session.welcome. Unknown
* feature strings are ignored and dropped during decoding.
+ *
+ * @param encodings supported payload encodings; defaults to {@code ["json"]}
+ * @param features advertised optional features (§6.2)
+ * @param agents §7.5 agent inventory, or {@code null} when not advertised
*/
public record Capabilities(
List encodings, Set features, @Nullable List agents) {
+ /** Canonical constructor applying defaults and defensive copies. */
public Capabilities {
encodings = encodings == null ? List.of("json") : List.copyOf(encodings);
features = features == null ? EnumSet.noneOf(Feature.class) : Set.copyOf(features);
agents = agents == null ? null : List.copyOf(agents);
}
+ /**
+ * Creates a JSON-only capability set advertising the given features and no agent inventory.
+ *
+ * @param features the optional features to advertise
+ * @return the capability set
+ */
public static Capabilities of(Set features) {
return new Capabilities(List.of("json"), features, null);
}
@@ -41,12 +52,25 @@ static Capabilities fromJson(
return new Capabilities(encodings == null ? List.of("json") : encodings, parsed, agents);
}
+ /**
+ * Returns the features as sorted wire strings for serializing the {@code features} array.
+ *
+ * @return the sorted wire feature strings
+ */
@JsonProperty("features")
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public List featuresWire() {
return features.stream().map(Feature::wire).sorted().toList();
}
+ /**
+ * Computes the effective feature set per §6.2: the intersection of {@code session.hello} and
+ * {@code session.welcome} features. Either peer MUST NOT use a feature outside it.
+ *
+ * @param a one peer's feature set
+ * @param b the other peer's feature set
+ * @return the intersection
+ */
public static Set intersect(Set a, Set b) {
Set out = new HashSet<>(a);
out.retainAll(b);
diff --git a/arcp-core/src/main/java/dev/arcp/core/capabilities/Feature.java b/arcp-core/src/main/java/dev/arcp/core/capabilities/Feature.java
index 4891662..1c305e5 100644
--- a/arcp-core/src/main/java/dev/arcp/core/capabilities/Feature.java
+++ b/arcp-core/src/main/java/dev/arcp/core/capabilities/Feature.java
@@ -13,16 +13,40 @@
* #fromWire(String)} as {@link Optional#empty()}.
*/
public enum Feature {
+ /**
+ * §6.4 liveness: {@code session.ping}/{@code session.pong} exchanged at least every {@code
+ * heartbeat_interval_sec}.
+ */
HEARTBEAT("heartbeat"),
+
+ /** §6.5 event acknowledgement via {@code session.ack} with {@code last_processed_seq}. */
ACK("ack"),
+
+ /** §6.6 read-only job inventory via {@code session.list_jobs} / {@code session.jobs}. */
LIST_JOBS("list_jobs"),
+
+ /** §7.6 attaching to jobs from other or earlier sessions via {@code job.subscribe}. */
SUBSCRIBE("subscribe"),
+
+ /** §9.5 lease expiration via {@code lease_constraints.expires_at}. */
LEASE_EXPIRES_AT("lease_expires_at"),
+
+ /** §9.6 budget capability: {@code cost.budget} lease entries with runtime spend enforcement. */
COST_BUDGET("cost.budget"),
+
+ /** §9.7 model capability: {@code model.use} lease patterns restricting model access. */
MODEL_USE("model.use"),
+
+ /** §9.8 short-lived upstream credentials delivered on {@code job.accepted}. */
PROVISIONED_CREDENTIALS("provisioned_credentials"),
+
+ /** §8.2.1 advisory {@code progress} job events. */
PROGRESS("progress"),
+
+ /** §8.4 result streaming via {@code result_chunk} events and {@code result_id}. */
RESULT_CHUNK("result_chunk"),
+
+ /** §7.5 agent versioning: {@code name@version} submission and version inventory. */
AGENT_VERSIONS("agent_versions");
private final String wire;
@@ -31,6 +55,11 @@ public enum Feature {
this.wire = wire;
}
+ /**
+ * Returns the canonical wire string carried in the capabilities {@code features} array.
+ *
+ * @return the wire string
+ */
@JsonValue
public String wire() {
return wire;
@@ -46,6 +75,13 @@ public String wire() {
BY_WIRE = Collections.unmodifiableMap(m);
}
+ /**
+ * Resolves a wire string to a feature. Unknown strings yield {@link Optional#empty()} so
+ * unrecognized features are dropped rather than failing capability decoding.
+ *
+ * @param wire the wire feature string
+ * @return the matching feature, or empty when unrecognized
+ */
@JsonCreator
public static Optional fromWire(String wire) {
return Optional.ofNullable(BY_WIRE.get(wire));
diff --git a/arcp-core/src/main/java/dev/arcp/core/credentials/Credential.java b/arcp-core/src/main/java/dev/arcp/core/credentials/Credential.java
index 5c135f1..4d6aa22 100644
--- a/arcp-core/src/main/java/dev/arcp/core/credentials/Credential.java
+++ b/arcp-core/src/main/java/dev/arcp/core/credentials/Credential.java
@@ -6,6 +6,21 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * Provisioned credential delivered in {@code job.accepted.payload.credentials} per §9.8.1. The
+ * upstream service at {@code endpoint} is the enforcement boundary for the constraints baked into
+ * the credential (§9.8.3).
+ *
+ * @param id stable identifier within the job, used for audit, rotation, and revocation
+ * @param scheme the authentication scheme; {@code bearer} is the only scheme the spec defines
+ * @param value the credential material; treat as a secret and never log
+ * @param endpoint the base URL at which the credential is valid; agents MUST NOT present it to
+ * other URLs
+ * @param profile vendor-neutral hint for the API protocol spoken at {@code endpoint} (e.g. {@code
+ * openai}), or {@code null}
+ * @param constraints read-only echo of the lease restrictions the upstream enforces, or {@code
+ * null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Credential(
CredentialId id,
@@ -14,6 +29,7 @@ public record Credential(
String endpoint,
@Nullable String profile,
@Nullable CredentialConstraints constraints) {
+ /** Canonical constructor requiring the §9.8.1 mandatory fields. */
@JsonCreator
public Credential(
@JsonProperty("id") CredentialId id,
diff --git a/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialConstraints.java b/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialConstraints.java
index c40c5f8..5373045 100644
--- a/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialConstraints.java
+++ b/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialConstraints.java
@@ -6,11 +6,23 @@
import java.util.List;
import org.jspecify.annotations.Nullable;
+/**
+ * Read-only echo of the lease restrictions baked into a §9.8.1 provisioned credential.
+ * Informational only: the upstream is the enforcer, and absence of a constraint here does not imply
+ * the lease lacks it (§9.8.3).
+ *
+ * @param costBudget {@code cost.budget} entries (e.g. {@code USD:5.00}) mapped to the upstream
+ * spend cap, or {@code null}
+ * @param modelUse {@code model.use} patterns mapped to the upstream allowed-model list, or {@code
+ * null}
+ * @param expiresAt credential TTL mirroring {@code lease_constraints.expires_at}, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record CredentialConstraints(
@JsonProperty("cost.budget") @Nullable List costBudget,
@JsonProperty("model.use") @Nullable List modelUse,
@JsonProperty("expires_at") @Nullable Instant expiresAt) {
+ /** Canonical constructor; list components are defensively copied. */
public CredentialConstraints {
costBudget = costBudget == null ? null : List.copyOf(costBudget);
modelUse = modelUse == null ? null : List.copyOf(modelUse);
diff --git a/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialId.java b/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialId.java
index dd88f50..d93e801 100644
--- a/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialId.java
+++ b/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialId.java
@@ -4,15 +4,35 @@
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Objects;
+/**
+ * Identifier of a §9.8 provisioned credential (wire example {@code cred_01J...}), serialized as a
+ * bare JSON string. Stable within the job; correlates {@code credential_rotated} status events and
+ * revocation with the credential they affect (§9.8.2).
+ *
+ * @param value the identifier string
+ */
public record CredentialId(@JsonValue String value) {
+ /** Canonical constructor requiring a non-null value. */
public CredentialId {
Objects.requireNonNull(value, "value");
}
+ /**
+ * Creates a credential id from its string form.
+ *
+ * @param v the identifier string
+ * @return the credential id
+ */
public static CredentialId of(String v) {
return new CredentialId(v);
}
+ /**
+ * Jackson factory deserializing a credential id from its JSON string form.
+ *
+ * @param v the identifier string
+ * @return the credential id
+ */
@JsonCreator
public static CredentialId fromJson(String v) {
return new CredentialId(v);
diff --git a/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialScheme.java b/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialScheme.java
index 8a6ff0b..c060094 100644
--- a/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialScheme.java
+++ b/arcp-core/src/main/java/dev/arcp/core/credentials/CredentialScheme.java
@@ -4,7 +4,15 @@
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
+/**
+ * Authentication scheme of a §9.8.1 provisioned credential. {@code bearer} is the only scheme
+ * defined by the ARCP v1.1 spec.
+ */
public enum CredentialScheme {
+ /**
+ * Bearer-token credential ({@code bearer}): {@code value} is presented as a bearer secret at the
+ * credential's {@code endpoint}.
+ */
BEARER("bearer"),
/**
@@ -22,15 +30,32 @@ public enum CredentialScheme {
this.wire = wire;
}
+ /**
+ * Returns the canonical wire string for this scheme.
+ *
+ * @return the wire string
+ */
@JsonValue
public String wire() {
return wire;
}
+ /**
+ * Returns whether this is the {@code bearer} scheme.
+ *
+ * @return {@code true} for {@link #BEARER}
+ */
public boolean isBearer() {
return this == BEARER;
}
+ /**
+ * Resolves a wire string to a scheme, yielding {@link #UNKNOWN} for unrecognized extension
+ * schemes per §9.8.1 rather than failing decode.
+ *
+ * @param wire the wire scheme string
+ * @return the matching scheme, or {@link #UNKNOWN}
+ */
@JsonCreator
public static CredentialScheme fromWire(String wire) {
return Arrays.stream(values())
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/AgentNotAvailableException.java b/arcp-core/src/main/java/dev/arcp/core/error/AgentNotAvailableException.java
index 2909fe3..8cb6338 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/AgentNotAvailableException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/AgentNotAvailableException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code AGENT_NOT_AVAILABLE}: the requested {@code agent} is not registered. */
public final class AgentNotAvailableException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public AgentNotAvailableException(String message) {
super(ErrorCode.AGENT_NOT_AVAILABLE, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/AgentVersionNotAvailableException.java b/arcp-core/src/main/java/dev/arcp/core/error/AgentVersionNotAvailableException.java
index 15648d3..0ed6b83 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/AgentVersionNotAvailableException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/AgentVersionNotAvailableException.java
@@ -1,6 +1,15 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code AGENT_VERSION_NOT_AVAILABLE}: the agent name resolved but the requested version is
+ * unavailable (§7.5).
+ */
public final class AgentVersionNotAvailableException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public AgentVersionNotAvailableException(String message) {
super(ErrorCode.AGENT_VERSION_NOT_AVAILABLE, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/ArcpException.java b/arcp-core/src/main/java/dev/arcp/core/error/ArcpException.java
index 7e71137..fd64541 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/ArcpException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/ArcpException.java
@@ -3,26 +3,61 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * Base of the sealed exception hierarchy mirroring the §12 error taxonomy. Every instance carries
+ * an {@link ErrorCode}; the {@link RetryableArcpException} / {@link NonRetryableArcpException}
+ * branches fix the {@link #retryable()} flag.
+ */
public abstract sealed class ArcpException extends Exception
permits RetryableArcpException, NonRetryableArcpException {
+ /** The §12 error code carried by this exception. */
private final ErrorCode code;
+ /**
+ * Creates an exception without a cause.
+ *
+ * @param code the §12 error code
+ * @param message human-readable detail
+ */
protected ArcpException(ErrorCode code, String message) {
this(code, message, null);
}
+ /**
+ * Creates an exception with an optional cause.
+ *
+ * @param code the §12 error code
+ * @param message human-readable detail
+ * @param cause the underlying cause, or {@code null}
+ */
protected ArcpException(ErrorCode code, String message, @Nullable Throwable cause) {
super(message, cause);
this.code = Objects.requireNonNull(code, "code");
}
+ /**
+ * Returns the §12 error code.
+ *
+ * @return the error code
+ */
public ErrorCode code() {
return code;
}
+ /**
+ * Returns whether retrying the failed operation may succeed, per the §12 taxonomy.
+ *
+ * @return {@code true} if a retry may succeed
+ */
public abstract boolean retryable();
+ /**
+ * Maps a wire error payload to its typed exception.
+ *
+ * @param p the decoded error payload
+ * @return the exception subtype matching {@code p.code()}
+ */
public static ArcpException from(ErrorPayload p) {
return switch (p.code()) {
case PERMISSION_DENIED -> new PermissionDeniedException(p.message());
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/BudgetExhaustedException.java b/arcp-core/src/main/java/dev/arcp/core/error/BudgetExhaustedException.java
index b5aba6d..c0fbccf 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/BudgetExhaustedException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/BudgetExhaustedException.java
@@ -1,6 +1,15 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code BUDGET_EXHAUSTED}: a {@code cost.budget} counter reached zero (§9.6). Never retryable
+ * — a naive retry fails identically.
+ */
public class BudgetExhaustedException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public BudgetExhaustedException(String message) {
super(ErrorCode.BUDGET_EXHAUSTED, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/CancelledException.java b/arcp-core/src/main/java/dev/arcp/core/error/CancelledException.java
index c99d486..69531d9 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/CancelledException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/CancelledException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code CANCELLED}: the job ended due to client cancellation (§7.4). */
public final class CancelledException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public CancelledException(String message) {
super(ErrorCode.CANCELLED, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/DuplicateKeyException.java b/arcp-core/src/main/java/dev/arcp/core/error/DuplicateKeyException.java
index 2d612b3..c7682aa 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/DuplicateKeyException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/DuplicateKeyException.java
@@ -1,6 +1,15 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code DUPLICATE_KEY}: an {@code idempotency_key} was reused with conflicting parameters
+ * (§7.2).
+ */
public final class DuplicateKeyException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public DuplicateKeyException(String message) {
super(ErrorCode.DUPLICATE_KEY, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/ErrorCode.java b/arcp-core/src/main/java/dev/arcp/core/error/ErrorCode.java
index 0df1bc4..022b676 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/ErrorCode.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/ErrorCode.java
@@ -4,21 +4,51 @@
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
+/** Canonical §12 error codes, each carrying its default retryability. */
public enum ErrorCode {
+ /** Operation rejected by lease enforcement (§9.3). */
PERMISSION_DENIED(false),
+
+ /** Delegation request expanded beyond the parent lease (§9.4). */
LEASE_SUBSET_VIOLATION(false),
+
+ /** Referenced {@code job_id} does not exist or is not visible to the principal. */
JOB_NOT_FOUND(false),
+
+ /** {@code idempotency_key} reused with conflicting parameters (§7.2). */
DUPLICATE_KEY(false),
+
+ /** Requested {@code agent} is not registered. */
AGENT_NOT_AVAILABLE(false),
+
+ /** Agent name resolved but the requested version is unavailable (§7.5). */
AGENT_VERSION_NOT_AVAILABLE(false),
+
+ /** Job ended due to client cancellation (§7.4). */
CANCELLED(false),
+
+ /** Job exceeded {@code max_runtime_sec}; retryable. */
TIMEOUT(true),
+
+ /** Resume attempted after the buffer window closed (§6.3). */
RESUME_WINDOW_EXPIRED(false),
+
+ /** Peer detected counterparty disconnection (§6.4); retryable via resume. */
HEARTBEAT_LOST(true),
+
+ /** Lease's {@code expires_at} reached during execution (§9.5); never retryable. */
LEASE_EXPIRED(false),
+
+ /** A {@code cost.budget} counter reached zero (§9.6); never retryable. */
BUDGET_EXHAUSTED(false),
+
+ /** Malformed envelope or schema violation. */
INVALID_REQUEST(false),
+
+ /** Missing or invalid authentication (§6.1). */
UNAUTHENTICATED(false),
+
+ /** Unrecoverable runtime fault; always retryable. */
INTERNAL_ERROR(true);
private final boolean retryable;
@@ -27,15 +57,32 @@ public enum ErrorCode {
this.retryable = retryable;
}
+ /**
+ * Returns the default retryability of this code per §12.
+ *
+ * @return {@code true} if a retry may succeed
+ */
public boolean retryable() {
return retryable;
}
+ /**
+ * Returns the canonical wire string, which is the enum constant name itself.
+ *
+ * @return the wire string
+ */
@JsonValue
public String wire() {
return name();
}
+ /**
+ * Resolves a wire string to a code, defaulting to {@link #INTERNAL_ERROR} for unknown codes so
+ * decoding an error payload never fails.
+ *
+ * @param wire the wire code string
+ * @return the matching code, or {@link #INTERNAL_ERROR}
+ */
@JsonCreator
public static ErrorCode fromWire(String wire) {
return Arrays.stream(values())
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/ErrorPayload.java b/arcp-core/src/main/java/dev/arcp/core/error/ErrorPayload.java
index b1158a0..b22ec20 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/ErrorPayload.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/ErrorPayload.java
@@ -7,10 +7,21 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * Wire shape of a §12 error: {@code { code, message, retryable, details? }}, carried by error
+ * responses such as the {@code job.error} payload.
+ *
+ * @param code the canonical §12 error code
+ * @param message human-readable detail
+ * @param retryable whether retrying may succeed; defaults to {@link ErrorCode#retryable()} when
+ * absent on the wire
+ * @param details implementation-defined detail object, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorPayload(
ErrorCode code, String message, boolean retryable, @Nullable JsonNode details) {
+ /** Canonical constructor requiring code and message. */
public ErrorPayload {
Objects.requireNonNull(code, "code");
Objects.requireNonNull(message, "message");
@@ -26,6 +37,13 @@ static ErrorPayload from(
code, message, retryable != null ? retryable : code.retryable(), details);
}
+ /**
+ * Creates a payload with the code's default §12 retryability and no details.
+ *
+ * @param code the error code
+ * @param message human-readable detail
+ * @return the payload
+ */
public static ErrorPayload of(ErrorCode code, String message) {
return new ErrorPayload(code, message, code.retryable(), null);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/HeartbeatLostException.java b/arcp-core/src/main/java/dev/arcp/core/error/HeartbeatLostException.java
index 416d52e..050221e 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/HeartbeatLostException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/HeartbeatLostException.java
@@ -1,6 +1,16 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code HEARTBEAT_LOST}: no traffic from the peer for two consecutive heartbeat intervals, so
+ * the connection is presumed dead (§6.4). Retryable — the session remains resumable within the
+ * resume window.
+ */
public final class HeartbeatLostException extends RetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public HeartbeatLostException(String message) {
super(ErrorCode.HEARTBEAT_LOST, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/InternalErrorException.java b/arcp-core/src/main/java/dev/arcp/core/error/InternalErrorException.java
index 5030d86..28dd7a4 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/InternalErrorException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/InternalErrorException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code INTERNAL_ERROR}: unrecoverable runtime fault. Always retryable. */
public final class InternalErrorException extends RetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public InternalErrorException(String message) {
super(ErrorCode.INTERNAL_ERROR, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/InvalidRequestException.java b/arcp-core/src/main/java/dev/arcp/core/error/InvalidRequestException.java
index 1aabe8b..9ff1ba4 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/InvalidRequestException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/InvalidRequestException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code INVALID_REQUEST}: malformed envelope or schema violation. */
public final class InvalidRequestException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public InvalidRequestException(String message) {
super(ErrorCode.INVALID_REQUEST, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/JobNotFoundException.java b/arcp-core/src/main/java/dev/arcp/core/error/JobNotFoundException.java
index 9e4cd32..85f28d3 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/JobNotFoundException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/JobNotFoundException.java
@@ -1,6 +1,15 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code JOB_NOT_FOUND}: the referenced {@code job_id} does not exist or is not visible to the
+ * session's principal.
+ */
public final class JobNotFoundException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public JobNotFoundException(String message) {
super(ErrorCode.JOB_NOT_FOUND, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/LeaseExpiredException.java b/arcp-core/src/main/java/dev/arcp/core/error/LeaseExpiredException.java
index 0477c99..f9641e4 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/LeaseExpiredException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/LeaseExpiredException.java
@@ -1,6 +1,15 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code LEASE_EXPIRED}: the lease's {@code expires_at} was reached during execution (§9.5).
+ * Never retryable — a naive retry fails identically.
+ */
public final class LeaseExpiredException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public LeaseExpiredException(String message) {
super(ErrorCode.LEASE_EXPIRED, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/LeaseSubsetViolationException.java b/arcp-core/src/main/java/dev/arcp/core/error/LeaseSubsetViolationException.java
index 31a6dd9..81db107 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/LeaseSubsetViolationException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/LeaseSubsetViolationException.java
@@ -1,6 +1,14 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code LEASE_SUBSET_VIOLATION}: a delegation request expanded beyond the parent lease (§9.4).
+ */
public final class LeaseSubsetViolationException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public LeaseSubsetViolationException(String message) {
super(ErrorCode.LEASE_SUBSET_VIOLATION, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/NonRetryableArcpException.java b/arcp-core/src/main/java/dev/arcp/core/error/NonRetryableArcpException.java
index 78b6d05..82a0235 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/NonRetryableArcpException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/NonRetryableArcpException.java
@@ -1,6 +1,16 @@
package dev.arcp.core.error;
+/**
+ * Branch of the sealed hierarchy for §12 errors where {@link #retryable()} is always {@code false}:
+ * retrying the same operation fails identically.
+ */
public abstract non-sealed class NonRetryableArcpException extends ArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param code the §12 error code
+ * @param message human-readable detail
+ */
protected NonRetryableArcpException(ErrorCode code, String message) {
super(code, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/PermissionDeniedException.java b/arcp-core/src/main/java/dev/arcp/core/error/PermissionDeniedException.java
index 056c044..13bb703 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/PermissionDeniedException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/PermissionDeniedException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code PERMISSION_DENIED}: the operation was rejected by lease enforcement (§9.3). */
public final class PermissionDeniedException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public PermissionDeniedException(String message) {
super(ErrorCode.PERMISSION_DENIED, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/ResumeWindowExpiredException.java b/arcp-core/src/main/java/dev/arcp/core/error/ResumeWindowExpiredException.java
index 7b4fc18..1d35e3b 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/ResumeWindowExpiredException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/ResumeWindowExpiredException.java
@@ -1,6 +1,15 @@
package dev.arcp.core.error;
+/**
+ * §12 {@code RESUME_WINDOW_EXPIRED}: resume was attempted after the event buffer window closed
+ * (§6.3).
+ */
public final class ResumeWindowExpiredException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public ResumeWindowExpiredException(String message) {
super(ErrorCode.RESUME_WINDOW_EXPIRED, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/RetryableArcpException.java b/arcp-core/src/main/java/dev/arcp/core/error/RetryableArcpException.java
index 189b4fb..f530b20 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/RetryableArcpException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/RetryableArcpException.java
@@ -1,6 +1,16 @@
package dev.arcp.core.error;
+/**
+ * Branch of the sealed hierarchy for §12 errors where {@link #retryable()} is always {@code true}:
+ * retrying the operation may succeed.
+ */
public abstract non-sealed class RetryableArcpException extends ArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param code the §12 error code
+ * @param message human-readable detail
+ */
protected RetryableArcpException(ErrorCode code, String message) {
super(code, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/TimeoutException.java b/arcp-core/src/main/java/dev/arcp/core/error/TimeoutException.java
index a085b88..802ee86 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/TimeoutException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/TimeoutException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code TIMEOUT}: the job exceeded {@code max_runtime_sec}. Retryable. */
public final class TimeoutException extends RetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public TimeoutException(String message) {
super(ErrorCode.TIMEOUT, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/UnauthenticatedException.java b/arcp-core/src/main/java/dev/arcp/core/error/UnauthenticatedException.java
index f44e4fd..b0179ea 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/UnauthenticatedException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/UnauthenticatedException.java
@@ -1,6 +1,12 @@
package dev.arcp.core.error;
+/** §12 {@code UNAUTHENTICATED}: missing or invalid authentication (§6.1). */
public final class UnauthenticatedException extends NonRetryableArcpException {
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ */
public UnauthenticatedException(String message) {
super(ErrorCode.UNAUTHENTICATED, message);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/error/UpstreamBudgetExhaustedException.java b/arcp-core/src/main/java/dev/arcp/core/error/UpstreamBudgetExhaustedException.java
index 75ef891..6615816 100644
--- a/arcp-core/src/main/java/dev/arcp/core/error/UpstreamBudgetExhaustedException.java
+++ b/arcp-core/src/main/java/dev/arcp/core/error/UpstreamBudgetExhaustedException.java
@@ -2,14 +2,31 @@
import org.jspecify.annotations.Nullable;
+/**
+ * Budget exhaustion reported by an upstream enforcement boundary, such as a §9.8 provisioned
+ * credential's gateway, rather than by the runtime's own §9.6 accounting. Carries the upstream
+ * response body for diagnostics.
+ */
public final class UpstreamBudgetExhaustedException extends BudgetExhaustedException {
+ /** Raw response body returned by the upstream, or {@code null} if unavailable. */
private final @Nullable String upstreamResponseBody;
+ /**
+ * Creates the exception.
+ *
+ * @param message human-readable detail
+ * @param upstreamResponseBody raw upstream response body, or {@code null} if unavailable
+ */
public UpstreamBudgetExhaustedException(String message, @Nullable String upstreamResponseBody) {
super(message);
this.upstreamResponseBody = upstreamResponseBody;
}
+ /**
+ * Returns the raw response body returned by the upstream.
+ *
+ * @return the upstream response body, or {@code null} if unavailable
+ */
public @Nullable String upstreamResponseBody() {
return upstreamResponseBody;
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/ArtifactRefEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/ArtifactRefEvent.java
index ac52160..bd8a335 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/ArtifactRefEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/ArtifactRefEvent.java
@@ -5,6 +5,15 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jspecify.annotations.Nullable;
+/**
+ * §8.2 {@code artifact_ref} event body: a reference to an artifact produced by the job, to be
+ * fetched out of band.
+ *
+ * @param uri location of the artifact
+ * @param contentType MIME type of the artifact ({@code content_type})
+ * @param byteSize artifact size in bytes ({@code byte_size}), or {@code null} if unknown
+ * @param sha256 hex SHA-256 digest of the artifact bytes, or {@code null} if not computed
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ArtifactRefEvent(
String uri,
@@ -13,6 +22,7 @@ public record ArtifactRefEvent(
@Nullable String sha256)
implements EventBody {
+ /** Canonical constructor. */
@JsonCreator
public ArtifactRefEvent(
@JsonProperty("uri") String uri,
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/CredentialRotatedBody.java b/arcp-core/src/main/java/dev/arcp/core/events/CredentialRotatedBody.java
index 7ed713f..14a9790 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/CredentialRotatedBody.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/CredentialRotatedBody.java
@@ -4,7 +4,15 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.arcp.core.credentials.CredentialId;
+/**
+ * Body of the §9.8.2 {@code status} event with {@code phase: "credential_rotated"}: a provisioned
+ * credential was re-issued mid-job. The prior {@code value} is revoked promptly.
+ *
+ * @param id id of the rotated credential
+ * @param value the replacement credential material; treat as a secret
+ */
public record CredentialRotatedBody(CredentialId id, String value) {
+ /** Canonical constructor. */
@JsonCreator
public CredentialRotatedBody(
@JsonProperty("id") CredentialId id, @JsonProperty("value") String value) {
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/DelegateEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/DelegateEvent.java
index 76d95b7..65f26cf 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/DelegateEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/DelegateEvent.java
@@ -4,9 +4,17 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.arcp.core.ids.JobId;
+/**
+ * §10 {@code delegate} event body: the agent delegated a subset of its lease to a sub-agent running
+ * as a child job, bounded by §9.4 subsetting rules.
+ *
+ * @param childJobId id of the spawned child job ({@code child_job_id})
+ * @param agent agent reference executing the child job
+ */
public record DelegateEvent(@JsonProperty("child_job_id") JobId childJobId, String agent)
implements EventBody {
+ /** Canonical constructor. */
@JsonCreator
public DelegateEvent(
@JsonProperty("child_job_id") JobId childJobId, @JsonProperty("agent") String agent) {
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/EventBody.java b/arcp-core/src/main/java/dev/arcp/core/events/EventBody.java
index 62ea5e2..2d2d8fb 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/EventBody.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/EventBody.java
@@ -17,18 +17,49 @@ public sealed interface EventBody
ProgressEvent,
ResultChunkEvent {
+ /**
+ * Returns the {@link Kind} discriminator matching this body's wire {@code kind} string.
+ *
+ * @return the event kind
+ */
Kind kind();
+ /** Event {@code kind} discriminator per §8.2. */
enum Kind {
+ /** {@code log}: diagnostic output, body {@code { level, message }}. */
LOG("log"),
+
+ /** {@code thought}: agent reasoning trace, body {@code { text }}. */
THOUGHT("thought"),
+
+ /** {@code tool_call}: tool invocation, body {@code { tool, args, call_id }}. */
TOOL_CALL("tool_call"),
+
+ /** {@code tool_result}: tool outcome, body {@code { call_id, result | error }}. */
TOOL_RESULT("tool_result"),
+
+ /** {@code status}: job phase transition, body {@code { phase, message? }}. */
STATUS("status"),
+
+ /** {@code metric}: numeric measurement, body {@code { name, value, unit?, dimensions? }}. */
METRIC("metric"),
+
+ /**
+ * {@code artifact_ref}: out-of-band artifact pointer, body {@code { uri, content_type,
+ * byte_size?, sha256? }}.
+ */
ARTIFACT_REF("artifact_ref"),
+
+ /** {@code delegate}: §10 sub-agent delegation. */
DELEGATE("delegate"),
+
+ /**
+ * {@code progress}: §8.2.1 advisory progress, body {@code { current, total?, units?, message?
+ * }}.
+ */
PROGRESS("progress"),
+
+ /** {@code result_chunk}: §8.4 streamed result fragment. */
RESULT_CHUNK("result_chunk");
private final String wire;
@@ -37,6 +68,11 @@ enum Kind {
this.wire = wire;
}
+ /**
+ * Returns the canonical wire {@code kind} string.
+ *
+ * @return the wire string
+ */
public String wire() {
return wire;
}
@@ -51,6 +87,13 @@ public String wire() {
BY_WIRE = java.util.Collections.unmodifiableMap(m);
}
+ /**
+ * Resolves a wire {@code kind} string to its enum constant.
+ *
+ * @param wire the wire kind string
+ * @return the matching kind
+ * @throws IllegalArgumentException if the kind is unknown
+ */
public static Kind fromWire(String wire) {
Kind k = BY_WIRE.get(wire);
if (k == null) {
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/Events.java b/arcp-core/src/main/java/dev/arcp/core/events/Events.java
index a9074d7..78471e0 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/Events.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/Events.java
@@ -8,7 +8,13 @@ public final class Events {
private Events() {}
- /** Serialize an event body into a flat JSON object with the {@code kind} field set. */
+ /**
+ * Serializes an event body into a flat JSON object.
+ *
+ * @param mapper the mapper to use
+ * @param body the event body to serialize
+ * @return the body as a JSON object, ready to compose into a {@code job.event} payload
+ */
public static com.fasterxml.jackson.databind.node.ObjectNode encode(
ObjectMapper mapper, EventBody body) {
com.fasterxml.jackson.databind.node.ObjectNode bodyNode =
@@ -17,7 +23,15 @@ public static com.fasterxml.jackson.databind.node.ObjectNode encode(
return bodyNode;
}
- /** Decode a body from {@code kind} discriminator and the body JSON. */
+ /**
+ * Decodes a typed body from the wire {@code kind} discriminator and the body JSON.
+ *
+ * @param mapper the mapper to use
+ * @param kindWire the wire {@code kind} string (§8.2)
+ * @param body the kind-specific body JSON
+ * @return the typed event body
+ * @throws IllegalArgumentException if the kind is unknown
+ */
public static EventBody decode(ObjectMapper mapper, String kindWire, JsonNode body) {
return switch (EventBody.Kind.fromWire(kindWire)) {
case LOG -> mapper.convertValue(body, LogEvent.class);
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/LogEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/LogEvent.java
index 438f5a1..7f5a90f 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/LogEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/LogEvent.java
@@ -3,7 +3,14 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+/**
+ * §8.2 {@code log} event body: a line of diagnostic output from the job.
+ *
+ * @param level log severity label (e.g. {@code info}, {@code warn})
+ * @param message the log message
+ */
public record LogEvent(String level, String message) implements EventBody {
+ /** Canonical constructor. */
@JsonCreator
public LogEvent(@JsonProperty("level") String level, @JsonProperty("message") String message) {
this.level = level;
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/MetricEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/MetricEvent.java
index 7e731ef..2e0966f 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/MetricEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/MetricEvent.java
@@ -7,11 +7,20 @@
import java.util.Map;
import org.jspecify.annotations.Nullable;
+/**
+ * §8.2 {@code metric} event body: a numeric measurement emitted by the job.
+ *
+ * @param name metric name
+ * @param value measured value
+ * @param unit unit label, or {@code null}
+ * @param dimensions key/value dimensions qualifying the measurement, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record MetricEvent(
String name, BigDecimal value, @Nullable String unit, @Nullable Map dimensions)
implements EventBody {
+ /** Canonical constructor; {@code dimensions} is defensively copied. */
@JsonCreator
public MetricEvent(
@JsonProperty("name") String name,
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/ProgressEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/ProgressEvent.java
index 398e58e..9c56a5a 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/ProgressEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/ProgressEvent.java
@@ -7,13 +7,23 @@
/**
* §8.2.1 progress event. {@code current} MUST be ≥ 0. If {@code total} is present, {@code current}
- * SHOULD be ≤ {@code total}.
+ * SHOULD be ≤ {@code total}. Advisory only: the protocol does not act on progress events.
+ *
+ * @param current units of work completed so far; never negative
+ * @param total expected total in the same units, or {@code null} when indeterminate
+ * @param units label for the unit of work (e.g. {@code files}), or {@code null}
+ * @param message human-readable progress note, or {@code null}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ProgressEvent(
long current, @Nullable Long total, @Nullable String units, @Nullable String message)
implements EventBody {
+ /**
+ * Canonical constructor enforcing §8.2.1 bounds.
+ *
+ * @throws IllegalArgumentException if {@code current} or {@code total} is negative
+ */
@JsonCreator
public ProgressEvent(
@JsonProperty("current") long current,
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/ResultChunkEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/ResultChunkEvent.java
index 178044b..b26ba30 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/ResultChunkEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/ResultChunkEvent.java
@@ -7,6 +7,12 @@
/**
* §8.4 result_chunk. {@code encoding} is one of {@code utf8} or {@code base64}. Chunks for one
* {@code result_id} MUST be emitted in {@code chunk_seq} order.
+ *
+ * @param resultId stable id of the assembled result ({@code result_id})
+ * @param chunkSeq 0-based monotonic chunk index per result ({@code chunk_seq})
+ * @param data chunk payload, text or Base64 bytes per {@code encoding}
+ * @param encoding {@link #UTF8} or {@link #BASE64}
+ * @param more {@code true} if additional chunks follow, {@code false} on the final chunk
*/
public record ResultChunkEvent(
@JsonProperty("result_id") ResultId resultId,
@@ -16,9 +22,18 @@ public record ResultChunkEvent(
boolean more)
implements EventBody {
+ /** Wire {@code encoding} for text chunks: {@code data} is the raw text fragment. */
public static final String UTF8 = "utf8";
+
+ /** Wire {@code encoding} for binary chunks: {@code data} is Base64-encoded bytes. */
public static final String BASE64 = "base64";
+ /**
+ * Canonical constructor enforcing the §8.4 field constraints.
+ *
+ * @throws IllegalArgumentException if {@code chunkSeq} is negative or {@code encoding} is neither
+ * {@code utf8} nor {@code base64}
+ */
@JsonCreator
public ResultChunkEvent(
@JsonProperty("result_id") ResultId resultId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/StatusEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/StatusEvent.java
index ceb692c..a787dc9 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/StatusEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/StatusEvent.java
@@ -6,13 +6,29 @@
import com.fasterxml.jackson.databind.JsonNode;
import org.jspecify.annotations.Nullable;
+/**
+ * §8.2 {@code status} event body: a job phase transition. Phases include implementation-defined
+ * signals such as {@code back_pressure} (§6.5) and {@code credential_rotated} (§9.8.2).
+ *
+ * @param phase the phase label
+ * @param message human-readable detail, or {@code null}
+ * @param details structured phase-specific payload (e.g. a §9.8.2 rotated credential body), or
+ * {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record StatusEvent(String phase, @Nullable String message, @Nullable JsonNode details)
implements EventBody {
+ /**
+ * Creates a status event without details.
+ *
+ * @param phase the phase label
+ * @param message human-readable detail, or {@code null}
+ */
public StatusEvent(String phase, @Nullable String message) {
this(phase, message, null);
}
+ /** Canonical constructor. */
@JsonCreator
public StatusEvent(
@JsonProperty("phase") String phase,
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/ThoughtEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/ThoughtEvent.java
index 71b0b07..f64a3c5 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/ThoughtEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/ThoughtEvent.java
@@ -3,7 +3,13 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+/**
+ * §8.2 {@code thought} event body: a fragment of the agent's reasoning trace.
+ *
+ * @param text the reasoning text
+ */
public record ThoughtEvent(String text) implements EventBody {
+ /** Canonical constructor. */
@JsonCreator
public ThoughtEvent(@JsonProperty("text") String text) {
this.text = text;
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/ToolCallEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/ToolCallEvent.java
index 1f196d0..09463fa 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/ToolCallEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/ToolCallEvent.java
@@ -4,8 +4,16 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
+/**
+ * §8.2 {@code tool_call} event body: the agent invoked a tool.
+ *
+ * @param tool name of the invoked tool
+ * @param args tool arguments as JSON
+ * @param callId correlation id echoed by the matching {@code tool_result} ({@code call_id})
+ */
public record ToolCallEvent(String tool, JsonNode args, @JsonProperty("call_id") String callId)
implements EventBody {
+ /** Canonical constructor. */
@JsonCreator
public ToolCallEvent(
@JsonProperty("tool") String tool,
diff --git a/arcp-core/src/main/java/dev/arcp/core/events/ToolResultEvent.java b/arcp-core/src/main/java/dev/arcp/core/events/ToolResultEvent.java
index 86bce44..f261ac2 100644
--- a/arcp-core/src/main/java/dev/arcp/core/events/ToolResultEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/events/ToolResultEvent.java
@@ -7,11 +7,20 @@
import dev.arcp.core.error.ErrorPayload;
import org.jspecify.annotations.Nullable;
+/**
+ * §8.2 {@code tool_result} event body: outcome of an earlier {@code tool_call}. Exactly one of
+ * {@code result} or {@code error} is expected.
+ *
+ * @param callId correlation id of the originating call ({@code call_id})
+ * @param result successful result as JSON, or {@code null} on failure
+ * @param error failure payload, or {@code null} on success
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ToolResultEvent(
@JsonProperty("call_id") String callId, @Nullable JsonNode result, @Nullable ErrorPayload error)
implements EventBody {
+ /** Canonical constructor. */
@JsonCreator
public ToolResultEvent(
@JsonProperty("call_id") String callId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/ids/JobId.java b/arcp-core/src/main/java/dev/arcp/core/ids/JobId.java
index e8844e9..746e6c4 100644
--- a/arcp-core/src/main/java/dev/arcp/core/ids/JobId.java
+++ b/arcp-core/src/main/java/dev/arcp/core/ids/JobId.java
@@ -5,20 +5,43 @@
import com.github.f4b6a3.ulid.UlidCreator;
import java.util.Objects;
+/**
+ * Identifier of a job, the envelope {@code job_id} field (wire example {@code job_01JABC...}).
+ * Serialized as a bare JSON string.
+ *
+ * @param value the identifier string
+ */
public record JobId(String value) {
+ /** Canonical constructor requiring a non-null value. */
public JobId {
Objects.requireNonNull(value, "value");
}
+ /**
+ * Generates a new {@code job_}-prefixed monotonic ULID identifier.
+ *
+ * @return a fresh job id
+ */
public static JobId generate() {
return new JobId("job_" + UlidCreator.getMonotonicUlid());
}
+ /**
+ * Wraps an existing identifier string.
+ *
+ * @param value the identifier string
+ * @return the job id
+ */
@JsonCreator
public static JobId of(String value) {
return new JobId(value);
}
+ /**
+ * Returns the raw identifier string serialized on the wire.
+ *
+ * @return the identifier string
+ */
@JsonValue
public String asString() {
return value;
diff --git a/arcp-core/src/main/java/dev/arcp/core/ids/MessageId.java b/arcp-core/src/main/java/dev/arcp/core/ids/MessageId.java
index d521967..b4c0f36 100644
--- a/arcp-core/src/main/java/dev/arcp/core/ids/MessageId.java
+++ b/arcp-core/src/main/java/dev/arcp/core/ids/MessageId.java
@@ -5,20 +5,43 @@
import com.github.f4b6a3.ulid.UlidCreator;
import java.util.Objects;
+/**
+ * Unique identifier of one wire envelope, the §5 {@code id} field. Serialized as a bare JSON
+ * string.
+ *
+ * @param value the identifier string
+ */
public record MessageId(String value) {
+ /** Canonical constructor requiring a non-null value. */
public MessageId {
Objects.requireNonNull(value, "value");
}
+ /**
+ * Generates a new monotonic ULID identifier.
+ *
+ * @return a fresh message id
+ */
public static MessageId generate() {
return new MessageId(UlidCreator.getMonotonicUlid().toString());
}
+ /**
+ * Wraps an existing identifier string.
+ *
+ * @param value the identifier string
+ * @return the message id
+ */
@JsonCreator
public static MessageId of(String value) {
return new MessageId(value);
}
+ /**
+ * Returns the raw identifier string serialized on the wire.
+ *
+ * @return the identifier string
+ */
@JsonValue
public String asString() {
return value;
diff --git a/arcp-core/src/main/java/dev/arcp/core/ids/ResultId.java b/arcp-core/src/main/java/dev/arcp/core/ids/ResultId.java
index d398ae6..c0926dc 100644
--- a/arcp-core/src/main/java/dev/arcp/core/ids/ResultId.java
+++ b/arcp-core/src/main/java/dev/arcp/core/ids/ResultId.java
@@ -5,20 +5,44 @@
import com.github.f4b6a3.ulid.UlidCreator;
import java.util.Objects;
+/**
+ * Identifier of a §8.4 streamed result, the {@code result_id} field (wire example {@code
+ * res_01J...}). Correlates {@code result_chunk} events with the terminating {@code job.result}.
+ * Serialized as a bare JSON string.
+ *
+ * @param value the identifier string
+ */
public record ResultId(String value) {
+ /** Canonical constructor requiring a non-null value. */
public ResultId {
Objects.requireNonNull(value, "value");
}
+ /**
+ * Generates a new {@code res_}-prefixed monotonic ULID identifier.
+ *
+ * @return a fresh result id
+ */
public static ResultId generate() {
return new ResultId("res_" + UlidCreator.getMonotonicUlid());
}
+ /**
+ * Wraps an existing identifier string.
+ *
+ * @param value the identifier string
+ * @return the result id
+ */
@JsonCreator
public static ResultId of(String value) {
return new ResultId(value);
}
+ /**
+ * Returns the raw identifier string serialized on the wire.
+ *
+ * @return the identifier string
+ */
@JsonValue
public String asString() {
return value;
diff --git a/arcp-core/src/main/java/dev/arcp/core/ids/SessionId.java b/arcp-core/src/main/java/dev/arcp/core/ids/SessionId.java
index 488bbba..4d809d0 100644
--- a/arcp-core/src/main/java/dev/arcp/core/ids/SessionId.java
+++ b/arcp-core/src/main/java/dev/arcp/core/ids/SessionId.java
@@ -5,20 +5,43 @@
import com.github.f4b6a3.ulid.UlidCreator;
import java.util.Objects;
+/**
+ * Identifier of a session (§6), the envelope {@code session_id} field (wire example {@code
+ * sess_01J...}). Serialized as a bare JSON string.
+ *
+ * @param value the identifier string
+ */
public record SessionId(String value) {
+ /** Canonical constructor requiring a non-null value. */
public SessionId {
Objects.requireNonNull(value, "value");
}
+ /**
+ * Generates a new {@code sess_}-prefixed monotonic ULID identifier.
+ *
+ * @return a fresh session id
+ */
public static SessionId generate() {
return new SessionId("sess_" + UlidCreator.getMonotonicUlid());
}
+ /**
+ * Wraps an existing identifier string.
+ *
+ * @param value the identifier string
+ * @return the session id
+ */
@JsonCreator
public static SessionId of(String value) {
return new SessionId(value);
}
+ /**
+ * Returns the raw identifier string serialized on the wire.
+ *
+ * @return the identifier string
+ */
@JsonValue
public String asString() {
return value;
diff --git a/arcp-core/src/main/java/dev/arcp/core/ids/TraceId.java b/arcp-core/src/main/java/dev/arcp/core/ids/TraceId.java
index 5f3627a..c4fd3f3 100644
--- a/arcp-core/src/main/java/dev/arcp/core/ids/TraceId.java
+++ b/arcp-core/src/main/java/dev/arcp/core/ids/TraceId.java
@@ -6,24 +6,48 @@
import java.util.HexFormat;
import java.util.Objects;
+/**
+ * §11 trace context, the envelope {@code trace_id} field carrying the W3C {@code traceparent} value
+ * for the operation. Runtimes forward it to tool servers and sub-agents. Serialized as a bare JSON
+ * string.
+ *
+ * @param value the trace context string
+ */
public record TraceId(String value) {
private static final SecureRandom RNG = new SecureRandom();
+ /** Canonical constructor requiring a non-null value. */
public TraceId {
Objects.requireNonNull(value, "value");
}
+ /**
+ * Generates a new trace id of 16 secure-random bytes, hex-encoded (32 characters).
+ *
+ * @return a fresh trace id
+ */
public static TraceId generate() {
byte[] bytes = new byte[16];
RNG.nextBytes(bytes);
return new TraceId(HexFormat.of().formatHex(bytes));
}
+ /**
+ * Wraps an existing trace context string.
+ *
+ * @param value the trace context string
+ * @return the trace id
+ */
@JsonCreator
public static TraceId of(String value) {
return new TraceId(value);
}
+ /**
+ * Returns the raw trace context string serialized on the wire.
+ *
+ * @return the trace context string
+ */
@JsonValue
public String asString() {
return value;
diff --git a/arcp-core/src/main/java/dev/arcp/core/lease/Lease.java b/arcp-core/src/main/java/dev/arcp/core/lease/Lease.java
index a86c163..d388da7 100644
--- a/arcp-core/src/main/java/dev/arcp/core/lease/Lease.java
+++ b/arcp-core/src/main/java/dev/arcp/core/lease/Lease.java
@@ -21,6 +21,12 @@ public final class Lease {
private final Map> capabilities;
+ /**
+ * Creates a lease from a namespace → pattern-list map. The map and its lists are defensively
+ * copied; iteration order is preserved.
+ *
+ * @param capabilities namespace to pattern-list map
+ */
public Lease(Map> capabilities) {
Objects.requireNonNull(capabilities, "capabilities");
this.capabilities =
@@ -34,20 +40,41 @@ public Lease(Map> capabilities) {
LinkedHashMap::new)));
}
+ /**
+ * Returns a lease granting no capabilities.
+ *
+ * @return the empty lease
+ */
public static Lease empty() {
return new Lease(Map.of());
}
+ /**
+ * Returns the full capability map, which is also the §9.2 wire form of the lease.
+ *
+ * @return immutable namespace → pattern-list map
+ */
@JsonValue
public Map> capabilities() {
return capabilities;
}
+ /**
+ * Returns the patterns granted under one namespace.
+ *
+ * @param namespace the capability namespace (e.g. {@code fs.read})
+ * @return the granted patterns, empty when the namespace is not present
+ */
public List patterns(String namespace) {
return capabilities.getOrDefault(namespace, List.of());
}
- /** Parsed cost.budget initial amounts per currency. */
+ /**
+ * Parses the {@code cost.budget} entries (§9.6) into initial amounts per currency.
+ *
+ * @return currency → amount map, empty when no budget capability is present
+ * @throws IllegalArgumentException if an entry is not of the form {@code CURRENCY:amount}
+ */
public Map budget() {
return patterns("cost.budget").stream()
.map(
@@ -74,6 +101,9 @@ static Lease fromJson(Map> wire) {
* a child pattern is covered when some parent pattern covers it; for {@code cost.budget} the
* comparison is numeric — every child currency must be present in the parent and its amount must
* not exceed the parent's amount (a child cannot grant more spend than the parent holds).
+ *
+ * @param child the candidate subset lease
+ * @return {@code true} if every child capability is covered by this lease
*/
public boolean contains(Lease child) {
return child.capabilities.entrySet().stream()
@@ -125,9 +155,20 @@ private static boolean covers(String parentPattern, String childPattern) {
return false;
}
+ /** Mutable accumulator building a {@link Lease} namespace by namespace. */
public static final class Builder {
private final Map> caps = new LinkedHashMap<>();
+ /** Creates a builder granting no capabilities. */
+ public Builder() {}
+
+ /**
+ * Grants patterns under a namespace, appending to any patterns already granted for it.
+ *
+ * @param namespace the capability namespace (e.g. {@code net.fetch})
+ * @param patterns the §9.2 patterns to grant
+ * @return this builder
+ */
public Builder allow(String namespace, String... patterns) {
List existing = caps.getOrDefault(namespace, new ArrayList<>());
List merged = new ArrayList<>(existing);
@@ -136,11 +177,21 @@ public Builder allow(String namespace, String... patterns) {
return this;
}
+ /**
+ * Builds the immutable lease.
+ *
+ * @return the lease
+ */
public Lease build() {
return new Lease(caps);
}
}
+ /**
+ * Creates a new {@link Builder}.
+ *
+ * @return an empty builder
+ */
public static Builder builder() {
return new Builder();
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/lease/LeaseConstraints.java b/arcp-core/src/main/java/dev/arcp/core/lease/LeaseConstraints.java
index e172d85..34845ff 100644
--- a/arcp-core/src/main/java/dev/arcp/core/lease/LeaseConstraints.java
+++ b/arcp-core/src/main/java/dev/arcp/core/lease/LeaseConstraints.java
@@ -13,18 +13,36 @@
/**
* §9.5 lease_constraints. {@code expires_at} MUST be UTC ({@code Z} suffix) and MUST be in the
* future at submit time. Past or non-{@code Z} values are rejected as {@code INVALID_REQUEST}.
+ *
+ * @param expiresAt UTC instant at which the lease expires, or {@code null} for no expiration
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record LeaseConstraints(@Nullable Instant expiresAt) {
+ /**
+ * Returns constraints with no expiration.
+ *
+ * @return the unconstrained instance
+ */
public static LeaseConstraints none() {
return new LeaseConstraints(null);
}
+ /**
+ * Creates constraints expiring at the given instant.
+ *
+ * @param expiresAt the expiry instant
+ * @return the constraints
+ */
public static LeaseConstraints of(Instant expiresAt) {
return new LeaseConstraints(expiresAt);
}
+ /**
+ * Serializes the expiry under its wire name {@code expires_at}.
+ *
+ * @return the expiry instant, or {@code null}
+ */
@JsonProperty("expires_at")
public @Nullable Instant expiresAtJson() {
return expiresAt;
@@ -38,7 +56,13 @@ static LeaseConstraints fromJson(@JsonProperty("expires_at") @Nullable String ra
return new LeaseConstraints(parseStrictUtc(raw));
}
- /** Parser: only ISO-8601 with {@code Z} suffix accepted; offsets or naive datetimes rejected. */
+ /**
+ * Parser: only ISO-8601 with {@code Z} suffix accepted; offsets or naive datetimes rejected.
+ *
+ * @param raw the wire {@code expires_at} string
+ * @return the parsed instant
+ * @throws IllegalArgumentException if {@code raw} is not ISO-8601 UTC with a {@code Z} suffix
+ */
public static Instant parseStrictUtc(String raw) {
try {
ZonedDateTime zdt = ZonedDateTime.parse(raw, DateTimeFormatter.ISO_DATE_TIME);
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/ClientInfo.java b/arcp-core/src/main/java/dev/arcp/core/messages/ClientInfo.java
index f77f967..c59dac4 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/ClientInfo.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/ClientInfo.java
@@ -4,7 +4,14 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;
+/**
+ * Client identification carried in {@code session.hello.payload.client} (§6.2).
+ *
+ * @param name client implementation name (e.g. {@code examplectl})
+ * @param version client implementation version
+ */
public record ClientInfo(String name, String version) {
+ /** Canonical constructor requiring both fields. */
@JsonCreator
public ClientInfo(@JsonProperty("name") String name, @JsonProperty("version") String version) {
this.name = Objects.requireNonNull(name, "name");
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobAccepted.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobAccepted.java
index 06be8e7..c4b69bb 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobAccepted.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobAccepted.java
@@ -14,6 +14,22 @@
import java.util.Map;
import org.jspecify.annotations.Nullable;
+/**
+ * §7.1 {@code job.accepted} payload: the runtime's acceptance of a {@code job.submit}, echoing the
+ * effective lease and constraints plus initial budget counters and any §9.8 provisioned
+ * credentials.
+ *
+ * @param jobId the assigned job id ({@code job_id})
+ * @param agent the resolved agent as {@code name@version} (§7.5)
+ * @param lease the effective lease granted to the job
+ * @param leaseConstraints the effective {@code lease_constraints} (§9.5), or {@code null} when the
+ * lease has no expiration
+ * @param budget initial budget counters per currency when {@code cost.budget} is leased (§9.6), or
+ * {@code null}
+ * @param credentials §9.8 provisioned credentials, or {@code null} when none are issued
+ * @param acceptedAt acceptance timestamp ({@code accepted_at})
+ * @param traceId §11 trace context ({@code trace_id}), or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobAccepted(
@JsonProperty("job_id") JobId jobId,
@@ -26,6 +42,7 @@ public record JobAccepted(
@JsonProperty("trace_id") @Nullable TraceId traceId)
implements Message {
+ /** Canonical constructor; {@code budget} and {@code credentials} are defensively copied. */
@JsonCreator
public JobAccepted(
@JsonProperty("job_id") JobId jobId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobCancel.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobCancel.java
index b621af7..d0caf3d 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobCancel.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobCancel.java
@@ -5,8 +5,15 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jspecify.annotations.Nullable;
+/**
+ * §7.4 {@code job.cancel} payload: requests cancellation of a non-terminal job identified by the
+ * envelope's {@code job_id}. Cancellation is reserved for the session that submitted the job.
+ *
+ * @param reason human-readable cancellation reason, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobCancel(@Nullable String reason) implements Message {
+ /** Canonical constructor. */
@JsonCreator
public JobCancel(@JsonProperty("reason") @Nullable String reason) {
this.reason = reason;
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobCancelled.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobCancelled.java
index 3d2397e..42472e2 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobCancelled.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobCancelled.java
@@ -9,9 +9,12 @@
* §7.4 cancel acknowledgement. The runtime sends {@code job.cancelled} (carrying the job id in the
* envelope) to acknowledge a {@code job.cancel}, followed by a terminal {@code job.error} with code
* {@code CANCELLED}.
+ *
+ * @param reason human-readable cancellation reason, or {@code null}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobCancelled(@Nullable String reason) implements Message {
+ /** Canonical constructor. */
@JsonCreator
public JobCancelled(@JsonProperty("reason") @Nullable String reason) {
this.reason = reason;
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobError.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobError.java
index 7878615..a5d7cac 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobError.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobError.java
@@ -7,6 +7,18 @@
import dev.arcp.core.error.ErrorCode;
import org.jspecify.annotations.Nullable;
+/**
+ * Terminal {@code job.error} payload. Per §7.3, {@code BUDGET_EXHAUSTED} and {@code LEASE_EXPIRED}
+ * surface with {@code final_status: "error"}; cancellation ends with code {@code CANCELLED} and
+ * {@code final_status: "cancelled"} (§7.4).
+ *
+ * @param finalStatus terminal status ({@code final_status}): {@link #ERROR}, {@link #CANCELLED}, or
+ * {@link #TIMED_OUT}
+ * @param code the canonical §12 error code
+ * @param message human-readable detail
+ * @param retryable whether resubmitting may succeed; defaults to the code's §12 retryability
+ * @param details implementation-defined detail object, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobError(
@JsonProperty("final_status") String finalStatus,
@@ -16,10 +28,25 @@ public record JobError(
@Nullable JsonNode details)
implements Message {
+ /** {@code final_status} for jobs that failed. */
public static final String ERROR = "error";
+
+ /** {@code final_status} for jobs cancelled by the client (§7.4). */
public static final String CANCELLED = "cancelled";
+
+ /** {@code final_status} for jobs that exceeded {@code max_runtime_sec}. */
public static final String TIMED_OUT = "timed_out";
+ /**
+ * Jackson factory applying the code's §12 default when {@code retryable} is absent.
+ *
+ * @param finalStatus terminal status ({@code final_status})
+ * @param code the §12 error code
+ * @param message human-readable detail
+ * @param retryable the wire {@code retryable} flag, or {@code null} to default from the code
+ * @param details implementation-defined detail object, or {@code null}
+ * @return the decoded payload
+ */
@JsonCreator
public static JobError fromJson(
@JsonProperty("final_status") String finalStatus,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobEvent.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobEvent.java
index 088b5b2..b3a9f78 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobEvent.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobEvent.java
@@ -10,11 +10,16 @@
/**
* §8.1 job.event payload: {@code { kind, ts, body }}. {@code body} is held generically; decode via
* {@link dev.arcp.core.events.Events#decode}.
+ *
+ * @param eventKind the §8.2 event kind discriminator ({@code kind})
+ * @param ts event timestamp ({@code ts})
+ * @param body kind-specific body JSON
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobEvent(@JsonProperty("kind") String eventKind, Instant ts, JsonNode body)
implements Message {
+ /** Canonical constructor requiring all fields. */
@JsonCreator
public JobEvent(
@JsonProperty("kind") String eventKind,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobFilter.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobFilter.java
index 76cc136..0b181bc 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobFilter.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobFilter.java
@@ -7,11 +7,20 @@
import java.util.List;
import org.jspecify.annotations.Nullable;
+/**
+ * Filter block of a §6.6 {@code session.list_jobs} request. All fields are optional; {@code null}
+ * leaves that dimension unconstrained.
+ *
+ * @param status job statuses to include (e.g. {@code running}, {@code pending}), or {@code null}
+ * @param agent agent reference to match, or {@code null}
+ * @param createdAfter lower bound on creation time ({@code created_after}), or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobFilter(
@Nullable List status,
@Nullable String agent,
@JsonProperty("created_after") @Nullable Instant createdAfter) {
+ /** Canonical constructor; {@code status} is defensively copied. */
@JsonCreator
public JobFilter(
@JsonProperty("status") @Nullable List status,
@@ -22,6 +31,11 @@ public JobFilter(
this.createdAfter = createdAfter;
}
+ /**
+ * Returns the filter matching every job.
+ *
+ * @return an unconstrained filter
+ */
public static JobFilter all() {
return new JobFilter(null, null, null);
}
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobResult.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobResult.java
index 87fa8a1..80eb126 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobResult.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobResult.java
@@ -11,6 +11,13 @@
* §7 / §8.4 terminal job.result. When {@code resultId} is present the result is the concatenation
* of streamed {@code result_chunk} bodies; otherwise the inline {@code result} payload carries the
* result directly.
+ *
+ * @param finalStatus terminal status ({@code final_status}), {@link #SUCCESS} on success
+ * @param resultId id of the §8.4 streamed result ({@code result_id}), or {@code null} when the
+ * result is inline
+ * @param resultSize total byte size of the assembled result ({@code result_size}), or {@code null}
+ * @param result inline result payload, or {@code null} when streamed
+ * @param summary human-readable result summary, or {@code null}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobResult(
@@ -21,8 +28,10 @@ public record JobResult(
@Nullable String summary)
implements Message {
+ /** {@code final_status} for jobs that completed successfully (§7.3). */
public static final String SUCCESS = "success";
+ /** Canonical constructor. */
@JsonCreator
public JobResult(
@JsonProperty("final_status") String finalStatus,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobSubmit.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobSubmit.java
index 60acd3d..21a9bfc 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobSubmit.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobSubmit.java
@@ -10,6 +10,19 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * §7.1 {@code job.submit} payload: requests execution of {@code input} by {@code agent}, with an
+ * optional capability lease request, §9.5 constraints, §7.2 idempotency key, and runtime cap.
+ *
+ * @param agent the target agent, optionally version-pinned (§7.5)
+ * @param input agent-defined input payload
+ * @param leaseRequest requested capability lease ({@code lease_request}, §9.2), or {@code null}
+ * @param leaseConstraints requested {@code lease_constraints} (§9.5), or {@code null} for no
+ * expiration
+ * @param idempotencyKey §7.2 idempotency key ({@code idempotency_key}), or {@code null}
+ * @param maxRuntimeSec runtime cap in seconds ({@code max_runtime_sec}); exceeding it ends the job
+ * with {@code TIMEOUT}; or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobSubmit(
AgentRef agent,
@@ -20,6 +33,7 @@ public record JobSubmit(
@JsonProperty("max_runtime_sec") @Nullable Integer maxRuntimeSec)
implements Message {
+ /** Canonical constructor requiring {@code agent} and {@code input}. */
@JsonCreator
public JobSubmit(
@JsonProperty("agent") AgentRef agent,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribe.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribe.java
index 03c77a4..f9999ee 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribe.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribe.java
@@ -6,6 +6,15 @@
import dev.arcp.core.ids.JobId;
import org.jspecify.annotations.Nullable;
+/**
+ * §7.6 {@code job.subscribe} payload: attaches the session to a job's live event stream, optionally
+ * replaying buffered history. Subscription grants observation only — no cancel authority.
+ *
+ * @param jobId the job to attach to ({@code job_id})
+ * @param fromEventSeq with {@code history=true}, replay buffered events with {@code seq >
+ * from_event_seq} before live streaming ({@code from_event_seq}); {@code null} means live-only
+ * @param history whether to replay buffered history; {@code null} defaults to {@code false}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobSubscribe(
@JsonProperty("job_id") JobId jobId,
@@ -13,6 +22,7 @@ public record JobSubscribe(
@Nullable Boolean history)
implements Message {
+ /** Canonical constructor. */
@JsonCreator
public JobSubscribe(
@JsonProperty("job_id") JobId jobId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribed.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribed.java
index 57850fe..c903a92 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribed.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobSubscribed.java
@@ -8,6 +8,20 @@
import dev.arcp.core.lease.Lease;
import org.jspecify.annotations.Nullable;
+/**
+ * §7.6 {@code job.subscribed} payload: acknowledges a {@code job.subscribe} with a snapshot of the
+ * job's current state.
+ *
+ * @param jobId the subscribed job ({@code job_id})
+ * @param currentStatus job status at subscription time ({@code current_status})
+ * @param agent resolved agent as {@code name@version}
+ * @param lease the job's effective lease, or {@code null}
+ * @param parentJobId parent job for §10 delegated jobs ({@code parent_job_id}), or {@code null}
+ * @param traceId §11 trace context ({@code trace_id}), or {@code null}
+ * @param subscribedFrom event sequence from which the subscriber receives events ({@code
+ * subscribed_from})
+ * @param replayed whether buffered history was replayed
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobSubscribed(
@JsonProperty("job_id") JobId jobId,
@@ -20,6 +34,7 @@ public record JobSubscribed(
boolean replayed)
implements Message {
+ /** Canonical constructor. */
@JsonCreator
public JobSubscribed(
@JsonProperty("job_id") JobId jobId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobSummary.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobSummary.java
index b846b5c..18645fb 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobSummary.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobSummary.java
@@ -9,6 +9,19 @@
import java.time.Instant;
import org.jspecify.annotations.Nullable;
+/**
+ * One entry of a §6.6 {@code session.jobs} listing.
+ *
+ * @param jobId the job id ({@code job_id})
+ * @param agent resolved agent as {@code name@version}
+ * @param status current job status (e.g. {@code running})
+ * @param lease the job's effective lease, or {@code null}
+ * @param parentJobId parent job for §10 delegated jobs ({@code parent_job_id}), or {@code null}
+ * @param createdAt creation timestamp ({@code created_at})
+ * @param traceId §11 trace context ({@code trace_id}), or {@code null}
+ * @param lastEventSeq sequence of the job's most recent event ({@code last_event_seq}), or {@code
+ * null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JobSummary(
@JsonProperty("job_id") JobId jobId,
@@ -20,6 +33,7 @@ public record JobSummary(
@JsonProperty("trace_id") @Nullable TraceId traceId,
@JsonProperty("last_event_seq") @Nullable Long lastEventSeq) {
+ /** Canonical constructor. */
@JsonCreator
public JobSummary(
@JsonProperty("job_id") JobId jobId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/JobUnsubscribe.java b/arcp-core/src/main/java/dev/arcp/core/messages/JobUnsubscribe.java
index 6071bc2..7cc0f31 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/JobUnsubscribe.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/JobUnsubscribe.java
@@ -4,7 +4,13 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.arcp.core.ids.JobId;
+/**
+ * §7.6 {@code job.unsubscribe} payload: cancels a previous subscription to a job's event stream.
+ *
+ * @param jobId the job to detach from ({@code job_id})
+ */
public record JobUnsubscribe(@JsonProperty("job_id") JobId jobId) implements Message {
+ /** Canonical constructor. */
@JsonCreator
public JobUnsubscribe(@JsonProperty("job_id") JobId jobId) {
this.jobId = jobId;
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/Message.java b/arcp-core/src/main/java/dev/arcp/core/messages/Message.java
index f89afe2..6a42b34 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/Message.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/Message.java
@@ -25,29 +25,75 @@ public sealed interface Message
JobSubscribed,
JobUnsubscribe {
+ /**
+ * Returns the {@link Type} discriminator matching this message's wire {@code type} string.
+ *
+ * @return the message type
+ */
Type kind();
+ /** Wire {@code type} discriminator covering every protocol message (§3). */
enum Type {
+ /** §6.2 {@code session.hello}: client handshake with auth and capabilities. */
SESSION_HELLO("session.hello"),
+
+ /** §6.2 {@code session.welcome}: runtime handshake response. */
SESSION_WELCOME("session.welcome"),
+
// §6.7: the canonical graceful-close request is `session.close`; `session.bye` is accepted as a
// deprecated alias on decode for one release (see #96).
+ /**
+ * §6.7 {@code session.close}: graceful close request ({@code session.bye} remains a deprecated
+ * decode alias).
+ */
SESSION_BYE("session.close"),
+
+ /** §6.7 {@code session.closed}: runtime acknowledgement of a graceful close. */
SESSION_CLOSED("session.closed"),
+
+ /** §6.4 {@code session.ping}: heartbeat probe from an idle peer. */
SESSION_PING("session.ping"),
+
+ /** §6.4 {@code session.pong}: prompt heartbeat reply. */
SESSION_PONG("session.pong"),
+
+ /** §6.5 {@code session.ack}: advisory highest-processed event sequence. */
SESSION_ACK("session.ack"),
+
+ /** §6.6 {@code session.list_jobs}: read-only job inventory request. */
SESSION_LIST_JOBS("session.list_jobs"),
+
+ /** §6.6 {@code session.jobs}: job inventory response. */
SESSION_JOBS("session.jobs"),
+
+ /** §7.1 {@code job.submit}: job submission request. */
JOB_SUBMIT("job.submit"),
+
+ /** §7.1 {@code job.accepted}: acceptance with effective lease and credentials. */
JOB_ACCEPTED("job.accepted"),
+
+ /** §8 {@code job.event}: kind-discriminated job event. */
JOB_EVENT("job.event"),
+
+ /** §7.3 {@code job.result}: terminal success payload (streamed per §8.4 when chunked). */
JOB_RESULT("job.result"),
+
+ /** §7.3 {@code job.error}: terminal failure payload with a §12 error code. */
JOB_ERROR("job.error"),
+
+ /** §7.4 {@code job.cancel}: cancellation request from the submitting session. */
JOB_CANCEL("job.cancel"),
+
+ /** §7.4 {@code job.cancelled}: cancellation acknowledgement. */
JOB_CANCELLED("job.cancelled"),
+
+ /** §7.6 {@code job.subscribe}: attach to a job's event stream. */
JOB_SUBSCRIBE("job.subscribe"),
+
+ /** §7.6 {@code job.subscribed}: subscription acknowledgement with a job snapshot. */
JOB_SUBSCRIBED("job.subscribed"),
+
+ /** §7.6 {@code job.unsubscribe}: detach from a job's event stream. */
JOB_UNSUBSCRIBE("job.unsubscribe");
private final String wire;
@@ -56,6 +102,11 @@ enum Type {
this.wire = wire;
}
+ /**
+ * Returns the canonical wire {@code type} string.
+ *
+ * @return the wire string
+ */
public String wire() {
return wire;
}
@@ -72,6 +123,14 @@ public String wire() {
BY_WIRE = java.util.Collections.unmodifiableMap(m);
}
+ /**
+ * Resolves a wire {@code type} string, accepting the deprecated {@code session.bye} alias for
+ * {@link #SESSION_BYE} (§6.7).
+ *
+ * @param wire the wire type string
+ * @return the matching type
+ * @throws IllegalArgumentException if the type is unknown
+ */
public static Type fromWire(String wire) {
Type t = BY_WIRE.get(wire);
if (t == null) {
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/Messages.java b/arcp-core/src/main/java/dev/arcp/core/messages/Messages.java
index 7d795f0..634b7bb 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/Messages.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/Messages.java
@@ -13,6 +13,14 @@ public final class Messages {
private Messages() {}
+ /**
+ * Decodes an envelope's {@code payload} into the typed message named by its {@code type}.
+ *
+ * @param mapper the mapper to use
+ * @param envelope the envelope to decode
+ * @return the typed message
+ * @throws IllegalArgumentException if the envelope {@code type} is unknown
+ */
public static Message decode(ObjectMapper mapper, Envelope envelope) {
Message.Type type = Message.Type.fromWire(envelope.type());
JsonNode payload = envelope.payload();
@@ -39,6 +47,14 @@ public static Message decode(ObjectMapper mapper, Envelope envelope) {
};
}
+ /**
+ * Encodes a message as the JSON object carried in an envelope's {@code payload}.
+ *
+ * @param mapper the mapper to use
+ * @param m the message to encode
+ * @return the payload object
+ * @throws IllegalStateException if the message does not serialize to a JSON object
+ */
public static ObjectNode encodePayload(ObjectMapper mapper, Message m) {
JsonNode tree = mapper.valueToTree(m);
if (!tree.isObject()) {
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/RuntimeInfo.java b/arcp-core/src/main/java/dev/arcp/core/messages/RuntimeInfo.java
index 2f05bc1..63c5c8a 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/RuntimeInfo.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/RuntimeInfo.java
@@ -4,7 +4,14 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;
+/**
+ * Runtime identification carried in {@code session.welcome.payload.runtime} (§6.2).
+ *
+ * @param name runtime implementation name
+ * @param version runtime implementation version
+ */
public record RuntimeInfo(String name, String version) {
+ /** Canonical constructor requiring both fields. */
@JsonCreator
public RuntimeInfo(@JsonProperty("name") String name, @JsonProperty("version") String version) {
this.name = Objects.requireNonNull(name, "name");
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionAck.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionAck.java
index d4a108e..b7f3797 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionAck.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionAck.java
@@ -3,8 +3,17 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+/**
+ * §6.5 {@code session.ack} payload: advisory notice of the client's highest processed event
+ * sequence, letting the runtime free buffered events early and detect slow consumers. Resume still
+ * requires {@code last_event_seq} independently.
+ *
+ * @param lastProcessedSeq highest event sequence the client has processed ({@code
+ * last_processed_seq})
+ */
public record SessionAck(@JsonProperty("last_processed_seq") long lastProcessedSeq)
implements Message {
+ /** Canonical constructor. */
@JsonCreator
public SessionAck {}
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionBye.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionBye.java
index ef0be30..dbf51be 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionBye.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionBye.java
@@ -5,8 +5,16 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jspecify.annotations.Nullable;
+/**
+ * §6.7 graceful-close request, written on the wire as {@code session.close} ({@code session.bye}
+ * remains a deprecated decode alias). In-flight jobs continue running and remain resumable within
+ * the resume window.
+ *
+ * @param reason human-readable close reason, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SessionBye(@Nullable String reason) implements Message {
+ /** Canonical constructor. */
@JsonCreator
public SessionBye(@JsonProperty("reason") @Nullable String reason) {
this.reason = reason;
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionClosed.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionClosed.java
index 023afdd..1e250e7 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionClosed.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionClosed.java
@@ -8,9 +8,12 @@
/**
* §6.7 graceful-close acknowledgement. The runtime sends {@code session.closed} in response to a
* {@code session.close} request before tearing the session down.
+ *
+ * @param reason human-readable close reason, or {@code null}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SessionClosed(@Nullable String reason) implements Message {
+ /** Canonical constructor. */
@JsonCreator
public SessionClosed(@JsonProperty("reason") @Nullable String reason) {
this.reason = reason;
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionHello.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionHello.java
index 4b455fd..4e6d404 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionHello.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionHello.java
@@ -8,6 +8,17 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * §6.2 {@code session.hello} payload: opens (or resumes, §6.3) a session, identifying the client,
+ * presenting §6.1 authentication, and advertising capabilities for feature negotiation.
+ *
+ * @param client client identification
+ * @param auth §6.1 authentication block
+ * @param capabilities advertised client capabilities; defaults to JSON-only with no features
+ * @param resumeToken §6.3 resume token from the prior welcome ({@code resume_token}), or {@code
+ * null} for a fresh session
+ * @param lastEventSeq §6.3 last received event sequence ({@code last_event_seq}), or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SessionHello(
ClientInfo client,
@@ -17,6 +28,7 @@ public record SessionHello(
@JsonProperty("last_event_seq") @Nullable Long lastEventSeq)
implements Message {
+ /** Canonical constructor; a null {@code capabilities} becomes the empty JSON-only set. */
@JsonCreator
public SessionHello(
@JsonProperty("client") ClientInfo client,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionJobs.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionJobs.java
index 77d7684..1e4ad3d 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionJobs.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionJobs.java
@@ -7,6 +7,15 @@
import java.util.List;
import org.jspecify.annotations.Nullable;
+/**
+ * §6.6 {@code session.jobs} payload: a page of job summaries answering a {@code session.list_jobs}
+ * request.
+ *
+ * @param requestId envelope id of the originating {@code session.list_jobs} ({@code request_id})
+ * @param jobs the job summaries on this page
+ * @param nextCursor opaque cursor for the next page ({@code next_cursor}), or {@code null} when the
+ * listing is exhausted
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SessionJobs(
@JsonProperty("request_id") MessageId requestId,
@@ -14,6 +23,7 @@ public record SessionJobs(
@JsonProperty("next_cursor") @Nullable String nextCursor)
implements Message {
+ /** Canonical constructor; {@code jobs} is defensively copied. */
@JsonCreator
public SessionJobs(
@JsonProperty("request_id") MessageId requestId,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionListJobs.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionListJobs.java
index bf9f44c..ad77c97 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionListJobs.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionListJobs.java
@@ -5,10 +5,19 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jspecify.annotations.Nullable;
+/**
+ * §6.6 {@code session.list_jobs} payload: requests a read-only, principal-scoped job inventory.
+ * Listing does not subscribe to events; use {@code job.subscribe} (§7.6) for that.
+ *
+ * @param filter optional filter on status, agent, and creation time, or {@code null} for all jobs
+ * @param limit maximum number of jobs to return, or {@code null} for the runtime default
+ * @param cursor opaque pagination cursor from a prior {@code session.jobs}, or {@code null}
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SessionListJobs(
@Nullable JobFilter filter, @Nullable Integer limit, @Nullable String cursor)
implements Message {
+ /** Canonical constructor. */
@JsonCreator
public SessionListJobs(
@JsonProperty("filter") @Nullable JobFilter filter,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionPing.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionPing.java
index 2f98da7..fcde868 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionPing.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionPing.java
@@ -5,8 +5,16 @@
import java.time.Instant;
import java.util.Objects;
+/**
+ * §6.4 {@code session.ping} payload: heartbeat probe from an idle peer. The receiver must answer
+ * promptly with {@code session.pong}. Heartbeats are not included in {@code event_seq}.
+ *
+ * @param nonce opaque value echoed back as the pong's {@code ping_nonce}
+ * @param sentAt send timestamp ({@code sent_at})
+ */
public record SessionPing(String nonce, @JsonProperty("sent_at") Instant sentAt)
implements Message {
+ /** Canonical constructor requiring both fields. */
@JsonCreator
public SessionPing(@JsonProperty("nonce") String nonce, @JsonProperty("sent_at") Instant sentAt) {
this.nonce = Objects.requireNonNull(nonce, "nonce");
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionPong.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionPong.java
index e5c33a5..15e1d7b 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionPong.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionPong.java
@@ -5,9 +5,16 @@
import java.time.Instant;
import java.util.Objects;
+/**
+ * §6.4 {@code session.pong} payload: prompt reply to a {@code session.ping}.
+ *
+ * @param pingNonce nonce of the ping being answered ({@code ping_nonce})
+ * @param receivedAt time the ping was received ({@code received_at})
+ */
public record SessionPong(
@JsonProperty("ping_nonce") String pingNonce, @JsonProperty("received_at") Instant receivedAt)
implements Message {
+ /** Canonical constructor requiring both fields. */
@JsonCreator
public SessionPong(
@JsonProperty("ping_nonce") String pingNonce,
diff --git a/arcp-core/src/main/java/dev/arcp/core/messages/SessionWelcome.java b/arcp-core/src/main/java/dev/arcp/core/messages/SessionWelcome.java
index 8893022..11187c2 100644
--- a/arcp-core/src/main/java/dev/arcp/core/messages/SessionWelcome.java
+++ b/arcp-core/src/main/java/dev/arcp/core/messages/SessionWelcome.java
@@ -7,6 +7,20 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
+/**
+ * §6.2 {@code session.welcome} payload: the runtime's handshake response carrying resume
+ * parameters, the heartbeat interval, and acknowledged capabilities. The effective feature set is
+ * the intersection of hello and welcome features.
+ *
+ * @param runtime runtime identification
+ * @param resumeToken §6.3 resume token, rotated on every successful welcome ({@code resume_token}),
+ * or {@code null}
+ * @param resumeWindowSec seconds the event buffer is retained for resume ({@code
+ * resume_window_sec}), or {@code null}
+ * @param heartbeatIntervalSec §6.4 heartbeat interval ({@code heartbeat_interval_sec}), or {@code
+ * null} when heartbeats are not negotiated
+ * @param capabilities runtime capabilities, including the §7.5 agent inventory
+ */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SessionWelcome(
RuntimeInfo runtime,
@@ -16,6 +30,7 @@ public record SessionWelcome(
Capabilities capabilities)
implements Message {
+ /** Canonical constructor requiring {@code runtime} and {@code capabilities}. */
@JsonCreator
public SessionWelcome(
@JsonProperty("runtime") RuntimeInfo runtime,
diff --git a/arcp-core/src/main/java/dev/arcp/core/transport/MemoryTransport.java b/arcp-core/src/main/java/dev/arcp/core/transport/MemoryTransport.java
index 44461e9..782ec43 100644
--- a/arcp-core/src/main/java/dev/arcp/core/transport/MemoryTransport.java
+++ b/arcp-core/src/main/java/dev/arcp/core/transport/MemoryTransport.java
@@ -18,6 +18,9 @@ public final class MemoryTransport implements Transport {
* runtime} endpoint is intended to be attached to an {@code ArcpRuntime} and the {@code client}
* endpoint to an {@code ArcpClient}. Either component is functionally interchangeable, but the
* names exist to remove the index-juggling that array returns required.
+ *
+ * @param runtime the endpoint intended for the runtime side
+ * @param client the endpoint intended for the client side
*/
public record Pair(MemoryTransport runtime, MemoryTransport client) {}
diff --git a/arcp-core/src/main/java/dev/arcp/core/transport/StdioTransport.java b/arcp-core/src/main/java/dev/arcp/core/transport/StdioTransport.java
index 52d0eca..d91d5f6 100644
--- a/arcp-core/src/main/java/dev/arcp/core/transport/StdioTransport.java
+++ b/arcp-core/src/main/java/dev/arcp/core/transport/StdioTransport.java
@@ -22,8 +22,8 @@
import org.slf4j.LoggerFactory;
/**
- * §4.2 stdio transport: newline-delimited JSON over a pair of byte streams. Each envelope is
- * written as one line (UTF-8, JSON, terminated by {@code \n}).
+ * §4 stdio transport: newline-delimited JSON over a pair of byte streams. Each envelope is written
+ * as one line (UTF-8, JSON, terminated by {@code \n}).
*
* Use case: a parent process spawns a child agent and wires its {@code
* process.getOutputStream()} → child stdin, child stdout → {@code process.getInputStream()}. Both
@@ -54,10 +54,23 @@ public final class StdioTransport implements Transport {
private final Thread readerThread;
private volatile boolean closed;
+ /**
+ * Creates a transport over the given stream pair using the shared {@link ArcpMapper}.
+ *
+ * @param in stream to read inbound envelopes from
+ * @param out stream to write outbound envelopes to
+ */
public StdioTransport(InputStream in, OutputStream out) {
this(in, out, ArcpMapper.shared());
}
+ /**
+ * Creates a transport over the given stream pair.
+ *
+ * @param in stream to read inbound envelopes from
+ * @param out stream to write outbound envelopes to
+ * @param mapper mapper for envelope JSON, or {@code null} to use the shared {@link ArcpMapper}
+ */
public StdioTransport(InputStream in, OutputStream out, ObjectMapper mapper) {
this.mapper = mapper != null ? mapper : ArcpMapper.shared();
this.writer =
@@ -71,7 +84,11 @@ public StdioTransport(InputStream in, OutputStream out, ObjectMapper mapper) {
this.readerThread = Thread.ofVirtual().name("arcp-stdio-reader").unstarted(this::readLoop);
}
- /** Begin reading lines from the input stream. */
+ /**
+ * Begins reading lines from the input stream on the virtual reader thread.
+ *
+ * @return this transport
+ */
public StdioTransport start() {
readerThread.start();
return this;
diff --git a/arcp-core/src/main/java/dev/arcp/core/transport/Transport.java b/arcp-core/src/main/java/dev/arcp/core/transport/Transport.java
index fa99575..9673cf2 100644
--- a/arcp-core/src/main/java/dev/arcp/core/transport/Transport.java
+++ b/arcp-core/src/main/java/dev/arcp/core/transport/Transport.java
@@ -10,8 +10,18 @@
* inbound stream as a {@link Flow.Publisher}. {@link #close()} releases resources.
*/
public interface Transport extends AutoCloseable {
+ /**
+ * Delivers one envelope to the peer.
+ *
+ * @param envelope the envelope to send
+ */
void send(Envelope envelope);
+ /**
+ * Returns the inbound envelope stream.
+ *
+ * @return publisher of envelopes received from the peer
+ */
Flow.Publisher incoming();
@Override
diff --git a/arcp-core/src/main/java/dev/arcp/core/wire/ArcpMapper.java b/arcp-core/src/main/java/dev/arcp/core/wire/ArcpMapper.java
index b962b51..042fbb7 100644
--- a/arcp-core/src/main/java/dev/arcp/core/wire/ArcpMapper.java
+++ b/arcp-core/src/main/java/dev/arcp/core/wire/ArcpMapper.java
@@ -11,7 +11,7 @@
*
*
* {@code USE_BIG_DECIMAL_FOR_FLOATS}: §9.6 budget arithmetic on {@code BigDecimal}.
- * {@code FAIL_ON_UNKNOWN_PROPERTIES=false}: §5.1 mandates ignoring unknown fields.
+ * {@code FAIL_ON_UNKNOWN_PROPERTIES=false}: §5 mandates ignoring unknown fields.
* {@link JavaTimeModule}: {@link java.time.Instant} for §9.5 expires_at.
* {@code WRITE_DATES_AS_TIMESTAMPS=false}: ISO-8601 on the wire.
* {@code Include.NON_NULL}: omit null fields globally.
@@ -21,6 +21,11 @@ public final class ArcpMapper {
private ArcpMapper() {}
+ /**
+ * Creates a new mapper with the ARCP wire configuration applied.
+ *
+ * @return a fresh, caller-owned mapper that is safe to reconfigure
+ */
public static ObjectMapper create() {
ObjectMapper m = new ObjectMapper();
m.registerModule(new JavaTimeModule());
@@ -38,6 +43,8 @@ public static ObjectMapper create() {
*
* Treat this instance as read-only. Reconfiguring the shared mapper can affect unrelated
* callers because the same ObjectMapper is reused across the SDK.
+ *
+ * @return the shared SDK-wide mapper
*/
public static ObjectMapper shared() {
return SHARED;
diff --git a/arcp-core/src/main/java/dev/arcp/core/wire/Envelope.java b/arcp-core/src/main/java/dev/arcp/core/wire/Envelope.java
index fddd51c..089b39d 100644
--- a/arcp-core/src/main/java/dev/arcp/core/wire/Envelope.java
+++ b/arcp-core/src/main/java/dev/arcp/core/wire/Envelope.java
@@ -12,11 +12,20 @@
import org.jspecify.annotations.Nullable;
/**
- * ARCP wire envelope per spec §5.1.
+ * ARCP wire envelope per spec §5.
*
*
Required fields: {@code arcp}, {@code id}, {@code type}, {@code payload}. Conditional fields:
* {@code session_id}, {@code trace_id}, {@code job_id}, {@code event_seq}. Unknown top-level fields
* MUST be ignored.
+ *
+ * @param arcp the {@code arcp} protocol version string; {@link #VERSION} for this SDK
+ * @param id unique message id ({@code id})
+ * @param type wire message type (e.g. {@code job.submit})
+ * @param sessionId session correlation id ({@code session_id}), or {@code null}
+ * @param traceId §11 trace context ({@code trace_id}), or {@code null}
+ * @param jobId job correlation id ({@code job_id}), or {@code null}
+ * @param eventSeq §8.3 session-scoped event sequence ({@code event_seq}), or {@code null}
+ * @param payload type-specific payload object
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Envelope(
@@ -29,8 +38,10 @@ public record Envelope(
@JsonProperty("event_seq") @Nullable Long eventSeq,
JsonNode payload) {
+ /** The {@code arcp} protocol version string ({@code "1.1"}) stamped by {@link Builder#build}. */
public static final String VERSION = "1.1";
+ /** Canonical constructor requiring the §5 mandatory fields. */
public Envelope {
Objects.requireNonNull(arcp, "arcp");
Objects.requireNonNull(id, "id");
@@ -38,6 +49,19 @@ public record Envelope(
Objects.requireNonNull(payload, "payload");
}
+ /**
+ * Jackson factory deserializing an envelope from its §5 wire fields.
+ *
+ * @param arcp the {@code arcp} protocol version string
+ * @param id unique message id
+ * @param type wire message type
+ * @param sessionId session correlation id, or {@code null}
+ * @param traceId trace context, or {@code null}
+ * @param jobId job correlation id, or {@code null}
+ * @param eventSeq event sequence, or {@code null}
+ * @param payload type-specific payload object
+ * @return the envelope
+ */
@JsonCreator
public static Envelope create(
@JsonProperty("arcp") String arcp,
@@ -51,10 +75,17 @@ public static Envelope create(
return new Envelope(arcp, id, type, sessionId, traceId, jobId, eventSeq, payload);
}
+ /**
+ * Starts a builder for an envelope of the given type with a generated message id.
+ *
+ * @param type wire message type (e.g. {@code session.hello})
+ * @return a new builder
+ */
public static Builder builder(String type) {
return new Builder(type);
}
+ /** Fluent builder assembling an {@link Envelope} stamped with {@link Envelope#VERSION}. */
public static final class Builder {
private MessageId id = MessageId.generate();
private final String type;
@@ -68,36 +99,78 @@ public static final class Builder {
this.type = Objects.requireNonNull(type, "type");
}
+ /**
+ * Overrides the generated message id.
+ *
+ * @param id the message id to use
+ * @return this builder
+ */
public Builder id(MessageId id) {
this.id = id;
return this;
}
+ /**
+ * Sets the {@code session_id} field.
+ *
+ * @param sessionId the session id, or {@code null} to omit
+ * @return this builder
+ */
public Builder sessionId(@Nullable SessionId sessionId) {
this.sessionId = sessionId;
return this;
}
+ /**
+ * Sets the {@code trace_id} field (§11).
+ *
+ * @param traceId the trace context, or {@code null} to omit
+ * @return this builder
+ */
public Builder traceId(@Nullable TraceId traceId) {
this.traceId = traceId;
return this;
}
+ /**
+ * Sets the {@code job_id} field.
+ *
+ * @param jobId the job id, or {@code null} to omit
+ * @return this builder
+ */
public Builder jobId(@Nullable JobId jobId) {
this.jobId = jobId;
return this;
}
+ /**
+ * Sets the {@code event_seq} field (§8.3).
+ *
+ * @param eventSeq the event sequence, or {@code null} to omit
+ * @return this builder
+ */
public Builder eventSeq(@Nullable Long eventSeq) {
this.eventSeq = eventSeq;
return this;
}
+ /**
+ * Sets the required payload object.
+ *
+ * @param payload the type-specific payload
+ * @return this builder
+ */
public Builder payload(JsonNode payload) {
this.payload = payload;
return this;
}
+ /**
+ * Builds the envelope with {@code arcp} set to {@link Envelope#VERSION}.
+ *
+ * @return the envelope
+ * @throws NullPointerException if no payload was set
+ */
public Envelope build() {
Objects.requireNonNull(payload, "payload");
return new Envelope(VERSION, id, type, sessionId, traceId, jobId, eventSeq, payload);
diff --git a/arcp-core/src/test/java/dev/arcp/core/coverage/AgentRefCoverageTest.java b/arcp-core/src/test/java/dev/arcp/core/coverage/AgentRefCoverageTest.java
new file mode 100644
index 0000000..cb6866a
--- /dev/null
+++ b/arcp-core/src/test/java/dev/arcp/core/coverage/AgentRefCoverageTest.java
@@ -0,0 +1,61 @@
+package dev.arcp.core.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.arcp.core.agents.AgentRef;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Branch coverage for §7.5 agent reference parsing. */
+class AgentRefCoverageTest {
+
+ @Test
+ void parseWithoutVersion() {
+ AgentRef ref = AgentRef.parse("echo");
+ assertThat(ref.name()).isEqualTo("echo");
+ assertThat(ref.version()).isNull();
+ assertThat(ref.versionOpt()).isEmpty();
+ assertThat(ref.wire()).isEqualTo("echo");
+ assertThat(ref).hasToString("echo");
+ }
+
+ @Test
+ void parseWithVersion() {
+ AgentRef ref = AgentRef.parse("echo@1.2.3-beta+build_7");
+ assertThat(ref.name()).isEqualTo("echo");
+ assertThat(ref.version()).isEqualTo("1.2.3-beta+build_7");
+ assertThat(ref.versionOpt()).contains("1.2.3-beta+build_7");
+ assertThat(ref.wire()).isEqualTo("echo@1.2.3-beta+build_7");
+ assertThat(ref).hasToString("echo@1.2.3-beta+build_7");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"Echo", "_agent", "", "agent name", "-lead"})
+ void rejectsInvalidNames(String raw) {
+ assertThatThrownBy(() -> AgentRef.parse(raw))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid agent name");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"echo@", "echo@1 0", "echo@v!"})
+ void rejectsInvalidVersions(String raw) {
+ assertThatThrownBy(() -> AgentRef.parse(raw))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid agent version");
+ }
+
+ @Test
+ void rejectsNullInputs() {
+ assertThatThrownBy(() -> AgentRef.parse(null)).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> new AgentRef(null, null)).isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void constructorAcceptsExplicitNullVersion() {
+ AgentRef ref = new AgentRef("planner.v2", null);
+ assertThat(ref.wire()).isEqualTo("planner.v2");
+ }
+}
diff --git a/arcp-core/src/test/java/dev/arcp/core/coverage/CoreWireCoverageTest.java b/arcp-core/src/test/java/dev/arcp/core/coverage/CoreWireCoverageTest.java
new file mode 100644
index 0000000..fb8fdb1
--- /dev/null
+++ b/arcp-core/src/test/java/dev/arcp/core/coverage/CoreWireCoverageTest.java
@@ -0,0 +1,228 @@
+package dev.arcp.core.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.capabilities.AgentDescriptor;
+import dev.arcp.core.capabilities.Capabilities;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.credentials.CredentialScheme;
+import dev.arcp.core.error.ErrorCode;
+import dev.arcp.core.error.ErrorPayload;
+import dev.arcp.core.events.EventBody;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.ResultId;
+import dev.arcp.core.ids.SessionId;
+import dev.arcp.core.ids.TraceId;
+import dev.arcp.core.messages.ClientInfo;
+import dev.arcp.core.messages.JobFilter;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.Messages;
+import dev.arcp.core.messages.SessionAck;
+import dev.arcp.core.messages.SessionHello;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+/** Branch coverage for wire-level value types: capabilities, envelopes, payload codecs, enums. */
+class CoreWireCoverageTest {
+
+ private final ObjectMapper mapper = ArcpMapper.shared();
+
+ @Test
+ void capabilitiesConstructorAppliesDefaults() {
+ Capabilities caps = new Capabilities(null, null, null);
+ assertThat(caps.encodings()).containsExactly("json");
+ assertThat(caps.features()).isEmpty();
+ assertThat(caps.agents()).isNull();
+ assertThat(caps.featuresWire()).isEmpty();
+ }
+
+ @Test
+ void capabilitiesConstructorCopiesProvidedValues() {
+ Capabilities caps =
+ new Capabilities(
+ List.of("json"),
+ Set.of(Feature.ACK),
+ List.of(new AgentDescriptor("echo", List.of("1.0.0"), "1.0.0")));
+ assertThat(caps.encodings()).containsExactly("json");
+ assertThat(caps.features()).containsExactly(Feature.ACK);
+ assertThat(caps.agents()).hasSize(1);
+ }
+
+ @Test
+ void capabilitiesFromJsonDropsUnknownFeatures() throws Exception {
+ Capabilities caps =
+ mapper.readValue("{\"features\":[\"heartbeat\",\"mystery\"]}", Capabilities.class);
+ assertThat(caps.features()).containsExactly(Feature.HEARTBEAT);
+ assertThat(caps.encodings()).containsExactly("json");
+
+ Capabilities defaults = mapper.readValue("{}", Capabilities.class);
+ assertThat(defaults.features()).isEmpty();
+ assertThat(defaults.encodings()).containsExactly("json");
+
+ Capabilities explicit =
+ mapper.readValue("{\"encodings\":[\"json\",\"cbor\"],\"features\":[]}", Capabilities.class);
+ assertThat(explicit.encodings()).containsExactly("json", "cbor");
+ }
+
+ @Test
+ void capabilitiesIntersectHandlesEmptyAndNonEmptyOverlap() {
+ Set ackOnly =
+ Capabilities.intersect(EnumSet.of(Feature.HEARTBEAT, Feature.ACK), EnumSet.of(Feature.ACK));
+ assertThat(ackOnly).containsExactly(Feature.ACK);
+ Set none =
+ Capabilities.intersect(EnumSet.of(Feature.HEARTBEAT), EnumSet.of(Feature.ACK));
+ assertThat(none).isEmpty();
+ }
+
+ @Test
+ void featuresWireIsSorted() {
+ Capabilities caps = Capabilities.of(EnumSet.of(Feature.HEARTBEAT, Feature.ACK));
+ assertThat(caps.featuresWire()).containsExactly("ack", "heartbeat");
+ }
+
+ @Test
+ void envelopeBuilderCarriesConditionalFields() {
+ Envelope env =
+ Envelope.builder("session.ping")
+ .id(MessageId.of("m1"))
+ .sessionId(SessionId.of("s1"))
+ .traceId(TraceId.of("t1"))
+ .jobId(JobId.of("j1"))
+ .eventSeq(7L)
+ .payload(JsonNodeFactory.instance.objectNode())
+ .build();
+ assertThat(env.arcp()).isEqualTo(Envelope.VERSION);
+ assertThat(env.id()).isEqualTo(MessageId.of("m1"));
+ assertThat(env.sessionId()).isEqualTo(SessionId.of("s1"));
+ assertThat(env.traceId()).isEqualTo(TraceId.of("t1"));
+ assertThat(env.jobId()).isEqualTo(JobId.of("j1"));
+ assertThat(env.eventSeq()).isEqualTo(7L);
+ }
+
+ @Test
+ void envelopeBuilderRequiresPayload() {
+ assertThatThrownBy(() -> Envelope.builder("session.ping").build())
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void encodePayloadRejectsNonObjectTrees() {
+ ObjectMapper custom = new ObjectMapper();
+ SimpleModule module = new SimpleModule();
+ module.addSerializer(
+ SessionAck.class,
+ new StdSerializer<>(SessionAck.class) {
+ @Override
+ public void serialize(SessionAck value, JsonGenerator gen, SerializerProvider provider)
+ throws IOException {
+ gen.writeString("not-an-object");
+ }
+ });
+ custom.registerModule(module);
+ assertThatThrownBy(() -> Messages.encodePayload(custom, new SessionAck(1)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must be an object");
+ }
+
+ @Test
+ void errorPayloadDefaultsRetryabilityFromCode() throws Exception {
+ ErrorPayload defaulted =
+ mapper.readValue("{\"code\":\"TIMEOUT\",\"message\":\"m\"}", ErrorPayload.class);
+ assertThat(defaulted.retryable()).isTrue();
+ ErrorPayload explicit =
+ mapper.readValue(
+ "{\"code\":\"TIMEOUT\",\"message\":\"m\",\"retryable\":false}", ErrorPayload.class);
+ assertThat(explicit.retryable()).isFalse();
+ assertThat(ErrorPayload.of(ErrorCode.CANCELLED, "stop").retryable()).isFalse();
+ }
+
+ @Test
+ void errorCodeWireMapping() {
+ assertThat(ErrorCode.fromWire("TIMEOUT")).isEqualTo(ErrorCode.TIMEOUT);
+ assertThat(ErrorCode.fromWire("NOT_A_CODE")).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ assertThat(ErrorCode.TIMEOUT.wire()).isEqualTo("TIMEOUT");
+ assertThat(ErrorCode.HEARTBEAT_LOST.retryable()).isTrue();
+ assertThat(ErrorCode.PERMISSION_DENIED.retryable()).isFalse();
+ }
+
+ @Test
+ void credentialSchemeWireMapping() {
+ assertThat(CredentialScheme.fromWire("bearer")).isEqualTo(CredentialScheme.BEARER);
+ assertThat(CredentialScheme.fromWire("signed_url")).isEqualTo(CredentialScheme.UNKNOWN);
+ assertThat(CredentialScheme.BEARER.isBearer()).isTrue();
+ assertThat(CredentialScheme.UNKNOWN.isBearer()).isFalse();
+ assertThat(CredentialScheme.BEARER.wire()).isEqualTo("bearer");
+ }
+
+ @Test
+ void sessionHelloDefaultsMissingCapabilities() {
+ SessionHello hello =
+ new SessionHello(new ClientInfo("c", "1"), Auth.anonymous(), null, null, null);
+ assertThat(hello.capabilities().features()).isEmpty();
+ assertThat(hello.kind()).isEqualTo(Message.Type.SESSION_HELLO);
+ }
+
+ @Test
+ void agentDescriptorDefaultsVersions() {
+ AgentDescriptor descriptor = new AgentDescriptor("echo", null, null);
+ assertThat(descriptor.versions()).isEmpty();
+ AgentDescriptor versioned = new AgentDescriptor("echo", List.of("1.0.0"), "1.0.0");
+ assertThat(versioned.versions()).containsExactly("1.0.0");
+ }
+
+ @Test
+ void jobFilterCopiesStatusAndSupportsAll() {
+ JobFilter filter = new JobFilter(List.of("running"), "echo", Instant.EPOCH);
+ assertThat(filter.status()).containsExactly("running");
+ assertThat(JobFilter.all().status()).isNull();
+ }
+
+ @Test
+ void idsExposeWireValueThroughToString() {
+ assertThat(JobId.of("j1")).hasToString("j1");
+ assertThat(JobId.generate().asString()).startsWith("job_");
+ assertThat(MessageId.of("m1")).hasToString("m1");
+ assertThat(SessionId.of("s1")).hasToString("s1");
+ assertThat(ResultId.of("r1")).hasToString("r1");
+ assertThat(TraceId.of("t1")).hasToString("t1");
+ }
+
+ @Test
+ void authFactories() {
+ assertThat(Auth.anonymous().scheme()).isEqualTo(Auth.ANONYMOUS);
+ assertThat(Auth.anonymous().token()).isNull();
+ assertThat(Auth.bearer("tok").scheme()).isEqualTo(Auth.BEARER);
+ }
+
+ @Test
+ void messageTypeWireMappingIncludesLegacyByeAlias() {
+ assertThat(Message.Type.fromWire("session.close")).isEqualTo(Message.Type.SESSION_BYE);
+ assertThat(Message.Type.fromWire("session.bye")).isEqualTo(Message.Type.SESSION_BYE);
+ assertThatThrownBy(() -> Message.Type.fromWire("session.nope"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unknown message type");
+ }
+
+ @Test
+ void eventKindWireMappingRejectsUnknown() {
+ assertThat(EventBody.Kind.fromWire("log")).isEqualTo(EventBody.Kind.LOG);
+ assertThatThrownBy(() -> EventBody.Kind.fromWire("nope"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unknown event kind");
+ }
+}
diff --git a/arcp-core/src/test/java/dev/arcp/core/coverage/EventAndConstraintCoverageTest.java b/arcp-core/src/test/java/dev/arcp/core/coverage/EventAndConstraintCoverageTest.java
new file mode 100644
index 0000000..9906a99
--- /dev/null
+++ b/arcp-core/src/test/java/dev/arcp/core/coverage/EventAndConstraintCoverageTest.java
@@ -0,0 +1,142 @@
+package dev.arcp.core.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.auth.BearerVerifier;
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.credentials.CredentialConstraints;
+import dev.arcp.core.error.UnauthenticatedException;
+import dev.arcp.core.events.EventBody;
+import dev.arcp.core.events.MetricEvent;
+import dev.arcp.core.events.ProgressEvent;
+import dev.arcp.core.events.ResultChunkEvent;
+import dev.arcp.core.events.StatusEvent;
+import dev.arcp.core.ids.ResultId;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.wire.ArcpMapper;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Branch coverage for event-body validation, credential/lease constraints, and bearer auth. */
+class EventAndConstraintCoverageTest {
+
+ private final ObjectMapper mapper = ArcpMapper.shared();
+
+ @Test
+ void progressEventValidatesBounds() {
+ ProgressEvent withTotal = new ProgressEvent(5, 10L, "files", "halfway");
+ assertThat(withTotal.kind()).isEqualTo(EventBody.Kind.PROGRESS);
+ ProgressEvent withoutTotal = new ProgressEvent(0, null, null, null);
+ assertThat(withoutTotal.total()).isNull();
+ assertThatThrownBy(() -> new ProgressEvent(-1, null, null, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("current");
+ assertThatThrownBy(() -> new ProgressEvent(0, -1L, null, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("total");
+ }
+
+ @Test
+ void resultChunkEventValidatesSeqAndEncoding() {
+ ResultId rid = ResultId.of("r1");
+ assertThat(new ResultChunkEvent(rid, 0, "hi", ResultChunkEvent.UTF8, true).kind())
+ .isEqualTo(EventBody.Kind.RESULT_CHUNK);
+ assertThatCode(() -> new ResultChunkEvent(rid, 1, "aGk=", ResultChunkEvent.BASE64, false))
+ .doesNotThrowAnyException();
+ assertThatThrownBy(() -> new ResultChunkEvent(rid, -1, "x", ResultChunkEvent.UTF8, true))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("chunk_seq");
+ assertThatThrownBy(() -> new ResultChunkEvent(rid, 0, "x", "hex", true))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("encoding");
+ }
+
+ @Test
+ void metricEventCopiesDimensions() {
+ MetricEvent bare = new MetricEvent("latency", new BigDecimal("1.5"), null, null);
+ assertThat(bare.dimensions()).isNull();
+ assertThat(bare.kind()).isEqualTo(EventBody.Kind.METRIC);
+ MetricEvent dimensioned =
+ new MetricEvent("latency", BigDecimal.ONE, "ms", Map.of("region", "us"));
+ assertThat(dimensioned.dimensions()).containsEntry("region", "us");
+ }
+
+ @Test
+ void statusEventTwoArgConstructorOmitsDetails() {
+ StatusEvent status = new StatusEvent("running", "warming up");
+ assertThat(status.details()).isNull();
+ assertThat(status.kind()).isEqualTo(EventBody.Kind.STATUS);
+ }
+
+ @Test
+ void credentialConstraintsCopiesListsAndAllowsNulls() {
+ CredentialConstraints empty = new CredentialConstraints(null, null, null);
+ assertThat(empty.costBudget()).isNull();
+ assertThat(empty.modelUse()).isNull();
+ CredentialConstraints full =
+ new CredentialConstraints(List.of("usd:1"), List.of("gpt*"), Instant.EPOCH);
+ assertThat(full.costBudget()).containsExactly("usd:1");
+ assertThat(full.modelUse()).containsExactly("gpt*");
+ }
+
+ @Test
+ void leaseConstraintsFactoriesAndJsonDefaults() throws Exception {
+ assertThat(LeaseConstraints.none().expiresAt()).isNull();
+ Instant expiry = Instant.parse("2030-01-01T00:00:00Z");
+ assertThat(LeaseConstraints.of(expiry).expiresAtJson()).isEqualTo(expiry);
+ assertThat(mapper.readValue("{}", LeaseConstraints.class)).isEqualTo(LeaseConstraints.none());
+ LeaseConstraints parsed =
+ mapper.readValue("{\"expires_at\":\"2030-01-01T00:00:00Z\"}", LeaseConstraints.class);
+ assertThat(parsed.expiresAt()).isEqualTo(expiry);
+ }
+
+ @Test
+ void parseStrictUtcAcceptsOnlyZSuffix() {
+ assertThat(LeaseConstraints.parseStrictUtc("2030-01-01T00:00:00Z"))
+ .isEqualTo(Instant.parse("2030-01-01T00:00:00Z"));
+ assertThatThrownBy(() -> LeaseConstraints.parseStrictUtc("2030-01-01T00:00:00+02:00"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must be UTC");
+ assertThatThrownBy(() -> LeaseConstraints.parseStrictUtc("2030-01-01T00:00:00+00:00"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Z suffix");
+ assertThatThrownBy(() -> LeaseConstraints.parseStrictUtc("not-a-date"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid expires_at");
+ }
+
+ @Test
+ void staticTokenVerifierComparesInConstantTime() throws Exception {
+ Principal alice = new Principal("alice");
+ BearerVerifier verifier = BearerVerifier.staticToken("hunter2", alice);
+ assertThat(verifier.verify("hunter2")).isEqualTo(alice);
+ assertThatThrownBy(() -> verifier.verify("hunter3"))
+ .isInstanceOf(UnauthenticatedException.class);
+ assertThatThrownBy(() -> verifier.verify("short")).isInstanceOf(UnauthenticatedException.class);
+ assertThatThrownBy(() -> verifier.verify(null)).isInstanceOf(UnauthenticatedException.class);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"tok-a", "tok-b"})
+ void acceptAnyDerivesStablePrincipalFromDigest(String token) throws Exception {
+ BearerVerifier verifier = BearerVerifier.acceptAny();
+ Principal first = verifier.verify(token);
+ assertThat(first.id()).startsWith("bearer:").hasSize("bearer:".length() + 32);
+ assertThat(verifier.verify(token)).isEqualTo(first);
+ }
+
+ @Test
+ void acceptAnyRejectsMissingTokens() {
+ BearerVerifier verifier = BearerVerifier.acceptAny();
+ assertThatThrownBy(() -> verifier.verify(null)).isInstanceOf(UnauthenticatedException.class);
+ assertThatThrownBy(() -> verifier.verify("")).isInstanceOf(UnauthenticatedException.class);
+ }
+}
diff --git a/arcp-core/src/test/java/dev/arcp/core/coverage/LeaseCoverageTest.java b/arcp-core/src/test/java/dev/arcp/core/coverage/LeaseCoverageTest.java
new file mode 100644
index 0000000..8260f69
--- /dev/null
+++ b/arcp-core/src/test/java/dev/arcp/core/coverage/LeaseCoverageTest.java
@@ -0,0 +1,139 @@
+package dev.arcp.core.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.wire.ArcpMapper;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Branch coverage for {@link Lease} glob matching, budget arithmetic, and identity. */
+class LeaseCoverageTest {
+
+ @ParameterizedTest(name = "covers({0}, {1}) == {2}")
+ @CsvSource({
+ "'/a/b', '/a/b', true",
+ "'**', '/x/y', true",
+ "'*', 'file', true",
+ "'*', 'd/file', false",
+ "'/a/**', '/a/b/c', true",
+ "'/a/**', '/b/c', false",
+ "'/a/*', '/a/b', true",
+ "'/a/*', '/a/b/c', false",
+ "'/a/*', '/b/x', false",
+ "'abc', 'def', false",
+ })
+ void coversMatrix(String parentPattern, String childPattern, boolean expected) {
+ Lease parent = Lease.builder().allow("fs.read", parentPattern).build();
+ Lease child = Lease.builder().allow("fs.read", childPattern).build();
+ assertThat(parent.contains(child)).isEqualTo(expected);
+ }
+
+ @Test
+ void budgetParsesAmountsAndMergesDuplicateCurrencies() {
+ Lease lease = Lease.builder().allow("cost.budget", "usd:5.50", "usd:1.25", "eur:3").build();
+ Map budget = lease.budget();
+ assertThat(budget)
+ .containsEntry("usd", new BigDecimal("6.75"))
+ .containsEntry("eur", new BigDecimal("3"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"usd", ":5", "usd:"})
+ void budgetRejectsMalformedPatterns(String pattern) {
+ Lease lease = Lease.builder().allow("cost.budget", pattern).build();
+ assertThatThrownBy(lease::budget)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid cost.budget pattern");
+ }
+
+ @Test
+ void containsComparesBudgetsNumerically() {
+ Lease parent = Lease.builder().allow("cost.budget", "usd:10").build();
+ assertThat(parent.contains(Lease.builder().allow("cost.budget", "usd:5").build())).isTrue();
+ assertThat(parent.contains(Lease.builder().allow("cost.budget", "usd:10").build())).isTrue();
+ assertThat(parent.contains(Lease.builder().allow("cost.budget", "usd:10.01").build()))
+ .isFalse();
+ assertThat(parent.contains(Lease.builder().allow("cost.budget", "eur:1").build())).isFalse();
+ }
+
+ @Test
+ void containsRequiresParentNamespacePresence() {
+ Lease parent = Lease.builder().allow("fs.read", "/a/**").build();
+ assertThat(parent.contains(Lease.builder().allow("net.fetch", "example.com").build()))
+ .isFalse();
+ assertThat(parent.contains(Lease.empty())).isTrue();
+ }
+
+ @Test
+ void containsAcceptsMixedNamespaceSubset() {
+ Lease parent =
+ Lease.builder()
+ .allow("fs.read", "/data/**")
+ .allow("cost.budget", "usd:100")
+ .allow("tool.call", "*")
+ .build();
+ Lease child =
+ Lease.builder()
+ .allow("fs.read", "/data/in.txt")
+ .allow("cost.budget", "usd:1")
+ .allow("tool.call", "search")
+ .build();
+ assertThat(parent.contains(child)).isTrue();
+ }
+
+ @Test
+ void patternsReturnsEmptyForUnknownNamespace() {
+ assertThat(Lease.empty().patterns("fs.read")).isEmpty();
+ assertThat(Lease.empty().budget()).isEmpty();
+ }
+
+ @Test
+ void equalsHashCodeAndToString() {
+ Lease a = Lease.builder().allow("fs.read", "/a").build();
+ Lease b = Lease.builder().allow("fs.read", "/a").build();
+ Lease c = Lease.builder().allow("fs.read", "/b").build();
+ assertThat(a).isEqualTo(b).isNotEqualTo(c).isNotEqualTo("not-a-lease");
+ assertThat(a.hashCode()).isEqualTo(b.hashCode());
+ assertThat(a.toString()).contains("fs.read");
+ }
+
+ @Test
+ void builderMergesRepeatedAllowCalls() {
+ Lease lease =
+ Lease.builder().allow("fs.read", "/a").allow("fs.read", "/b").allow("fs.write").build();
+ assertThat(lease.patterns("fs.read")).containsExactly("/a", "/b");
+ assertThat(lease.patterns("fs.write")).isEmpty();
+ }
+
+ @Test
+ void fromJsonNullYieldsEmptyLease() throws Exception {
+ Method fromJson = Lease.class.getDeclaredMethod("fromJson", Map.class);
+ fromJson.setAccessible(true);
+ Lease lease = (Lease) fromJson.invoke(null, new Object[] {null});
+ assertThat(lease).isEqualTo(Lease.empty());
+ }
+
+ @Test
+ void jsonRoundTripPreservesCapabilities() throws Exception {
+ ObjectMapper mapper = ArcpMapper.shared();
+ Lease lease = mapper.readValue("{\"fs.read\":[\"/a/**\"]}", Lease.class);
+ assertThat(lease.patterns("fs.read")).containsExactly("/a/**");
+ assertThat(mapper.writeValueAsString(lease)).isEqualTo("{\"fs.read\":[\"/a/**\"]}");
+ }
+
+ @Test
+ void capabilitiesViewIsImmutable() {
+ Lease lease = Lease.builder().allow("fs.read", "/a").build();
+ assertThatThrownBy(() -> lease.capabilities().put("x", List.of()))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+}
diff --git a/arcp-core/src/test/java/dev/arcp/core/coverage/LeaseSubsetCoverageTest.java b/arcp-core/src/test/java/dev/arcp/core/coverage/LeaseSubsetCoverageTest.java
new file mode 100644
index 0000000..f0a78f0
--- /dev/null
+++ b/arcp-core/src/test/java/dev/arcp/core/coverage/LeaseSubsetCoverageTest.java
@@ -0,0 +1,102 @@
+package dev.arcp.core.coverage;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.arcp.core.error.LeaseSubsetViolationException;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseSubset;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/** Branch coverage for §9.4 delegation subset validation. */
+class LeaseSubsetCoverageTest {
+
+ private static final Instant PARENT_EXPIRY = Instant.parse("2030-01-01T00:00:00Z");
+
+ @Test
+ void acceptsCoveredPatternsWithUnboundedParentExpiry() {
+ Lease parent = Lease.builder().allow("fs.read", "/a/**").build();
+ Lease child = Lease.builder().allow("fs.read", "/a/b").build();
+ assertThatCode(() -> LeaseSubset.validate(parent, null, child, null))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void rejectsNamespaceNotGrantedByParent() {
+ Lease parent = Lease.builder().allow("fs.read", "/a/**").build();
+ Lease child = Lease.builder().allow("net.fetch", "example.com").build();
+ assertThatThrownBy(() -> LeaseSubset.validate(parent, null, child, null))
+ .isInstanceOf(LeaseSubsetViolationException.class)
+ .hasMessageContaining("namespace not granted");
+ }
+
+ @Test
+ void rejectsPatternsExceedingParent() {
+ Lease parent = Lease.builder().allow("fs.read", "/a/*").build();
+ Lease child = Lease.builder().allow("fs.read", "/a/b/c").build();
+ assertThatThrownBy(() -> LeaseSubset.validate(parent, null, child, null))
+ .isInstanceOf(LeaseSubsetViolationException.class)
+ .hasMessageContaining("exceed parent");
+ }
+
+ @Test
+ void acceptsBudgetWithinParentAllowance() {
+ Lease parent = Lease.builder().allow("cost.budget", "usd:10").build();
+ Lease child = Lease.builder().allow("cost.budget", "usd:10").build();
+ assertThatCode(() -> LeaseSubset.validate(parent, null, child, null))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void rejectsBudgetCurrencyNotGrantedByParent() {
+ Lease parent = Lease.builder().allow("cost.budget", "usd:10").build();
+ Lease child = Lease.builder().allow("cost.budget", "eur:1").build();
+ assertThatThrownBy(() -> LeaseSubset.validate(parent, null, child, null))
+ .isInstanceOf(LeaseSubsetViolationException.class)
+ .hasMessageContaining("currency not granted");
+ }
+
+ @Test
+ void rejectsBudgetExceedingParentAmount() {
+ Lease parent = Lease.builder().allow("cost.budget", "usd:10").build();
+ Lease child = Lease.builder().allow("cost.budget", "usd:10.50").build();
+ assertThatThrownBy(() -> LeaseSubset.validate(parent, null, child, null))
+ .isInstanceOf(LeaseSubsetViolationException.class)
+ .hasMessageContaining("exceeds parent");
+ }
+
+ @Test
+ void rejectsUnboundedChildExpiryWhenParentIsBounded() {
+ Lease lease = Lease.builder().allow("fs.read", "/a").build();
+ assertThatThrownBy(() -> LeaseSubset.validate(lease, PARENT_EXPIRY, lease, null))
+ .isInstanceOf(LeaseSubsetViolationException.class)
+ .hasMessageContaining("expires_at");
+ }
+
+ @Test
+ void rejectsChildExpiryAfterParentExpiry() {
+ Lease lease = Lease.builder().allow("fs.read", "/a").build();
+ Instant later = PARENT_EXPIRY.plusSeconds(1);
+ assertThatThrownBy(() -> LeaseSubset.validate(lease, PARENT_EXPIRY, lease, later))
+ .isInstanceOf(LeaseSubsetViolationException.class)
+ .hasMessageContaining("exceeds parent");
+ }
+
+ @Test
+ void acceptsChildExpiryAtOrBeforeParentExpiry() {
+ Lease lease = Lease.builder().allow("fs.read", "/a").build();
+ assertThatCode(() -> LeaseSubset.validate(lease, PARENT_EXPIRY, lease, PARENT_EXPIRY))
+ .doesNotThrowAnyException();
+ assertThatCode(
+ () -> LeaseSubset.validate(lease, PARENT_EXPIRY, lease, PARENT_EXPIRY.minusSeconds(60)))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void acceptsAnyChildExpiryWhenParentIsUnbounded() {
+ Lease lease = Lease.builder().allow("fs.read", "/a").build();
+ assertThatCode(() -> LeaseSubset.validate(lease, null, lease, PARENT_EXPIRY))
+ .doesNotThrowAnyException();
+ }
+}
diff --git a/arcp-core/src/test/java/dev/arcp/core/coverage/TransportEdgeCoverageTest.java b/arcp-core/src/test/java/dev/arcp/core/coverage/TransportEdgeCoverageTest.java
new file mode 100644
index 0000000..b306fdc
--- /dev/null
+++ b/arcp-core/src/test/java/dev/arcp/core/coverage/TransportEdgeCoverageTest.java
@@ -0,0 +1,204 @@
+package dev.arcp.core.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.transport.MemoryTransport;
+import dev.arcp.core.transport.StdioTransport;
+import dev.arcp.core.wire.Envelope;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+/** Branch coverage for transport edge cases: closed endpoints and stdio framing failures. */
+class TransportEdgeCoverageTest {
+
+ private static Envelope envelope(String type) {
+ return Envelope.builder(type)
+ .id(MessageId.of("m_edge"))
+ .payload(JsonNodeFactory.instance.objectNode())
+ .build();
+ }
+
+ @Test
+ void memoryTransportRejectsSendAfterOwnClose() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ pair.runtime().close();
+ assertThatThrownBy(() -> pair.runtime().send(envelope("session.ping")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("transport closed");
+ pair.client().close();
+ }
+
+ @Test
+ void stdioTransportNullMapperFallsBackToShared() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ StdioTransport transport = new StdioTransport(new ByteArrayInputStream(new byte[0]), out, null);
+ transport.send(envelope("session.ping"));
+ assertThat(out.toString(StandardCharsets.UTF_8)).contains("\"type\":\"session.ping\"");
+ transport.close();
+ }
+
+ @Test
+ void stdioTransportReadFailureClosesInboundExceptionally() throws Exception {
+ InputStream failing =
+ new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("read boom");
+ }
+ };
+ StdioTransport transport = new StdioTransport(failing, new ByteArrayOutputStream());
+ CountDownLatch errored = new CountDownLatch(1);
+ transport.incoming().subscribe(signalSubscriber(null, errored));
+ transport.start();
+ assertThat(errored.await(3, TimeUnit.SECONDS)).isTrue();
+ transport.close();
+ }
+
+ @Test
+ void stdioTransportWrapsWriteFailures() {
+ OutputStream failing =
+ new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ throw new IOException("write boom");
+ }
+ };
+ StdioTransport transport = new StdioTransport(new ByteArrayInputStream(new byte[0]), failing);
+ assertThatThrownBy(() -> transport.send(envelope("session.ping")))
+ .isInstanceOf(UncheckedIOException.class);
+ }
+
+ @Test
+ void stdioTransportCloseSwallowsStreamCloseFailures() {
+ InputStream inThrowsOnClose =
+ new InputStream() {
+ @Override
+ public int read() {
+ return -1;
+ }
+
+ @Override
+ public void close() throws IOException {
+ throw new IOException("in close boom");
+ }
+ };
+ OutputStream outThrowsOnClose =
+ new OutputStream() {
+ @Override
+ public void write(int b) {}
+
+ @Override
+ public void close() throws IOException {
+ throw new IOException("out close boom");
+ }
+ };
+ StdioTransport transport = new StdioTransport(inThrowsOnClose, outThrowsOnClose);
+ assertThatCode(transport::close).doesNotThrowAnyException();
+ }
+
+ /**
+ * Drives the reader loop into its {@code closed}-flag exits: the loop condition re-check after an
+ * empty line, and the IOException swallow once {@code close()} has begun. The input stream blocks
+ * until the transport's writer (closed first in {@code close()}) signals that the closed flag is
+ * already set, then either yields an empty line or throws.
+ */
+ @Test
+ void stdioTransportReaderObservesCloseFlagDeterministically() throws Exception {
+ for (boolean throwInsteadOfLine : new boolean[] {false, true}) {
+ CountDownLatch readEntered = new CountDownLatch(1);
+ CountDownLatch closeStarted = new CountDownLatch(1);
+ InputStream blocking =
+ new InputStream() {
+ private boolean delivered;
+
+ @Override
+ public int read() throws IOException {
+ readEntered.countDown();
+ try {
+ closeStarted.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted", e);
+ }
+ if (throwInsteadOfLine) {
+ throw new IOException("stream torn down");
+ }
+ if (!delivered) {
+ delivered = true;
+ return '\n';
+ }
+ return -1;
+ }
+ };
+ OutputStream signalingOut =
+ new OutputStream() {
+ @Override
+ public void write(int b) {}
+
+ @Override
+ public void close() {
+ // StdioTransport.close() closes the writer after setting the closed flag and
+ // before closing the reader, so this signal proves closed == true.
+ closeStarted.countDown();
+ }
+ };
+ StdioTransport transport = new StdioTransport(blocking, signalingOut);
+ transport.incoming().subscribe(signalSubscriber(null, null));
+ transport.start();
+ assertThat(readEntered.await(3, TimeUnit.SECONDS)).isTrue();
+ Thread closer = new Thread(transport::close, "stdio-closer");
+ closer.start();
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ assertThat(closer.isAlive()).isFalse();
+ Thread readerThread = readerThreadOf(transport);
+ readerThread.join(TimeUnit.SECONDS.toMillis(5));
+ assertThat(readerThread.isAlive()).isFalse();
+ }
+ }
+
+ private static Thread readerThreadOf(StdioTransport transport) throws Exception {
+ java.lang.reflect.Field field = StdioTransport.class.getDeclaredField("readerThread");
+ field.setAccessible(true);
+ return (Thread) field.get(transport);
+ }
+
+ private static Flow.Subscriber signalSubscriber(
+ CountDownLatch onComplete, CountDownLatch onError) {
+ return new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {}
+
+ @Override
+ public void onError(Throwable throwable) {
+ if (onError != null) {
+ onError.countDown();
+ }
+ }
+
+ @Override
+ public void onComplete() {
+ if (onComplete != null) {
+ onComplete.countDown();
+ }
+ }
+ };
+ }
+}
diff --git a/arcp-middleware-jakarta/pom.xml b/arcp-middleware-jakarta/pom.xml
index d31c29d..9153f94 100644
--- a/arcp-middleware-jakarta/pom.xml
+++ b/arcp-middleware-jakarta/pom.xml
@@ -11,6 +11,10 @@
arcp-middleware-jakarta
+
+
+ false
+
arcp-middleware-jakarta
Jakarta WebSocket endpoint adapter for ARCP runtimes.
diff --git a/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaAdapter.java b/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaAdapter.java
index 2055712..5930ebd 100644
--- a/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaAdapter.java
+++ b/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaAdapter.java
@@ -38,11 +38,21 @@ private ArcpJakartaAdapter(Builder b) {
this.allowedOrigins = List.copyOf(b.allowedOrigins);
}
+ /**
+ * Returns a new builder.
+ *
+ * @return a fresh builder with the default {@code /arcp} path and empty allowlists
+ */
public static Builder builder() {
return new Builder();
}
- /** Build the {@link ServerEndpointConfig} ready to hand to a container. */
+ /**
+ * Builds the {@link ServerEndpointConfig} ready to hand to a container.
+ *
+ * @return a config that mounts an ARCP WebSocket endpoint, serving JSON envelopes as text frames
+ * per §4, at the configured path
+ */
public ServerEndpointConfig serverEndpointConfig() {
// The Jakarta WebSocket API offers no veto in modifyHandshake: clearing response headers does
// not stop the server from completing the upgrade, so a client that ignores
@@ -89,48 +99,104 @@ public void modifyHandshake(
return config;
}
+ /**
+ * Returns the runtime accepted sessions are handed to.
+ *
+ * @return the configured {@link ArcpRuntime}
+ */
public ArcpRuntime runtime() {
return runtime;
}
+ /**
+ * Returns the request path the endpoint is mounted at.
+ *
+ * @return the endpoint path, {@code /arcp} by default
+ */
public String path() {
return path;
}
+ /**
+ * Returns the {@code Host} header allowlist enforced during the handshake (§14).
+ *
+ * @return the allowed {@code Host} values; empty means all hosts are accepted
+ */
public List allowedHosts() {
return allowedHosts;
}
+ /**
+ * Returns the {@code Origin} header allowlist checked during the handshake (§14).
+ *
+ * @return the allowed {@code Origin} values; empty means all origins are accepted
+ */
public List allowedOrigins() {
return allowedOrigins;
}
+ /** Builder for {@link ArcpJakartaAdapter}. */
public static final class Builder {
private ArcpRuntime runtime;
private String path = "/arcp";
private List allowedHosts = List.of();
private List allowedOrigins = List.of();
+ /** Creates a builder with the default {@code /arcp} path and empty allowlists. */
+ public Builder() {}
+
+ /**
+ * Sets the runtime that accepted sessions are handed to; required.
+ *
+ * @param runtime the ARCP runtime
+ * @return this builder
+ */
public Builder runtime(ArcpRuntime runtime) {
this.runtime = runtime;
return this;
}
+ /**
+ * Sets the request path the endpoint is mounted at; defaults to {@code /arcp}.
+ *
+ * @param path the endpoint path
+ * @return this builder
+ */
public Builder path(String path) {
this.path = path;
return this;
}
+ /**
+ * Sets the {@code Host} header allowlist; sessions from any other host are closed before the
+ * runtime sees them (§14). An empty list (the default) disables the check.
+ *
+ * @param hosts the allowed {@code Host} header values
+ * @return this builder
+ */
public Builder allowedHosts(List hosts) {
this.allowedHosts = List.copyOf(hosts);
return this;
}
+ /**
+ * Sets the {@code Origin} header allowlist; handshakes from any other origin are refused (§14).
+ * An empty list (the default) accepts all origins.
+ *
+ * @param origins the allowed {@code Origin} header values
+ * @return this builder
+ */
public Builder allowedOrigins(List origins) {
this.allowedOrigins = List.copyOf(origins);
return this;
}
+ /**
+ * Builds the adapter.
+ *
+ * @return the configured adapter; pass {@link ArcpJakartaAdapter#serverEndpointConfig()} to the
+ * container
+ */
public ArcpJakartaAdapter build() {
return new ArcpJakartaAdapter(this);
}
diff --git a/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaEndpoint.java b/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaEndpoint.java
index ec23d3c..50b7b27 100644
--- a/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaEndpoint.java
+++ b/arcp-middleware-jakarta/src/main/java/dev/arcp/middleware/jakarta/ArcpJakartaEndpoint.java
@@ -11,7 +11,8 @@
import org.slf4j.LoggerFactory;
/**
- * Jakarta WebSocket endpoint that adapts every accepted session into an ARCP {@link Transport}.
+ * Jakarta WebSocket endpoint that adapts every accepted session into an ARCP {@link
+ * dev.arcp.core.transport.Transport}.
*
* The {@link ArcpRuntime} instance is sourced from the endpoint configuration's {@link
* EndpointConfig#getUserProperties() user properties} under {@link #RUNTIME_KEY}. The companion
@@ -21,11 +22,19 @@ public final class ArcpJakartaEndpoint extends Endpoint {
private static final Logger log = LoggerFactory.getLogger(ArcpJakartaEndpoint.class);
+ /**
+ * {@link EndpointConfig#getUserProperties()} key under which the {@link ArcpRuntime} rides;
+ * {@link ArcpJakartaAdapter#serverEndpointConfig()} populates it automatically.
+ */
public static final String RUNTIME_KEY = ArcpRuntime.class.getName();
private final Map transports = new ConcurrentHashMap<>();
private final boolean hostRejected;
+ /**
+ * Creates an endpoint that treats the handshake's {@code Host} as allowed; containers
+ * instantiating the endpoint outside {@link ArcpJakartaAdapter}'s configurator use this form.
+ */
public ArcpJakartaEndpoint() {
this(false);
}
diff --git a/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaEndpointLifecycleTest.java b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaEndpointLifecycleTest.java
new file mode 100644
index 0000000..e439392
--- /dev/null
+++ b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaEndpointLifecycleTest.java
@@ -0,0 +1,109 @@
+package dev.arcp.middleware.jakarta.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.middleware.jakarta.ArcpJakartaAdapter;
+import dev.arcp.middleware.jakarta.ArcpJakartaEndpoint;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import jakarta.websocket.CloseReason;
+import jakarta.websocket.server.ServerEndpointConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers {@link ArcpJakartaEndpoint} message decode/dispatch wiring and the close/error callbacks,
+ * including the missing-runtime guard.
+ */
+class JakartaEndpointLifecycleTest {
+
+ private ArcpRuntime runtime;
+
+ @BeforeEach
+ void startRuntime() {
+ runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ }
+
+ @AfterEach
+ void stopRuntime() {
+ runtime.close();
+ }
+
+ private ServerEndpointConfig configWithRuntime() {
+ return ArcpJakartaAdapter.builder()
+ .runtime(runtime)
+ .path("/arcp")
+ .build()
+ .serverEndpointConfig();
+ }
+
+ @Test
+ void missingRuntimeClosesWithUnexpectedCondition() {
+ ServerEndpointConfig bare =
+ ServerEndpointConfig.Builder.create(ArcpJakartaEndpoint.class, "/arcp").build();
+ ArcpJakartaEndpoint endpoint = new ArcpJakartaEndpoint();
+
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ endpoint.onOpen(JakartaTestSupport.session("no-runtime", rec), bare);
+
+ assertThat(rec.closeReasons).hasSize(1);
+ assertThat(rec.closeReasons.get(0).getCloseCode())
+ .isEqualTo(CloseReason.CloseCodes.UNEXPECTED_CONDITION);
+ assertThat(rec.textHandler.get()).isNull();
+ }
+
+ @Test
+ void missingRuntimeCloseFailureIsSwallowed() {
+ ServerEndpointConfig bare =
+ ServerEndpointConfig.Builder.create(ArcpJakartaEndpoint.class, "/arcp").build();
+ ArcpJakartaEndpoint endpoint = new ArcpJakartaEndpoint();
+
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ endpoint.onOpen(JakartaTestSupport.session("no-runtime-throwing", rec, true, false), bare);
+
+ assertThat(rec.closeReasons).isEmpty();
+ assertThat(rec.textHandler.get()).isNull();
+ }
+
+ @Test
+ void openDeliverCloseLifecycle() throws Exception {
+ ServerEndpointConfig config = configWithRuntime();
+ ArcpJakartaEndpoint endpoint = new ArcpJakartaEndpoint();
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+
+ endpoint.onOpen(JakartaTestSupport.session("lifecycle", rec), config);
+ assertThat(rec.textHandler.get()).isNotNull();
+
+ // Decode + dispatch: a well-formed envelope frame reaches the transport...
+ rec.textHandler.get().onMessage(JakartaTestSupport.pingFrame());
+ // ...and a malformed frame is dropped without breaking the session.
+ rec.textHandler.get().onMessage("this is not an envelope");
+
+ // Close for a tracked session completes the inbound stream; a second close for the same id
+ // hits the already-removed branch.
+ CloseReason normal = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "bye");
+ endpoint.onClose(JakartaTestSupport.session("lifecycle", rec), normal);
+ endpoint.onClose(JakartaTestSupport.session("lifecycle", rec), normal);
+ endpoint.onError(JakartaTestSupport.session("lifecycle", rec), new RuntimeException("late"));
+ }
+
+ @Test
+ void errorCallbackFailsInboundForTrackedSession() {
+ ServerEndpointConfig config = configWithRuntime();
+ ArcpJakartaEndpoint endpoint = new ArcpJakartaEndpoint();
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+
+ endpoint.onOpen(JakartaTestSupport.session("erroring", rec), config);
+ assertThat(rec.textHandler.get()).isNotNull();
+
+ endpoint.onError(JakartaTestSupport.session("erroring", rec), new RuntimeException("boom"));
+ // The transport was removed by onError; the close callback now sees no tracked transport.
+ endpoint.onClose(
+ JakartaTestSupport.session("erroring", rec),
+ new CloseReason(CloseReason.CloseCodes.CLOSED_ABNORMALLY, "gone"));
+ }
+}
diff --git a/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaHostAllowlistConfiguratorTest.java b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaHostAllowlistConfiguratorTest.java
new file mode 100644
index 0000000..7fd5c93
--- /dev/null
+++ b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaHostAllowlistConfiguratorTest.java
@@ -0,0 +1,205 @@
+package dev.arcp.middleware.jakarta.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.middleware.jakarta.ArcpJakartaAdapter;
+import dev.arcp.middleware.jakarta.ArcpJakartaEndpoint;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import jakarta.websocket.CloseReason;
+import jakarta.websocket.server.ServerEndpointConfig;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Exercises the per-handshake host-allowlist decision recording in the anonymous {@link
+ * ServerEndpointConfig.Configurator} built by {@link ArcpJakartaAdapter} and the resulting
+ * VIOLATED_POLICY close performed by {@link ArcpJakartaEndpoint} (#100).
+ */
+class JakartaHostAllowlistConfiguratorTest {
+
+ private static ArcpRuntime runtime;
+
+ @BeforeAll
+ static void startRuntime() {
+ runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ }
+
+ @AfterAll
+ static void stopRuntime() {
+ runtime.close();
+ }
+
+ static Stream hostDecisions() {
+ return Stream.of(
+ // Empty allowlist disables the check entirely (allow-all).
+ Arguments.of(List.of(), Map.of("Host", List.of("anything.example")), false),
+ Arguments.of(List.of(), Map.of(), false),
+ // Exact match is allowed.
+ Arguments.of(
+ List.of("agents.example.com"), Map.of("Host", List.of("agents.example.com")), false),
+ // Second entry of a multi-host allowlist matches too.
+ Arguments.of(
+ List.of("a.example", "b.example"), Map.of("Host", List.of("b.example")), false),
+ // Non-allowlisted host is rejected.
+ Arguments.of(
+ List.of("agents.example.com"), Map.of("Host", List.of("evil.example.com")), true),
+ // Missing Host header is rejected when an allowlist is configured.
+ Arguments.of(List.of("agents.example.com"), Map.of(), true),
+ // Present-but-empty Host header list is rejected.
+ Arguments.of(List.of("agents.example.com"), Map.of("Host", List.of()), true),
+ // Matching is exact: case differences are rejected.
+ Arguments.of(
+ List.of("agents.example.com"), Map.of("Host", List.of("AGENTS.EXAMPLE.COM")), true),
+ // Matching is exact: a port suffix is not stripped.
+ Arguments.of(
+ List.of("agents.example.com"),
+ Map.of("Host", List.of("agents.example.com:8443")),
+ true),
+ // ...but an allowlist entry that includes the port matches exactly.
+ Arguments.of(
+ List.of("agents.example.com:8443"),
+ Map.of("Host", List.of("agents.example.com:8443")),
+ false));
+ }
+
+ @ParameterizedTest
+ @MethodSource("hostDecisions")
+ void handshakeHostDecisionDrivesEndpointPolicyClose(
+ List allowlist, Map> headers, boolean expectRejected)
+ throws Exception {
+ ArcpJakartaAdapter adapter =
+ ArcpJakartaAdapter.builder().runtime(runtime).path("/arcp").allowedHosts(allowlist).build();
+ ServerEndpointConfig config = adapter.serverEndpointConfig();
+ ServerEndpointConfig.Configurator configurator = config.getConfigurator();
+
+ configurator.modifyHandshake(
+ config,
+ JakartaTestSupport.handshakeRequest(headers),
+ JakartaTestSupport.handshakeResponse());
+ ArcpJakartaEndpoint endpoint = configurator.getEndpointInstance(ArcpJakartaEndpoint.class);
+
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ endpoint.onOpen(JakartaTestSupport.session("matrix", rec), config);
+
+ if (expectRejected) {
+ assertThat(rec.closeReasons).hasSize(1);
+ assertThat(rec.closeReasons.get(0).getCloseCode())
+ .isEqualTo(CloseReason.CloseCodes.VIOLATED_POLICY);
+ assertThat(rec.textHandler.get()).as("runtime must never see a rejected session").isNull();
+ } else {
+ assertThat(rec.closeReasons).isEmpty();
+ assertThat(rec.textHandler.get()).as("accepted session reaches the runtime").isNotNull();
+ endpoint.onClose(
+ JakartaTestSupport.session("matrix", rec),
+ new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "done"));
+ }
+ }
+
+ @Test
+ void rejectionDecisionIsConsumedPerHandshake() throws Exception {
+ ArcpJakartaAdapter adapter =
+ ArcpJakartaAdapter.builder()
+ .runtime(runtime)
+ .allowedHosts(List.of("agents.example.com"))
+ .build();
+ ServerEndpointConfig config = adapter.serverEndpointConfig();
+ ServerEndpointConfig.Configurator configurator = config.getConfigurator();
+
+ configurator.modifyHandshake(
+ config,
+ JakartaTestSupport.handshakeRequest(Map.of()),
+ JakartaTestSupport.handshakeResponse());
+ ArcpJakartaEndpoint rejected = configurator.getEndpointInstance(ArcpJakartaEndpoint.class);
+ // The ThreadLocal decision is removed by getEndpointInstance: a follow-up instance created on
+ // the same thread without a new handshake falls back to "not rejected".
+ ArcpJakartaEndpoint fresh = configurator.getEndpointInstance(ArcpJakartaEndpoint.class);
+
+ JakartaTestSupport.SessionRecorder rejectedRec = new JakartaTestSupport.SessionRecorder();
+ rejected.onOpen(JakartaTestSupport.session("rejected", rejectedRec), config);
+ assertThat(rejectedRec.closeReasons).hasSize(1);
+ assertThat(rejectedRec.closeReasons.get(0).getCloseCode())
+ .isEqualTo(CloseReason.CloseCodes.VIOLATED_POLICY);
+
+ JakartaTestSupport.SessionRecorder freshRec = new JakartaTestSupport.SessionRecorder();
+ fresh.onOpen(JakartaTestSupport.session("fresh", freshRec), config);
+ assertThat(freshRec.closeReasons).isEmpty();
+ fresh.onClose(
+ JakartaTestSupport.session("fresh", freshRec),
+ new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "done"));
+ }
+
+ @Test
+ void violatedPolicyCloseFailureIsSwallowed() throws Exception {
+ ArcpJakartaAdapter adapter =
+ ArcpJakartaAdapter.builder()
+ .runtime(runtime)
+ .allowedHosts(List.of("agents.example.com"))
+ .build();
+ ServerEndpointConfig config = adapter.serverEndpointConfig();
+ ServerEndpointConfig.Configurator configurator = config.getConfigurator();
+
+ configurator.modifyHandshake(
+ config,
+ JakartaTestSupport.handshakeRequest(Map.of("Host", List.of("evil.example.com"))),
+ JakartaTestSupport.handshakeResponse());
+ ArcpJakartaEndpoint endpoint = configurator.getEndpointInstance(ArcpJakartaEndpoint.class);
+
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ // close() throws IOException; onOpen must treat the close as best-effort and not propagate.
+ endpoint.onOpen(JakartaTestSupport.session("throwing", rec, true, false), config);
+ assertThat(rec.closeReasons).isEmpty();
+ assertThat(rec.textHandler.get()).isNull();
+ }
+
+ @Test
+ void checkOriginIsAllowAllWhenNoOriginsConfigured() {
+ ArcpJakartaAdapter adapter = ArcpJakartaAdapter.builder().runtime(runtime).build();
+ ServerEndpointConfig.Configurator configurator =
+ adapter.serverEndpointConfig().getConfigurator();
+
+ assertThat(configurator.checkOrigin("https://anywhere.example")).isTrue();
+ assertThat(configurator.checkOrigin(null)).isTrue();
+ }
+
+ @Test
+ void checkOriginEnforcesConfiguredOrigins() {
+ ArcpJakartaAdapter adapter =
+ ArcpJakartaAdapter.builder()
+ .runtime(runtime)
+ .allowedOrigins(List.of("https://app.example.com"))
+ .build();
+ ServerEndpointConfig.Configurator configurator =
+ adapter.serverEndpointConfig().getConfigurator();
+
+ assertThat(configurator.checkOrigin("https://app.example.com")).isTrue();
+ assertThat(configurator.checkOrigin("https://evil.example.com")).isFalse();
+ assertThat(configurator.checkOrigin(null)).isFalse();
+ }
+
+ @Test
+ void builderExposesConfiguredState() {
+ ArcpJakartaAdapter adapter =
+ ArcpJakartaAdapter.builder()
+ .runtime(runtime)
+ .path("/custom")
+ .allowedHosts(List.of("agents.example.com"))
+ .allowedOrigins(List.of("https://app.example.com"))
+ .build();
+
+ assertThat(adapter.runtime()).isSameAs(runtime);
+ assertThat(adapter.path()).isEqualTo("/custom");
+ assertThat(adapter.allowedHosts()).containsExactly("agents.example.com");
+ assertThat(adapter.allowedOrigins()).containsExactly("https://app.example.com");
+ }
+}
diff --git a/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaTestSupport.java b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaTestSupport.java
new file mode 100644
index 0000000..67c7cff
--- /dev/null
+++ b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaTestSupport.java
@@ -0,0 +1,224 @@
+package dev.arcp.middleware.jakarta.coverage;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import jakarta.websocket.CloseReason;
+import jakarta.websocket.HandshakeResponse;
+import jakarta.websocket.MessageHandler;
+import jakarta.websocket.RemoteEndpoint;
+import jakarta.websocket.Session;
+import jakarta.websocket.server.HandshakeRequest;
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.net.URI;
+import java.security.Principal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
+/** Shared fakes for exercising the Jakarta adapter without a running container. */
+final class JakartaTestSupport {
+
+ private JakartaTestSupport() {}
+
+ /** Records the interactions the adapter/endpoint perform against a session. */
+ static final class SessionRecorder {
+ final List closeReasons = new CopyOnWriteArrayList<>();
+ final List sentText = new CopyOnWriteArrayList<>();
+ final AtomicReference> textHandler = new AtomicReference<>();
+ volatile boolean plainCloseCalled;
+ }
+
+ static Session session(String id, SessionRecorder rec) {
+ return session(id, rec, false, false);
+ }
+
+ @SuppressWarnings("unchecked")
+ static Session session(
+ String id, SessionRecorder rec, boolean throwOnClose, boolean throwOnSend) {
+ RemoteEndpoint.Basic basic =
+ (RemoteEndpoint.Basic)
+ Proxy.newProxyInstance(
+ JakartaTestSupport.class.getClassLoader(),
+ new Class>[] {RemoteEndpoint.Basic.class},
+ (proxy, method, args) -> {
+ if ("sendText".equals(method.getName()) && args != null && args.length == 1) {
+ if (throwOnSend) {
+ throw new IOException("send refused by fake");
+ }
+ rec.sentText.add((String) args[0]);
+ return null;
+ }
+ return defaultValue(method.getReturnType());
+ });
+ return (Session)
+ Proxy.newProxyInstance(
+ JakartaTestSupport.class.getClassLoader(),
+ new Class>[] {Session.class},
+ (proxy, method, args) ->
+ switch (method.getName()) {
+ case "getId" -> id;
+ case "close" -> {
+ if (throwOnClose) {
+ throw new IOException("close refused by fake");
+ }
+ if (args != null && args.length == 1) {
+ rec.closeReasons.add((CloseReason) args[0]);
+ } else {
+ rec.plainCloseCalled = true;
+ }
+ yield null;
+ }
+ case "addMessageHandler" -> {
+ if (args != null
+ && args.length == 2
+ && args[1] instanceof MessageHandler.Whole> whole) {
+ rec.textHandler.set((MessageHandler.Whole) whole);
+ }
+ yield null;
+ }
+ case "getBasicRemote" -> basic;
+ case "isOpen" -> true;
+ case "toString" -> "FakeSession(" + id + ")";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ static HandshakeRequest handshakeRequest(Map> headers) {
+ return new HandshakeRequest() {
+ @Override
+ public Map> getHeaders() {
+ return headers;
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ return null;
+ }
+
+ @Override
+ public URI getRequestURI() {
+ return URI.create("/arcp");
+ }
+
+ @Override
+ public boolean isUserInRole(String role) {
+ return false;
+ }
+
+ @Override
+ public Object getHttpSession() {
+ return null;
+ }
+
+ @Override
+ public Map> getParameterMap() {
+ return Map.of();
+ }
+
+ @Override
+ public String getQueryString() {
+ return "";
+ }
+ };
+ }
+
+ static HandshakeResponse handshakeResponse() {
+ Map> headers = new HashMap<>();
+ return () -> headers;
+ }
+
+ static String pingFrame() throws Exception {
+ Envelope envelope =
+ Envelope.builder("session.ping").payload(JsonNodeFactory.instance.objectNode()).build();
+ return ArcpMapper.shared().writeValueAsString(envelope);
+ }
+
+ static Envelope pingEnvelope() {
+ return Envelope.builder("session.ping").payload(JsonNodeFactory.instance.objectNode()).build();
+ }
+
+ static void awaitTrue(String what, BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!condition.getAsBoolean()) {
+ if (System.nanoTime() > deadline) {
+ throw new AssertionError("timed out waiting for " + what);
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ /** Flow subscriber that records terminal signals for inbound-publisher assertions. */
+ static final class CollectingSubscriber implements Flow.Subscriber {
+ final List items = new CopyOnWriteArrayList<>();
+ final CountDownLatch completed = new CountDownLatch(1);
+ final CountDownLatch errored = new CountDownLatch(1);
+ final AtomicReference error = new AtomicReference<>();
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ items.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ error.set(throwable);
+ errored.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ completed.countDown();
+ }
+
+ boolean awaitCompleted() throws InterruptedException {
+ return completed.await(5, TimeUnit.SECONDS);
+ }
+
+ boolean awaitErrored() throws InterruptedException {
+ return errored.await(5, TimeUnit.SECONDS);
+ }
+ }
+
+ static Object defaultValue(Class> type) {
+ if (!type.isPrimitive() || type == void.class) {
+ return null;
+ }
+ if (type == boolean.class) {
+ return false;
+ }
+ if (type == long.class) {
+ return 0L;
+ }
+ if (type == double.class) {
+ return 0d;
+ }
+ if (type == float.class) {
+ return 0f;
+ }
+ if (type == char.class) {
+ return (char) 0;
+ }
+ if (type == byte.class) {
+ return (byte) 0;
+ }
+ if (type == short.class) {
+ return (short) 0;
+ }
+ return 0;
+ }
+}
diff --git a/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaWebSocketTransportCoverageTest.java b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaWebSocketTransportCoverageTest.java
new file mode 100644
index 0000000..feb3cc4
--- /dev/null
+++ b/arcp-middleware-jakarta/src/test/java/dev/arcp/middleware/jakarta/coverage/JakartaWebSocketTransportCoverageTest.java
@@ -0,0 +1,116 @@
+package dev.arcp.middleware.jakarta.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.ArcpMapper;
+import jakarta.websocket.Session;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Drives the package-private {@code JakartaWebSocketTransport} (reflectively constructed) through
+ * its frame-decode, outbound-write, and close paths.
+ */
+class JakartaWebSocketTransportCoverageTest {
+
+ private static final String TRANSPORT_CLASS =
+ "dev.arcp.middleware.jakarta.JakartaWebSocketTransport";
+
+ private static Transport newTransport(Session session, ObjectMapper mapper) throws Exception {
+ Class> cls = Class.forName(TRANSPORT_CLASS);
+ Constructor> ctor = cls.getDeclaredConstructor(Session.class, ObjectMapper.class);
+ ctor.setAccessible(true);
+ return (Transport) ctor.newInstance(session, mapper);
+ }
+
+ private static void invoke(Transport transport, String name, Class>[] sig, Object... args)
+ throws Exception {
+ Method method = Class.forName(TRANSPORT_CLASS).getDeclaredMethod(name, sig);
+ method.setAccessible(true);
+ method.invoke(transport, args);
+ }
+
+ @Test
+ void deliverParsesFramesAndDropsMalformedOnes() throws Exception {
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ Transport transport = newTransport(JakartaTestSupport.session("deliver", rec), null);
+ JakartaTestSupport.CollectingSubscriber sub = new JakartaTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(transport, "deliver", new Class>[] {String.class}, JakartaTestSupport.pingFrame());
+ JakartaTestSupport.awaitTrue("envelope delivery", () -> sub.items.size() == 1);
+ assertThat(sub.items.get(0).type()).isEqualTo("session.ping");
+
+ invoke(transport, "deliver", new Class>[] {String.class}, "{malformed");
+ invoke(transport, "completeInbound", new Class>[0]);
+ assertThat(sub.awaitCompleted()).isTrue();
+ // The malformed frame never became an envelope.
+ assertThat(sub.items).hasSize(1);
+ }
+
+ @Test
+ void failInboundSurfacesAsSubscriberError() throws Exception {
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ Transport transport = newTransport(JakartaTestSupport.session("failing", rec), null);
+ JakartaTestSupport.CollectingSubscriber sub = new JakartaTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(
+ transport, "failInbound", new Class>[] {Throwable.class}, new RuntimeException("boom"));
+ assertThat(sub.awaitErrored()).isTrue();
+ assertThat(sub.error.get()).hasMessage("boom");
+ }
+
+ @Test
+ void sendWritesJsonTextFrames() throws Exception {
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ Transport transport =
+ newTransport(JakartaTestSupport.session("send", rec), ArcpMapper.create());
+
+ transport.send(JakartaTestSupport.pingEnvelope());
+
+ assertThat(rec.sentText).hasSize(1);
+ assertThat(rec.sentText.get(0)).contains("\"session.ping\"");
+ }
+
+ @Test
+ void sendFailureSurfacesAsUncheckedIoException() throws Exception {
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ Transport transport =
+ newTransport(JakartaTestSupport.session("send-fail", rec, false, true), null);
+
+ assertThatThrownBy(() -> transport.send(JakartaTestSupport.pingEnvelope()))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void closeClosesSessionAndInbound() throws Exception {
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ Transport transport = newTransport(JakartaTestSupport.session("close", rec), null);
+ JakartaTestSupport.CollectingSubscriber sub = new JakartaTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ transport.close();
+
+ assertThat(rec.plainCloseCalled).isTrue();
+ assertThat(sub.awaitCompleted()).isTrue();
+ }
+
+ @Test
+ void closeSwallowsSessionCloseFailure() throws Exception {
+ JakartaTestSupport.SessionRecorder rec = new JakartaTestSupport.SessionRecorder();
+ Transport transport =
+ newTransport(JakartaTestSupport.session("close-fail", rec, true, false), null);
+
+ transport.close();
+
+ assertThat(rec.plainCloseCalled).isFalse();
+ }
+}
diff --git a/arcp-middleware-spring-boot/pom.xml b/arcp-middleware-spring-boot/pom.xml
index 8556fc4..13f018c 100644
--- a/arcp-middleware-spring-boot/pom.xml
+++ b/arcp-middleware-spring-boot/pom.xml
@@ -11,6 +11,10 @@
arcp-middleware-spring-boot
+
+
+ false
+
arcp-middleware-spring-boot
Spring Boot 3.x adapter for ARCP runtimes.
diff --git a/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootAutoConfiguration.java b/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootAutoConfiguration.java
index 716a1a4..5ae0e9b 100644
--- a/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootAutoConfiguration.java
+++ b/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootAutoConfiguration.java
@@ -25,11 +25,22 @@ public class ArcpSpringBootAutoConfiguration implements WebSocketConfigurer {
private final ArcpRuntime runtime;
private final ArcpSpringBootProperties properties;
+ /**
+ * Creates the auto-configuration; invoked by Spring with the discovered beans.
+ *
+ * @param runtime the {@link ArcpRuntime} bean that accepted sessions are handed to
+ * @param properties the {@code arcp.middleware.*} configuration properties
+ */
public ArcpSpringBootAutoConfiguration(ArcpRuntime runtime, ArcpSpringBootProperties properties) {
this.runtime = runtime;
this.properties = properties;
}
+ /**
+ * Exposes the handler that serves ARCP JSON envelopes as WebSocket text frames per §4.
+ *
+ * @return the handler registered at the {@code arcp.middleware.path} endpoint
+ */
@Bean
public ArcpWebSocketHandler arcpWebSocketHandler() {
return new ArcpWebSocketHandler(runtime);
diff --git a/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootProperties.java b/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootProperties.java
index 4b3a9e7..28be00a 100644
--- a/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootProperties.java
+++ b/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpSpringBootProperties.java
@@ -11,26 +11,61 @@ public final class ArcpSpringBootProperties {
private List allowedHosts = List.of();
private List allowedOrigins = List.of();
+ /** Creates the property holder with defaults: {@code /arcp} path and empty allowlists. */
+ public ArcpSpringBootProperties() {}
+
+ /**
+ * Returns the request path the WebSocket handler is mounted at.
+ *
+ * @return the endpoint path, {@code /arcp} by default
+ */
public String getPath() {
return path;
}
+ /**
+ * Sets the request path the WebSocket handler is mounted at ({@code arcp.middleware.path}).
+ *
+ * @param path the endpoint path
+ */
public void setPath(String path) {
this.path = path;
}
+ /**
+ * Returns the {@code Host} header allowlist enforced before the handshake (§14).
+ *
+ * @return the allowed {@code Host} values; empty (the default) disables the check
+ */
public List getAllowedHosts() {
return allowedHosts;
}
+ /**
+ * Sets the {@code Host} header allowlist ({@code arcp.middleware.allowed-hosts}); upgrades from
+ * any other host are rejected with HTTP 403 per §14.
+ *
+ * @param allowedHosts the allowed {@code Host} header values
+ */
public void setAllowedHosts(List allowedHosts) {
this.allowedHosts = List.copyOf(allowedHosts);
}
+ /**
+ * Returns the {@code Origin} values accepted during the WebSocket handshake.
+ *
+ * @return the allowed {@code Origin} values; empty (the default) allows all origins
+ */
public List getAllowedOrigins() {
return allowedOrigins;
}
+ /**
+ * Sets the {@code Origin} allowlist ({@code arcp.middleware.allowed-origins}) applied to the
+ * WebSocket handler registration.
+ *
+ * @param allowedOrigins the allowed {@code Origin} header values
+ */
public void setAllowedOrigins(List allowedOrigins) {
this.allowedOrigins = List.copyOf(allowedOrigins);
}
diff --git a/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpWebSocketHandler.java b/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpWebSocketHandler.java
index 3ce8470..33d58b6 100644
--- a/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpWebSocketHandler.java
+++ b/arcp-middleware-spring-boot/src/main/java/dev/arcp/middleware/spring/ArcpWebSocketHandler.java
@@ -18,6 +18,11 @@ public final class ArcpWebSocketHandler extends TextWebSocketHandler {
private final ConcurrentHashMap transports =
new ConcurrentHashMap<>();
+ /**
+ * Creates a handler that hands each accepted WebSocket session to {@code runtime}.
+ *
+ * @param runtime the ARCP runtime; must not be {@code null}
+ */
public ArcpWebSocketHandler(ArcpRuntime runtime) {
this.runtime = Objects.requireNonNull(runtime, "runtime");
}
diff --git a/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/ArcpWebSocketHandlerCoverageTest.java b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/ArcpWebSocketHandlerCoverageTest.java
new file mode 100644
index 0000000..a88ad7f
--- /dev/null
+++ b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/ArcpWebSocketHandlerCoverageTest.java
@@ -0,0 +1,87 @@
+package dev.arcp.middleware.spring.coverage;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.arcp.middleware.spring.ArcpWebSocketHandler;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+
+/**
+ * Covers {@link ArcpWebSocketHandler} text/close/error handling for both tracked and untracked
+ * sessions.
+ */
+class ArcpWebSocketHandlerCoverageTest {
+
+ private ArcpRuntime runtime;
+ private ArcpWebSocketHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ handler = new ArcpWebSocketHandler(runtime);
+ }
+
+ @AfterEach
+ void tearDown() {
+ runtime.close();
+ }
+
+ private static WebSocketSession session(String id) {
+ return SpringTestSupport.webSocketSession(id, new SpringTestSupport.WebSessionRecorder());
+ }
+
+ @Test
+ void nullRuntimeIsRejected() {
+ assertThatThrownBy(() -> new ArcpWebSocketHandler(null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void textMessagesAreDeliveredToTheTrackedTransport() throws Exception {
+ WebSocketSession session = session("text-1");
+ handler.afterConnectionEstablished(session);
+
+ // Well-formed and malformed frames both go through the established transport.
+ handler.handleMessage(session, new TextMessage(SpringTestSupport.pingFrame()));
+ handler.handleMessage(session, new TextMessage("not an envelope"));
+
+ handler.afterConnectionClosed(session, CloseStatus.NORMAL);
+ }
+
+ @Test
+ void textMessageForUnknownSessionIsIgnored() {
+ assertThatCode(() -> handler.handleMessage(session("never-established"), new TextMessage("{}")))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void closeCompletesInboundOnceAndIgnoresRepeats() throws Exception {
+ WebSocketSession session = session("close-1");
+ handler.afterConnectionEstablished(session);
+
+ handler.afterConnectionClosed(session, CloseStatus.NORMAL);
+ // Second close for the same id hits the already-removed branch.
+ handler.afterConnectionClosed(session, CloseStatus.NORMAL);
+ }
+
+ @Test
+ void transportErrorFailsInboundOnceAndIgnoresRepeats() throws Exception {
+ WebSocketSession session = session("error-1");
+ handler.afterConnectionEstablished(session);
+
+ handler.handleTransportError(session, new RuntimeException("boom"));
+ // Transport was removed by the first error; the repeat sees no tracked transport.
+ handler.handleTransportError(session, new RuntimeException("boom again"));
+ handler.afterConnectionClosed(session, CloseStatus.SERVER_ERROR);
+ }
+}
diff --git a/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/HostAllowlistInterceptorCoverageTest.java b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/HostAllowlistInterceptorCoverageTest.java
new file mode 100644
index 0000000..a0c8f85
--- /dev/null
+++ b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/HostAllowlistInterceptorCoverageTest.java
@@ -0,0 +1,91 @@
+package dev.arcp.middleware.spring.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Constructor;
+import java.util.HashMap;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.socket.server.HandshakeInterceptor;
+
+/**
+ * Covers the allowed / denied / missing-host / no-allowlist branches of the package-private {@code
+ * HostAllowlistHandshakeInterceptor} (§14, #99).
+ */
+class HostAllowlistInterceptorCoverageTest {
+
+ private static HandshakeInterceptor interceptor(List allowedHosts) throws Exception {
+ Class> cls = Class.forName("dev.arcp.middleware.spring.HostAllowlistHandshakeInterceptor");
+ Constructor> ctor = cls.getDeclaredConstructor(List.class);
+ ctor.setAccessible(true);
+ return (HandshakeInterceptor) ctor.newInstance(allowedHosts);
+ }
+
+ static Stream hostDecisions() {
+ return Stream.of(
+ // Empty allowlist disables the check (allow-all), with or without a Host header.
+ Arguments.of(List.of(), "agents.example.com", true),
+ Arguments.of(List.of(), null, true),
+ // Exact match passes; any other entry of the list works too.
+ Arguments.of(List.of("agents.example.com"), "agents.example.com", true),
+ Arguments.of(List.of("a.example", "b.example"), "b.example", true),
+ // Disallowed host is refused with 403.
+ Arguments.of(List.of("agents.example.com"), "evil.example.com", false),
+ // Missing Host header is refused when an allowlist is configured.
+ Arguments.of(List.of("agents.example.com"), null, false),
+ // Matching is exact: case differences are refused.
+ Arguments.of(List.of("agents.example.com"), "AGENTS.EXAMPLE.COM", false),
+ // Matching is exact: ports are not stripped before comparison.
+ Arguments.of(List.of("agents.example.com"), "agents.example.com:8443", false),
+ Arguments.of(List.of("agents.example.com:8443"), "agents.example.com:8443", true));
+ }
+
+ @ParameterizedTest
+ @MethodSource("hostDecisions")
+ void beforeHandshakeEnforcesHostAllowlist(
+ List allowedHosts, String hostHeader, boolean expectAllowed) throws Exception {
+ MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/arcp");
+ if (hostHeader != null) {
+ servletRequest.addHeader("Host", hostHeader);
+ }
+ MockHttpServletResponse servletResponse = new MockHttpServletResponse();
+
+ boolean allowed =
+ interceptor(allowedHosts)
+ .beforeHandshake(
+ new ServletServerHttpRequest(servletRequest),
+ new ServletServerHttpResponse(servletResponse),
+ null,
+ new HashMap<>());
+
+ assertThat(allowed).isEqualTo(expectAllowed);
+ if (!expectAllowed) {
+ assertThat(servletResponse.getStatus()).isEqualTo(403);
+ } else {
+ assertThat(servletResponse.getStatus()).isNotEqualTo(403);
+ }
+ }
+
+ @Test
+ void afterHandshakeIsANoOp() throws Exception {
+ MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/arcp");
+ MockHttpServletResponse servletResponse = new MockHttpServletResponse();
+
+ interceptor(List.of("agents.example.com"))
+ .afterHandshake(
+ new ServletServerHttpRequest(servletRequest),
+ new ServletServerHttpResponse(servletResponse),
+ null,
+ null);
+
+ assertThat(servletResponse.getStatus()).isEqualTo(200);
+ }
+}
diff --git a/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringAutoConfigurationCoverageTest.java b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringAutoConfigurationCoverageTest.java
new file mode 100644
index 0000000..306811b
--- /dev/null
+++ b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringAutoConfigurationCoverageTest.java
@@ -0,0 +1,85 @@
+package dev.arcp.middleware.spring.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.middleware.spring.ArcpSpringBootAutoConfiguration;
+import dev.arcp.middleware.spring.ArcpSpringBootProperties;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers the registration branches of {@link ArcpSpringBootAutoConfiguration}: default allow-all
+ * origins vs configured origins, and conditional host-allowlist interceptor wiring (#99).
+ */
+class SpringAutoConfigurationCoverageTest {
+
+ private ArcpRuntime runtime;
+
+ @BeforeEach
+ void setUp() {
+ runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ }
+
+ @AfterEach
+ void tearDown() {
+ runtime.close();
+ }
+
+ @Test
+ void defaultsRegisterWildcardOriginsWithoutHostInterceptor() {
+ ArcpSpringBootProperties properties = new ArcpSpringBootProperties();
+ ArcpSpringBootAutoConfiguration configuration =
+ new ArcpSpringBootAutoConfiguration(runtime, properties);
+ SpringTestSupport.RegistryRecorder rec = new SpringTestSupport.RegistryRecorder();
+
+ configuration.registerWebSocketHandlers(SpringTestSupport.registry(rec));
+
+ assertThat(rec.handler).isNotNull();
+ assertThat(rec.paths).containsExactly("/arcp");
+ assertThat(rec.origins).containsExactly("*");
+ assertThat(rec.interceptors).isEmpty();
+ }
+
+ @Test
+ void configuredHostsAndOriginsAreEnforced() {
+ ArcpSpringBootProperties properties = new ArcpSpringBootProperties();
+ properties.setPath("/ws");
+ properties.setAllowedOrigins(List.of("https://app.example.com"));
+ properties.setAllowedHosts(List.of("agents.example.com"));
+ ArcpSpringBootAutoConfiguration configuration =
+ new ArcpSpringBootAutoConfiguration(runtime, properties);
+ SpringTestSupport.RegistryRecorder rec = new SpringTestSupport.RegistryRecorder();
+
+ configuration.registerWebSocketHandlers(SpringTestSupport.registry(rec));
+
+ assertThat(rec.paths).containsExactly("/ws");
+ assertThat(rec.origins).containsExactly("https://app.example.com");
+ assertThat(rec.interceptors).hasSize(1);
+ assertThat(rec.interceptors.get(0).getClass().getSimpleName())
+ .isEqualTo("HostAllowlistHandshakeInterceptor");
+ }
+
+ @Test
+ void propertiesRoundTrip() {
+ ArcpSpringBootProperties properties = new ArcpSpringBootProperties();
+
+ assertThat(properties.getPath()).isEqualTo("/arcp");
+ assertThat(properties.getAllowedHosts()).isEmpty();
+ assertThat(properties.getAllowedOrigins()).isEmpty();
+
+ properties.setPath("/elsewhere");
+ properties.setAllowedHosts(List.of("agents.example.com"));
+ properties.setAllowedOrigins(List.of("https://app.example.com"));
+
+ assertThat(properties.getPath()).isEqualTo("/elsewhere");
+ assertThat(properties.getAllowedHosts()).containsExactly("agents.example.com");
+ assertThat(properties.getAllowedOrigins()).containsExactly("https://app.example.com");
+ }
+}
diff --git a/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringTestSupport.java b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringTestSupport.java
new file mode 100644
index 0000000..70d2ead
--- /dev/null
+++ b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringTestSupport.java
@@ -0,0 +1,190 @@
+package dev.arcp.middleware.spring.coverage;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+import org.springframework.web.socket.WebSocketHandler;
+import org.springframework.web.socket.WebSocketMessage;
+import org.springframework.web.socket.WebSocketSession;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistration;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+import org.springframework.web.socket.server.HandshakeInterceptor;
+
+/** Shared fakes and wire helpers for the Spring adapter coverage tests. */
+final class SpringTestSupport {
+
+ private SpringTestSupport() {}
+
+ /** Records the interactions the handler/transport perform against a session. */
+ static final class WebSessionRecorder {
+ final List> sent = new CopyOnWriteArrayList<>();
+ volatile boolean closed;
+ volatile boolean throwOnSend;
+ volatile boolean throwOnClose;
+ }
+
+ static WebSocketSession webSocketSession(String id, WebSessionRecorder rec) {
+ return (WebSocketSession)
+ Proxy.newProxyInstance(
+ SpringTestSupport.class.getClassLoader(),
+ new Class>[] {WebSocketSession.class},
+ (proxy, method, args) ->
+ switch (method.getName()) {
+ case "getId" -> id;
+ case "sendMessage" -> {
+ if (rec.throwOnSend) {
+ throw new IOException("send refused by fake");
+ }
+ rec.sent.add((WebSocketMessage>) args[0]);
+ yield null;
+ }
+ case "close" -> {
+ if (rec.throwOnClose) {
+ throw new IOException("close refused by fake");
+ }
+ rec.closed = true;
+ yield null;
+ }
+ case "isOpen" -> true;
+ case "toString" -> "FakeWebSocketSession(" + id + ")";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ /** Records the handler registration performed by the auto-configuration. */
+ static final class RegistryRecorder {
+ volatile WebSocketHandler handler;
+ volatile String[] paths;
+ volatile String[] origins;
+ final List interceptors = new CopyOnWriteArrayList<>();
+ }
+
+ static WebSocketHandlerRegistry registry(RegistryRecorder rec) {
+ WebSocketHandlerRegistration registration =
+ (WebSocketHandlerRegistration)
+ Proxy.newProxyInstance(
+ SpringTestSupport.class.getClassLoader(),
+ new Class>[] {WebSocketHandlerRegistration.class},
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "setAllowedOrigins":
+ rec.origins = (String[]) args[0];
+ return proxy;
+ case "addInterceptors":
+ rec.interceptors.addAll(Arrays.asList((HandshakeInterceptor[]) args[0]));
+ return proxy;
+ default:
+ return method.getReturnType().isInstance(proxy)
+ ? proxy
+ : defaultValue(method.getReturnType());
+ }
+ });
+ return (WebSocketHandlerRegistry)
+ Proxy.newProxyInstance(
+ SpringTestSupport.class.getClassLoader(),
+ new Class>[] {WebSocketHandlerRegistry.class},
+ (proxy, method, args) -> {
+ if ("addHandler".equals(method.getName())) {
+ rec.handler = (WebSocketHandler) args[0];
+ rec.paths = (String[]) args[1];
+ return registration;
+ }
+ return defaultValue(method.getReturnType());
+ });
+ }
+
+ static Object defaultValue(Class> type) {
+ if (!type.isPrimitive() || type == void.class) {
+ return null;
+ }
+ if (type == boolean.class) {
+ return false;
+ }
+ if (type == long.class) {
+ return 0L;
+ }
+ if (type == double.class) {
+ return 0d;
+ }
+ if (type == float.class) {
+ return 0f;
+ }
+ if (type == char.class) {
+ return (char) 0;
+ }
+ if (type == byte.class) {
+ return (byte) 0;
+ }
+ if (type == short.class) {
+ return (short) 0;
+ }
+ return 0;
+ }
+
+ static String pingFrame() throws Exception {
+ return ArcpMapper.shared().writeValueAsString(pingEnvelope());
+ }
+
+ static Envelope pingEnvelope() {
+ return Envelope.builder("session.ping").payload(JsonNodeFactory.instance.objectNode()).build();
+ }
+
+ static void awaitTrue(String what, BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!condition.getAsBoolean()) {
+ if (System.nanoTime() > deadline) {
+ throw new AssertionError("timed out waiting for " + what);
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ /** Flow subscriber that records terminal signals for inbound-publisher assertions. */
+ static final class CollectingSubscriber implements Flow.Subscriber {
+ final List items = new CopyOnWriteArrayList<>();
+ final CountDownLatch completed = new CountDownLatch(1);
+ final CountDownLatch errored = new CountDownLatch(1);
+ final AtomicReference error = new AtomicReference<>();
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ items.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ error.set(throwable);
+ errored.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ completed.countDown();
+ }
+
+ boolean awaitCompleted() throws InterruptedException {
+ return completed.await(5, TimeUnit.SECONDS);
+ }
+
+ boolean awaitErrored() throws InterruptedException {
+ return errored.await(5, TimeUnit.SECONDS);
+ }
+ }
+}
diff --git a/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringWebSocketTransportCoverageTest.java b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringWebSocketTransportCoverageTest.java
new file mode 100644
index 0000000..d434a6c
--- /dev/null
+++ b/arcp-middleware-spring-boot/src/test/java/dev/arcp/middleware/spring/coverage/SpringWebSocketTransportCoverageTest.java
@@ -0,0 +1,120 @@
+package dev.arcp.middleware.spring.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.ArcpMapper;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+
+/**
+ * Drives the package-private {@code SpringWebSocketTransport} (reflectively constructed) through
+ * its frame-decode, outbound-write, and close paths.
+ */
+class SpringWebSocketTransportCoverageTest {
+
+ private static final String TRANSPORT_CLASS =
+ "dev.arcp.middleware.spring.SpringWebSocketTransport";
+
+ private static Transport newTransport(WebSocketSession session, ObjectMapper mapper)
+ throws Exception {
+ Class> cls = Class.forName(TRANSPORT_CLASS);
+ Constructor> ctor = cls.getDeclaredConstructor(WebSocketSession.class, ObjectMapper.class);
+ ctor.setAccessible(true);
+ return (Transport) ctor.newInstance(session, mapper);
+ }
+
+ private static void invoke(Transport transport, String name, Class>[] sig, Object... args)
+ throws Exception {
+ Method method = Class.forName(TRANSPORT_CLASS).getDeclaredMethod(name, sig);
+ method.setAccessible(true);
+ method.invoke(transport, args);
+ }
+
+ @Test
+ void deliverParsesFramesAndDropsMalformedOnes() throws Exception {
+ SpringTestSupport.WebSessionRecorder rec = new SpringTestSupport.WebSessionRecorder();
+ Transport transport = newTransport(SpringTestSupport.webSocketSession("deliver", rec), null);
+ SpringTestSupport.CollectingSubscriber sub = new SpringTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(transport, "deliver", new Class>[] {String.class}, SpringTestSupport.pingFrame());
+ SpringTestSupport.awaitTrue("envelope delivery", () -> sub.items.size() == 1);
+ assertThat(sub.items.get(0).type()).isEqualTo("session.ping");
+
+ invoke(transport, "deliver", new Class>[] {String.class}, "{malformed");
+ invoke(transport, "completeInbound", new Class>[0]);
+ assertThat(sub.awaitCompleted()).isTrue();
+ assertThat(sub.items).hasSize(1);
+ }
+
+ @Test
+ void failInboundSurfacesAsSubscriberError() throws Exception {
+ SpringTestSupport.WebSessionRecorder rec = new SpringTestSupport.WebSessionRecorder();
+ Transport transport = newTransport(SpringTestSupport.webSocketSession("failing", rec), null);
+ SpringTestSupport.CollectingSubscriber sub = new SpringTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(
+ transport, "failInbound", new Class>[] {Throwable.class}, new RuntimeException("boom"));
+ assertThat(sub.awaitErrored()).isTrue();
+ assertThat(sub.error.get()).hasMessage("boom");
+ }
+
+ @Test
+ void sendWritesJsonTextFrames() throws Exception {
+ SpringTestSupport.WebSessionRecorder rec = new SpringTestSupport.WebSessionRecorder();
+ Transport transport =
+ newTransport(SpringTestSupport.webSocketSession("send", rec), ArcpMapper.create());
+
+ transport.send(SpringTestSupport.pingEnvelope());
+
+ assertThat(rec.sent).hasSize(1);
+ assertThat(((TextMessage) rec.sent.get(0)).getPayload()).contains("\"session.ping\"");
+ }
+
+ @Test
+ void sendFailureSurfacesAsUncheckedIoException() throws Exception {
+ SpringTestSupport.WebSessionRecorder rec = new SpringTestSupport.WebSessionRecorder();
+ rec.throwOnSend = true;
+ Transport transport = newTransport(SpringTestSupport.webSocketSession("send-fail", rec), null);
+
+ assertThatThrownBy(() -> transport.send(SpringTestSupport.pingEnvelope()))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void closeClosesSessionAndInbound() throws Exception {
+ SpringTestSupport.WebSessionRecorder rec = new SpringTestSupport.WebSessionRecorder();
+ Transport transport = newTransport(SpringTestSupport.webSocketSession("close", rec), null);
+ SpringTestSupport.CollectingSubscriber sub = new SpringTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ transport.close();
+
+ assertThat(rec.closed).isTrue();
+ assertThat(sub.awaitCompleted()).isTrue();
+ }
+
+ @Test
+ void closeSwallowsSessionCloseFailure() throws Exception {
+ SpringTestSupport.WebSessionRecorder rec = new SpringTestSupport.WebSessionRecorder();
+ rec.throwOnClose = true;
+ Transport transport = newTransport(SpringTestSupport.webSocketSession("close-fail", rec), null);
+ SpringTestSupport.CollectingSubscriber sub = new SpringTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ transport.close();
+
+ assertThat(rec.closed).isFalse();
+ assertThat(sub.awaitCompleted()).isTrue();
+ }
+}
diff --git a/arcp-middleware-vertx/pom.xml b/arcp-middleware-vertx/pom.xml
index 8732891..c64d506 100644
--- a/arcp-middleware-vertx/pom.xml
+++ b/arcp-middleware-vertx/pom.xml
@@ -11,6 +11,10 @@
arcp-middleware-vertx
+
+
+ false
+
arcp-middleware-vertx
Vert.x 5 WebSocket adapter for ARCP runtimes.
diff --git a/arcp-middleware-vertx/src/main/java/dev/arcp/middleware/vertx/ArcpVertxHandler.java b/arcp-middleware-vertx/src/main/java/dev/arcp/middleware/vertx/ArcpVertxHandler.java
index ea018e3..6aa670c 100644
--- a/arcp-middleware-vertx/src/main/java/dev/arcp/middleware/vertx/ArcpVertxHandler.java
+++ b/arcp-middleware-vertx/src/main/java/dev/arcp/middleware/vertx/ArcpVertxHandler.java
@@ -29,6 +29,11 @@ private ArcpVertxHandler(Builder b) {
this.allowedHosts = List.copyOf(b.allowedHosts);
}
+ /**
+ * Returns a new builder.
+ *
+ * @return a fresh builder with the default {@code /arcp} path and an empty host allowlist
+ */
public static Builder builder() {
return new Builder();
}
@@ -57,38 +62,82 @@ public void handle(ServerWebSocket ws) {
runtime.accept(transport);
}
+ /**
+ * Returns the runtime accepted sockets are handed to.
+ *
+ * @return the configured {@link ArcpRuntime}
+ */
public ArcpRuntime runtime() {
return runtime;
}
+ /**
+ * Returns the request path sockets must arrive at; others are closed with code {@code 1008}.
+ *
+ * @return the endpoint path, {@code /arcp} by default
+ */
public String path() {
return path;
}
+ /**
+ * Returns the {@code Host} header allowlist enforced on each accepted socket (§14).
+ *
+ * @return the allowed {@code Host} values; empty means all hosts are accepted
+ */
public List allowedHosts() {
return allowedHosts;
}
+ /** Builder for {@link ArcpVertxHandler}. */
public static final class Builder {
private ArcpRuntime runtime;
private String path = "/arcp";
private List allowedHosts = List.of();
+ /** Creates a builder with the default {@code /arcp} path and an empty host allowlist. */
+ public Builder() {}
+
+ /**
+ * Sets the runtime that accepted sockets are handed to; required.
+ *
+ * @param runtime the ARCP runtime
+ * @return this builder
+ */
public Builder runtime(ArcpRuntime runtime) {
this.runtime = runtime;
return this;
}
+ /**
+ * Sets the request path served by the handler; defaults to {@code /arcp}.
+ *
+ * @param path the endpoint path
+ * @return this builder
+ */
public Builder path(String path) {
this.path = path;
return this;
}
+ /**
+ * Sets the {@code Host} header allowlist; sockets from any other host are closed with code
+ * {@code 1008} before the runtime sees them (§14). An empty list (the default) disables the
+ * check.
+ *
+ * @param hosts the allowed {@code Host} header values
+ * @return this builder
+ */
public Builder allowedHosts(List hosts) {
this.allowedHosts = List.copyOf(hosts);
return this;
}
+ /**
+ * Builds the handler, ready to register via {@code HttpServer.webSocketHandler(handler)}.
+ *
+ * @return the configured handler
+ */
public ArcpVertxHandler build() {
return new ArcpVertxHandler(this);
}
diff --git a/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxHandlerCoverageTest.java b/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxHandlerCoverageTest.java
new file mode 100644
index 0000000..2e731c3
--- /dev/null
+++ b/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxHandlerCoverageTest.java
@@ -0,0 +1,125 @@
+package dev.arcp.middleware.vertx.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.middleware.vertx.ArcpVertxHandler;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Covers {@link ArcpVertxHandler} handshake accept/reject branches (path + host allowlist) and
+ * close/error propagation into the transport.
+ */
+class VertxHandlerCoverageTest {
+
+ private static ArcpRuntime runtime;
+
+ @BeforeAll
+ static void startRuntime() {
+ runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ }
+
+ @AfterAll
+ static void stopRuntime() {
+ runtime.close();
+ }
+
+ @Test
+ void pathMismatchIsClosedWithPolicyViolation() {
+ ArcpVertxHandler handler = ArcpVertxHandler.builder().runtime(runtime).path("/arcp").build();
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ rec.path = "/elsewhere";
+
+ handler.handle(VertxTestSupport.serverWebSocket(rec));
+
+ assertThat(rec.closeReasons).containsExactly("1008:path mismatch");
+ assertThat(rec.textHandler.get()).isNull();
+ }
+
+ static Stream hostDecisions() {
+ return Stream.of(
+ // Empty allowlist disables the check (allow-all), with or without a Host header.
+ Arguments.of(List.of(), "agents.example.com", true),
+ Arguments.of(List.of(), null, true),
+ // Exact match passes; later entries match too.
+ Arguments.of(List.of("agents.example.com"), "agents.example.com", true),
+ Arguments.of(List.of("a.example", "b.example"), "b.example", true),
+ // Disallowed or missing host is refused before the runtime sees the socket.
+ Arguments.of(List.of("agents.example.com"), "evil.example.com", false),
+ Arguments.of(List.of("agents.example.com"), null, false),
+ // Matching is exact: case differences and port suffixes are refused.
+ Arguments.of(List.of("agents.example.com"), "AGENTS.EXAMPLE.COM", false),
+ Arguments.of(List.of("agents.example.com"), "agents.example.com:8443", false),
+ Arguments.of(List.of("agents.example.com:8443"), "agents.example.com:8443", true));
+ }
+
+ @ParameterizedTest
+ @MethodSource("hostDecisions")
+ void hostAllowlistGatesTheHandshake(
+ List allowedHosts, String hostHeader, boolean expectAccepted) {
+ ArcpVertxHandler handler =
+ ArcpVertxHandler.builder().runtime(runtime).allowedHosts(allowedHosts).build();
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ if (hostHeader != null) {
+ rec.headers.set("Host", hostHeader);
+ }
+
+ handler.handle(VertxTestSupport.serverWebSocket(rec));
+
+ if (expectAccepted) {
+ assertThat(rec.closeReasons).isEmpty();
+ assertThat(rec.textHandler.get()).as("accepted socket reaches the runtime").isNotNull();
+ assertThat(rec.closeHandler.get()).isNotNull();
+ assertThat(rec.exceptionHandler.get()).isNotNull();
+ rec.closeHandler.get().handle(null);
+ } else {
+ assertThat(rec.closeReasons).containsExactly("1008:host not allowed");
+ assertThat(rec.textHandler.get()).as("rejected socket never reaches the runtime").isNull();
+ }
+ }
+
+ @Test
+ void acceptedSocketDeliversFramesAndPropagatesCloseAndErrors() throws Exception {
+ ArcpVertxHandler handler = ArcpVertxHandler.builder().runtime(runtime).build();
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+
+ handler.handle(VertxTestSupport.serverWebSocket(rec));
+ assertThat(rec.textHandler.get()).isNotNull();
+
+ // Decode + dispatch: well-formed and malformed frames both flow through the transport.
+ rec.textHandler.get().handle(VertxTestSupport.pingFrame());
+ rec.textHandler.get().handle("not an envelope");
+
+ // Error propagation fails the inbound stream; close propagation completes it.
+ rec.exceptionHandler.get().handle(new RuntimeException("boom"));
+
+ VertxTestSupport.WsRecorder second = new VertxTestSupport.WsRecorder();
+ handler.handle(VertxTestSupport.serverWebSocket(second));
+ second.closeHandler.get().handle(null);
+ }
+
+ @Test
+ void builderExposesConfiguredState() {
+ ArcpVertxHandler handler =
+ ArcpVertxHandler.builder()
+ .runtime(runtime)
+ .path("/custom")
+ .allowedHosts(List.of("agents.example.com"))
+ .build();
+
+ assertThat(handler.runtime()).isSameAs(runtime);
+ assertThat(handler.path()).isEqualTo("/custom");
+ assertThat(handler.allowedHosts()).containsExactly("agents.example.com");
+ }
+}
diff --git a/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxTestSupport.java b/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxTestSupport.java
new file mode 100644
index 0000000..4e7d553
--- /dev/null
+++ b/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxTestSupport.java
@@ -0,0 +1,173 @@
+package dev.arcp.middleware.vertx.coverage;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import io.vertx.core.Future;
+import io.vertx.core.Handler;
+import io.vertx.core.MultiMap;
+import io.vertx.core.http.ServerWebSocket;
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
+/** Fake {@link ServerWebSocket} plumbing for handler/transport tests without a Vert.x server. */
+final class VertxTestSupport {
+
+ private VertxTestSupport() {}
+
+ /** Records every interaction the handler/transport performs against the socket. */
+ static final class WsRecorder {
+ volatile String path = "/arcp";
+ volatile boolean failWrites;
+ final MultiMap headers = MultiMap.caseInsensitiveMultiMap();
+ final List written = new CopyOnWriteArrayList<>();
+ final List closeReasons = new CopyOnWriteArrayList<>();
+ final AtomicInteger closeCalls = new AtomicInteger();
+ final AtomicReference> textHandler = new AtomicReference<>();
+ final AtomicReference> closeHandler = new AtomicReference<>();
+ final AtomicReference> exceptionHandler = new AtomicReference<>();
+ }
+
+ @SuppressWarnings("unchecked")
+ static ServerWebSocket serverWebSocket(WsRecorder rec) {
+ return (ServerWebSocket)
+ Proxy.newProxyInstance(
+ VertxTestSupport.class.getClassLoader(),
+ new Class>[] {ServerWebSocket.class},
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "path":
+ return rec.path;
+ case "headers":
+ return rec.headers;
+ case "textMessageHandler":
+ rec.textHandler.set((Handler) args[0]);
+ return proxy;
+ case "closeHandler":
+ rec.closeHandler.set((Handler) args[0]);
+ return proxy;
+ case "exceptionHandler":
+ rec.exceptionHandler.set((Handler) args[0]);
+ return proxy;
+ case "writeTextMessage":
+ if (rec.failWrites) {
+ return Future.failedFuture(new IOException("write refused by fake"));
+ }
+ rec.written.add((String) args[0]);
+ return Future.succeededFuture();
+ case "close":
+ rec.closeCalls.incrementAndGet();
+ if (args != null && args.length == 2) {
+ rec.closeReasons.add(args[0] + ":" + args[1]);
+ }
+ return Future.succeededFuture();
+ case "toString":
+ return "FakeServerWebSocket(" + rec.path + ")";
+ case "hashCode":
+ return System.identityHashCode(proxy);
+ case "equals":
+ return proxy == args[0];
+ default:
+ Class> returnType = method.getReturnType();
+ if (returnType.isInstance(proxy)) {
+ return proxy;
+ }
+ if (returnType == Future.class) {
+ return Future.succeededFuture();
+ }
+ return defaultValue(returnType);
+ }
+ });
+ }
+
+ static String pingFrame() throws Exception {
+ return ArcpMapper.shared().writeValueAsString(pingEnvelope());
+ }
+
+ static Envelope pingEnvelope() {
+ return Envelope.builder("session.ping").payload(JsonNodeFactory.instance.objectNode()).build();
+ }
+
+ static void awaitTrue(String what, BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!condition.getAsBoolean()) {
+ if (System.nanoTime() > deadline) {
+ throw new AssertionError("timed out waiting for " + what);
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ /** Flow subscriber that records terminal signals for inbound-publisher assertions. */
+ static final class CollectingSubscriber implements Flow.Subscriber {
+ final List items = new CopyOnWriteArrayList<>();
+ final CountDownLatch completed = new CountDownLatch(1);
+ final CountDownLatch errored = new CountDownLatch(1);
+ final AtomicReference error = new AtomicReference<>();
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ items.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ error.set(throwable);
+ errored.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ completed.countDown();
+ }
+
+ boolean awaitCompleted() throws InterruptedException {
+ return completed.await(5, TimeUnit.SECONDS);
+ }
+
+ boolean awaitErrored() throws InterruptedException {
+ return errored.await(5, TimeUnit.SECONDS);
+ }
+ }
+
+ static Object defaultValue(Class> type) {
+ if (!type.isPrimitive() || type == void.class) {
+ return null;
+ }
+ if (type == boolean.class) {
+ return false;
+ }
+ if (type == long.class) {
+ return 0L;
+ }
+ if (type == double.class) {
+ return 0d;
+ }
+ if (type == float.class) {
+ return 0f;
+ }
+ if (type == char.class) {
+ return (char) 0;
+ }
+ if (type == byte.class) {
+ return (byte) 0;
+ }
+ if (type == short.class) {
+ return (short) 0;
+ }
+ return 0;
+ }
+}
diff --git a/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxTransportCoverageTest.java b/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxTransportCoverageTest.java
new file mode 100644
index 0000000..d21c0de
--- /dev/null
+++ b/arcp-middleware-vertx/src/test/java/dev/arcp/middleware/vertx/coverage/VertxTransportCoverageTest.java
@@ -0,0 +1,130 @@
+package dev.arcp.middleware.vertx.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.ArcpMapper;
+import io.vertx.core.http.ServerWebSocket;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Drives the package-private {@code VertxWebSocketTransport} (reflectively constructed) through
+ * decode, async write success/failure observation (#110), and close paths.
+ */
+class VertxTransportCoverageTest {
+
+ private static final String TRANSPORT_CLASS = "dev.arcp.middleware.vertx.VertxWebSocketTransport";
+
+ private static Transport newTransport(ServerWebSocket socket, ObjectMapper mapper)
+ throws Exception {
+ Class> cls = Class.forName(TRANSPORT_CLASS);
+ Constructor> ctor = cls.getDeclaredConstructor(ServerWebSocket.class, ObjectMapper.class);
+ ctor.setAccessible(true);
+ return (Transport) ctor.newInstance(socket, mapper);
+ }
+
+ private static void invoke(Transport transport, String name, Class>[] sig, Object... args)
+ throws Exception {
+ Method method = Class.forName(TRANSPORT_CLASS).getDeclaredMethod(name, sig);
+ method.setAccessible(true);
+ method.invoke(transport, args);
+ }
+
+ @Test
+ void deliverParsesFramesAndDropsMalformedOnes() throws Exception {
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ Transport transport = newTransport(VertxTestSupport.serverWebSocket(rec), null);
+ VertxTestSupport.CollectingSubscriber sub = new VertxTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(transport, "deliver", new Class>[] {String.class}, VertxTestSupport.pingFrame());
+ VertxTestSupport.awaitTrue("envelope delivery", () -> sub.items.size() == 1);
+ assertThat(sub.items.get(0).type()).isEqualTo("session.ping");
+
+ invoke(transport, "deliver", new Class>[] {String.class}, "{malformed");
+ invoke(transport, "completeInbound", new Class>[0]);
+ assertThat(sub.awaitCompleted()).isTrue();
+ assertThat(sub.items).hasSize(1);
+ }
+
+ @Test
+ void failInboundSurfacesAsSubscriberError() throws Exception {
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ Transport transport = newTransport(VertxTestSupport.serverWebSocket(rec), null);
+ VertxTestSupport.CollectingSubscriber sub = new VertxTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(
+ transport, "failInbound", new Class>[] {Throwable.class}, new RuntimeException("boom"));
+ assertThat(sub.awaitErrored()).isTrue();
+ assertThat(sub.error.get()).hasMessage("boom");
+ }
+
+ @Test
+ void sendWritesTextMessageWhenWriteSucceeds() throws Exception {
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ Transport transport = newTransport(VertxTestSupport.serverWebSocket(rec), ArcpMapper.create());
+
+ transport.send(VertxTestSupport.pingEnvelope());
+
+ assertThat(rec.written).hasSize(1);
+ assertThat(rec.written.get(0)).contains("\"session.ping\"");
+ assertThat(rec.closeCalls.get()).isZero();
+ }
+
+ @Test
+ void failedWriteIsObservedAsTransportFailureAndClosesSocket() throws Exception {
+ // §110: a failed async write must fail the inbound stream and close the socket so the runtime
+ // notices the dead session, instead of the write being silently dropped.
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ rec.failWrites = true;
+ Transport transport = newTransport(VertxTestSupport.serverWebSocket(rec), null);
+ VertxTestSupport.CollectingSubscriber sub = new VertxTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ transport.send(VertxTestSupport.pingEnvelope());
+
+ assertThat(sub.awaitErrored()).isTrue();
+ assertThat(sub.error.get()).isInstanceOf(IOException.class);
+ VertxTestSupport.awaitTrue("socket close after failed write", () -> rec.closeCalls.get() > 0);
+ assertThat(rec.written).isEmpty();
+ }
+
+ @Test
+ void serializationFailureSurfacesAsUncheckedIoException() throws Exception {
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ ObjectMapper throwingMapper =
+ new ObjectMapper() {
+ @Override
+ public String writeValueAsString(Object value) throws JsonProcessingException {
+ throw new JsonProcessingException("serialization refused by fake") {};
+ }
+ };
+ Transport transport = newTransport(VertxTestSupport.serverWebSocket(rec), throwingMapper);
+
+ assertThatThrownBy(() -> transport.send(VertxTestSupport.pingEnvelope()))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasCauseInstanceOf(IOException.class);
+ assertThat(rec.written).isEmpty();
+ }
+
+ @Test
+ void closeClosesSocketAndInbound() throws Exception {
+ VertxTestSupport.WsRecorder rec = new VertxTestSupport.WsRecorder();
+ Transport transport = newTransport(VertxTestSupport.serverWebSocket(rec), null);
+ VertxTestSupport.CollectingSubscriber sub = new VertxTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ transport.close();
+
+ assertThat(rec.closeCalls.get()).isEqualTo(1);
+ assertThat(sub.awaitCompleted()).isTrue();
+ }
+}
diff --git a/arcp-otel/pom.xml b/arcp-otel/pom.xml
index 8c3e809..7f9cdbd 100644
--- a/arcp-otel/pom.xml
+++ b/arcp-otel/pom.xml
@@ -11,6 +11,10 @@
arcp-otel
+
+
+ false
+
arcp-otel
OpenTelemetry adapter for ARCP transports.
diff --git a/arcp-otel/src/main/java/dev/arcp/otel/ArcpOtel.java b/arcp-otel/src/main/java/dev/arcp/otel/ArcpOtel.java
index e0381a7..4c1521a 100644
--- a/arcp-otel/src/main/java/dev/arcp/otel/ArcpOtel.java
+++ b/arcp-otel/src/main/java/dev/arcp/otel/ArcpOtel.java
@@ -30,24 +30,62 @@
*/
public final class ArcpOtel {
+ /**
+ * Key under {@code payload.extensions} where W3C Trace Context headers are carried so traces
+ * survive the ARCP hop (§11).
+ */
public static final String EXTENSION_NAME = "x-vendor.opentelemetry.tracecontext";
+ /** Span attribute for envelope direction: {@code out} for sends, {@code in} for receives. */
public static final AttributeKey ATTR_DIRECTION =
AttributeKey.stringKey("arcp.direction");
+
+ /** Span attribute carrying the envelope's wire {@code type}, e.g. {@code job.submit}. */
public static final AttributeKey ATTR_TYPE = AttributeKey.stringKey("arcp.type");
+
+ /** Span attribute carrying the envelope's message {@code id}. */
public static final AttributeKey ATTR_ID = AttributeKey.stringKey("arcp.id");
+
+ /** Span attribute carrying the envelope's {@code session_id}, when present. */
public static final AttributeKey ATTR_SESSION_ID =
AttributeKey.stringKey("arcp.session_id");
+
+ /** Span attribute carrying the envelope's {@code job_id}, when present. */
public static final AttributeKey ATTR_JOB_ID = AttributeKey.stringKey("arcp.job_id");
+
+ /** Span attribute carrying the envelope's {@code trace_id} (§11), when present. */
public static final AttributeKey ATTR_TRACE_ID = AttributeKey.stringKey("arcp.trace_id");
+
+ /** Span attribute carrying the envelope's {@code event_seq}, when present. */
public static final AttributeKey ATTR_EVENT_SEQ = AttributeKey.longKey("arcp.event_seq");
private ArcpOtel() {}
+ /**
+ * Wraps {@code inner} so every sent and received {@link Envelope} is surrounded by a span. No
+ * trace context is injected into or extracted from envelopes; see {@link #withTracing(Transport,
+ * Tracer, TextMapPropagator)} for cross-process propagation.
+ *
+ * @param inner transport to decorate
+ * @param tracer tracer used to start the per-envelope spans
+ * @return a transport that delegates to {@code inner} and records spans
+ */
public static Transport withTracing(Transport inner, Tracer tracer) {
return withTracing(inner, tracer, null);
}
+ /**
+ * Wraps {@code inner} so every sent and received {@link Envelope} is surrounded by a span. When a
+ * propagator is supplied, outbound envelopes carry W3C Trace Context under {@link
+ * #EXTENSION_NAME} in {@code payload.extensions}, and inbound envelopes have it extracted as the
+ * receive span's parent (§11).
+ *
+ * @param inner transport to decorate
+ * @param tracer tracer used to start the per-envelope spans
+ * @param propagator trace-context propagator, or {@code null} to skip envelope injection and
+ * extraction
+ * @return a transport that delegates to {@code inner} and records spans
+ */
public static Transport withTracing(
Transport inner, Tracer tracer, @Nullable TextMapPropagator propagator) {
return new TracingTransport(inner, tracer, propagator);
@@ -245,7 +283,13 @@ public Iterable keys(Map carrier) {
}
}
- /** Build an opaque {@link SpanContext} for tests. */
+ /**
+ * Build an opaque {@link SpanContext} for tests, sampled and with default trace state.
+ *
+ * @param traceId 32-hex-character W3C trace id
+ * @param spanId 16-hex-character W3C span id
+ * @return a valid, sampled span context carrying the given ids
+ */
public static SpanContext newSpanContext(String traceId, String spanId) {
return SpanContext.create(traceId, spanId, TraceFlags.getSampled(), TraceState.getDefault());
}
diff --git a/arcp-otel/src/test/java/dev/arcp/otel/ArcpOtelBranchTest.java b/arcp-otel/src/test/java/dev/arcp/otel/ArcpOtelBranchTest.java
new file mode 100644
index 0000000..0f20596
--- /dev/null
+++ b/arcp-otel/src/test/java/dev/arcp/otel/ArcpOtelBranchTest.java
@@ -0,0 +1,274 @@
+package dev.arcp.otel;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.awaitility.Awaitility.await;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.SessionId;
+import dev.arcp.core.ids.TraceId;
+import dev.arcp.core.transport.MemoryTransport;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.Envelope;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Flow;
+import org.junit.jupiter.api.Test;
+
+/** Branch coverage for {@link ArcpOtel}'s tracing transport (#33). */
+class ArcpOtelBranchTest {
+
+ private final InMemorySpanExporter exporter = InMemorySpanExporter.create();
+ private final Tracer tracer = tracer(exporter);
+
+ private static Tracer tracer(InMemorySpanExporter exporter) {
+ SdkTracerProvider provider =
+ SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)).build();
+ OpenTelemetry otel = OpenTelemetrySdk.builder().setTracerProvider(provider).build();
+ return otel.getTracer("arcp-otel-branch-test");
+ }
+
+ private static Envelope envelope(ObjectNode payload) {
+ return new Envelope(
+ Envelope.VERSION, MessageId.generate(), "job.event", null, null, null, null, payload);
+ }
+
+ private static Envelope fullEnvelope(ObjectNode payload) {
+ return new Envelope(
+ Envelope.VERSION,
+ MessageId.generate(),
+ "job.event",
+ SessionId.of("sess_otel"),
+ TraceId.of("trace_otel"),
+ JobId.of("job_otel"),
+ 7L,
+ payload);
+ }
+
+ @Test
+ void sendFailureRecordsExceptionAndRethrows() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ pair.client().close();
+ Transport tracing = ArcpOtel.withTracing(pair.runtime(), tracer);
+ assertThatThrownBy(() -> tracing.send(envelope(JsonNodeFactory.instance.objectNode())))
+ .isInstanceOf(IllegalStateException.class);
+ List spans = exporter.getFinishedSpanItems();
+ assertThat(spans).hasSize(1);
+ assertThat(spans.get(0).getEvents()).anyMatch(e -> e.getName().equals("exception"));
+ }
+
+ @Test
+ void optionalEnvelopeAttributesAreRecordedWhenPresent() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Transport tracing = ArcpOtel.withTracing(pair.runtime(), tracer);
+ tracing.send(fullEnvelope(JsonNodeFactory.instance.objectNode()));
+ SpanData span = exporter.getFinishedSpanItems().get(0);
+ assertThat(span.getAttributes().get(ArcpOtel.ATTR_SESSION_ID)).isEqualTo("sess_otel");
+ assertThat(span.getAttributes().get(ArcpOtel.ATTR_JOB_ID)).isEqualTo("job_otel");
+ assertThat(span.getAttributes().get(ArcpOtel.ATTR_TRACE_ID)).isEqualTo("trace_otel");
+ assertThat(span.getAttributes().get(ArcpOtel.ATTR_EVENT_SEQ)).isEqualTo(7L);
+ }
+
+ @Test
+ void injectSkipsWhenNoActiveSpanContext() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ CopyOnWriteArrayList seen = collect(pair.client().incoming());
+ Transport tracing =
+ ArcpOtel.withTracing(pair.runtime(), tracer, W3CTraceContextPropagator.getInstance());
+
+ // No active span: TracingTransport's own send span IS current, so traceparent will inject.
+ // The carrier-empty branch needs a propagator that never injects.
+ Transport noop =
+ ArcpOtel.withTracing(
+ pair.runtime(), tracer, io.opentelemetry.context.propagation.TextMapPropagator.noop());
+ Envelope plain = envelope(JsonNodeFactory.instance.objectNode());
+ noop.send(plain);
+ await().atMost(Duration.ofSeconds(5)).until(() -> !seen.isEmpty());
+ assertThat(seen.get(0).payload().has("extensions")).isFalse();
+
+ // And the injecting propagator adds the extension under a fresh extensions object.
+ tracing.send(envelope(JsonNodeFactory.instance.objectNode()));
+ await().atMost(Duration.ofSeconds(5)).until(() -> seen.size() == 2);
+ assertThat(seen.get(1).payload().path("extensions").has(ArcpOtel.EXTENSION_NAME)).isTrue();
+ }
+
+ @Test
+ void injectMergesIntoExistingExtensionsObject() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ CopyOnWriteArrayList seen = collect(pair.client().incoming());
+ Transport tracing =
+ ArcpOtel.withTracing(pair.runtime(), tracer, W3CTraceContextPropagator.getInstance());
+
+ ObjectNode withExt = JsonNodeFactory.instance.objectNode();
+ withExt.putObject("extensions").put("x-vendor.other", "keep");
+ tracing.send(envelope(withExt));
+ await().atMost(Duration.ofSeconds(5)).until(() -> !seen.isEmpty());
+ ObjectNode ext = (ObjectNode) seen.get(0).payload().get("extensions");
+ assertThat(ext.get("x-vendor.other").asText()).isEqualTo("keep");
+ assertThat(ext.has(ArcpOtel.EXTENSION_NAME)).isTrue();
+ }
+
+ @Test
+ void injectLeavesEnvelopeUntouchedWhenExtensionsIsNotAnObject() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ CopyOnWriteArrayList seen = collect(pair.client().incoming());
+ Transport tracing =
+ ArcpOtel.withTracing(pair.runtime(), tracer, W3CTraceContextPropagator.getInstance());
+
+ ObjectNode badExt = JsonNodeFactory.instance.objectNode().put("extensions", "not-an-object");
+ tracing.send(envelope(badExt));
+ await().atMost(Duration.ofSeconds(5)).until(() -> !seen.isEmpty());
+ assertThat(seen.get(0).payload().get("extensions").asText()).isEqualTo("not-an-object");
+ }
+
+ @Test
+ void extractToleratesMissingOrMalformedTraceContext() {
+ // Each malformed shape must still deliver the envelope and produce a receive span whose parent
+ // is simply Context.current() — the extract branches: non-object extensions, missing extension
+ // key, non-object extension value.
+ ObjectNode nonObjectExt = JsonNodeFactory.instance.objectNode().put("extensions", 42);
+ ObjectNode noKey = JsonNodeFactory.instance.objectNode();
+ noKey.putObject("extensions").put("x-vendor.other", "v");
+ ObjectNode nonObjectValue = JsonNodeFactory.instance.objectNode();
+ nonObjectValue.putObject("extensions").put(ArcpOtel.EXTENSION_NAME, "bogus");
+
+ for (ObjectNode payload : List.of(nonObjectExt, noKey, nonObjectValue)) {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Transport receiver =
+ ArcpOtel.withTracing(pair.client(), tracer, W3CTraceContextPropagator.getInstance());
+ CopyOnWriteArrayList seen = collect(receiver.incoming());
+ pair.runtime().send(envelope(payload));
+ await().atMost(Duration.ofSeconds(5)).until(() -> !seen.isEmpty());
+ }
+ }
+
+ @Test
+ void extractUsesValidTraceContextAsParent() throws Exception {
+ Span parent = tracer.spanBuilder("upstream").startSpan();
+ String traceparent;
+ try (Scope ignored = parent.makeCurrent()) {
+ java.util.Map carrier = new java.util.HashMap<>();
+ W3CTraceContextPropagator.getInstance()
+ .inject(
+ io.opentelemetry.context.Context.current(),
+ carrier,
+ (c, k, v) -> {
+ if (c != null) {
+ c.put(k, v);
+ }
+ });
+ traceparent = carrier.get("traceparent");
+ } finally {
+ parent.end();
+ }
+
+ ObjectNode payload = JsonNodeFactory.instance.objectNode();
+ payload
+ .putObject("extensions")
+ .putObject(ArcpOtel.EXTENSION_NAME)
+ .put("traceparent", traceparent);
+
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Transport receiver =
+ ArcpOtel.withTracing(pair.client(), tracer, W3CTraceContextPropagator.getInstance());
+ CopyOnWriteArrayList seen = collect(receiver.incoming());
+ pair.runtime().send(envelope(payload));
+ await().atMost(Duration.ofSeconds(5)).until(() -> !seen.isEmpty());
+
+ await()
+ .atMost(Duration.ofSeconds(5))
+ .until(
+ () ->
+ exporter.getFinishedSpanItems().stream()
+ .anyMatch(
+ s ->
+ s.getName().equals("arcp.receive.job.event")
+ && s.getTraceId().equals(parent.getSpanContext().getTraceId())));
+ }
+
+ @Test
+ void incomingIsCachedAcrossCalls() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Transport tracing = ArcpOtel.withTracing(pair.client(), tracer);
+ assertThat(tracing.incoming()).isSameAs(tracing.incoming());
+ }
+
+ @Test
+ void closeBeforeIncomingIsSafeAndIdempotent() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Transport tracing = ArcpOtel.withTracing(pair.client(), tracer);
+ tracing.close(); // wrappedPublisher still null
+ Transport tracing2 = ArcpOtel.withTracing(MemoryTransport.pair().client(), tracer);
+ tracing2.incoming();
+ tracing2.close(); // wrappedPublisher present
+ }
+
+ @Test
+ void upstreamCompletionAndErrorPropagate() {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Transport tracing = ArcpOtel.withTracing(pair.client(), tracer);
+ CopyOnWriteArrayList events = new CopyOnWriteArrayList<>();
+ tracing
+ .incoming()
+ .subscribe(
+ new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription s) {
+ s.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {}
+
+ @Override
+ public void onError(Throwable throwable) {
+ events.add("error");
+ }
+
+ @Override
+ public void onComplete() {
+ events.add("complete");
+ }
+ });
+ pair.client().close(); // closes the inner inbound publisher → onComplete
+ await().atMost(Duration.ofSeconds(5)).until(() -> !events.isEmpty());
+ assertThat(events.get(0)).isEqualTo("complete");
+ }
+
+ private static CopyOnWriteArrayList collect(Flow.Publisher publisher) {
+ CopyOnWriteArrayList seen = new CopyOnWriteArrayList<>();
+ publisher.subscribe(
+ new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription s) {
+ s.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ seen.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {}
+
+ @Override
+ public void onComplete() {}
+ });
+ return seen;
+ }
+}
diff --git a/arcp-runtime-jetty/pom.xml b/arcp-runtime-jetty/pom.xml
index 5c9086c..a028957 100644
--- a/arcp-runtime-jetty/pom.xml
+++ b/arcp-runtime-jetty/pom.xml
@@ -11,6 +11,10 @@
arcp-runtime-jetty
+
+
+ false
+
arcp-runtime-jetty
Embedded Jetty 12 WebSocket transport for ARCP runtimes.
diff --git a/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyEndpoint.java b/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyEndpoint.java
index 9ae8999..2b2586c 100644
--- a/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyEndpoint.java
+++ b/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyEndpoint.java
@@ -18,10 +18,17 @@ public final class ArcpJettyEndpoint extends Endpoint {
private static final Logger log = LoggerFactory.getLogger(ArcpJettyEndpoint.class);
+ /**
+ * {@link EndpointConfig#getUserProperties()} key under which {@link ArcpJettyServer} stashes the
+ * {@link ArcpRuntime} that accepted sessions are handed to.
+ */
public static final String RUNTIME_KEY = ArcpRuntime.class.getName();
private final Map transports = new ConcurrentHashMap<>();
+ /** Creates an endpoint instance; {@link ArcpJettyServer} builds one per accepted session. */
+ public ArcpJettyEndpoint() {}
+
@Override
public void onOpen(Session session, EndpointConfig config) {
ArcpRuntime runtime = (ArcpRuntime) config.getUserProperties().get(RUNTIME_KEY);
diff --git a/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyServer.java b/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyServer.java
index 3c67037..bd8aba7 100644
--- a/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyServer.java
+++ b/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/ArcpJettyServer.java
@@ -64,20 +64,44 @@ public T getEndpointInstance(Class endpointClass) {
});
}
+ /**
+ * Returns a builder for a server that exposes {@code runtime} over WebSocket.
+ *
+ * @param runtime the ARCP runtime that accepted sessions are handed to
+ * @return a new builder with the default {@code /arcp} path and an ephemeral port
+ */
public static Builder builder(ArcpRuntime runtime) {
return new Builder(runtime);
}
+ /**
+ * Starts the embedded Jetty server and binds the configured port.
+ *
+ * @return this server, for chaining
+ * @throws Exception if Jetty fails to start or the port cannot be bound
+ */
public ArcpJettyServer start() throws Exception {
server.start();
return this;
}
+ /**
+ * Returns the loopback {@code ws://} URI of the mounted endpoint, reflecting the actually bound
+ * port.
+ *
+ * @return the WebSocket URI clients connect to
+ */
public URI uri() {
int boundPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort();
return URI.create("ws://127.0.0.1:" + boundPort + path);
}
+ /**
+ * Returns the TCP port the server is bound to, which differs from the configured port when an
+ * ephemeral port ({@code 0}) was requested.
+ *
+ * @return the bound port
+ */
public int port() {
return ((ServerConnector) server.getConnectors()[0]).getLocalPort();
}
@@ -87,6 +111,7 @@ public void close() throws Exception {
server.stop();
}
+ /** Builder for {@link ArcpJettyServer}. */
public static final class Builder {
private final ArcpRuntime runtime;
private String path = "/arcp";
@@ -97,21 +122,45 @@ public static final class Builder {
this.runtime = runtime;
}
+ /**
+ * Sets the request path the WebSocket endpoint is mounted at; defaults to {@code /arcp}.
+ *
+ * @param path the endpoint path
+ * @return this builder
+ */
public Builder path(String path) {
this.path = path;
return this;
}
+ /**
+ * Sets the TCP port to bind; defaults to {@code 0}, which picks an ephemeral port.
+ *
+ * @param port the port to bind
+ * @return this builder
+ */
public Builder port(int port) {
this.port = port;
return this;
}
+ /**
+ * Sets the {@code Host} header allowlist; requests from any other host are rejected with HTTP
+ * 403 before the WebSocket handshake (§14). An empty list (the default) disables the check.
+ *
+ * @param hosts the allowed {@code Host} header values
+ * @return this builder
+ */
public Builder allowedHosts(List hosts) {
this.allowedHosts = List.copyOf(hosts);
return this;
}
+ /**
+ * Builds the configured server; call {@link ArcpJettyServer#start()} to bind it.
+ *
+ * @return the configured, not-yet-started server
+ */
public ArcpJettyServer build() {
return new ArcpJettyServer(this);
}
diff --git a/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/WebSocketJsonTransport.java b/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/WebSocketJsonTransport.java
index 6023732..1301e87 100644
--- a/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/WebSocketJsonTransport.java
+++ b/arcp-runtime-jetty/src/main/java/dev/arcp/runtime/jetty/WebSocketJsonTransport.java
@@ -18,7 +18,7 @@
/**
* Bridges a Jakarta WebSocket {@link Session} to the ARCP {@link Transport} SPI. JSON envelopes
- * ride as text frames per §4.1.
+ * ride as text frames per §4.
*/
final class WebSocketJsonTransport implements Transport {
diff --git a/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/HostAllowlistFilterCoverageTest.java b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/HostAllowlistFilterCoverageTest.java
new file mode 100644
index 0000000..d89074e
--- /dev/null
+++ b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/HostAllowlistFilterCoverageTest.java
@@ -0,0 +1,94 @@
+package dev.arcp.runtime.jetty.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Covers the §14/#99 branches of the package-private {@code HostAllowlistFilter}: allow-all on
+ * empty allowlist, exact-match allow, deny with 403 (including null Host), exact (no
+ * port-stripping, case-sensitive) matching, and the non-HTTP passthrough guard.
+ */
+class HostAllowlistFilterCoverageTest {
+
+ private static Filter filter(List allowedHosts) throws Exception {
+ Class> cls = Class.forName("dev.arcp.runtime.jetty.HostAllowlistFilter");
+ Constructor> ctor = cls.getDeclaredConstructor(List.class);
+ ctor.setAccessible(true);
+ return (Filter) ctor.newInstance(allowedHosts);
+ }
+
+ static Stream hostDecisions() {
+ return Stream.of(
+ // Empty allowlist disables the check (allow-all), with or without a Host header.
+ Arguments.of(List.of(), "agents.example.com", true),
+ Arguments.of(List.of(), null, true),
+ // Exact match passes; later entries match too.
+ Arguments.of(List.of("agents.example.com"), "agents.example.com", true),
+ Arguments.of(List.of("a.example", "b.example"), "b.example", true),
+ // Disallowed host gets 403 before the upgrade; so does a missing Host header.
+ Arguments.of(List.of("agents.example.com"), "evil.example.com", false),
+ Arguments.of(List.of("agents.example.com"), null, false),
+ // Matching is exact: case differences are refused.
+ Arguments.of(List.of("agents.example.com"), "AGENTS.EXAMPLE.COM", false),
+ // Matching is exact: ports are not stripped before comparison.
+ Arguments.of(List.of("agents.example.com"), "agents.example.com:8443", false),
+ Arguments.of(List.of("agents.example.com:8443"), "agents.example.com:8443", true));
+ }
+
+ @ParameterizedTest
+ @MethodSource("hostDecisions")
+ void hostAllowlistReturns403BeforeUpgrade(
+ List allowedHosts, String hostHeader, boolean expectAllowed) throws Exception {
+ List errors = new ArrayList<>();
+ AtomicBoolean chained = new AtomicBoolean();
+ FilterChain chain = (request, response) -> chained.set(true);
+
+ filter(allowedHosts)
+ .doFilter(
+ JettyTestSupport.httpRequest(hostHeader), JettyTestSupport.httpResponse(errors), chain);
+
+ if (expectAllowed) {
+ assertThat(chained).isTrue();
+ assertThat(errors).isEmpty();
+ } else {
+ assertThat(chained).isFalse();
+ assertThat(errors).containsExactly("403:host not allowed");
+ }
+ }
+
+ @Test
+ void nonHttpRequestPassesThroughUnchecked() throws Exception {
+ AtomicBoolean chained = new AtomicBoolean();
+ FilterChain chain = (request, response) -> chained.set(true);
+
+ filter(List.of("agents.example.com"))
+ .doFilter(JettyTestSupport.plainRequest(), JettyTestSupport.plainResponse(), chain);
+
+ assertThat(chained).isTrue();
+ }
+
+ @Test
+ void nonHttpResponsePassesThroughUnchecked() throws Exception {
+ AtomicBoolean chained = new AtomicBoolean();
+ FilterChain chain = (request, response) -> chained.set(true);
+
+ filter(List.of("agents.example.com"))
+ .doFilter(
+ JettyTestSupport.httpRequest("evil.example.com"),
+ JettyTestSupport.plainResponse(),
+ chain);
+
+ assertThat(chained).isTrue();
+ }
+}
diff --git a/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyEndpointLifecycleTest.java b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyEndpointLifecycleTest.java
new file mode 100644
index 0000000..d653aae
--- /dev/null
+++ b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyEndpointLifecycleTest.java
@@ -0,0 +1,105 @@
+package dev.arcp.runtime.jetty.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.jetty.ArcpJettyEndpoint;
+import jakarta.websocket.CloseReason;
+import jakarta.websocket.server.ServerEndpointConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers {@link ArcpJettyEndpoint} open/close/error callbacks, including the missing-runtime guard
+ * and the already-removed-transport branches.
+ */
+class JettyEndpointLifecycleTest {
+
+ private ArcpRuntime runtime;
+
+ @BeforeEach
+ void startRuntime() {
+ runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ }
+
+ @AfterEach
+ void stopRuntime() {
+ runtime.close();
+ }
+
+ private ServerEndpointConfig configWithRuntime() {
+ ServerEndpointConfig config =
+ ServerEndpointConfig.Builder.create(ArcpJettyEndpoint.class, "/arcp").build();
+ config.getUserProperties().put(ArcpJettyEndpoint.RUNTIME_KEY, runtime);
+ return config;
+ }
+
+ @Test
+ void missingRuntimeClosesWithUnexpectedCondition() {
+ ServerEndpointConfig bare =
+ ServerEndpointConfig.Builder.create(ArcpJettyEndpoint.class, "/arcp").build();
+ ArcpJettyEndpoint endpoint = new ArcpJettyEndpoint();
+
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ endpoint.onOpen(JettyTestSupport.session("no-runtime", rec), bare);
+
+ assertThat(rec.closeReasons).hasSize(1);
+ assertThat(rec.closeReasons.get(0).getCloseCode())
+ .isEqualTo(CloseReason.CloseCodes.UNEXPECTED_CONDITION);
+ assertThat(rec.textHandler.get()).isNull();
+ }
+
+ @Test
+ void missingRuntimeCloseFailureIsSwallowed() {
+ ServerEndpointConfig bare =
+ ServerEndpointConfig.Builder.create(ArcpJettyEndpoint.class, "/arcp").build();
+ ArcpJettyEndpoint endpoint = new ArcpJettyEndpoint();
+
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ endpoint.onOpen(JettyTestSupport.session("no-runtime-throwing", rec, true, false), bare);
+
+ assertThat(rec.closeReasons).isEmpty();
+ assertThat(rec.textHandler.get()).isNull();
+ }
+
+ @Test
+ void openDeliverCloseLifecycle() throws Exception {
+ ServerEndpointConfig config = configWithRuntime();
+ ArcpJettyEndpoint endpoint = new ArcpJettyEndpoint();
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+
+ endpoint.onOpen(JettyTestSupport.session("lifecycle", rec), config);
+ assertThat(rec.textHandler.get()).isNotNull();
+
+ // Decode + dispatch: a well-formed envelope frame reaches the transport, a malformed one is
+ // logged and dropped.
+ rec.textHandler.get().onMessage(JettyTestSupport.pingFrame());
+ rec.textHandler.get().onMessage("this is not an envelope");
+
+ CloseReason normal = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "bye");
+ endpoint.onClose(JettyTestSupport.session("lifecycle", rec), normal);
+ // Second close and a late error both hit the already-removed branches.
+ endpoint.onClose(JettyTestSupport.session("lifecycle", rec), normal);
+ endpoint.onError(JettyTestSupport.session("lifecycle", rec), new RuntimeException("late"));
+ }
+
+ @Test
+ void errorCallbackFailsInboundForTrackedSession() {
+ ServerEndpointConfig config = configWithRuntime();
+ ArcpJettyEndpoint endpoint = new ArcpJettyEndpoint();
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+
+ endpoint.onOpen(JettyTestSupport.session("erroring", rec), config);
+ assertThat(rec.textHandler.get()).isNotNull();
+
+ endpoint.onError(JettyTestSupport.session("erroring", rec), new RuntimeException("boom"));
+ endpoint.onClose(
+ JettyTestSupport.session("erroring", rec),
+ new CloseReason(CloseReason.CloseCodes.CLOSED_ABNORMALLY, "gone"));
+ }
+}
diff --git a/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyServerHostAllowlistTest.java b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyServerHostAllowlistTest.java
new file mode 100644
index 0000000..0fe1679
--- /dev/null
+++ b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyServerHostAllowlistTest.java
@@ -0,0 +1,66 @@
+package dev.arcp.runtime.jetty.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.jetty.ArcpJettyServer;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end §14/#99 check: a server built with a host allowlist installs the filter and refuses a
+ * disallowed Host with 403 before any WebSocket upgrade can run.
+ */
+class JettyServerHostAllowlistTest {
+
+ @Test
+ void disallowedHostIsRefusedWith403BeforeUpgrade() throws Exception {
+ ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build();
+ try (ArcpJettyServer server =
+ ArcpJettyServer.builder(runtime)
+ .path("/arcp")
+ .port(0)
+ .allowedHosts(List.of("agents.example.com"))
+ .build()
+ .start()) {
+ int port = server.port();
+ assertThat(server.uri().getPort()).isEqualTo(port);
+
+ assertThat(rawHttpStatus(port, "evil.example.com")).isEqualTo(403);
+ assertThat(rawHttpStatus(port, "agents.example.com:9999")).isEqualTo(403);
+ // An allowlisted Host clears the filter; the request then fails the upgrade (no WebSocket
+ // headers) but is not refused by the host allowlist.
+ assertThat(rawHttpStatus(port, "agents.example.com")).isNotEqualTo(403);
+ } finally {
+ runtime.close();
+ }
+ }
+
+ private static int rawHttpStatus(int port, String hostHeader) throws IOException {
+ try (Socket socket = new Socket("127.0.0.1", port)) {
+ socket.setSoTimeout(5000);
+ OutputStream out = socket.getOutputStream();
+ out.write(
+ ("GET /arcp HTTP/1.1\r\nHost: " + hostHeader + "\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ try (BufferedReader reader =
+ new BufferedReader(
+ new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII))) {
+ String statusLine = reader.readLine();
+ assertThat(statusLine).as("HTTP status line").isNotNull().startsWith("HTTP/1.1 ");
+ return Integer.parseInt(statusLine.split(" ")[1]);
+ }
+ }
+ }
+}
diff --git a/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyTestSupport.java b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyTestSupport.java
new file mode 100644
index 0000000..22e880b
--- /dev/null
+++ b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/JettyTestSupport.java
@@ -0,0 +1,220 @@
+package dev.arcp.runtime.jetty.coverage;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.websocket.CloseReason;
+import jakarta.websocket.MessageHandler;
+import jakarta.websocket.RemoteEndpoint;
+import jakarta.websocket.Session;
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
+/** Shared fakes for exercising the Jetty runtime endpoint and host-allowlist filter. */
+final class JettyTestSupport {
+
+ private JettyTestSupport() {}
+
+ /** Records the interactions the endpoint/transport perform against a session. */
+ static final class SessionRecorder {
+ final List closeReasons = new CopyOnWriteArrayList<>();
+ final List sentText = new CopyOnWriteArrayList<>();
+ final AtomicReference> textHandler = new AtomicReference<>();
+ volatile boolean plainCloseCalled;
+ }
+
+ static Session session(String id, SessionRecorder rec) {
+ return session(id, rec, false, false);
+ }
+
+ @SuppressWarnings("unchecked")
+ static Session session(
+ String id, SessionRecorder rec, boolean throwOnClose, boolean throwOnSend) {
+ RemoteEndpoint.Basic basic =
+ (RemoteEndpoint.Basic)
+ Proxy.newProxyInstance(
+ JettyTestSupport.class.getClassLoader(),
+ new Class>[] {RemoteEndpoint.Basic.class},
+ (proxy, method, args) -> {
+ if ("sendText".equals(method.getName()) && args != null && args.length == 1) {
+ if (throwOnSend) {
+ throw new IOException("send refused by fake");
+ }
+ rec.sentText.add((String) args[0]);
+ return null;
+ }
+ return defaultValue(method.getReturnType());
+ });
+ return (Session)
+ Proxy.newProxyInstance(
+ JettyTestSupport.class.getClassLoader(),
+ new Class>[] {Session.class},
+ (proxy, method, args) ->
+ switch (method.getName()) {
+ case "getId" -> id;
+ case "close" -> {
+ if (throwOnClose) {
+ throw new IOException("close refused by fake");
+ }
+ if (args != null && args.length == 1) {
+ rec.closeReasons.add((CloseReason) args[0]);
+ } else {
+ rec.plainCloseCalled = true;
+ }
+ yield null;
+ }
+ case "addMessageHandler" -> {
+ if (args != null
+ && args.length == 2
+ && args[1] instanceof MessageHandler.Whole> whole) {
+ rec.textHandler.set((MessageHandler.Whole) whole);
+ }
+ yield null;
+ }
+ case "getBasicRemote" -> basic;
+ case "isOpen" -> true;
+ case "toString" -> "FakeSession(" + id + ")";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ static HttpServletRequest httpRequest(String hostHeader) {
+ return (HttpServletRequest)
+ Proxy.newProxyInstance(
+ JettyTestSupport.class.getClassLoader(),
+ new Class>[] {HttpServletRequest.class},
+ (proxy, method, args) -> {
+ if ("getHeader".equals(method.getName()) && "Host".equals(args[0])) {
+ return hostHeader;
+ }
+ return defaultValue(method.getReturnType());
+ });
+ }
+
+ /** Records {@code sendError} calls as {@code "status:message"} strings. */
+ static HttpServletResponse httpResponse(List errors) {
+ return (HttpServletResponse)
+ Proxy.newProxyInstance(
+ JettyTestSupport.class.getClassLoader(),
+ new Class>[] {HttpServletResponse.class},
+ (proxy, method, args) -> {
+ if ("sendError".equals(method.getName())) {
+ errors.add(args.length == 2 ? args[0] + ":" + args[1] : String.valueOf(args[0]));
+ return null;
+ }
+ return defaultValue(method.getReturnType());
+ });
+ }
+
+ static ServletRequest plainRequest() {
+ return (ServletRequest)
+ Proxy.newProxyInstance(
+ JettyTestSupport.class.getClassLoader(),
+ new Class>[] {ServletRequest.class},
+ (proxy, method, args) -> defaultValue(method.getReturnType()));
+ }
+
+ static ServletResponse plainResponse() {
+ return (ServletResponse)
+ Proxy.newProxyInstance(
+ JettyTestSupport.class.getClassLoader(),
+ new Class>[] {ServletResponse.class},
+ (proxy, method, args) -> defaultValue(method.getReturnType()));
+ }
+
+ static String pingFrame() throws Exception {
+ return ArcpMapper.shared().writeValueAsString(pingEnvelope());
+ }
+
+ static Envelope pingEnvelope() {
+ return Envelope.builder("session.ping").payload(JsonNodeFactory.instance.objectNode()).build();
+ }
+
+ static void awaitTrue(String what, BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!condition.getAsBoolean()) {
+ if (System.nanoTime() > deadline) {
+ throw new AssertionError("timed out waiting for " + what);
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ /** Flow subscriber that records terminal signals for inbound-publisher assertions. */
+ static final class CollectingSubscriber implements Flow.Subscriber {
+ final List items = new CopyOnWriteArrayList<>();
+ final CountDownLatch completed = new CountDownLatch(1);
+ final CountDownLatch errored = new CountDownLatch(1);
+ final AtomicReference error = new AtomicReference<>();
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ items.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ error.set(throwable);
+ errored.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ completed.countDown();
+ }
+
+ boolean awaitCompleted() throws InterruptedException {
+ return completed.await(5, TimeUnit.SECONDS);
+ }
+
+ boolean awaitErrored() throws InterruptedException {
+ return errored.await(5, TimeUnit.SECONDS);
+ }
+ }
+
+ static Object defaultValue(Class> type) {
+ if (!type.isPrimitive() || type == void.class) {
+ return null;
+ }
+ if (type == boolean.class) {
+ return false;
+ }
+ if (type == long.class) {
+ return 0L;
+ }
+ if (type == double.class) {
+ return 0d;
+ }
+ if (type == float.class) {
+ return 0f;
+ }
+ if (type == char.class) {
+ return (char) 0;
+ }
+ if (type == byte.class) {
+ return (byte) 0;
+ }
+ if (type == short.class) {
+ return (short) 0;
+ }
+ return 0;
+ }
+}
diff --git a/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/WebSocketJsonTransportCoverageTest.java b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/WebSocketJsonTransportCoverageTest.java
new file mode 100644
index 0000000..80c0b01
--- /dev/null
+++ b/arcp-runtime-jetty/src/test/java/dev/arcp/runtime/jetty/coverage/WebSocketJsonTransportCoverageTest.java
@@ -0,0 +1,113 @@
+package dev.arcp.runtime.jetty.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.ArcpMapper;
+import jakarta.websocket.Session;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Drives the package-private {@code WebSocketJsonTransport} (reflectively constructed) through its
+ * frame-decode, outbound-write, and close paths.
+ */
+class WebSocketJsonTransportCoverageTest {
+
+ private static final String TRANSPORT_CLASS = "dev.arcp.runtime.jetty.WebSocketJsonTransport";
+
+ private static Transport newTransport(Session session, ObjectMapper mapper) throws Exception {
+ Class> cls = Class.forName(TRANSPORT_CLASS);
+ Constructor> ctor = cls.getDeclaredConstructor(Session.class, ObjectMapper.class);
+ ctor.setAccessible(true);
+ return (Transport) ctor.newInstance(session, mapper);
+ }
+
+ private static void invoke(Transport transport, String name, Class>[] sig, Object... args)
+ throws Exception {
+ Method method = Class.forName(TRANSPORT_CLASS).getDeclaredMethod(name, sig);
+ method.setAccessible(true);
+ method.invoke(transport, args);
+ }
+
+ @Test
+ void deliverParsesFramesAndDropsMalformedOnes() throws Exception {
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ Transport transport = newTransport(JettyTestSupport.session("deliver", rec), null);
+ JettyTestSupport.CollectingSubscriber sub = new JettyTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(transport, "deliver", new Class>[] {String.class}, JettyTestSupport.pingFrame());
+ JettyTestSupport.awaitTrue("envelope delivery", () -> sub.items.size() == 1);
+ assertThat(sub.items.get(0).type()).isEqualTo("session.ping");
+
+ invoke(transport, "deliver", new Class>[] {String.class}, "{malformed");
+ invoke(transport, "completeInbound", new Class>[0]);
+ assertThat(sub.awaitCompleted()).isTrue();
+ assertThat(sub.items).hasSize(1);
+ }
+
+ @Test
+ void failInboundSurfacesAsSubscriberError() throws Exception {
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ Transport transport = newTransport(JettyTestSupport.session("failing", rec), null);
+ JettyTestSupport.CollectingSubscriber sub = new JettyTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ invoke(
+ transport, "failInbound", new Class>[] {Throwable.class}, new RuntimeException("boom"));
+ assertThat(sub.awaitErrored()).isTrue();
+ assertThat(sub.error.get()).hasMessage("boom");
+ }
+
+ @Test
+ void sendWritesJsonTextFrames() throws Exception {
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ Transport transport = newTransport(JettyTestSupport.session("send", rec), ArcpMapper.create());
+
+ transport.send(JettyTestSupport.pingEnvelope());
+
+ assertThat(rec.sentText).hasSize(1);
+ assertThat(rec.sentText.get(0)).contains("\"session.ping\"");
+ }
+
+ @Test
+ void sendFailureSurfacesAsUncheckedIoException() throws Exception {
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ Transport transport =
+ newTransport(JettyTestSupport.session("send-fail", rec, false, true), null);
+
+ assertThatThrownBy(() -> transport.send(JettyTestSupport.pingEnvelope()))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void closeClosesSessionAndInbound() throws Exception {
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ Transport transport = newTransport(JettyTestSupport.session("close", rec), null);
+ JettyTestSupport.CollectingSubscriber sub = new JettyTestSupport.CollectingSubscriber();
+ transport.incoming().subscribe(sub);
+
+ transport.close();
+
+ assertThat(rec.plainCloseCalled).isTrue();
+ assertThat(sub.awaitCompleted()).isTrue();
+ }
+
+ @Test
+ void closeSwallowsSessionCloseFailure() throws Exception {
+ JettyTestSupport.SessionRecorder rec = new JettyTestSupport.SessionRecorder();
+ Transport transport =
+ newTransport(JettyTestSupport.session("close-fail", rec, true, false), null);
+
+ transport.close();
+
+ assertThat(rec.plainCloseCalled).isFalse();
+ }
+}
diff --git a/arcp-runtime/pom.xml b/arcp-runtime/pom.xml
index aceac16..d372e68 100644
--- a/arcp-runtime/pom.xml
+++ b/arcp-runtime/pom.xml
@@ -11,6 +11,10 @@
arcp-runtime
+
+
+ false
+
arcp-runtime
ARCP runtime SDK.
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/ArcpRuntime.java b/arcp-runtime/src/main/java/dev/arcp/runtime/ArcpRuntime.java
index 22e6877..81fe945 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/ArcpRuntime.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/ArcpRuntime.java
@@ -119,11 +119,21 @@ static EnumSet safeFeatureCopy(Set features) {
return EnumSet.copyOf(features);
}
+ /**
+ * Creates a builder for assembling a runtime.
+ *
+ * @return a new {@link Builder} preloaded with default settings
+ */
public static Builder builder() {
return new Builder();
}
- /** Attach a transport; the returned handle is closed on session.bye or transport close. */
+ /**
+ * Attaches a transport; the returned handle is closed on {@code session.bye} or transport close.
+ *
+ * @param transport the connected transport to drive a session over
+ * @return the session loop now serving {@code transport}
+ */
public SessionLoop accept(Transport transport) {
SessionLoop loop = new SessionLoop(this, transport);
// Insert before start() so that if the transport completes/errors synchronously during start
@@ -140,97 +150,214 @@ public SessionLoop accept(Transport transport) {
return loop;
}
+ /**
+ * Returns the Jackson mapper used for ARCP wire I/O (§5).
+ *
+ * @return the configured {@link ObjectMapper}
+ */
public ObjectMapper mapper() {
return mapper;
}
+ /**
+ * Returns the registry of agents this runtime can resolve and run (§7.5).
+ *
+ * @return the agent registry
+ */
public AgentRegistry agents() {
return agents;
}
+ /**
+ * Returns the verifier applied to the bearer token in {@code session.hello} (§6.1).
+ *
+ * @return the bearer-token verifier
+ */
public BearerVerifier verifier() {
return verifier;
}
+ /**
+ * Returns the feature set advertised in {@code session.welcome} (§6.2).
+ *
+ * @return an unmodifiable set of advertised features
+ */
public Set advertised() {
return advertised;
}
+ /**
+ * Returns the {@code heartbeat_interval_sec} advertised in {@code session.welcome} (§6.4).
+ *
+ * @return the heartbeat interval in seconds
+ */
public int heartbeatIntervalSec() {
return heartbeatIntervalSec;
}
+ /**
+ * Returns the {@code resume_window_sec} during which a dropped session may resume (§6.3).
+ *
+ * @return the resume window in seconds
+ */
public int resumeWindowSec() {
return resumeWindowSec;
}
+ /**
+ * Returns the per-session capacity of the outbound event buffer kept for §6.3 resume replay.
+ *
+ * @return the resume buffer capacity in envelopes
+ */
public int resumeBufferCapacity() {
return resumeBufferCapacity;
}
+ /**
+ * Returns the clock used for timestamps, idempotency TTLs, and lease-expiry decisions.
+ *
+ * @return the runtime clock
+ */
public Clock clock() {
return clock;
}
+ /**
+ * Returns the executor on which agent jobs execute.
+ *
+ * @return the worker pool
+ */
public ExecutorService workerPool() {
return workerPool;
}
+ /**
+ * Returns the scheduler used for heartbeat ticks, watchdogs, and background pruning.
+ *
+ * @return the shared scheduler
+ */
public ScheduledExecutorService scheduler() {
return scheduler;
}
+ /**
+ * Returns the runtime name reported in {@code session.welcome.payload.runtime} (§6.2).
+ *
+ * @return the runtime name
+ */
public String runtimeName() {
return runtimeName;
}
+ /**
+ * Returns the runtime version reported in {@code session.welcome.payload.runtime} (§6.2).
+ *
+ * @return the runtime version string
+ */
public String runtimeVersion() {
return runtimeVersion;
}
+ /**
+ * Returns the store backing {@code idempotency_key} deduplication (§7.2).
+ *
+ * @return the idempotency store
+ */
public IdempotencyStore idempotency() {
return idempotency;
}
+ /**
+ * Returns the provisioner that mints per-job upstream credentials (§9.8).
+ *
+ * @return the credential provisioner; a no-op instance when none was configured
+ */
public CredentialProvisioner credentialProvisioner() {
return credentialProvisioner;
}
+ /**
+ * Returns the store tracking issued credentials until their revocation succeeds (§9.8).
+ *
+ * @return the credential revocation store
+ */
public CredentialRevocationStore credentialRevocationStore() {
return credentialRevocationStore;
}
+ /**
+ * Registers an accepted job so it is visible to {@code session.list_jobs} (§6.6) and {@code
+ * job.subscribe} (§7.6).
+ *
+ * @param job the record of the accepted job
+ */
public void registerJob(JobRecord job) {
jobs.put(job.jobId(), job);
}
+ /**
+ * Looks up a registered job by id.
+ *
+ * @param jobId the job id to look up
+ * @return the job record, or {@code null} if no such job is registered
+ */
public @Nullable JobRecord job(JobId jobId) {
return jobs.get(jobId);
}
+ /**
+ * Removes a job from the registry.
+ *
+ * @param jobId the id of the job to remove
+ */
public void removeJob(JobId jobId) {
jobs.remove(jobId);
}
+ /**
+ * Returns a live view of every registered job, across all sessions.
+ *
+ * @return the registered job records
+ */
public Collection jobs() {
return jobs.values();
}
- /** Park a session for resume, keyed by its resume token (§6.3, #22). */
+ /**
+ * Parks a session for resume, keyed by its resume token (§6.3, #22).
+ *
+ * @param resumeToken the token a reconnecting client must present in {@code session.resume}
+ * @param loop the session loop to park
+ */
public void parkResumable(String resumeToken, SessionLoop loop) {
resumable.put(resumeToken, loop);
}
- /** Atomically claim a parked session for resume, or {@code null} if unknown/expired (#22). */
+ /**
+ * Atomically claims a parked session for resume (#22).
+ *
+ * @param resumeToken the token presented in {@code session.resume}
+ * @return the parked session loop, or {@code null} if the token is unknown or expired (§6.3)
+ */
public @Nullable SessionLoop takeResumable(String resumeToken) {
return resumable.remove(resumeToken);
}
- /** Remove a parked session only if it still maps to {@code loop} (#22). */
+ /**
+ * Removes a parked session only if it still maps to {@code loop} (#22).
+ *
+ * @param resumeToken the token the session was parked under
+ * @param loop the session loop expected at that token
+ */
public void removeResumable(String resumeToken, SessionLoop loop) {
resumable.remove(resumeToken, loop);
}
+ /**
+ * Drops a closed session from the live-session map.
+ *
+ * @param loop the session loop to remove
+ */
public void removeSession(SessionLoop loop) {
// Always remove via the pending key the loop was inserted under, since
// idOrPending() flips to the real session id after handshake and would
@@ -258,6 +385,13 @@ public void close() {
}
}
+ /**
+ * Fluent builder for {@link ArcpRuntime}. Every setting has a working default; the minimal useful
+ * configuration registers at least one {@linkplain #agent agent}. Unless overridden, the runtime
+ * accepts any bearer token, runs jobs on an owned virtual-thread executor, and advertises all
+ * features except {@code model.use} and {@code provisioned_credentials} — those two are added
+ * automatically when a real {@linkplain #credentialProvisioner provisioner} is configured (§9.8).
+ */
public static final class Builder {
private @Nullable ObjectMapper mapper;
private AgentRegistry agents = new AgentRegistry();
@@ -276,6 +410,9 @@ public static final class Builder {
private @Nullable CredentialProvisioner credentialProvisioner;
private @Nullable CredentialRevocationStore credentialRevocationStore;
+ /** Creates a builder with all settings at their defaults. */
+ public Builder() {}
+
private static Set defaultFeatures() {
EnumSet features = EnumSet.allOf(Feature.class);
features.remove(Feature.MODEL_USE);
@@ -283,87 +420,209 @@ private static Set defaultFeatures() {
return features;
}
+ /**
+ * Registers an agent version with the runtime's registry (§7.5). The first version registered
+ * under a name becomes that name's default.
+ *
+ * @param name the agent name as it appears in {@code job.submit.payload.agent}
+ * @param version the version string this handler implements
+ * @param agent the handler invoked for jobs resolved to this version
+ * @return this builder
+ */
public Builder agent(String name, String version, Agent agent) {
agents.register(name, version, agent);
return this;
}
+ /**
+ * Replaces the backing agent registry, discarding any agents registered so far.
+ *
+ * @param registry the registry to resolve agents from
+ * @return this builder
+ */
public Builder agents(AgentRegistry registry) {
this.agents = registry;
return this;
}
+ /**
+ * Sets the verifier applied to the bearer token in {@code session.hello} (§6.1). Defaults to
+ * accepting any token.
+ *
+ * @param v the bearer-token verifier
+ * @return this builder
+ */
public Builder verifier(BearerVerifier v) {
this.verifier = v;
return this;
}
+ /**
+ * Sets the feature set advertised in {@code session.welcome} (§6.2) explicitly, disabling the
+ * provisioner-driven defaulting of {@code model.use} and {@code provisioned_credentials}.
+ *
+ * @param features the features to advertise; copied defensively
+ * @return this builder
+ */
public Builder features(Set features) {
this.advertised = safeFeatureCopy(features);
this.featuresConfigured = true;
return this;
}
+ /**
+ * Sets the {@code heartbeat_interval_sec} advertised in {@code session.welcome} (§6.4).
+ * Defaults to 30.
+ *
+ * @param sec the heartbeat interval in seconds
+ * @return this builder
+ */
public Builder heartbeatIntervalSec(int sec) {
this.heartbeatIntervalSec = sec;
return this;
}
+ /**
+ * Sets the {@code resume_window_sec} during which a dropped session may resume (§6.3). Defaults
+ * to 600.
+ *
+ * @param sec the resume window in seconds
+ * @return this builder
+ */
public Builder resumeWindowSec(int sec) {
this.resumeWindowSec = sec;
return this;
}
+ /**
+ * Sets the per-session capacity of the outbound event buffer used for §6.3 resume replay.
+ * Defaults to 1024 envelopes.
+ *
+ * @param cap the buffer capacity in envelopes
+ * @return this builder
+ */
public Builder resumeBufferCapacity(int cap) {
this.resumeBufferCapacity = cap;
return this;
}
+ /**
+ * Sets the clock used for timestamps, TTLs, and lease-expiry decisions; useful for
+ * deterministic tests. Defaults to {@link Clock#systemUTC()}.
+ *
+ * @param c the clock to use
+ * @return this builder
+ */
public Builder clock(Clock c) {
this.clock = c;
return this;
}
+ /**
+ * Sets the Jackson mapper used for ARCP wire I/O (§5). Defaults to {@link ArcpMapper#shared()}.
+ *
+ * @param m the mapper to use
+ * @return this builder
+ */
public Builder mapper(ObjectMapper m) {
this.mapper = m;
return this;
}
+ /**
+ * Sets the executor on which agent jobs run. A supplied pool is not shut down by {@link
+ * ArcpRuntime#close()}; by default the runtime owns (and shuts down) a virtual-thread-per-task
+ * executor.
+ *
+ * @param e the worker pool
+ * @return this builder
+ */
public Builder workerPool(ExecutorService e) {
this.workerPool = e;
return this;
}
+ /**
+ * Sets the scheduler used for heartbeat ticks, watchdogs, and background pruning. A supplied
+ * scheduler is not shut down by {@link ArcpRuntime#close()}; by default the runtime owns a
+ * single-threaded daemon scheduler.
+ *
+ * @param s the scheduler to use
+ * @return this builder
+ */
public Builder scheduler(ScheduledExecutorService s) {
this.scheduler = s;
return this;
}
+ /**
+ * Sets the runtime name reported in {@code session.welcome.payload.runtime} (§6.2). Defaults to
+ * {@code arcp-runtime-java}.
+ *
+ * @param n the runtime name
+ * @return this builder
+ */
public Builder runtimeName(String n) {
this.runtimeName = n;
return this;
}
+ /**
+ * Sets the runtime version reported in {@code session.welcome.payload.runtime} (§6.2).
+ *
+ * @param v the runtime version string
+ * @return this builder
+ */
public Builder runtimeVersion(String v) {
this.runtimeVersion = v;
return this;
}
+ /**
+ * Sets how long {@code idempotency_key} claims are retained (§7.2). Defaults to 24 hours.
+ *
+ * @param ttl the retention window for idempotency entries
+ * @return this builder
+ */
public Builder idempotencyTtl(Duration ttl) {
this.idempotencyTtl = ttl;
return this;
}
+ /**
+ * Sets the backend that mints and revokes per-job upstream credentials (§9.8). Unless {@link
+ * #features} was called, configuring a real provisioner also advertises {@code model.use} and
+ * {@code provisioned_credentials}.
+ *
+ * @param provisioner the credential provisioner
+ * @return this builder
+ */
public Builder credentialProvisioner(CredentialProvisioner provisioner) {
this.credentialProvisioner = provisioner;
return this;
}
+ /**
+ * Sets the store tracking issued credentials until their revocation succeeds (§9.8). Required
+ * to be durable in production whenever {@code provisioned_credentials} is advertised; defaults
+ * to an in-memory store.
+ *
+ * @param store the credential revocation store
+ * @return this builder
+ */
public Builder credentialRevocationStore(CredentialRevocationStore store) {
this.credentialRevocationStore = store;
return this;
}
+ /**
+ * Validates the configuration and creates the runtime.
+ *
+ * @return the configured runtime
+ * @throws IllegalStateException if {@code provisioned_credentials} or {@code model.use} is
+ * advertised without a configured provisioner, or if {@code provisioned_credentials} is
+ * advertised without a revocation store (§9.8)
+ */
public ArcpRuntime build() {
Set effective = effectiveFeatures(this);
boolean advertisesCredentials = effective.contains(Feature.PROVISIONED_CREDENTIALS);
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/Agent.java b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/Agent.java
index 8074de4..0db59bf 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/Agent.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/Agent.java
@@ -3,5 +3,17 @@
/** User-supplied handler producing a job's events and final result. */
@FunctionalInterface
public interface Agent {
+
+ /**
+ * Runs one job to completion. Invoked on the runtime's worker pool after {@code job.accepted};
+ * implementations should emit progress via {@link JobContext#emit} and poll {@link
+ * JobContext#cancelled()} at convenient checkpoints (§7.4).
+ *
+ * @param input the submitted payload plus job identity, lease, and provisioned credentials
+ * @param ctx per-job runtime services: event emission, cancellation, and lease checks
+ * @return the job's terminal outcome (success or failure)
+ * @throws Exception if the job fails; the runtime maps the failure to a {@code job.error} with an
+ * appropriate §12 error code
+ */
JobOutcome run(JobInput input, JobContext ctx) throws Exception;
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/AgentRegistry.java b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/AgentRegistry.java
index d4bb6b4..f5f6be0 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/AgentRegistry.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/AgentRegistry.java
@@ -18,17 +18,47 @@ private record Entry(String version, Agent agent) {}
private final Map> byName = new LinkedHashMap<>();
private final Map defaults = new LinkedHashMap<>();
+ /** Creates an empty registry. */
+ public AgentRegistry() {}
+
+ /**
+ * Registers a handler for {@code name} at {@code version}. The first version registered under a
+ * name becomes that name's default until {@link #setDefault} overrides it.
+ *
+ * @param name the agent name as it appears in {@code job.submit.payload.agent}
+ * @param version the version string this handler implements (opaque per §7.5)
+ * @param agent the handler invoked for jobs resolved to this version
+ * @return this registry
+ */
public synchronized AgentRegistry register(String name, String version, Agent agent) {
byName.computeIfAbsent(name, k -> new ArrayList<>()).add(new Entry(version, agent));
defaults.putIfAbsent(name, version);
return this;
}
+ /**
+ * Sets the version a bare {@code name} (no {@code @version} suffix) resolves to (§7.5).
+ *
+ * @param name the agent name
+ * @param version the version advertised as {@code default} in {@code session.welcome}
+ * @return this registry
+ */
public synchronized AgentRegistry setDefault(String name, String version) {
defaults.put(name, version);
return this;
}
+ /**
+ * Resolves an {@link AgentRef} to a registered handler per §7.5: a bare name resolves to the
+ * default version (falling back to the first registered version), while {@code name@version}
+ * requires an exact match.
+ *
+ * @param ref the agent reference from {@code job.submit}
+ * @return the resolved name, version, and handler
+ * @throws AgentNotAvailableException if no version of {@code ref.name()} is registered
+ * @throws AgentVersionNotAvailableException if the explicitly requested version is not registered
+ * ({@code AGENT_VERSION_NOT_AVAILABLE}, §12)
+ */
public synchronized Resolved resolve(AgentRef ref)
throws AgentNotAvailableException, AgentVersionNotAvailableException {
List versions = byName.get(ref.name());
@@ -58,6 +88,12 @@ public synchronized Resolved resolve(AgentRef ref)
return new Resolved(ref.name(), match.version, match.agent);
}
+ /**
+ * Returns the agent inventory advertised in {@code session.welcome} (§6.2), sorted by name for
+ * stable wire output.
+ *
+ * @return one descriptor per registered agent name, with its versions and default
+ */
public synchronized List describe() {
// Sorted for stable wire output.
return new TreeMap<>(byName)
@@ -71,7 +107,19 @@ public synchronized List describe() {
.toList();
}
+ /**
+ * Outcome of {@link #resolve}: the agent identity a job is pinned to for its lifetime (§7.5).
+ *
+ * @param name the agent name
+ * @param version the resolved version string
+ * @param agent the handler registered for that version
+ */
public record Resolved(String name, String version, Agent agent) {
+ /**
+ * Returns the {@code name@version} form reported in {@code job.accepted} and listings (§7.5).
+ *
+ * @return the wire representation of the resolved agent
+ */
public String wire() {
return name + "@" + version;
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobContext.java b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobContext.java
index b608711..d04306c 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobContext.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobContext.java
@@ -11,25 +11,50 @@
/** Per-job runtime handle passed to {@link Agent#run}. */
public interface JobContext {
- /** Emit a job event for this job. Thread-safe; ordering preserved per job. */
+ /**
+ * Emits a job event for this job (§8). Thread-safe; ordering is preserved per job.
+ *
+ * @param body the event body to wrap in a {@code job.event} envelope
+ */
void emit(EventBody body);
- /** {@code true} if a {@code job.cancel} or session close has been observed. */
+ /**
+ * Reports whether cancellation has been requested for this job.
+ *
+ * @return {@code true} if a {@code job.cancel} or session close has been observed (§7.4)
+ */
boolean cancelled();
/**
- * Authorize an operation against the active lease (§9.3 / §9.5 / §9.6). Throws on lease subset
+ * Authorizes an operation against the active lease (§9.3 / §9.5 / §9.6). Throws on lease subset
* violation, expired lease, or exhausted budget.
+ *
+ * @param namespace the capability namespace, e.g. {@code fs.read} or {@code model.use}
+ * @param pattern the concrete value being attempted, matched against the lease's patterns
+ * @throws PermissionDeniedException if no lease pattern in {@code namespace} permits {@code
+ * pattern} (§9.3)
+ * @throws LeaseExpiredException if the lease's {@code expires_at} has passed (§9.5)
+ * @throws BudgetExhaustedException if a budgeted counter has reached zero or below (§9.6)
*/
void authorize(String namespace, String pattern)
throws PermissionDeniedException, LeaseExpiredException, BudgetExhaustedException;
- /** Currently active provisioned credentials for this job. */
+ /**
+ * Returns the currently active provisioned credentials for this job (§9.8).
+ *
+ * @return the live credentials, or an empty list when none are provisioned
+ */
default List credentials() {
return List.of();
}
- /** Rotate an issued credential value and publish a credential_rotated status event. */
+ /**
+ * Rotates an issued credential value and publishes a {@code credential_rotated} status event
+ * (§9.8.2). The prior value is revoked promptly.
+ *
+ * @param id the id of the credential being rotated
+ * @param newValue the replacement credential material
+ */
default void rotateCredential(CredentialId id, String newValue) {
// Default is a no-op for runtimes without provisioned credentials.
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobInput.java b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobInput.java
index 7454866..8fd8d89 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobInput.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobInput.java
@@ -9,6 +9,16 @@
import java.util.List;
import org.jspecify.annotations.Nullable;
+/**
+ * Immutable view of one accepted {@code job.submit}, handed to {@link Agent#run}.
+ *
+ * @param payload the submitted {@code input} document, opaque to the runtime
+ * @param jobId the id assigned at acceptance (§7.1)
+ * @param sessionId the session the job was submitted on
+ * @param traceId the propagated trace id, or {@code null} if the client sent none (§11)
+ * @param lease the granted lease the job runs under (§9)
+ * @param credentials provisioned credentials issued for this job, empty when none (§9.8)
+ */
public record JobInput(
JsonNode payload,
JobId jobId,
@@ -16,6 +26,7 @@ public record JobInput(
@Nullable TraceId traceId,
Lease lease,
List credentials) {
+ /** Defensively copies {@code credentials} into an immutable list. */
public JobInput {
credentials = List.copyOf(credentials);
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobOutcome.java b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobOutcome.java
index 71c7b38..ef1a2aa 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobOutcome.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/agent/JobOutcome.java
@@ -5,8 +5,22 @@
import dev.arcp.core.ids.ResultId;
import org.jspecify.annotations.Nullable;
+/**
+ * Terminal result of {@link Agent#run}: either a {@link Success} mapped to {@code job.result} or a
+ * {@link Failure} mapped to {@code job.error} (§7.3).
+ */
public sealed interface JobOutcome permits JobOutcome.Success, JobOutcome.Failure {
+ /**
+ * Successful completion. Exactly one of {@code inline} or {@code resultId} should be set: small
+ * results are returned inline, large ones are streamed as {@code result_chunk} events and
+ * referenced by id (§8.4).
+ *
+ * @param inline the inline result document, or {@code null} when the result was streamed
+ * @param resultId the id of a streamed result, or {@code null} for inline results
+ * @param resultSize the total byte size of a streamed result, or {@code null} for inline results
+ * @param summary an optional human-readable summary of the result
+ */
record Success(
@Nullable JsonNode inline,
@Nullable ResultId resultId,
@@ -14,14 +28,34 @@ record Success(
@Nullable String summary)
implements JobOutcome {
+ /**
+ * Creates a success whose result is carried inline in {@code job.result}.
+ *
+ * @param result the result document
+ * @return an inline success outcome
+ */
public static Success inline(JsonNode result) {
return new Success(result, null, null, null);
}
+ /**
+ * Creates a success whose result was streamed as {@code result_chunk} events (§8.4).
+ *
+ * @param id the result id the chunks were emitted under
+ * @param size the total size of the streamed result in bytes
+ * @param summary an optional human-readable summary, or {@code null}
+ * @return a streamed success outcome
+ */
public static Success streamed(ResultId id, long size, @Nullable String summary) {
return new Success(null, id, size, summary);
}
}
+ /**
+ * Failed completion, surfaced to the client as {@code job.error} (§12).
+ *
+ * @param code the §12 error code describing the failure
+ * @param message a human-readable description of the failure
+ */
record Failure(ErrorCode code, String message) implements JobOutcome {}
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialBinding.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialBinding.java
index 4783788..5f538f3 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialBinding.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialBinding.java
@@ -16,6 +16,12 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+/**
+ * Ties §9.8 provisioned credentials to a job's lifecycle: records every issued credential in the
+ * {@link CredentialRevocationStore}, attaches credentials to the {@link JobRecord}, emits {@code
+ * credential_rotated} status events on rotation, and revokes with bounded retry when the job
+ * terminates (§9.8.2).
+ */
public final class CredentialBinding {
private static final Logger log = LoggerFactory.getLogger(CredentialBinding.class);
private static final int MAX_REVOKE_ATTEMPTS = 3;
@@ -29,11 +35,27 @@ public final class CredentialBinding {
private final ObjectMapper mapper;
private final BiConsumer eventSink;
+ /**
+ * Creates a binding that discards rotation events.
+ *
+ * @param provisioner the backend that revokes credentials at the upstream
+ * @param store the store tracking credentials until revocation succeeds
+ * @param clock the runtime clock
+ */
public CredentialBinding(
CredentialProvisioner provisioner, CredentialRevocationStore store, Clock clock) {
this(provisioner, store, clock, (record, body) -> {});
}
+ /**
+ * Creates a binding that publishes {@code credential_rotated} status events through {@code
+ * eventSink} (§9.8.2).
+ *
+ * @param provisioner the backend that revokes credentials at the upstream
+ * @param store the store tracking credentials until revocation succeeds
+ * @param clock the runtime clock
+ * @param eventSink the sink receiving rotation status events for a job
+ */
public CredentialBinding(
CredentialProvisioner provisioner,
CredentialRevocationStore store,
@@ -46,6 +68,14 @@ public CredentialBinding(
this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
}
+ /**
+ * Records freshly issued credentials in the revocation store and attaches them to {@code record},
+ * returning the wire objects for {@code job.accepted.payload.credentials} (§9.8.1).
+ *
+ * @param record the job the credentials were issued for
+ * @param issued the credentials minted by the provisioner; copied defensively
+ * @return the wire form of each attached credential, in issue order
+ */
public List attach(JobRecord record, List issued) {
List copy = List.copyOf(issued);
for (IssuedCredential credential : copy) {
@@ -59,6 +89,14 @@ public List attach(JobRecord record, List issued)
return copy.stream().map(IssuedCredential::wire).toList();
}
+ /**
+ * Replaces credential {@code id} on the job with {@code next}, revokes the prior value, records
+ * the replacement, and emits a {@code credential_rotated} status event (§9.8.2).
+ *
+ * @param record the job whose credential is rotating
+ * @param id the id of the credential being rotated
+ * @param next the replacement credential
+ */
public void rotate(JobRecord record, CredentialId id, IssuedCredential next) {
IssuedCredential prior = record.replaceCredential(id, next);
if (prior != null) {
@@ -75,6 +113,12 @@ public void rotate(JobRecord record, CredentialId id, IssuedCredential next) {
mapper.valueToTree(new CredentialRotatedBody(next.wire().id(), next.wire().value()))));
}
+ /**
+ * Revokes every credential still attached to {@code record}; invoked when the job reaches a
+ * terminal state, regardless of how termination occurred (§9.8.2).
+ *
+ * @param record the terminated job
+ */
public void revokeAll(JobRecord record) {
for (IssuedCredential credential : record.drainCredentials()) {
revoke(credential);
@@ -82,9 +126,11 @@ public void revokeAll(JobRecord record) {
}
/**
- * Record then revoke a credential that was minted upstream but not attached to a job (e.g.
+ * Records then revokes a credential that was minted upstream but not attached to a job (e.g.
* surplus credentials returned during rotation), so its spend authority is tracked and released
* rather than dangling (§14, #98).
+ *
+ * @param credential the unattached credential to track and revoke
*/
public void revokeMinted(IssuedCredential credential) {
store.record(
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialProvisioner.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialProvisioner.java
index 09fc323..0f035a2 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialProvisioner.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialProvisioner.java
@@ -9,8 +9,27 @@
/** Plug-in point for upstream-specific provisioned credential issue/revoke. */
public interface CredentialProvisioner {
+
+ /**
+ * Mints credentials for one job, each pre-constrained to the job's lease so the upstream becomes
+ * the enforcement boundary (§9.8). The runtime surfaces the issued credentials in {@code
+ * job.accepted.payload.credentials} (§9.8.1).
+ *
+ * @param lease the granted lease the credentials must be scoped at or below
+ * @param constraints the lease constraints (budget, model, {@code expires_at}) to bake in
+ * @param ctx the job the credentials are being issued for
+ * @return a future completing with the issued credentials; may be empty when the upstream
+ * requires none
+ */
CompletableFuture> issue(
Lease lease, LeaseConstraints constraints, JobContext ctx);
+ /**
+ * Revokes a previously issued credential at the upstream (§9.8.2). The runtime calls this when
+ * the job reaches a terminal state or after rotation, retrying on transient failure.
+ *
+ * @param id the id of the credential to revoke
+ * @return a future completing when revocation succeeds, or exceptionally on failure
+ */
CompletableFuture revoke(CredentialId id);
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialRevocationStore.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialRevocationStore.java
index c9cc4f2..e39de11 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialRevocationStore.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/CredentialRevocationStore.java
@@ -3,20 +3,52 @@
import dev.arcp.core.credentials.CredentialId;
import java.util.List;
+/**
+ * Tracks issued credentials from mint until their upstream revocation succeeds, so spend authority
+ * never dangles silently (§9.8.2). Durable implementations let a restarted runtime discover and
+ * revoke credentials issued before a crash.
+ */
public interface CredentialRevocationStore {
+
+ /**
+ * Records that a credential was issued and is awaiting revocation.
+ *
+ * @param id the credential id (§9.8.1)
+ * @param providerHandle the upstream handle needed to revoke the credential later
+ */
void record(CredentialId id, String providerHandle);
+ /**
+ * Marks a credential as successfully revoked at the upstream, removing it from the outstanding
+ * set.
+ *
+ * @param id the credential id
+ */
void markRevoked(CredentialId id);
/**
- * Note that revocation failed for this credential after exhausting retries. Default
+ * Notes that revocation failed for this credential after exhausting retries. Default
* implementation is a no-op so existing implementations continue to compile.
+ *
+ * @param id the credential id whose revocation failed
+ * @param cause the failure from the final revocation attempt
*/
default void markRevocationFailed(CredentialId id, Throwable cause) {
// No-op default; durable stores may persist the failure for asynchronous recovery.
}
+ /**
+ * Returns credentials that were recorded but not yet marked revoked.
+ *
+ * @return the outstanding credentials, oldest first where the implementation preserves order
+ */
List outstanding();
+ /**
+ * A credential awaiting revocation.
+ *
+ * @param id the credential id (§9.8.1)
+ * @param providerHandle the upstream handle needed to revoke the credential
+ */
record Outstanding(CredentialId id, String providerHandle) {}
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/FileCredentialRevocationStore.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/FileCredentialRevocationStore.java
index 69ac540..7aa0276 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/FileCredentialRevocationStore.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/FileCredentialRevocationStore.java
@@ -15,6 +15,12 @@
import java.util.List;
import java.util.Map;
+/**
+ * Durable {@link CredentialRevocationStore} backed by an append-only JSON-lines log. Each {@code
+ * record}/{@code revoke} entry is appended and fsynced before the call returns; on construction the
+ * log is replayed to rebuild the outstanding set, so a restarted runtime can resume revoking
+ * credentials issued before a crash (§9.8.2).
+ */
public final class FileCredentialRevocationStore
implements CredentialRevocationStore, AutoCloseable {
private final Path path;
@@ -22,10 +28,24 @@ public final class FileCredentialRevocationStore
private final Map outstanding = new LinkedHashMap<>();
private final RandomAccessFile writer;
+ /**
+ * Opens the store at {@code path} (creating the file and parent directories if absent) using the
+ * shared wire mapper.
+ *
+ * @param path the log file location
+ */
public FileCredentialRevocationStore(Path path) {
this(path, ArcpMapper.shared());
}
+ /**
+ * Opens the store at {@code path} (creating the file and parent directories if absent), replaying
+ * any existing log entries to rebuild the outstanding set.
+ *
+ * @param path the log file location
+ * @param mapper the Jackson mapper used to read and write log entries
+ * @throws IllegalStateException if the log cannot be created, read, or opened for append
+ */
public FileCredentialRevocationStore(Path path, ObjectMapper mapper) {
this.path = path;
this.mapper = mapper;
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/InMemoryCredentialRevocationStore.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/InMemoryCredentialRevocationStore.java
index 0de298a..4c6ea4f 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/InMemoryCredentialRevocationStore.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/InMemoryCredentialRevocationStore.java
@@ -4,9 +4,18 @@
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
+/**
+ * Non-durable {@link CredentialRevocationStore} backed by a concurrent map. Suitable for tests and
+ * runtimes that do not advertise {@code provisioned_credentials}; outstanding credentials are lost
+ * on process exit, so production deployments should prefer {@link FileCredentialRevocationStore} or
+ * another durable store (§9.8.2).
+ */
public final class InMemoryCredentialRevocationStore implements CredentialRevocationStore {
private final ConcurrentHashMap outstanding = new ConcurrentHashMap<>();
+ /** Creates an empty store. */
+ public InMemoryCredentialRevocationStore() {}
+
@Override
public void record(CredentialId id, String providerHandle) {
outstanding.put(id, providerHandle);
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/IssuedCredential.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/IssuedCredential.java
index 9e145ac..09c2cee 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/IssuedCredential.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/IssuedCredential.java
@@ -3,4 +3,12 @@
import dev.arcp.core.credentials.Credential;
import org.jspecify.annotations.Nullable;
+/**
+ * A credential minted by a {@link CredentialProvisioner}, pairing the §9.8.1 wire object with the
+ * provisioner-private handle needed to revoke it at the upstream.
+ *
+ * @param wire the credential as surfaced in {@code job.accepted.payload.credentials} (§9.8.1)
+ * @param providerHandle the upstream revocation handle, or {@code null} when the credential {@code
+ * id} itself suffices
+ */
public record IssuedCredential(Credential wire, @Nullable String providerHandle) {}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/NoopCredentialProvisioner.java b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/NoopCredentialProvisioner.java
index 4e16e6e..9d0f530 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/NoopCredentialProvisioner.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/credentials/NoopCredentialProvisioner.java
@@ -7,7 +7,14 @@
import java.util.List;
import java.util.concurrent.CompletableFuture;
+/**
+ * {@link CredentialProvisioner} that issues nothing and revokes nothing. Used when {@code
+ * provisioned_credentials} is not advertised (§9.8); {@code ArcpRuntime.Builder} treats this
+ * instance as "no provisioner configured".
+ */
public final class NoopCredentialProvisioner implements CredentialProvisioner {
+
+ /** The single shared instance. */
public static final NoopCredentialProvisioner INSTANCE = new NoopCredentialProvisioner();
private NoopCredentialProvisioner() {}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/heartbeat/HeartbeatTracker.java b/arcp-runtime/src/main/java/dev/arcp/runtime/heartbeat/HeartbeatTracker.java
index 0c60a0d..cd436e4 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/heartbeat/HeartbeatTracker.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/heartbeat/HeartbeatTracker.java
@@ -15,25 +15,46 @@ public final class HeartbeatTracker {
private final Clock clock;
private final AtomicReference lastInbound;
+ /**
+ * Creates a tracker that counts construction time as the first inbound activity.
+ *
+ * @param clock the clock used to timestamp inbound messages
+ */
public HeartbeatTracker(Clock clock) {
this.clock = clock;
this.lastInbound = new AtomicReference<>(clock.instant());
}
+ /** Records inbound activity now; any message counts, not just {@code session.ping} (§6.4). */
public void onInbound() {
lastInbound.set(clock.instant());
}
+ /**
+ * Returns the time elapsed since the most recent inbound message.
+ *
+ * @return the duration since the last inbound activity
+ */
public Duration sinceLastInbound() {
return Duration.between(lastInbound.get(), clock.instant());
}
- /** Per §6.4: two consecutive missed intervals MAY close the transport. */
+ /**
+ * Per §6.4: two consecutive missed intervals MAY close the transport.
+ *
+ * @param interval the negotiated {@code heartbeat_interval_sec} as a duration
+ * @return {@code true} if more than two intervals have elapsed without inbound activity
+ */
public boolean shouldClose(Duration interval) {
return sinceLastInbound().compareTo(interval.multipliedBy(2)) > 0;
}
- /** Per §6.4: one elapsed interval triggers an outbound ping. */
+ /**
+ * Per §6.4: one elapsed interval triggers an outbound ping.
+ *
+ * @param interval the negotiated {@code heartbeat_interval_sec} as a duration
+ * @return {@code true} if at least one interval has elapsed without inbound activity
+ */
public boolean shouldPing(Duration interval) {
return sinceLastInbound().compareTo(interval) >= 0;
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyFingerprint.java b/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyFingerprint.java
index b293cde..ba46182 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyFingerprint.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyFingerprint.java
@@ -23,6 +23,14 @@ public final class IdempotencyFingerprint {
private IdempotencyFingerprint() {}
+ /**
+ * Computes the canonical fingerprint of a submission, used by the runtime to decide whether a
+ * reused {@code idempotency_key} carries identical parameters (§7.2).
+ *
+ * @param mapper the wire mapper; copied and reconfigured for canonical (sorted-key) output
+ * @param submit the submission to fingerprint
+ * @return the lowercase hex SHA-256 of the canonical serialization
+ */
public static String of(ObjectMapper mapper, JobSubmit submit) {
try {
ObjectMapper canonical = mapper.copy();
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyStore.java b/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyStore.java
index 4215922..5b97da1 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyStore.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/idempotency/IdempotencyStore.java
@@ -26,6 +26,12 @@
*/
public final class IdempotencyStore implements AutoCloseable {
+ /**
+ * Result of a {@link #claim} that found a prior entry for the same {@code (principal,
+ * idempotency_key)} pair (§7.2).
+ *
+ * @param existing the job id the key already maps to
+ */
public record Conflict(JobId existing) {}
private record Key(String principal, String idempotencyKey) {}
@@ -37,10 +43,25 @@ private record Entry(JobId jobId, String fingerprint, Instant insertedAt) {}
private final Clock clock;
private final @Nullable ScheduledFuture> pruneTask;
+ /**
+ * Creates a store without background eviction; callers must invoke {@link #prune()} themselves.
+ *
+ * @param clock the clock used to age entries
+ * @param ttl how long a claimed key is retained
+ */
public IdempotencyStore(Clock clock, Duration ttl) {
this(clock, ttl, null, Duration.ofMinutes(1));
}
+ /**
+ * Creates a store that, when a scheduler is supplied, evicts expired entries on a fixed delay.
+ *
+ * @param clock the clock used to age entries
+ * @param ttl how long a claimed key is retained
+ * @param scheduler the scheduler running the background prune task, or {@code null} to disable
+ * background eviction
+ * @param pruneInterval the delay between background prune runs (clamped to at least 1 ms)
+ */
public IdempotencyStore(
Clock clock,
Duration ttl,
@@ -66,6 +87,12 @@ public IdempotencyStore(
* {@code Conflict(existing)}: the same key already produced a job (identical fingerprint →
* reuse; different fingerprint → caller raises {@code DUPLICATE_KEY}).
*
+ *
+ * @param principal the authenticated submitter; keys are scoped per principal (§7.2)
+ * @param idempotencyKey the {@code idempotency_key} from {@code job.submit}
+ * @param fingerprint the canonical payload fingerprint ({@link IdempotencyFingerprint})
+ * @param freshId the job id to claim if the key is unused
+ * @return {@code null} if {@code freshId} was claimed, otherwise the conflicting entry
*/
public @Nullable Conflict claim(
Principal principal, String idempotencyKey, String fingerprint, JobId freshId) {
@@ -85,6 +112,16 @@ public IdempotencyStore(
return new Conflict(existing.jobId);
}
+ /**
+ * Tests whether the stored entry for this key carries an identical payload fingerprint —
+ * distinguishing a §7.2 replay (same parameters, reuse the job) from a {@code DUPLICATE_KEY}
+ * conflict.
+ *
+ * @param principal the authenticated submitter
+ * @param idempotencyKey the {@code idempotency_key} from {@code job.submit}
+ * @param fingerprint the canonical payload fingerprint of the new submission
+ * @return {@code true} if an entry exists and its fingerprint matches
+ */
public boolean matchesPayload(Principal principal, String idempotencyKey, String fingerprint) {
Entry e = entries.get(new Key(principal.id(), idempotencyKey));
return e != null && e.fingerprint.equals(fingerprint);
@@ -95,6 +132,9 @@ public boolean matchesPayload(Principal principal, String idempotencyKey, String
* claim when the corresponding accept fails (§7.2): without this the key stays poisoned for the
* full TTL and an identical retry is wrongly rejected with {@code DUPLICATE_KEY} (#90).
*
+ * @param principal the authenticated submitter the key is scoped to
+ * @param idempotencyKey the {@code idempotency_key} to release
+ * @param expected the job id the entry must still map to for the release to apply
* @return {@code true} if an entry for {@code expected} was removed
*/
public boolean release(Principal principal, String idempotencyKey, JobId expected) {
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/lease/BudgetCounters.java b/arcp-runtime/src/main/java/dev/arcp/runtime/lease/BudgetCounters.java
index cfbc7ef..3c43587 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/lease/BudgetCounters.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/lease/BudgetCounters.java
@@ -18,21 +18,45 @@ public final class BudgetCounters {
private final ConcurrentHashMap> counters =
new ConcurrentHashMap<>();
+ /**
+ * Creates counters initialized at the budgeted value per currency, as granted at job acceptance
+ * (§9.6).
+ *
+ * @param initial the budgeted amount per currency, e.g. {@code USD → 5.00}
+ */
public BudgetCounters(Map initial) {
for (var e : initial.entrySet()) {
counters.put(e.getKey(), new AtomicReference<>(e.getValue()));
}
}
+ /**
+ * Tests whether a currency is budgeted; cost metrics in unbudgeted currencies are ignored (§9.6).
+ *
+ * @param currency the currency code from a {@code cost.*} metric's {@code unit}
+ * @return {@code true} if a counter exists for {@code currency}
+ */
public boolean tracks(String currency) {
return counters.containsKey(currency);
}
+ /**
+ * Returns the remaining budget for a currency.
+ *
+ * @param currency the currency code
+ * @return the remaining amount, or {@link BigDecimal#ZERO} for an untracked currency
+ */
public BigDecimal remaining(String currency) {
var ref = counters.get(currency);
return ref == null ? BigDecimal.ZERO : ref.get();
}
+ /**
+ * Returns a point-in-time view of every counter, e.g. for the budget echoed in {@code
+ * job.accepted} (§9.6).
+ *
+ * @return an unmodifiable map of remaining amount per currency
+ */
public Map snapshot() {
return Collections.unmodifiableMap(
counters.entrySet().stream()
@@ -41,7 +65,13 @@ public Map snapshot() {
Map.Entry::getKey, e -> e.getValue().get(), (a, b) -> a, LinkedHashMap::new)));
}
- /** §9.6: negative metric values produce no decrement. */
+ /**
+ * Subtracts a reported cost from the currency's counter. Per §9.6, negative metric values produce
+ * no decrement; untracked currencies are ignored.
+ *
+ * @param currency the currency code from the {@code cost.*} metric's {@code unit}
+ * @param amount the reported cost to subtract
+ */
public void decrement(String currency, BigDecimal amount) {
if (amount.signum() < 0) {
return;
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/lease/LeaseGuard.java b/arcp-runtime/src/main/java/dev/arcp/runtime/lease/LeaseGuard.java
index 6d76dfd..26eb40f 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/lease/LeaseGuard.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/lease/LeaseGuard.java
@@ -21,20 +21,46 @@ public final class LeaseGuard {
private final Clock clock;
private final ConcurrentHashMap compiledGlobs = new ConcurrentHashMap<>();
+ /**
+ * Creates a guard for one job's granted lease.
+ *
+ * @param lease the granted lease whose patterns authorize operations (§9.2)
+ * @param constraints the lease constraints, including any {@code expires_at} (§9.5)
+ * @param clock the clock used for expiry decisions
+ */
public LeaseGuard(Lease lease, LeaseConstraints constraints, Clock clock) {
this.lease = lease;
this.constraints = constraints;
this.clock = clock;
}
+ /**
+ * Returns the lease this guard enforces.
+ *
+ * @return the granted lease
+ */
public Lease lease() {
return lease;
}
+ /**
+ * Returns the constraints attached to the lease.
+ *
+ * @return the lease constraints
+ */
public LeaseConstraints constraints() {
return constraints;
}
+ /**
+ * Authorizes an operation against the lease: checks {@code expires_at} first (§9.5), then
+ * requires at least one pattern in {@code namespace} to glob-match {@code pattern} (§9.3).
+ *
+ * @param namespace the capability namespace, e.g. {@code fs.read} or {@code model.use}
+ * @param pattern the concrete value being attempted
+ * @throws PermissionDeniedException if no lease pattern in {@code namespace} permits the value
+ * @throws LeaseExpiredException if the lease's {@code expires_at} has passed
+ */
public void authorize(String namespace, String pattern)
throws PermissionDeniedException, LeaseExpiredException {
if (constraints.expiresAt() != null && !clock.instant().isBefore(constraints.expiresAt())) {
@@ -48,6 +74,13 @@ public void authorize(String namespace, String pattern)
}
}
+ /**
+ * Authorizes use of a model against the {@code model.use} capability (§9.7).
+ *
+ * @param modelId the model identifier being requested
+ * @throws PermissionDeniedException if no {@code model.use} pattern permits {@code modelId}
+ * @throws LeaseExpiredException if the lease's {@code expires_at} has passed
+ */
public void authorizeModel(String modelId)
throws PermissionDeniedException, LeaseExpiredException {
authorize("model.use", modelId);
@@ -84,6 +117,14 @@ static java.util.regex.Pattern globToRegex(String glob) {
return java.util.regex.Pattern.compile(sb.toString());
}
+ /**
+ * Builds the {@link LeaseExpiredException} for an expired lease without throwing it, so callers
+ * can route it through their own error path (§9.5).
+ *
+ * @param constraints the lease constraints carrying the optional {@code expires_at}
+ * @param clock the clock used for the expiry decision
+ * @return the exception to surface, or {@code null} if no expiry is set or it has not passed
+ */
public static @Nullable LeaseExpiredException expiredOrNull(
LeaseConstraints constraints, Clock clock) {
if (constraints.expiresAt() == null) {
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobListing.java b/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobListing.java
index 345a2d1..efc918b 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobListing.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobListing.java
@@ -20,10 +20,24 @@ public final class JobListing {
private JobListing() {}
- /** A page of job summaries plus the opaque cursor for the next page (or {@code null}). */
+ /**
+ * A page of job summaries plus the opaque cursor for the next page (or {@code null}).
+ *
+ * @param jobs the summaries for this page, in (createdAt, jobId) order
+ * @param nextCursor the cursor for the next page, or {@code null} when this page is the last
+ */
public record Page(List jobs, @Nullable String nextCursor) {}
/**
+ * Computes one {@code session.list_jobs} result page (§6.6): jobs owned by {@code principal} that
+ * match {@code filter}, in stable (createdAt, jobId) order, sliced at {@code cursor}.
+ *
+ * @param jobs the runtime's job registry to filter
+ * @param principal the requesting principal; only that principal's jobs are visible
+ * @param filter the requested status/agent/created-after filter
+ * @param limit the maximum page size, or {@code null}/non-positive for no limit
+ * @param cursor the opaque cursor from a prior page, or {@code null} to start at the beginning
+ * @return the matching page and, when more results remain, the cursor for the next one
* @throws IllegalArgumentException if {@code cursor} is non-blank but not a valid cursor
*/
public static Page page(
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobRecord.java b/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobRecord.java
index 3b04522..491d951 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobRecord.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/session/JobRecord.java
@@ -27,18 +27,35 @@
/** Bookkeeping for one in-flight job on the runtime side. */
public final class JobRecord {
+ /** §7.3 job lifecycle states as tracked by the runtime. */
public enum Status {
+ /** Accepted but not yet running. */
PENDING,
+ /** Currently executing on the worker pool. */
RUNNING,
+ /** Terminal: completed and produced a {@code job.result}. */
SUCCESS,
+ /** Terminal: failed with a {@code job.error} (§12). */
ERROR,
+ /** Terminal: cancelled via {@code job.cancel} or session teardown (§7.4). */
CANCELLED,
+ /** Terminal: exceeded {@code max_runtime_sec}. */
TIMED_OUT;
+ /**
+ * Tests whether this status is terminal per §7.3.
+ *
+ * @return {@code true} unless the status is {@link #PENDING} or {@link #RUNNING}
+ */
public boolean terminal() {
return this != PENDING && this != RUNNING;
}
+ /**
+ * Returns the lowercase wire form used in listings and {@code final_status} (§7.3).
+ *
+ * @return the wire representation, e.g. {@code timed_out}
+ */
public String wire() {
return switch (this) {
case PENDING -> "pending";
@@ -54,7 +71,12 @@ public String wire() {
/** Default per-job event-history cap when none is supplied (e.g. in unit tests). */
public static final int DEFAULT_HISTORY_CAPACITY = 1024;
- /** A recorded event retained for §7.6 subscribe-history replay. */
+ /**
+ * A recorded event retained for §7.6 subscribe-history replay.
+ *
+ * @param producerSeq the job-scoped, monotonically increasing production sequence number
+ * @param event the event as originally emitted
+ */
public record RecordedEvent(long producerSeq, JobEvent event) {}
private final JobId jobId;
@@ -79,6 +101,18 @@ public record RecordedEvent(long producerSeq, JobEvent event) {}
private final Object credentialsLock = new Object();
private final ArrayList credentials = new ArrayList<>();
+ /**
+ * Creates a record with the {@linkplain #DEFAULT_HISTORY_CAPACITY default} event-history cap.
+ *
+ * @param jobId the id assigned at acceptance (§7.1)
+ * @param resolvedAgent the pinned {@code name@version} the job runs as (§7.5)
+ * @param principal the authenticated submitter
+ * @param lease the granted lease (§9)
+ * @param constraints the lease constraints, including any {@code expires_at} (§9.5)
+ * @param budget the per-currency budget counters (§9.6)
+ * @param createdAt the acceptance timestamp
+ * @param traceId the propagated trace id, or {@code null} if the client sent none (§11)
+ */
public JobRecord(
JobId jobId,
String resolvedAgent,
@@ -100,6 +134,20 @@ public JobRecord(
DEFAULT_HISTORY_CAPACITY);
}
+ /**
+ * Creates a record with an explicit event-history cap.
+ *
+ * @param jobId the id assigned at acceptance (§7.1)
+ * @param resolvedAgent the pinned {@code name@version} the job runs as (§7.5)
+ * @param principal the authenticated submitter
+ * @param lease the granted lease (§9)
+ * @param constraints the lease constraints, including any {@code expires_at} (§9.5)
+ * @param budget the per-currency budget counters (§9.6)
+ * @param createdAt the acceptance timestamp
+ * @param traceId the propagated trace id, or {@code null} if the client sent none (§11)
+ * @param historyCapacity the maximum events retained for §7.6 replay; non-positive values fall
+ * back to {@link #DEFAULT_HISTORY_CAPACITY}
+ */
public JobRecord(
JobId jobId,
String resolvedAgent,
@@ -121,42 +169,94 @@ public JobRecord(
this.historyCapacity = historyCapacity > 0 ? historyCapacity : DEFAULT_HISTORY_CAPACITY;
}
+ /**
+ * Returns the job's id (§7.1).
+ *
+ * @return the job id
+ */
public JobId jobId() {
return jobId;
}
+ /**
+ * Returns the pinned {@code name@version} the job runs as (§7.5).
+ *
+ * @return the resolved agent in wire form
+ */
public String resolvedAgent() {
return resolvedAgent;
}
+ /**
+ * Returns the authenticated submitter; listing and subscription are scoped to it (§6.6).
+ *
+ * @return the owning principal
+ */
public Principal principal() {
return principal;
}
+ /**
+ * Returns the lease the job runs under (§9).
+ *
+ * @return the granted lease
+ */
public Lease lease() {
return lease;
}
+ /**
+ * Returns the constraints attached to the lease (§9.5).
+ *
+ * @return the lease constraints
+ */
public LeaseConstraints constraints() {
return constraints;
}
+ /**
+ * Returns the live per-currency budget counters (§9.6).
+ *
+ * @return the budget counters
+ */
public BudgetCounters budget() {
return budget;
}
+ /**
+ * Returns when the job was accepted.
+ *
+ * @return the acceptance timestamp
+ */
public Instant createdAt() {
return createdAt;
}
+ /**
+ * Returns the trace id propagated from {@code job.submit} (§11).
+ *
+ * @return the trace id, or {@code null} if the client sent none
+ */
public @Nullable TraceId traceId() {
return traceId;
}
+ /**
+ * Returns the job's current lifecycle status (§7.3).
+ *
+ * @return the current status
+ */
public Status status() {
return status.get();
}
+ /**
+ * Atomically moves the job to {@code next} unless it already reached a terminal state, so the
+ * first terminal transition wins (§7.3).
+ *
+ * @param next the status to transition to
+ * @return {@code true} if the transition was applied
+ */
public boolean transitionTo(Status next) {
Status prev;
do {
@@ -168,47 +268,105 @@ public boolean transitionTo(Status next) {
return true;
}
+ /**
+ * Attaches the worker-pool future executing this job, used to interrupt it on cancellation.
+ *
+ * @param f the running worker future
+ */
public void setWorker(Future> f) {
this.worker = f;
}
+ /**
+ * Returns the worker-pool future executing this job.
+ *
+ * @return the worker future, or {@code null} before the job starts running
+ */
public @Nullable Future> worker() {
return worker;
}
+ /**
+ * Attaches the watchdog that fails the job when its lease's {@code expires_at} passes (§9.5).
+ *
+ * @param watchdog the scheduled lease-expiry task
+ */
public void setExpiryWatchdog(ScheduledFuture> watchdog) {
this.expiryWatchdog = watchdog;
}
+ /**
+ * Returns the lease-expiry watchdog (§9.5).
+ *
+ * @return the scheduled task, or {@code null} if the lease has no {@code expires_at}
+ */
public @Nullable ScheduledFuture> expiryWatchdog() {
return expiryWatchdog;
}
+ /**
+ * Attaches the watchdog that times the job out when {@code max_runtime_sec} elapses (§7.1).
+ *
+ * @param watchdog the scheduled max-runtime task
+ */
public void setMaxRuntimeWatchdog(ScheduledFuture> watchdog) {
this.maxRuntimeWatchdog = watchdog;
}
+ /**
+ * Returns the max-runtime watchdog (§7.1).
+ *
+ * @return the scheduled task, or {@code null} if no {@code max_runtime_sec} was requested
+ */
public @Nullable ScheduledFuture> maxRuntimeWatchdog() {
return maxRuntimeWatchdog;
}
- /** Snapshot of the budget map returned in the original {@code job.accepted} (§7.2 replay). */
+ /**
+ * Stores the budget map returned in the original {@code job.accepted}, replayed verbatim for an
+ * idempotent resubmission (§7.2).
+ *
+ * @param snapshot the per-currency budget echoed at acceptance; copied defensively
+ */
public void setAcceptedBudget(Map snapshot) {
this.acceptedBudget = Map.copyOf(snapshot);
}
+ /**
+ * Returns the budget map from the original {@code job.accepted} (§7.2 replay).
+ *
+ * @return the acceptance-time budget snapshot, or {@code null} if none was recorded
+ */
public @Nullable Map acceptedBudget() {
return acceptedBudget;
}
+ /**
+ * Records the {@code event_seq} of the most recent event sent for this job (§8.3).
+ *
+ * @param seq the session-scoped event sequence number
+ */
public void setLastEventSeq(long seq) {
lastEventSeq.set(seq);
}
+ /**
+ * Returns the {@code event_seq} of the most recent event sent for this job, surfaced as {@code
+ * last_event_seq} in §6.6 listings.
+ *
+ * @return the last event sequence, or {@code null} if no event has been sent yet
+ */
public @Nullable Long lastEventSeq() {
return lastEventSeq.get();
}
+ /**
+ * Appends an event to the bounded history kept for §7.6 subscribe replay, evicting the oldest
+ * entry when the cap is reached.
+ *
+ * @param producerSeq the job-scoped production sequence of the event
+ * @param event the event to retain
+ */
public void recordEvent(long producerSeq, JobEvent event) {
synchronized (eventHistory) {
if (eventHistory.size() == historyCapacity) {
@@ -218,36 +376,74 @@ public void recordEvent(long producerSeq, JobEvent event) {
}
}
+ /**
+ * Returns retained events for §7.6 {@code from_seq} replay.
+ *
+ * @param fromSeq the producer sequence to replay after
+ * @return recorded events with {@code producerSeq} greater than {@code fromSeq}, in order
+ */
public List eventsSince(long fromSeq) {
synchronized (eventHistory) {
return eventHistory.stream().filter(e -> e.producerSeq() > fromSeq).toList();
}
}
+ /**
+ * Returns the number of events currently retained for replay.
+ *
+ * @return the event-history size
+ */
public int eventHistorySize() {
synchronized (eventHistory) {
return eventHistory.size();
}
}
+ /**
+ * Returns the sessions subscribed to this job's events (§7.6).
+ *
+ * @return an unmodifiable view of the current subscribers
+ */
public List subscribers() {
return Collections.unmodifiableList(subscribers);
}
+ /**
+ * Adds a session subscription established via {@code job.subscribe} (§7.6).
+ *
+ * @param subscriber the subscribing session and the job id it subscribed under
+ */
public void addSubscriber(Subscriber subscriber) {
subscribers.add(subscriber);
}
+ /**
+ * Removes every subscriber matching {@code predicate}, e.g. all subscriptions of a closing
+ * session.
+ *
+ * @param predicate selects the subscribers to remove
+ * @return {@code true} if at least one subscriber was removed
+ */
public boolean removeSubscribersWhere(Predicate predicate) {
return subscribers.removeIf(predicate);
}
+ /**
+ * Returns the provisioned credentials currently attached to this job (§9.8).
+ *
+ * @return an immutable snapshot of the attached credentials
+ */
public List credentials() {
synchronized (credentialsLock) {
return List.copyOf(credentials);
}
}
+ /**
+ * Replaces the attached credentials with those issued at acceptance (§9.8.2).
+ *
+ * @param issued the credentials now live for this job
+ */
public void setCredentials(List issued) {
synchronized (credentialsLock) {
credentials.clear();
@@ -255,6 +451,14 @@ public void setCredentials(List issued) {
}
}
+ /**
+ * Swaps credential {@code id} for {@code next} during a §9.8.2 rotation, appending {@code next}
+ * if no credential with that id is attached.
+ *
+ * @param id the id of the credential being rotated
+ * @param next the replacement credential
+ * @return the credential previously held under {@code id}, or {@code null} if none matched
+ */
public @Nullable IssuedCredential replaceCredential(CredentialId id, IssuedCredential next) {
synchronized (credentialsLock) {
for (int i = 0; i < credentials.size(); i++) {
@@ -270,6 +474,12 @@ public void setCredentials(List issued) {
}
}
+ /**
+ * Detaches and returns all credentials so they can be revoked exactly once at job termination
+ * (§9.8.2).
+ *
+ * @return the credentials that were attached; subsequent calls return an empty list
+ */
public List drainCredentials() {
synchronized (credentialsLock) {
List drained = new ArrayList<>(credentials);
@@ -278,5 +488,11 @@ public List drainCredentials() {
}
}
+ /**
+ * One session's subscription to this job's events (§7.6).
+ *
+ * @param session the session receiving fanned-out events
+ * @param jobId the job id the subscription was established under
+ */
public record Subscriber(SessionLoop session, JobId jobId) {}
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/session/ResumeBuffer.java b/arcp-runtime/src/main/java/dev/arcp/runtime/session/ResumeBuffer.java
index 60cd204..fc7f1bc 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/session/ResumeBuffer.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/session/ResumeBuffer.java
@@ -15,6 +15,12 @@ public final class ResumeBuffer {
private final int capacity;
private final Deque ring;
+ /**
+ * Creates a buffer retaining at most {@code capacity} envelopes.
+ *
+ * @param capacity the maximum number of envelopes retained for replay
+ * @throws IllegalArgumentException if {@code capacity} is not positive
+ */
public ResumeBuffer(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive: " + capacity);
@@ -23,6 +29,12 @@ public ResumeBuffer(int capacity) {
this.ring = new ArrayDeque<>(capacity);
}
+ /**
+ * Records an outbound envelope, evicting the oldest entry when full. Envelopes without an {@code
+ * event_seq} (session control messages, §6.4) are not buffered.
+ *
+ * @param envelope the outbound envelope to retain for replay
+ */
public synchronized void record(Envelope envelope) {
Objects.requireNonNull(envelope, "envelope");
if (envelope.eventSeq() == null) {
@@ -34,10 +46,22 @@ public synchronized void record(Envelope envelope) {
ring.addLast(envelope);
}
+ /**
+ * Returns the buffered envelopes to replay after a resume (§6.3).
+ *
+ * @param lastEventSeq the {@code last_event_seq} the client reported in {@code session.resume}
+ * @return buffered envelopes with {@code event_seq} greater than {@code lastEventSeq}, in order
+ */
public synchronized List since(long lastEventSeq) {
return ring.stream().filter(e -> e.eventSeq() != null && e.eventSeq() > lastEventSeq).toList();
}
+ /**
+ * Returns the oldest buffered sequence number, used to detect when a requested replay falls
+ * outside the buffer and must fail with {@code RESUME_WINDOW_EXPIRED} (§6.3).
+ *
+ * @return the earliest buffered {@code event_seq}, or {@code -1} when the buffer is empty
+ */
public synchronized long earliestSeq() {
return ring.stream().map(Envelope::eventSeq).filter(Objects::nonNull).findFirst().orElse(-1L);
}
diff --git a/arcp-runtime/src/main/java/dev/arcp/runtime/session/SessionLoop.java b/arcp-runtime/src/main/java/dev/arcp/runtime/session/SessionLoop.java
index 6a3e0d8..5fb7818 100644
--- a/arcp-runtime/src/main/java/dev/arcp/runtime/session/SessionLoop.java
+++ b/arcp-runtime/src/main/java/dev/arcp/runtime/session/SessionLoop.java
@@ -65,6 +65,7 @@
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Flow;
import java.util.concurrent.ScheduledFuture;
@@ -82,10 +83,15 @@ public final class SessionLoop implements Flow.Subscriber {
private static final Logger log = LoggerFactory.getLogger(SessionLoop.class);
+ /** Lifecycle of one session loop. */
public enum Phase {
+ /** Transport attached; no {@code session.hello} processed yet (§6.2). */
AWAITING_HELLO,
+ /** Handshake complete; dispatching messages. */
ACTIVE,
+ /** Transport dropped unexpectedly; held for the resume window (§6.3). */
PARKED,
+ /** Torn down; the loop never leaves this phase. */
CLOSED
}
@@ -121,9 +127,22 @@ public enum Phase {
private final Set ownedJobs = ConcurrentHashMap.newKeySet();
+ // #109: acceptance epilogues (credential issuance completion → job.accepted → worker start) are
+ // chained through this sequence so job.accepted preserves submit order — clients correlate
+ // acceptances FIFO — while the dispatch thread never blocks on a slow provisioner. Written only
+ // from the dispatch thread; volatile so a resumed session's new dispatch thread sees the tail.
+ private volatile CompletableFuture> acceptSequence = CompletableFuture.completedFuture(null);
+
@SuppressWarnings("unused")
private Flow.@Nullable Subscription subscription;
+ /**
+ * Creates a loop serving one transport with the runtime's shared services; call {@link #start} to
+ * begin consuming inbound envelopes.
+ *
+ * @param runtime the owning runtime supplying mapper, agents, clock, and executors
+ * @param transport the connected transport this session speaks over
+ */
public SessionLoop(ArcpRuntime runtime, Transport transport) {
this.runtime = runtime;
this.transport = transport;
@@ -139,16 +158,26 @@ public SessionLoop(ArcpRuntime runtime, Transport transport) {
this::emitJobEvent);
}
+ /**
+ * Returns the best identity currently available for logging and map keys.
+ *
+ * @return the session id once the §6.2 handshake assigned one, otherwise the pending id
+ */
public String idOrPending() {
SessionId s = sessionId;
return s == null ? pendingId : s.value();
}
- /** Stable key used at session insertion time; never flips even after handshake. */
+ /**
+ * Returns the stable key used at session insertion time; never flips even after handshake.
+ *
+ * @return the pending id minted at construction
+ */
public String pendingKey() {
return pendingId;
}
+ /** Subscribes this loop to the transport's inbound envelopes, beginning dispatch. */
public void start() {
transport.incoming().subscribe(this);
}
@@ -231,6 +260,13 @@ private void expirePark(String token) {
}
}
+ /**
+ * Tears the session down immediately: cancels heartbeats and in-flight jobs, revokes their
+ * credentials, and closes the transport. Idempotent; unlike a §6.3 park, the session cannot be
+ * resumed afterwards.
+ *
+ * @param reason a short human-readable cause recorded in logs
+ */
public void shutdown(String reason) {
Phase prev = phase.getAndSet(Phase.CLOSED);
if (prev == Phase.CLOSED) {
@@ -564,7 +600,19 @@ private void handleSubmit(Envelope envelope, JobSubmit submit) {
if (runtime.idempotency().matchesPayload(pr, idempotencyKey, fingerprint)) {
JobRecord prior = runtime.job(conflict.existing());
if (prior != null) {
- emitReplayAccepted(prior, envelope.traceId());
+ // Chained behind in-flight acceptances so a replayed job.accepted cannot overtake the
+ // original acceptance (clients correlate FIFO, #109) and observes the budget captured
+ // by that acceptance (#79).
+ TraceId replayTrace = envelope.traceId();
+ acceptSequence =
+ acceptSequence.thenRun(
+ () -> {
+ try {
+ emitReplayAccepted(prior, replayTrace);
+ } catch (RuntimeException e) {
+ log.warn("idempotent replay emit failed: {}", e.toString());
+ }
+ });
return;
}
// Same key + identical params, but the prior job was never registered or was removed (a
@@ -656,30 +704,89 @@ private void acceptJob(
runtime.registerJob(record);
ownedJobs.add(jobId);
- List credentials = List.of();
+ CompletableFuture> issuance;
if (negotiated.contains(Feature.PROVISIONED_CREDENTIALS)) {
try {
- List issued =
- runtime.credentialProvisioner().issue(lease, constraints, issueContext(record)).join();
- credentials = credentialBinding.attach(record, issued);
+ issuance = runtime.credentialProvisioner().issue(lease, constraints, issueContext(record));
} catch (RuntimeException e) {
- ownedJobs.remove(jobId);
- runtime.removeJob(jobId);
- releaseIdempotency(pr, idempotencyKey, jobId);
- Throwable root = rootCause(e);
- if (root instanceof UpstreamBudgetExhaustedException budgetError) {
- sendJobErrorTopLevel(envelope, ErrorCode.BUDGET_EXHAUSTED, budgetError.getMessage());
- } else {
- sendJobErrorTopLevel(
- envelope,
- ErrorCode.INTERNAL_ERROR,
- root.getMessage() != null ? root.getMessage() : root.getClass().getSimpleName());
- }
- return;
+ issuance = CompletableFuture.failedFuture(e);
}
+ } else {
+ issuance = CompletableFuture.completedFuture(List.of());
}
- Map budgetSnapshot = budget.snapshot();
+ // §6.4/#109: never block the dispatch thread on credential issuance — a slow provisioner would
+ // leave pings unanswered (provoking HEARTBEAT_LOST teardown of a healthy session) and
+ // head-of-line-block every other message on the session. The epilogue runs when issuance
+ // completes, chained behind the previous acceptance so job.accepted keeps submit order.
+ CompletableFuture> previous = acceptSequence;
+ acceptSequence =
+ previous.thenCombine(
+ issuance.handle(IssueOutcome::new),
+ (ignored, outcome) -> {
+ try {
+ finishAccept(envelope, submit, record, resolved, idempotencyKey, outcome);
+ } catch (RuntimeException e) {
+ log.warn("acceptance epilogue failed for {}: {}", jobId, e.toString());
+ }
+ return null;
+ });
+ }
+
+ /** Result of a credential issuance attempt: exactly one of issued/failure is set. */
+ private record IssueOutcome(
+ @Nullable List issued, @Nullable Throwable failure) {}
+
+ /**
+ * Completes a job acceptance once credential issuance has resolved: attaches credentials, sends
+ * {@code job.accepted}, starts the worker, and schedules the lease/runtime watchdogs. Runs off
+ * the dispatch thread, sequenced per session in submit order (#109).
+ */
+ private void finishAccept(
+ Envelope envelope,
+ JobSubmit submit,
+ JobRecord record,
+ AgentRegistry.Resolved resolved,
+ @Nullable String idempotencyKey,
+ IssueOutcome outcome) {
+ JobId jobId = record.jobId();
+ Principal pr = record.principal();
+ List credentials;
+ try {
+ if (outcome.failure() != null) {
+ throw new IllegalStateException(outcome.failure());
+ }
+ List issued = outcome.issued() != null ? outcome.issued() : List.of();
+ credentials = credentialBinding.attach(record, issued);
+ } catch (RuntimeException e) {
+ ownedJobs.remove(jobId);
+ runtime.removeJob(jobId);
+ releaseIdempotency(pr, idempotencyKey, jobId);
+ Throwable root = rootCause(e);
+ if (root instanceof UpstreamBudgetExhaustedException budgetError) {
+ sendJobErrorTopLevel(envelope, ErrorCode.BUDGET_EXHAUSTED, budgetError.getMessage());
+ } else {
+ sendJobErrorTopLevel(
+ envelope,
+ ErrorCode.INTERNAL_ERROR,
+ root.getMessage() != null ? root.getMessage() : root.getClass().getSimpleName());
+ }
+ return;
+ }
+
+ // The session may have been torn down (or the job cancelled by a watchdog) while issuance was
+ // in flight. Revoke what was just minted and do not announce or start a job on a dead session;
+ // runJob's RUNNING transition guard (#104) covers the narrower post-check race.
+ if (record.status().terminal() || phase.get() == Phase.CLOSED) {
+ credentialBinding.revokeAll(record);
+ return;
+ }
+
+ Instant now = record.createdAt();
+ Lease lease = record.lease();
+ LeaseConstraints constraints = record.constraints();
+ TraceId traceId = record.traceId();
+ Map budgetSnapshot = record.budget().snapshot();
// §7.2: capture the budget returned at acceptance so an idempotent replay returns the same
// payload regardless of intervening spend (#79).
record.setAcceptedBudget(budgetSnapshot);
@@ -699,9 +806,11 @@ private void acceptJob(
// before setWorker and lose the interrupt (#104).
record.setWorker(runtime.workerPool().submit(() -> runJob(record, resolved.agent(), submit)));
- // §9.5 watchdog: terminate the job if the lease expires.
+ // §9.5 watchdog: terminate the job if the lease expires. The delay is measured from the
+ // current clock, not submit time — issuance latency must not postpone an absolute expires_at.
if (constraints.expiresAt() != null) {
- long delayMillis = Duration.between(now, constraints.expiresAt()).toMillis();
+ long delayMillis =
+ Duration.between(runtime.clock().instant(), constraints.expiresAt()).toMillis();
ScheduledFuture> watchdog =
runtime
.scheduler()
@@ -1189,18 +1298,39 @@ private void sendJobMessage(JobRecord rec, Message.Type type, Message msg, long
return env;
}
+ /**
+ * Returns the effective feature set: the intersection of {@code session.hello} and {@code
+ * session.welcome} features (§6.2).
+ *
+ * @return an unmodifiable view of the negotiated features; empty before the handshake
+ */
public Set negotiated() {
return java.util.Collections.unmodifiableSet(negotiated);
}
+ /**
+ * Returns the session's identity.
+ *
+ * @return the session id, or {@code null} before the §6.2 handshake completes
+ */
public @Nullable SessionId sessionId() {
return sessionId;
}
+ /**
+ * Returns the principal authenticated by the §6.1 bearer token.
+ *
+ * @return the principal, or {@code null} before the handshake completes
+ */
public @Nullable Principal principal() {
return principal;
}
+ /**
+ * Returns the loop's current lifecycle phase.
+ *
+ * @return the current {@link Phase}
+ */
public Phase phase() {
return phase.get();
}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/AsyncAcceptanceTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/AsyncAcceptanceTest.java
new file mode 100644
index 0000000..8766ed9
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/AsyncAcceptanceTest.java
@@ -0,0 +1,269 @@
+package dev.arcp.runtime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.agents.AgentRef;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.capabilities.Capabilities;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.credentials.Credential;
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.core.credentials.CredentialScheme;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.SessionId;
+import dev.arcp.core.messages.ClientInfo;
+import dev.arcp.core.messages.JobAccepted;
+import dev.arcp.core.messages.JobSubmit;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.Messages;
+import dev.arcp.core.messages.SessionHello;
+import dev.arcp.core.messages.SessionPing;
+import dev.arcp.core.messages.SessionPong;
+import dev.arcp.core.messages.SessionWelcome;
+import dev.arcp.core.transport.MemoryTransport;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.credentials.CredentialProvisioner;
+import dev.arcp.runtime.credentials.InMemoryCredentialRevocationStore;
+import dev.arcp.runtime.credentials.IssuedCredential;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Flow;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+/**
+ * §6.4/#109: credential issuance must not block the session dispatch thread. Heartbeats keep
+ * flowing while a slow provisioner is in flight, and {@code job.accepted} preserves submit order
+ * even when an earlier submission's issuance resolves after a later one.
+ */
+class AsyncAcceptanceTest {
+ private static final ObjectMapper MAPPER = ArcpMapper.shared();
+ private static final SessionId CLIENT_SESSION = SessionId.of("sess_async_accept");
+
+ @Test
+ void pingsAnsweredWhileIssuanceInFlight() throws Exception {
+ CompletableFuture> issuance = new CompletableFuture<>();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .heartbeatIntervalSec(1)
+ .credentialProvisioner(provisioner(ignored -> issuance))
+ .credentialRevocationStore(new InMemoryCredentialRevocationStore())
+ .build()) {
+ Harness h = Harness.connect(runtime);
+ h.handshake();
+
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("echo@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ null,
+ null,
+ null,
+ null));
+
+ // While issuance is parked, keep pinging past 2x the heartbeat interval. Every ping must be
+ // answered: a blocked dispatch thread would leave these unanswered and the runtime's own
+ // heartbeat watchdog would reap the session as HEARTBEAT_LOST (#109).
+ long deadline = System.nanoTime() + Duration.ofMillis(2_500).toNanos();
+ while (System.nanoTime() < deadline) {
+ h.send(Message.Type.SESSION_PING, new SessionPing("p_async", Instant.now()));
+ SessionPong pong = h.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(pong.pingNonce()).isEqualTo("p_async");
+ Thread.sleep(250);
+ }
+
+ // The session survived a provisioner stall > 2x interval: completing issuance still yields
+ // the acceptance on the live session.
+ issuance.complete(List.of(issued("cred_slow")));
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(accepted.credentials())
+ .extracting(c -> c.id().value())
+ .containsExactly("cred_slow");
+ }
+ }
+
+ @Test
+ void acceptedPreservesSubmitOrderAcrossSlowIssuance() throws Exception {
+ CompletableFuture> slow = new CompletableFuture<>();
+ AtomicInteger calls = new AtomicInteger();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("first", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .agent("second", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .credentialProvisioner(
+ provisioner(
+ ignored ->
+ calls.getAndIncrement() == 0
+ ? slow
+ : CompletableFuture.completedFuture(List.of(issued("cred_fast")))))
+ .credentialRevocationStore(new InMemoryCredentialRevocationStore())
+ .build()) {
+ Harness h = Harness.connect(runtime);
+ h.handshake();
+
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("first@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ null,
+ null,
+ null,
+ null));
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("second@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ null,
+ null,
+ null,
+ null));
+
+ // The second submission's issuance is already complete, but its acceptance must wait for the
+ // first: clients correlate job.accepted to pending submits FIFO.
+ slow.complete(List.of(issued("cred_slow")));
+ JobAccepted first = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ JobAccepted second = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(first.agent()).startsWith("first@");
+ assertThat(second.agent()).startsWith("second@");
+ }
+ }
+
+ private static IssuedCredential issued(String id) {
+ return new IssuedCredential(
+ new Credential(
+ CredentialId.of(id),
+ CredentialScheme.BEARER,
+ "v_" + id,
+ "https://upstream",
+ null,
+ null),
+ null);
+ }
+
+ private static CredentialProvisioner provisioner(
+ java.util.function.Function>> issue) {
+ return new CredentialProvisioner() {
+ @Override
+ public CompletableFuture> issue(
+ dev.arcp.core.lease.Lease lease,
+ dev.arcp.core.lease.LeaseConstraints constraints,
+ dev.arcp.runtime.agent.JobContext ctx) {
+ return issue.apply(lease);
+ }
+
+ @Override
+ public CompletableFuture revoke(CredentialId id) {
+ return CompletableFuture.completedFuture(null);
+ }
+ };
+ }
+
+ private static final class Harness {
+ private final MemoryTransport client;
+ private final Probe probe;
+
+ private Harness(MemoryTransport client, Probe probe) {
+ this.client = client;
+ this.probe = probe;
+ }
+
+ static Harness connect(ArcpRuntime runtime) {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Probe probe = new Probe();
+ pair.client().incoming().subscribe(probe);
+ assertThat(runtime.accept(pair.runtime())).isNotNull();
+ return new Harness(pair.client(), probe);
+ }
+
+ SessionWelcome handshake() throws Exception {
+ send(
+ Message.Type.SESSION_HELLO,
+ new SessionHello(
+ new ClientInfo("async-accept-test", "1.0.0"),
+ Auth.anonymous(),
+ new Capabilities(
+ List.of("json"),
+ EnumSet.of(Feature.PROVISIONED_CREDENTIALS, Feature.HEARTBEAT),
+ null),
+ null,
+ null));
+ return take(Message.Type.SESSION_WELCOME, SessionWelcome.class);
+ }
+
+ void send(Message.Type type, Message message) {
+ client.send(
+ new Envelope(
+ Envelope.VERSION,
+ MessageId.generate(),
+ type.wire(),
+ CLIENT_SESSION,
+ null,
+ null,
+ null,
+ Messages.encodePayload(MAPPER, message)));
+ }
+
+ T take(Message.Type type, Class messageClass) throws Exception {
+ return messageClass.cast(Messages.decode(MAPPER, probe.take(type)));
+ }
+ }
+
+ private static final class Probe implements Flow.Subscriber {
+ private final BlockingQueue envelopes = new LinkedBlockingQueue<>();
+ private final Queue backlog = new ArrayDeque<>();
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ envelopes.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {}
+
+ @Override
+ public void onComplete() {}
+
+ Envelope take(Message.Type type) throws InterruptedException {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (System.nanoTime() < deadline) {
+ for (Envelope existing : List.copyOf(backlog)) {
+ if (existing.type().equals(type.wire())) {
+ backlog.remove(existing);
+ return existing;
+ }
+ }
+ long remaining = deadline - System.nanoTime();
+ Envelope envelope = envelopes.poll(Math.max(1L, remaining), TimeUnit.NANOSECONDS);
+ if (envelope == null) {
+ break;
+ }
+ if (envelope.type().equals(type.wire())) {
+ return envelope;
+ }
+ backlog.add(envelope);
+ }
+ throw new AssertionError("timed out waiting for " + type.wire() + "; backlog=" + backlog);
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/ArcpRuntimeEdgeTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/ArcpRuntimeEdgeTest.java
new file mode 100644
index 0000000..8cfe89f
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/ArcpRuntimeEdgeTest.java
@@ -0,0 +1,196 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.auth.BearerVerifier;
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.transport.Transport;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobContext;
+import dev.arcp.runtime.credentials.CredentialProvisioner;
+import dev.arcp.runtime.credentials.InMemoryCredentialRevocationStore;
+import dev.arcp.runtime.credentials.IssuedCredential;
+import dev.arcp.runtime.session.SessionLoop;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Flow;
+import org.junit.jupiter.api.Test;
+
+/** Builder validation, advertised-feature derivation, and accept/close edges (#33). */
+class ArcpRuntimeEdgeTest {
+
+ private static CredentialProvisioner realProvisioner() {
+ return new CredentialProvisioner() {
+ @Override
+ public CompletableFuture> issue(
+ Lease lease, LeaseConstraints constraints, JobContext ctx) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+
+ @Override
+ public CompletableFuture revoke(CredentialId id) {
+ return CompletableFuture.completedFuture(null);
+ }
+ };
+ }
+
+ @Test
+ void explicitComponentsAreExposedThroughGetters() {
+ ObjectMapper mapper = ArcpMapper.shared();
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
+ BearerVerifier verifier = BearerVerifier.staticToken("t", new Principal("p"));
+ InMemoryCredentialRevocationStore store = new InMemoryCredentialRevocationStore();
+ try {
+ ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .mapper(mapper)
+ .clock(clock)
+ .scheduler(scheduler)
+ .workerPool(pool)
+ .verifier(verifier)
+ .credentialRevocationStore(store)
+ .runtimeName("custom-runtime")
+ .runtimeVersion("9.9.9")
+ .heartbeatIntervalSec(7)
+ .resumeWindowSec(11)
+ .resumeBufferCapacity(13)
+ .idempotencyTtl(Duration.ofMinutes(5))
+ .build();
+ assertThat(runtime.mapper()).isSameAs(mapper);
+ assertThat(runtime.clock()).isSameAs(clock);
+ assertThat(runtime.scheduler()).isSameAs(scheduler);
+ assertThat(runtime.workerPool()).isSameAs(pool);
+ assertThat(runtime.verifier()).isSameAs(verifier);
+ assertThat(runtime.credentialRevocationStore()).isSameAs(store);
+ assertThat(runtime.runtimeName()).isEqualTo("custom-runtime");
+ assertThat(runtime.runtimeVersion()).isEqualTo("9.9.9");
+ assertThat(runtime.heartbeatIntervalSec()).isEqualTo(7);
+ assertThat(runtime.resumeWindowSec()).isEqualTo(11);
+ assertThat(runtime.resumeBufferCapacity()).isEqualTo(13);
+ runtime.close();
+ // Externally-owned scheduler and worker pool are not shut down by close().
+ assertThat(scheduler.isShutdown()).isFalse();
+ assertThat(pool.isShutdown()).isFalse();
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ void provisionerWithDefaultFeaturesAdvertisesCredentialFeatures() {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .credentialProvisioner(realProvisioner())
+ .credentialRevocationStore(new InMemoryCredentialRevocationStore())
+ .build()) {
+ assertThat(runtime.advertised()).contains(Feature.PROVISIONED_CREDENTIALS, Feature.MODEL_USE);
+ }
+ }
+
+ @Test
+ void explicitFeaturesSuppressAutomaticCredentialAdvertisement() {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .credentialProvisioner(realProvisioner())
+ .credentialRevocationStore(new InMemoryCredentialRevocationStore())
+ .features(EnumSet.of(Feature.SUBSCRIBE))
+ .build()) {
+ assertThat(runtime.advertised()).containsExactly(Feature.SUBSCRIBE);
+ }
+ }
+
+ @Test
+ void emptyFeatureSetIsAllowed() {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder().features(EnumSet.noneOf(Feature.class)).build()) {
+ assertThat(runtime.advertised()).isEmpty();
+ }
+ }
+
+ @Test
+ void nullFeatureSetIsTreatedAsEmpty() {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().features(null).build()) {
+ assertThat(runtime.advertised()).isEmpty();
+ }
+ }
+
+ @Test
+ void noopProvisionerInstanceDoesNotAdvertiseCredentialFeatures() {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .credentialProvisioner(dev.arcp.runtime.credentials.NoopCredentialProvisioner.INSTANCE)
+ .build()) {
+ assertThat(runtime.advertised())
+ .doesNotContain(Feature.PROVISIONED_CREDENTIALS, Feature.MODEL_USE);
+ }
+ }
+
+ @Test
+ void modelUseWithoutProvisionerIsRejected() {
+ assertThatThrownBy(() -> ArcpRuntime.builder().features(EnumSet.of(Feature.MODEL_USE)).build())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("model.use");
+ }
+
+ @Test
+ void acceptOfAlreadyClosedTransportRemovesTheSession() {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionLoop loop = runtime.accept(new CompletedTransport());
+ assertThat(loop.phase()).isEqualTo(SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ @Test
+ void closeShutsDownParkedSessions() throws Exception {
+ ArcpRuntime runtime = ArcpRuntime.builder().build();
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.bearer("parked"), SessionHarness.DEFAULT_FEATURES);
+ h.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.PARKED);
+
+ runtime.close();
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.CLOSED);
+ }
+
+ /** Transport whose incoming stream completes synchronously on subscribe. */
+ private static final class CompletedTransport implements Transport {
+ @Override
+ public void send(Envelope envelope) {
+ throw new IllegalStateException("closed");
+ }
+
+ @Override
+ public Flow.Publisher incoming() {
+ return subscriber ->
+ subscriber.onSubscribe(
+ new Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {}
+ });
+ }
+
+ @Override
+ public void close() {}
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/CredentialBindingEdgeTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/CredentialBindingEdgeTest.java
new file mode 100644
index 0000000..bbef506
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/CredentialBindingEdgeTest.java
@@ -0,0 +1,232 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.credentials.Credential;
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.core.credentials.CredentialScheme;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.runtime.agent.JobContext;
+import dev.arcp.runtime.credentials.CredentialBinding;
+import dev.arcp.runtime.credentials.CredentialProvisioner;
+import dev.arcp.runtime.credentials.CredentialRevocationStore;
+import dev.arcp.runtime.credentials.InMemoryCredentialRevocationStore;
+import dev.arcp.runtime.credentials.IssuedCredential;
+import dev.arcp.runtime.lease.BudgetCounters;
+import dev.arcp.runtime.session.JobRecord;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+/** Rotation with surplus, revoke failure paths, and provider-handle fallbacks (#33, #98). */
+class CredentialBindingEdgeTest {
+
+ private static JobRecord record() {
+ return new JobRecord(
+ JobId.of("job_1"),
+ "agent@1.0.0",
+ new Principal("alice"),
+ Lease.empty(),
+ LeaseConstraints.none(),
+ new BudgetCounters(Map.of()),
+ Instant.parse("2026-05-21T12:00:00Z"),
+ null);
+ }
+
+ private static IssuedCredential issued(String id, String value, String handle) {
+ return new IssuedCredential(
+ new Credential(
+ CredentialId.of(id),
+ CredentialScheme.BEARER,
+ value,
+ "https://llm.example/v1",
+ null,
+ null),
+ handle);
+ }
+
+ private static final class FlakyProvisioner implements CredentialProvisioner {
+ final AtomicInteger revokeAttempts = new AtomicInteger();
+ final List revoked = new CopyOnWriteArrayList<>();
+ int failuresBeforeSuccess;
+ boolean throwRaw;
+ boolean throwCompletionWithoutCause;
+
+ @Override
+ public CompletableFuture> issue(
+ Lease lease, LeaseConstraints constraints, JobContext ctx) {
+ return CompletableFuture.completedFuture(List.of());
+ }
+
+ @Override
+ public CompletableFuture revoke(CredentialId id) {
+ int attempt = revokeAttempts.incrementAndGet();
+ if (attempt <= failuresBeforeSuccess) {
+ if (throwCompletionWithoutCause) {
+ throw new CompletionException((Throwable) null);
+ }
+ if (throwRaw) {
+ throw new IllegalStateException("revoker down");
+ }
+ return CompletableFuture.failedFuture(new IllegalStateException("transient"));
+ }
+ revoked.add(id);
+ return CompletableFuture.completedFuture(null);
+ }
+ }
+
+ private static final class RecordingStore implements CredentialRevocationStore {
+ private final InMemoryCredentialRevocationStore delegate =
+ new InMemoryCredentialRevocationStore();
+ final List failed = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void record(CredentialId id, String providerHandle) {
+ delegate.record(id, providerHandle);
+ }
+
+ @Override
+ public void markRevoked(CredentialId id) {
+ delegate.markRevoked(id);
+ }
+
+ @Override
+ public List outstanding() {
+ return delegate.outstanding();
+ }
+
+ @Override
+ public void markRevocationFailed(CredentialId id, Throwable cause) {
+ failed.add(id);
+ }
+ }
+
+ @Test
+ void attachUsesProviderHandleWhenPresent() {
+ InMemoryCredentialRevocationStore store = new InMemoryCredentialRevocationStore();
+ CredentialBinding binding =
+ new CredentialBinding(new FlakyProvisioner(), store, Clock.systemUTC());
+ binding.attach(record(), List.of(issued("cred_1", "v", "provider-handle-1")));
+ assertThat(store.outstanding())
+ .containsExactly(
+ new CredentialRevocationStore.Outstanding(
+ CredentialId.of("cred_1"), "provider-handle-1"));
+ }
+
+ @Test
+ void rotateWithoutPriorCredentialRecordsTheNewOne() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ InMemoryCredentialRevocationStore store = new InMemoryCredentialRevocationStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+ JobRecord rec = record();
+
+ binding.rotate(rec, CredentialId.of("cred_new"), issued("cred_new", "v", "h-new"));
+
+ // No prior credential existed, so nothing was revoked; the new one is tracked.
+ assertThat(provisioner.revoked).isEmpty();
+ assertThat(rec.credentials()).hasSize(1);
+ assertThat(store.outstanding())
+ .containsExactly(
+ new CredentialRevocationStore.Outstanding(CredentialId.of("cred_new"), "h-new"));
+ }
+
+ @Test
+ void revokeFailingAllAttemptsMarksRevocationFailed() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ provisioner.failuresBeforeSuccess = 99;
+ RecordingStore store = new RecordingStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+ JobRecord rec = record();
+ binding.attach(rec, List.of(issued("cred_1", "v", null)));
+
+ binding.revokeAll(rec);
+
+ assertThat(provisioner.revokeAttempts.get()).isEqualTo(3);
+ assertThat(store.failed).containsExactly(CredentialId.of("cred_1"));
+ }
+
+ @Test
+ void rawRuntimeExceptionRetriesThenSucceeds() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ provisioner.failuresBeforeSuccess = 1;
+ provisioner.throwRaw = true;
+ InMemoryCredentialRevocationStore store = new InMemoryCredentialRevocationStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+ JobRecord rec = record();
+ binding.attach(rec, List.of(issued("cred_1", "v", null)));
+
+ binding.revokeAll(rec);
+
+ assertThat(provisioner.revoked).containsExactly(CredentialId.of("cred_1"));
+ assertThat(store.outstanding()).isEmpty();
+ }
+
+ @Test
+ void rawRuntimeExceptionExhaustingRetriesMarksFailure() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ provisioner.failuresBeforeSuccess = 99;
+ provisioner.throwRaw = true;
+ RecordingStore store = new RecordingStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+
+ binding.revokeMinted(issued("cred_m", "v", "h-m"));
+
+ assertThat(provisioner.revokeAttempts.get()).isEqualTo(3);
+ assertThat(store.failed).containsExactly(CredentialId.of("cred_m"));
+ }
+
+ @Test
+ void completionExceptionWithoutCauseIsHandled() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ provisioner.failuresBeforeSuccess = 99;
+ provisioner.throwCompletionWithoutCause = true;
+ RecordingStore store = new RecordingStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+
+ binding.revokeMinted(issued("cred_c", "v", null));
+
+ assertThat(store.failed).containsExactly(CredentialId.of("cred_c"));
+ }
+
+ @Test
+ void revokeMintedTracksAndRevokesSurplusCredential() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ InMemoryCredentialRevocationStore store = new InMemoryCredentialRevocationStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+
+ binding.revokeMinted(issued("cred_s", "v", null));
+
+ assertThat(provisioner.revoked).containsExactly(CredentialId.of("cred_s"));
+ assertThat(store.outstanding()).isEmpty();
+ }
+
+ @Test
+ void interruptionDuringRetryBackoffStopsRevoking() {
+ FlakyProvisioner provisioner = new FlakyProvisioner();
+ provisioner.failuresBeforeSuccess = 99;
+ RecordingStore store = new RecordingStore();
+ CredentialBinding binding = new CredentialBinding(provisioner, store, Clock.systemUTC());
+ JobRecord rec = record();
+ binding.attach(rec, List.of(issued("cred_1", "v", null)));
+
+ Thread.currentThread().interrupt();
+ try {
+ binding.revokeAll(rec);
+ // Interrupted during the backoff sleep: gave up before exhausting all attempts.
+ assertThat(provisioner.revokeAttempts.get()).isEqualTo(1);
+ assertThat(store.failed).isEmpty();
+ assertThat(Thread.currentThread().isInterrupted()).isTrue();
+ } finally {
+ Thread.interrupted(); // clear flag for other tests
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/FileRevocationStoreEdgeTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/FileRevocationStoreEdgeTest.java
new file mode 100644
index 0000000..584985a
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/FileRevocationStoreEdgeTest.java
@@ -0,0 +1,97 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.runtime.credentials.FileCredentialRevocationStore;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Load/append IO paths for FileCredentialRevocationStore (#33). */
+class FileRevocationStoreEdgeTest {
+
+ @Test
+ void loadSkipsBlankLinesAndUnknownOps(@TempDir Path dir) throws Exception {
+ Path path = dir.resolve("revocations.jsonl");
+ Files.writeString(
+ path,
+ """
+ {"op":"record","id":"cred_1","provider_handle":"h1"}
+
+ {"op":"noop","id":"cred_1"}
+ {"op":"record","id":"cred_2","provider_handle":"h2"}
+ {"op":"revoke","id":"cred_2"}
+ """);
+ try (FileCredentialRevocationStore store = new FileCredentialRevocationStore(path)) {
+ List outstanding = store.outstanding();
+ assertThat(outstanding).hasSize(1);
+ assertThat(outstanding.getFirst().id()).isEqualTo(CredentialId.of("cred_1"));
+ assertThat(outstanding.getFirst().providerHandle()).isEqualTo("h1");
+ }
+ }
+
+ @Test
+ void recordAndRevokeRoundTripAcrossReload(@TempDir Path dir) {
+ Path path = dir.resolve("nested").resolve("revocations.jsonl");
+ try (FileCredentialRevocationStore store = new FileCredentialRevocationStore(path)) {
+ store.record(CredentialId.of("cred_a"), "ha");
+ store.record(CredentialId.of("cred_b"), "hb");
+ store.markRevoked(CredentialId.of("cred_a"));
+ }
+ try (FileCredentialRevocationStore reloaded = new FileCredentialRevocationStore(path)) {
+ assertThat(reloaded.outstanding()).hasSize(1);
+ assertThat(reloaded.outstanding().getFirst().id()).isEqualTo(CredentialId.of("cred_b"));
+ }
+ }
+
+ @Test
+ void singleSegmentRelativePathHasNoParentDirectory() throws Exception {
+ Path path = Path.of("revocation-coverage-" + UUID.randomUUID() + ".jsonl");
+ try {
+ try (FileCredentialRevocationStore store = new FileCredentialRevocationStore(path)) {
+ store.record(CredentialId.of("cred_rel"), "h");
+ assertThat(store.outstanding()).hasSize(1);
+ }
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ @Test
+ void appendAfterCloseFailsLoudly(@TempDir Path dir) {
+ Path path = dir.resolve("closed.jsonl");
+ FileCredentialRevocationStore store = new FileCredentialRevocationStore(path);
+ store.close();
+ assertThatThrownBy(() -> store.record(CredentialId.of("cred_x"), "h"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("could not append");
+ }
+
+ @Test
+ void unreadableStoreFailsAtLoad(@TempDir Path dir) {
+ // A directory in place of the journal file makes the initial load fail.
+ assertThatThrownBy(() -> new FileCredentialRevocationStore(dir))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("could not load");
+ }
+
+ @Test
+ void readOnlyJournalFailsAtWriterOpen(@TempDir Path dir) throws Exception {
+ Path path = dir.resolve("readonly.jsonl");
+ Files.createFile(path);
+ java.io.File file = path.toFile();
+ assertThat(file.setWritable(false)).isTrue();
+ try {
+ assertThatThrownBy(() -> new FileCredentialRevocationStore(path))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("could not open");
+ } finally {
+ assertThat(file.setWritable(true)).isTrue();
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/IdempotencyStoreEdgeTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/IdempotencyStoreEdgeTest.java
new file mode 100644
index 0000000..6f60682
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/IdempotencyStoreEdgeTest.java
@@ -0,0 +1,82 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.runtime.idempotency.IdempotencyStore;
+import java.time.Duration;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/** TTL expiry, claim/release/matchesPayload branches for IdempotencyStore (#33). */
+class IdempotencyStoreEdgeTest {
+
+ private static final Principal ALICE = new Principal("alice");
+
+ @Test
+ void claimReleaseAndMatchBranches() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ try (IdempotencyStore store = new IdempotencyStore(clock, Duration.ofHours(1))) {
+ JobId first = JobId.of("job_1");
+ assertThat(store.claim(ALICE, "k", "fp", first)).isNull();
+
+ // Same key: conflict carries the existing job id regardless of fingerprint.
+ IdempotencyStore.Conflict conflict = store.claim(ALICE, "k", "fp", JobId.of("job_2"));
+ assertThat(conflict).isNotNull();
+ assertThat(conflict.existing()).isEqualTo(first);
+
+ assertThat(store.matchesPayload(ALICE, "k", "fp")).isTrue();
+ assertThat(store.matchesPayload(ALICE, "k", "other-fp")).isFalse();
+ assertThat(store.matchesPayload(ALICE, "missing", "fp")).isFalse();
+
+ // Release only removes when the expected job id still owns the claim.
+ assertThat(store.release(ALICE, "k", JobId.of("job_wrong"))).isFalse();
+ assertThat(store.release(ALICE, "missing", first)).isFalse();
+ assertThat(store.release(ALICE, "k", first)).isTrue();
+ assertThat(store.claim(ALICE, "k", "fp", JobId.of("job_3"))).isNull();
+ }
+ }
+
+ @Test
+ void pruneEvictsOnlyExpiredEntries() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ try (IdempotencyStore store = new IdempotencyStore(clock, Duration.ofMinutes(10))) {
+ store.claim(ALICE, "old", "fp", JobId.of("job_old"));
+ clock.advance(Duration.ofMinutes(9));
+ store.claim(ALICE, "young", "fp", JobId.of("job_young"));
+
+ clock.advance(Duration.ofMinutes(2)); // old: 11m (expired), young: 2m (kept)
+ store.prune();
+
+ assertThat(store.claim(ALICE, "old", "fp", JobId.of("job_new"))).isNull();
+ IdempotencyStore.Conflict young = store.claim(ALICE, "young", "fp", JobId.of("job_x"));
+ assertThat(young).isNotNull();
+ assertThat(young.existing()).isEqualTo(JobId.of("job_young"));
+ }
+ }
+
+ @Test
+ void scheduledPruneTaskRunsAndSurvivesClockFailures() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ try (IdempotencyStore store =
+ new IdempotencyStore(clock, Duration.ofMinutes(10), scheduler, Duration.ofMinutes(1))) {
+ ManualScheduler.Task prune = scheduler.awaitTask(0);
+ assertThat(prune.periodic()).isTrue();
+
+ store.claim(ALICE, "k", "fp", JobId.of("job_1"));
+ clock.advance(Duration.ofMinutes(11));
+ prune.run();
+ assertThat(store.claim(ALICE, "k", "fp", JobId.of("job_2"))).isNull();
+
+ // A clock failure inside the background prune is swallowed.
+ clock.throwOnRead(true);
+ prune.run();
+ clock.throwOnRead(false);
+
+ store.close();
+ assertThat(prune.isCancelled()).isTrue();
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/JobListingFilterTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/JobListingFilterTest.java
new file mode 100644
index 0000000..d3b8b64
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/JobListingFilterTest.java
@@ -0,0 +1,149 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.messages.JobFilter;
+import dev.arcp.runtime.lease.BudgetCounters;
+import dev.arcp.runtime.session.JobListing;
+import dev.arcp.runtime.session.JobRecord;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Filter combinations, paging boundaries, and cursor variants for JobListing (#33). */
+class JobListingFilterTest {
+
+ private static final Principal ALICE = new Principal("alice");
+ private static final Instant BASE = Instant.parse("2026-05-21T12:00:00Z");
+
+ private static List records(int count) {
+ List out = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ out.add(record("job_" + i, "echo@1.0.0", BASE.plusSeconds(i)));
+ }
+ return out;
+ }
+
+ private static JobRecord record(String id, String agent, Instant createdAt) {
+ return new JobRecord(
+ JobId.of(id),
+ agent,
+ ALICE,
+ Lease.empty(),
+ LeaseConstraints.none(),
+ new BudgetCounters(Map.of()),
+ createdAt,
+ null);
+ }
+
+ private static String cursor(String raw) {
+ return Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(raw.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "pending, 1", // matches the record status
+ "success, 0", // excludes it
+ })
+ void statusFilterMatchesWireStatus(String status, int expected) {
+ JobListing.Page page =
+ JobListing.page(records(1), ALICE, new JobFilter(List.of(status), null, null), null, null);
+ assertThat(page.jobs()).hasSize(expected);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'echo@1.0.0', 1", // exact resolved agent
+ "echo, 1", // name-only matches any version
+ "'echo@2.0.0', 0", // version mismatch
+ "other, 0", // name mismatch
+ })
+ void agentFilterMatchesExactOrName(String agent, int expected) {
+ JobListing.Page page =
+ JobListing.page(records(1), ALICE, new JobFilter(null, agent, null), null, null);
+ assertThat(page.jobs()).hasSize(expected);
+ }
+
+ @Test
+ void createdAfterIsExclusive() {
+ List jobs = records(3); // BASE, BASE+1, BASE+2
+ JobListing.Page page =
+ JobListing.page(jobs, ALICE, new JobFilter(null, null, BASE.plusSeconds(1)), null, null);
+ assertThat(page.jobs()).hasSize(1);
+ assertThat(page.jobs().getFirst().jobId()).isEqualTo(JobId.of("job_2"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -3})
+ void nonPositiveLimitReturnsEverything(int limit) {
+ JobListing.Page page = JobListing.page(records(4), ALICE, JobFilter.all(), limit, null);
+ assertThat(page.jobs()).hasSize(4);
+ assertThat(page.nextCursor()).isNull();
+ }
+
+ @Test
+ void limitLargerThanMatchesYieldsNoCursor() {
+ JobListing.Page page = JobListing.page(records(2), ALICE, JobFilter.all(), 10, null);
+ assertThat(page.jobs()).hasSize(2);
+ assertThat(page.nextCursor()).isNull();
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {" "})
+ void missingOrBlankCursorStartsAtZero(String cursor) {
+ JobListing.Page page = JobListing.page(records(2), ALICE, JobFilter.all(), 1, cursor);
+ assertThat(page.jobs()).hasSize(1);
+ assertThat(page.jobs().getFirst().jobId()).isEqualTo(JobId.of("job_0"));
+ assertThat(page.nextCursor()).isNotNull();
+ }
+
+ @Test
+ void cursorBeyondMatchesReturnsEmptyPage() {
+ JobListing.Page page = JobListing.page(records(2), ALICE, JobFilter.all(), 2, cursor("7"));
+ assertThat(page.jobs()).isEmpty();
+ assertThat(page.nextCursor()).isNull();
+ }
+
+ @Test
+ void negativeCursorIsClampedToZero() {
+ JobListing.Page page = JobListing.page(records(2), ALICE, JobFilter.all(), 1, cursor("-5"));
+ assertThat(page.jobs()).hasSize(1);
+ assertThat(page.jobs().getFirst().jobId()).isEqualTo(JobId.of("job_0"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"#not-base64", "YWJj" /* "abc": valid base64, not a number */})
+ void invalidCursorVariantsThrow(String cursor) {
+ assertThatThrownBy(() -> JobListing.page(records(1), ALICE, JobFilter.all(), 1, cursor))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid cursor");
+ }
+
+ @Test
+ void pagingWalksStableOrderToTheEnd() {
+ List jobs = records(5);
+ JobListing.Page p1 = JobListing.page(jobs, ALICE, JobFilter.all(), 2, null);
+ JobListing.Page p2 = JobListing.page(jobs, ALICE, JobFilter.all(), 2, p1.nextCursor());
+ JobListing.Page p3 = JobListing.page(jobs, ALICE, JobFilter.all(), 2, p2.nextCursor());
+ assertThat(p1.jobs()).hasSize(2);
+ assertThat(p2.jobs()).hasSize(2);
+ assertThat(p3.jobs()).hasSize(1);
+ assertThat(p3.nextCursor()).isNull();
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/JobRecordTransitionTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/JobRecordTransitionTest.java
new file mode 100644
index 0000000..c0befa7
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/JobRecordTransitionTest.java
@@ -0,0 +1,142 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.credentials.Credential;
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.core.credentials.CredentialScheme;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.messages.JobEvent;
+import dev.arcp.runtime.credentials.IssuedCredential;
+import dev.arcp.runtime.lease.BudgetCounters;
+import dev.arcp.runtime.session.JobRecord;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/** Status transition matrix, history capacity, and credential bookkeeping for JobRecord (#33). */
+class JobRecordTransitionTest {
+
+ private static JobRecord record(int historyCapacity) {
+ return new JobRecord(
+ JobId.of("job_1"),
+ "echo@1.0.0",
+ new Principal("alice"),
+ Lease.empty(),
+ LeaseConstraints.none(),
+ new BudgetCounters(Map.of()),
+ Instant.parse("2026-05-21T12:00:00Z"),
+ null,
+ historyCapacity);
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = JobRecord.Status.class,
+ names = {"SUCCESS", "ERROR", "CANCELLED", "TIMED_OUT"})
+ void terminalStatesRejectFurtherTransitions(JobRecord.Status terminal) {
+ JobRecord rec = record(8);
+ assertThat(rec.transitionTo(terminal)).isTrue();
+ for (JobRecord.Status next : JobRecord.Status.values()) {
+ assertThat(rec.transitionTo(next)).as("from %s to %s", terminal, next).isFalse();
+ }
+ assertThat(rec.status()).isEqualTo(terminal);
+ }
+
+ @Test
+ void pendingAndRunningAllowTransitions() {
+ JobRecord rec = record(8);
+ assertThat(rec.status()).isEqualTo(JobRecord.Status.PENDING);
+ assertThat(rec.transitionTo(JobRecord.Status.RUNNING)).isTrue();
+ assertThat(rec.transitionTo(JobRecord.Status.SUCCESS)).isTrue();
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "PENDING, pending, false",
+ "RUNNING, running, false",
+ "SUCCESS, success, true",
+ "ERROR, error, true",
+ "CANCELLED, cancelled, true",
+ "TIMED_OUT, timed_out, true",
+ })
+ void wireAndTerminalMatrix(JobRecord.Status status, String wire, boolean terminal) {
+ assertThat(status.wire()).isEqualTo(wire);
+ assertThat(status.terminal()).isEqualTo(terminal);
+ }
+
+ @Test
+ void historyEvictsOldestAtCapacity() {
+ JobRecord rec = record(2);
+ rec.recordEvent(1, event("e1"));
+ rec.recordEvent(2, event("e2"));
+ rec.recordEvent(3, event("e3"));
+ assertThat(rec.eventHistorySize()).isEqualTo(2);
+ assertThat(rec.eventsSince(0))
+ .extracting(JobRecord.RecordedEvent::producerSeq)
+ .containsExactly(2L, 3L);
+ assertThat(rec.eventsSince(2))
+ .extracting(JobRecord.RecordedEvent::producerSeq)
+ .containsExactly(3L);
+ assertThat(rec.eventsSince(99)).isEmpty();
+ }
+
+ private static JobEvent event(String message) {
+ return new JobEvent(
+ "log", Instant.EPOCH, JsonNodeFactory.instance.objectNode().put("message", message));
+ }
+
+ @Test
+ void nonPositiveHistoryCapacityFallsBackToDefault() {
+ JobRecord rec = record(0);
+ for (int i = 1; i <= JobRecord.DEFAULT_HISTORY_CAPACITY + 1; i++) {
+ rec.recordEvent(i, event("e" + i));
+ }
+ assertThat(rec.eventHistorySize()).isEqualTo(JobRecord.DEFAULT_HISTORY_CAPACITY);
+ }
+
+ @Test
+ void subscribersAddAndRemoveWhere() {
+ JobRecord rec = record(8);
+ JobRecord.Subscriber sub = new JobRecord.Subscriber(null, JobId.of("job_1"));
+ rec.addSubscriber(sub);
+ assertThat(rec.subscribers()).hasSize(1);
+ assertThat(rec.removeSubscribersWhere(s -> s.jobId().equals(JobId.of("job_other")))).isFalse();
+ assertThat(rec.removeSubscribersWhere(s -> s == sub)).isTrue();
+ assertThat(rec.subscribers()).isEmpty();
+ }
+
+ @Test
+ void replaceCredentialSwapsMatchingAndAppendsUnknown() {
+ JobRecord rec = record(8);
+ rec.setCredentials(List.of(issued("cred_1", "a"), issued("cred_2", "b")));
+
+ // Replacing the second entry walks past the non-matching first entry.
+ IssuedCredential prior =
+ rec.replaceCredential(CredentialId.of("cred_2"), issued("cred_2", "b2"));
+ assertThat(prior).isNotNull();
+ assertThat(prior.wire().value()).isEqualTo("b");
+
+ // Replacing an unknown id appends and reports no prior credential.
+ assertThat(rec.replaceCredential(CredentialId.of("cred_3"), issued("cred_3", "c"))).isNull();
+ assertThat(rec.credentials()).hasSize(3);
+
+ assertThat(rec.drainCredentials()).hasSize(3);
+ assertThat(rec.credentials()).isEmpty();
+ }
+
+ private static IssuedCredential issued(String id, String value) {
+ return new IssuedCredential(
+ new Credential(
+ CredentialId.of(id), CredentialScheme.BEARER, value, "https://x.example", null, null),
+ null);
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/LeaseGuardEdgeTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/LeaseGuardEdgeTest.java
new file mode 100644
index 0000000..9163d60
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/LeaseGuardEdgeTest.java
@@ -0,0 +1,52 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.arcp.core.error.LeaseExpiredException;
+import dev.arcp.core.error.PermissionDeniedException;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.runtime.lease.LeaseGuard;
+import java.time.Duration;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/** Expiry helper and accessor branches for LeaseGuard (#33). */
+class LeaseGuardEdgeTest {
+
+ private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z");
+
+ @Test
+ void expiredOrNullCoversAllThreeOutcomes() {
+ MutableClock clock = new MutableClock(NOW);
+ assertThat(LeaseGuard.expiredOrNull(LeaseConstraints.none(), clock)).isNull();
+ assertThat(LeaseGuard.expiredOrNull(LeaseConstraints.of(NOW.plusSeconds(60)), clock)).isNull();
+ LeaseExpiredException expired =
+ LeaseGuard.expiredOrNull(LeaseConstraints.of(NOW.minusSeconds(1)), clock);
+ assertThat(expired).isNotNull();
+ assertThat(expired.getMessage()).contains("lease expired");
+ // Boundary: exactly-at-expiry counts as expired.
+ assertThat(LeaseGuard.expiredOrNull(LeaseConstraints.of(NOW), clock)).isNotNull();
+ }
+
+ @Test
+ void accessorsAndModelAuthorization() throws Exception {
+ MutableClock clock = new MutableClock(NOW);
+ Lease lease = Lease.builder().allow("model.use", "gpt-*").build();
+ LeaseConstraints constraints = LeaseConstraints.of(NOW.plusSeconds(60));
+ LeaseGuard guard = new LeaseGuard(lease, constraints, clock);
+
+ assertThat(guard.lease()).isSameAs(lease);
+ assertThat(guard.constraints()).isSameAs(constraints);
+
+ assertThatCode(() -> guard.authorizeModel("gpt-large")).doesNotThrowAnyException();
+ assertThatThrownBy(() -> guard.authorizeModel("claude-opus"))
+ .isInstanceOf(PermissionDeniedException.class);
+
+ clock.advance(Duration.ofSeconds(120));
+ assertThatThrownBy(() -> guard.authorizeModel("gpt-large"))
+ .isInstanceOf(LeaseExpiredException.class);
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/ManualScheduler.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/ManualScheduler.java
new file mode 100644
index 0000000..f11b5d2
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/ManualScheduler.java
@@ -0,0 +1,185 @@
+package dev.arcp.runtime.coverage;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Deterministic {@link ScheduledExecutorService}: nothing scheduled runs until the test runs it
+ * explicitly. {@code execute} runs inline.
+ */
+final class ManualScheduler extends AbstractExecutorService implements ScheduledExecutorService {
+
+ static final class Task implements ScheduledFuture {
+ private final Runnable command;
+ private final long delayMillis;
+ private final boolean periodic;
+ private final AtomicBoolean cancelled = new AtomicBoolean();
+
+ private Task(Runnable command, long delayMillis, boolean periodic) {
+ this.command = command;
+ this.delayMillis = delayMillis;
+ this.periodic = periodic;
+ }
+
+ boolean periodic() {
+ return periodic;
+ }
+
+ long delayMillis() {
+ return delayMillis;
+ }
+
+ /** Run the task body regardless of cancellation state (tests use this to model races). */
+ void runIgnoringCancel() {
+ command.run();
+ }
+
+ /** Run only if not cancelled. */
+ void run() {
+ if (!cancelled.get()) {
+ command.run();
+ }
+ }
+
+ @Override
+ public long getDelay(TimeUnit unit) {
+ return unit.convert(delayMillis, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public int compareTo(Delayed o) {
+ return Long.compare(getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS));
+ }
+
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ return cancelled.compareAndSet(false, true);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled.get();
+ }
+
+ @Override
+ public boolean isDone() {
+ return cancelled.get();
+ }
+
+ @Override
+ public Object get() throws InterruptedException, ExecutionException {
+ throw new UnsupportedOperationException("manual task");
+ }
+
+ @Override
+ public Object get(long timeout, TimeUnit unit)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ throw new UnsupportedOperationException("manual task");
+ }
+ }
+
+ private final List tasks = new CopyOnWriteArrayList<>();
+ private volatile boolean rejecting;
+ private volatile boolean shutdown;
+ private int drained;
+
+ void rejecting(boolean value) {
+ this.rejecting = value;
+ }
+
+ List tasks() {
+ return tasks;
+ }
+
+ /** Tasks scheduled since the previous call to this method. */
+ synchronized List drainNew() {
+ List fresh = List.copyOf(tasks.subList(drained, tasks.size()));
+ drained = tasks.size();
+ return fresh;
+ }
+
+ /** Wait (bounded) until at least {@code count} tasks have been scheduled in total. */
+ Task awaitTask(int index) throws InterruptedException {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (System.nanoTime() < deadline) {
+ if (tasks.size() > index) {
+ return tasks.get(index);
+ }
+ Thread.sleep(5);
+ }
+ throw new AssertionError("timed out waiting for scheduled task #" + index);
+ }
+
+ private Task add(Runnable command, long delay, TimeUnit unit, boolean periodic) {
+ if (rejecting) {
+ throw new RejectedExecutionException("manual scheduler rejecting (test)");
+ }
+ Task task = new Task(command, unit.toMillis(delay), periodic);
+ tasks.add(task);
+ return task;
+ }
+
+ @Override
+ public ScheduledFuture> schedule(Runnable command, long delay, TimeUnit unit) {
+ return add(command, delay, unit, false);
+ }
+
+ @Override
+ public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit) {
+ throw new UnsupportedOperationException("callable schedule not used");
+ }
+
+ @Override
+ public ScheduledFuture> scheduleAtFixedRate(
+ Runnable command, long initialDelay, long period, TimeUnit unit) {
+ return add(command, period, unit, true);
+ }
+
+ @Override
+ public ScheduledFuture> scheduleWithFixedDelay(
+ Runnable command, long initialDelay, long delay, TimeUnit unit) {
+ return add(command, delay, unit, true);
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ command.run();
+ }
+
+ @Override
+ public void shutdown() {
+ shutdown = true;
+ }
+
+ @Override
+ public List shutdownNow() {
+ shutdown = true;
+ return List.of();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return shutdown;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return shutdown;
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) {
+ return true;
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/MiscBranchTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/MiscBranchTest.java
new file mode 100644
index 0000000..bb1e559
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/MiscBranchTest.java
@@ -0,0 +1,75 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.arcp.core.agents.AgentRef;
+import dev.arcp.runtime.agent.AgentRegistry;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.heartbeat.HeartbeatTracker;
+import dev.arcp.runtime.lease.BudgetCounters;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/** Small remaining branches: budget counters, registry fallback, heartbeat thresholds (#33). */
+class MiscBranchTest {
+
+ @Test
+ void budgetCountersIgnoreUnknownCurrenciesAndNegativeAmounts() {
+ BudgetCounters counters = new BudgetCounters(Map.of("usd", new BigDecimal("10")));
+ assertThat(counters.tracks("usd")).isTrue();
+ assertThat(counters.tracks("eur")).isFalse();
+ assertThat(counters.remaining("eur")).isEqualByComparingTo(BigDecimal.ZERO);
+
+ counters.decrement("eur", new BigDecimal("5")); // untracked: no-op
+ counters.decrement("usd", new BigDecimal("-5")); // negative: no-op (§9.6)
+ assertThat(counters.remaining("usd")).isEqualByComparingTo("10");
+
+ counters.decrement("usd", new BigDecimal("4"));
+ assertThat(counters.remaining("usd")).isEqualByComparingTo("6");
+ assertThat(counters.snapshot()).containsEntry("usd", new BigDecimal("6"));
+ }
+
+ @Test
+ void registryFallsBackToFirstVersionWhenDefaultIsUnregistered() throws Exception {
+ AgentRegistry registry = new AgentRegistry();
+ registry.register("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()));
+ registry.register("echo", "2.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()));
+ registry.setDefault("echo", "9.9.9"); // points at a version that does not exist
+
+ AgentRegistry.Resolved resolved = registry.resolve(AgentRef.parse("echo"));
+ assertThat(resolved.wire()).isEqualTo("echo@1.0.0");
+
+ registry.setDefault("echo", "2.0.0");
+ assertThat(registry.resolve(AgentRef.parse("echo")).wire()).isEqualTo("echo@2.0.0");
+ assertThat(registry.describe()).hasSize(1);
+ assertThat(registry.describe().getFirst().versions()).containsExactly("1.0.0", "2.0.0");
+ }
+
+ @Test
+ void heartbeatThresholdsFollowSpecIntervals() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ HeartbeatTracker tracker = new HeartbeatTracker(clock);
+ Duration interval = Duration.ofSeconds(10);
+
+ assertThat(tracker.shouldPing(interval)).isFalse();
+ assertThat(tracker.shouldClose(interval)).isFalse();
+
+ clock.advance(Duration.ofSeconds(10)); // exactly one interval
+ assertThat(tracker.shouldPing(interval)).isTrue();
+ assertThat(tracker.shouldClose(interval)).isFalse();
+
+ clock.advance(Duration.ofSeconds(10)); // exactly two intervals: still not "more than" 2x
+ assertThat(tracker.shouldClose(interval)).isFalse();
+
+ clock.advance(Duration.ofSeconds(1)); // beyond two intervals
+ assertThat(tracker.shouldClose(interval)).isTrue();
+ assertThat(tracker.sinceLastInbound()).isEqualTo(Duration.ofSeconds(21));
+
+ tracker.onInbound();
+ assertThat(tracker.shouldPing(interval)).isFalse();
+ assertThat(tracker.shouldClose(interval)).isFalse();
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/MutableClock.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/MutableClock.java
new file mode 100644
index 0000000..0d8cc93
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/MutableClock.java
@@ -0,0 +1,49 @@
+package dev.arcp.runtime.coverage;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Deterministic test clock; advances only when told to. */
+final class MutableClock extends Clock {
+
+ private final AtomicReference now;
+ private volatile boolean throwOnRead;
+
+ MutableClock(Instant start) {
+ this.now = new AtomicReference<>(start);
+ }
+
+ void advance(Duration d) {
+ now.updateAndGet(i -> i.plus(d));
+ }
+
+ void set(Instant instant) {
+ now.set(instant);
+ }
+
+ void throwOnRead(boolean value) {
+ this.throwOnRead = value;
+ }
+
+ @Override
+ public Instant instant() {
+ if (throwOnRead) {
+ throw new IllegalStateException("clock read failure (test)");
+ }
+ return now.get();
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionHarness.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionHarness.java
new file mode 100644
index 0000000..305a6a5
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionHarness.java
@@ -0,0 +1,199 @@
+package dev.arcp.runtime.coverage;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.capabilities.Capabilities;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.SessionId;
+import dev.arcp.core.messages.ClientInfo;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.Messages;
+import dev.arcp.core.messages.SessionHello;
+import dev.arcp.core.messages.SessionWelcome;
+import dev.arcp.core.transport.MemoryTransport;
+import dev.arcp.core.wire.ArcpMapper;
+import dev.arcp.core.wire.Envelope;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.session.SessionLoop;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Flow;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/** Client-side harness over {@link MemoryTransport#pair()} mirroring the protocol-test pattern. */
+final class SessionHarness {
+
+ static final ObjectMapper MAPPER = ArcpMapper.shared();
+ static final Set DEFAULT_FEATURES =
+ EnumSet.of(Feature.SUBSCRIBE, Feature.LIST_JOBS, Feature.COST_BUDGET, Feature.ACK);
+
+ private final MemoryTransport client;
+ private final MemoryTransport runtimeEndpoint;
+ private final Probe probe;
+ private final SessionLoop loop;
+
+ private SessionHarness(
+ MemoryTransport client, MemoryTransport runtimeEndpoint, Probe probe, SessionLoop loop) {
+ this.client = client;
+ this.runtimeEndpoint = runtimeEndpoint;
+ this.probe = probe;
+ this.loop = loop;
+ }
+
+ static SessionHarness connect(ArcpRuntime runtime) {
+ MemoryTransport.Pair pair = MemoryTransport.pair();
+ Probe probe = new Probe();
+ pair.client().incoming().subscribe(probe);
+ return new SessionHarness(pair.client(), pair.runtime(), probe, runtime.accept(pair.runtime()));
+ }
+
+ SessionLoop loop() {
+ return loop;
+ }
+
+ MemoryTransport client() {
+ return client;
+ }
+
+ MemoryTransport runtimeEndpoint() {
+ return runtimeEndpoint;
+ }
+
+ Probe probe() {
+ return probe;
+ }
+
+ SessionWelcome handshake() throws Exception {
+ return handshake(Auth.anonymous(), DEFAULT_FEATURES);
+ }
+
+ SessionWelcome handshake(Auth auth, Set features) throws Exception {
+ hello(auth, features, null, null);
+ return take(Message.Type.SESSION_WELCOME, SessionWelcome.class);
+ }
+
+ void hello(Auth auth, Set features, String resumeToken, Long lastEventSeq) {
+ send(
+ Message.Type.SESSION_HELLO,
+ new SessionHello(
+ new ClientInfo("coverage-test", "1.0.0"),
+ auth,
+ new Capabilities(List.of("json"), features, null),
+ resumeToken,
+ lastEventSeq));
+ }
+
+ void send(Message.Type type, Message message) {
+ sendInternal(type, message, MessageId.generate(), null);
+ }
+
+ void sendJob(Message.Type type, Message message, JobId jobId) {
+ sendInternal(type, message, MessageId.generate(), jobId);
+ }
+
+ void sendRaw(Envelope envelope) {
+ client.send(envelope);
+ }
+
+ private void sendInternal(Message.Type type, Message message, MessageId messageId, JobId jobId) {
+ client.send(
+ new Envelope(
+ Envelope.VERSION,
+ messageId,
+ type.wire(),
+ SessionId.of("sess_client"),
+ null,
+ jobId,
+ null,
+ Messages.encodePayload(MAPPER, message)));
+ }
+
+ T take(Message.Type type, Class messageClass) throws Exception {
+ return messageClass.cast(Messages.decode(MAPPER, probe.take(type)));
+ }
+
+ Envelope takeEnvelope(Message.Type type) throws InterruptedException {
+ return probe.take(type);
+ }
+
+ static void awaitPhase(SessionLoop loop, SessionLoop.Phase target) throws InterruptedException {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (System.nanoTime() < deadline) {
+ if (loop.phase() == target) {
+ return;
+ }
+ Thread.sleep(5);
+ }
+ throw new AssertionError("expected phase " + target + " but was " + loop.phase());
+ }
+
+ static void await(java.util.function.BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (System.nanoTime() < deadline) {
+ if (condition.getAsBoolean()) {
+ return;
+ }
+ Thread.sleep(5);
+ }
+ throw new AssertionError("timed out awaiting condition");
+ }
+
+ static final class Probe implements Flow.Subscriber {
+ private final BlockingQueue envelopes = new LinkedBlockingQueue<>();
+ private final Queue backlog = new ArrayDeque<>();
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Envelope item) {
+ envelopes.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {}
+
+ @Override
+ public void onComplete() {}
+
+ /** True if an envelope of {@code type} is sitting in the backlog or queue right now. */
+ boolean saw(Message.Type type) {
+ if (backlog.stream().anyMatch(e -> e.type().equals(type.wire()))) {
+ return true;
+ }
+ return envelopes.stream().anyMatch(e -> e.type().equals(type.wire()));
+ }
+
+ Envelope take(Message.Type type) throws InterruptedException {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (System.nanoTime() < deadline) {
+ for (Envelope existing : List.copyOf(backlog)) {
+ if (existing.type().equals(type.wire())) {
+ backlog.remove(existing);
+ return existing;
+ }
+ }
+ long remaining = deadline - System.nanoTime();
+ Envelope envelope = envelopes.poll(Math.max(1L, remaining), TimeUnit.NANOSECONDS);
+ if (envelope == null) {
+ break;
+ }
+ if (envelope.type().equals(type.wire())) {
+ return envelope;
+ }
+ backlog.add(envelope);
+ }
+ throw new AssertionError("timed out waiting for " + type.wire() + "; backlog=" + backlog);
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopBranchTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopBranchTest.java
new file mode 100644
index 0000000..3f96a43
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopBranchTest.java
@@ -0,0 +1,734 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import dev.arcp.core.agents.AgentRef;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.auth.BearerVerifier;
+import dev.arcp.core.auth.Principal;
+import dev.arcp.core.capabilities.Capabilities;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.error.ErrorCode;
+import dev.arcp.core.events.LogEvent;
+import dev.arcp.core.events.MetricEvent;
+import dev.arcp.core.events.StatusEvent;
+import dev.arcp.core.ids.JobId;
+import dev.arcp.core.ids.MessageId;
+import dev.arcp.core.ids.SessionId;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.messages.JobAccepted;
+import dev.arcp.core.messages.JobCancel;
+import dev.arcp.core.messages.JobCancelled;
+import dev.arcp.core.messages.JobError;
+import dev.arcp.core.messages.JobEvent;
+import dev.arcp.core.messages.JobFilter;
+import dev.arcp.core.messages.JobResult;
+import dev.arcp.core.messages.JobSubmit;
+import dev.arcp.core.messages.JobSubscribe;
+import dev.arcp.core.messages.JobSubscribed;
+import dev.arcp.core.messages.JobUnsubscribe;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.RuntimeInfo;
+import dev.arcp.core.messages.SessionClosed;
+import dev.arcp.core.messages.SessionJobs;
+import dev.arcp.core.messages.SessionListJobs;
+import dev.arcp.core.messages.SessionPing;
+import dev.arcp.core.messages.SessionPong;
+import dev.arcp.core.messages.SessionWelcome;
+import dev.arcp.core.wire.Envelope;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.session.SessionLoop;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.Test;
+
+/** Branch coverage for SessionLoop dispatch, auth, cancel, subscribe, and resume edges (#33). */
+class SessionLoopBranchTest {
+
+ private static JobSubmit submit(String agent) {
+ return new JobSubmit(
+ AgentRef.parse(agent), JsonNodeFactory.instance.objectNode(), null, null, null, null);
+ }
+
+ @Test
+ void clientOnlyAndDuplicateMessagesAreIgnored() throws Exception {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ // Duplicate hello after handshake.
+ h.hello(Auth.anonymous(), SessionHarness.DEFAULT_FEATURES, null, null);
+ // Unsolicited pong.
+ h.send(Message.Type.SESSION_PONG, new SessionPong("n1", Instant.now()));
+ // Client-only payloads arriving at the runtime.
+ h.send(Message.Type.SESSION_CLOSED, new SessionClosed("done"));
+ h.send(Message.Type.JOB_CANCELLED, new JobCancelled("done"));
+ h.send(
+ Message.Type.SESSION_WELCOME,
+ new SessionWelcome(
+ new RuntimeInfo("x", "1"),
+ null,
+ null,
+ null,
+ new Capabilities(List.of("json"), EnumSet.noneOf(Feature.class), null)));
+ h.send(
+ Message.Type.JOB_ACCEPTED,
+ new JobAccepted(
+ JobId.of("job_x"),
+ "echo@1.0.0",
+ Lease.empty(),
+ null,
+ null,
+ null,
+ Instant.now(),
+ null));
+ h.send(
+ Message.Type.JOB_EVENT,
+ new JobEvent("log", Instant.now(), JsonNodeFactory.instance.objectNode()));
+ h.send(
+ Message.Type.JOB_RESULT,
+ new JobResult(
+ JobResult.SUCCESS, null, null, JsonNodeFactory.instance.objectNode(), null));
+ h.send(
+ Message.Type.JOB_ERROR,
+ JobError.fromJson("error", ErrorCode.INTERNAL_ERROR, "x", null, null));
+ h.send(
+ Message.Type.JOB_SUBSCRIBED,
+ new JobSubscribed(
+ JobId.of("job_x"), "running", "echo@1.0.0", null, null, null, 0L, false));
+ h.send(Message.Type.SESSION_JOBS, new SessionJobs(MessageId.of("req"), List.of(), null));
+
+ // The session is still alive and responsive afterwards.
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ assertThat(h.take(Message.Type.SESSION_PONG, SessionPong.class).pingNonce())
+ .isEqualTo("fence");
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.ACTIVE);
+ }
+ }
+
+ @Test
+ void malformedEnvelopesAreDroppedPreHandshakeAndRejectedWhenActive() throws Exception {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+
+ ObjectNode badSubmit = JsonNodeFactory.instance.objectNode();
+ badSubmit.put("agent", "echo@1.0.0");
+ badSubmit.set("input", JsonNodeFactory.instance.objectNode());
+ badSubmit.set(
+ "lease_constraints",
+ JsonNodeFactory.instance.objectNode().put("expires_at", "2030-01-01T00:00:00+02:00"));
+
+ // Pre-handshake: malformed payload is dropped without a reply.
+ h.sendRaw(envelope("job.submit", badSubmit));
+ // Pre-handshake: a decodable non-hello message is dropped too.
+ h.send(Message.Type.SESSION_PING, new SessionPing("early", Instant.now()));
+
+ h.handshake();
+
+ // Active: same malformed payload now answers INVALID_REQUEST.
+ h.sendRaw(envelope("job.submit", badSubmit));
+ assertThat(h.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.INVALID_REQUEST);
+
+ // Active: an unknown message type is INVALID_REQUEST as well.
+ h.sendRaw(envelope("bogus.type", JsonNodeFactory.instance.objectNode()));
+ assertThat(h.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.INVALID_REQUEST);
+
+ // No pong was ever sent for the pre-handshake ping.
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ assertThat(h.take(Message.Type.SESSION_PONG, SessionPong.class).pingNonce())
+ .isEqualTo("fence");
+ }
+ }
+
+ private static Envelope envelope(String type, ObjectNode payload) {
+ return new Envelope(
+ Envelope.VERSION,
+ MessageId.generate(),
+ type,
+ SessionId.of("sess_client"),
+ null,
+ null,
+ null,
+ payload);
+ }
+
+ @Test
+ void bearerWithoutTokenIsRejected() throws Exception {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.hello(new Auth(Auth.BEARER, null), SessionHarness.DEFAULT_FEATURES, null, null);
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ @Test
+ void unsupportedAuthSchemeIsRejected() throws Exception {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.hello(new Auth("mtls", "cert"), SessionHarness.DEFAULT_FEATURES, null, null);
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ @Test
+ void invalidBearerTokenIsRejectedByVerifier() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .verifier(BearerVerifier.staticToken("expected", new Principal("alice")))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.hello(Auth.bearer("wrong"), SessionHarness.DEFAULT_FEATURES, null, null);
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.CLOSED);
+
+ SessionHarness ok = SessionHarness.connect(runtime);
+ ok.handshake(Auth.bearer("expected"), SessionHarness.DEFAULT_FEATURES);
+ assertThat(ok.loop().principal()).isEqualTo(new Principal("alice"));
+ }
+ }
+
+ @Test
+ void unnegotiatedFeaturesAreSilentlyIgnored() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ SessionWelcome welcome = h.handshake(Auth.anonymous(), EnumSet.of(Feature.ACK));
+ assertThat(welcome.capabilities().features())
+ .doesNotContain(Feature.LIST_JOBS, Feature.SUBSCRIBE);
+
+ h.send(Message.Type.SESSION_LIST_JOBS, new SessionListJobs(null, 10, null));
+ h.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(JobId.of("job_missing"), null, null));
+
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ assertThat(h.take(Message.Type.SESSION_PONG, SessionPong.class).pingNonce())
+ .isEqualTo("fence");
+ assertThat(h.probe().saw(Message.Type.SESSION_JOBS)).isFalse();
+ assertThat(h.probe().saw(Message.Type.JOB_SUBSCRIBED)).isFalse();
+ assertThat(h.probe().saw(Message.Type.JOB_ERROR)).isFalse();
+ }
+ }
+
+ @Test
+ void listJobsAppliesWireFilters() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(Message.Type.JOB_SUBMIT, submit("echo@1.0.0"));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+
+ h.send(
+ Message.Type.SESSION_LIST_JOBS,
+ new SessionListJobs(new JobFilter(List.of("success"), "echo", null), 10, null));
+ assertThat(h.take(Message.Type.SESSION_JOBS, SessionJobs.class).jobs()).hasSize(1);
+
+ h.send(
+ Message.Type.SESSION_LIST_JOBS,
+ new SessionListJobs(new JobFilter(List.of("running"), null, null), 10, null));
+ assertThat(h.take(Message.Type.SESSION_JOBS, SessionJobs.class).jobs()).isEmpty();
+ }
+ }
+
+ @Test
+ void cancelBranchesCoverMissingUnknownForeignAndTerminal() throws Exception {
+ CountDownLatch started = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "block",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ while (!ctx.cancelled()) {
+ Thread.onSpinWait();
+ }
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness alice = SessionHarness.connect(runtime);
+ alice.handshake();
+
+ // Missing job_id.
+ alice.send(Message.Type.JOB_CANCEL, new JobCancel("why"));
+ assertThat(alice.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.INVALID_REQUEST);
+
+ // Unknown job id.
+ alice.sendJob(Message.Type.JOB_CANCEL, new JobCancel("why"), JobId.of("job_unknown"));
+ assertThat(alice.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.JOB_NOT_FOUND);
+
+ alice.send(Message.Type.JOB_SUBMIT, submit("block@1.0.0"));
+ JobAccepted accepted = alice.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+ // A different (anonymous) principal can neither cancel nor subscribe to it.
+ SessionHarness bob = SessionHarness.connect(runtime);
+ bob.handshake();
+ bob.sendJob(Message.Type.JOB_CANCEL, new JobCancel("steal"), accepted.jobId());
+ assertThat(bob.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.JOB_NOT_FOUND);
+ bob.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(accepted.jobId(), 0L, true));
+ assertThat(bob.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.JOB_NOT_FOUND);
+
+ // Owner cancel without a reason defaults to "cancelled".
+ alice.sendJob(Message.Type.JOB_CANCEL, new JobCancel(null), accepted.jobId());
+ assertThat(alice.take(Message.Type.JOB_CANCELLED, JobCancelled.class).reason())
+ .isEqualTo("cancelled");
+ JobError cancelled = alice.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(cancelled.code()).isEqualTo(ErrorCode.CANCELLED);
+ assertThat(cancelled.message()).isEqualTo("cancelled");
+
+ // Cancelling a terminal job acknowledges idempotently.
+ alice.sendJob(Message.Type.JOB_CANCEL, new JobCancel("again"), accepted.jobId());
+ assertThat(alice.take(Message.Type.JOB_CANCELLED, JobCancelled.class).reason())
+ .isEqualTo("again");
+ }
+ }
+
+ @Test
+ void subscribeFanOutAndUnsubscribeBranches() throws Exception {
+ CountDownLatch gate = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "stream",
+ "1.0.0",
+ (input, ctx) -> {
+ ctx.emit(new LogEvent("info", "e1"));
+ ctx.emit(new StatusEvent("working", "phase"));
+ gate.await();
+ ctx.emit(new LogEvent("info", "e2"));
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness a = SessionHarness.connect(runtime);
+ a.handshake(Auth.bearer("shared"), SessionHarness.DEFAULT_FEATURES);
+ SessionHarness b = SessionHarness.connect(runtime);
+ b.handshake(Auth.bearer("shared"), SessionHarness.DEFAULT_FEATURES);
+
+ a.send(Message.Type.JOB_SUBMIT, submit("stream@1.0.0"));
+ JobAccepted accepted = a.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ JobId jobId = accepted.jobId();
+ SessionHarness.await(
+ () -> runtime.job(jobId) != null && runtime.job(jobId).eventHistorySize() >= 2);
+
+ // First subscribe: no history, no from_event_seq.
+ b.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(jobId, null, null));
+ assertThat(b.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class).replayed()).isFalse();
+
+ // Second subscribe (already subscribed) with history replays recorded events.
+ b.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(jobId, 0L, true));
+ assertThat(b.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class).replayed()).isTrue();
+ assertThat(b.take(Message.Type.JOB_EVENT, JobEvent.class).eventKind()).isEqualTo("log");
+ assertThat(b.take(Message.Type.JOB_EVENT, JobEvent.class).eventKind()).isEqualTo("status");
+
+ // The owner can subscribe to its own job too.
+ a.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(jobId, null, null));
+ a.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class);
+
+ // Live continuation fans out to the subscriber session, once each.
+ gate.countDown();
+ a.take(Message.Type.JOB_EVENT, JobEvent.class);
+ assertThat(b.take(Message.Type.JOB_EVENT, JobEvent.class).eventKind()).isEqualTo("log");
+ a.take(Message.Type.JOB_RESULT, JobResult.class);
+ assertThat(b.take(Message.Type.JOB_RESULT, JobResult.class).finalStatus())
+ .isEqualTo(JobResult.SUCCESS);
+
+ // Unsubscribe removes only this session's subscription; unknown job ids are a no-op.
+ b.send(Message.Type.JOB_UNSUBSCRIBE, new JobUnsubscribe(jobId));
+ b.send(Message.Type.JOB_UNSUBSCRIBE, new JobUnsubscribe(JobId.of("job_gone")));
+ b.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ b.take(Message.Type.SESSION_PONG, SessionPong.class);
+ SessionHarness.await(
+ () -> runtime.job(jobId) == null || runtime.job(jobId).subscribers().size() == 1);
+ }
+ }
+
+ @Test
+ void teardownRemovesSubscribersOfClosedSession() throws Exception {
+ CountDownLatch gate = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "block",
+ "1.0.0",
+ (input, ctx) -> {
+ gate.await();
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness a = SessionHarness.connect(runtime);
+ a.handshake(Auth.bearer("shared"), SessionHarness.DEFAULT_FEATURES);
+ SessionHarness b = SessionHarness.connect(runtime);
+ b.handshake(Auth.bearer("shared"), SessionHarness.DEFAULT_FEATURES);
+
+ a.send(Message.Type.JOB_SUBMIT, submit("block@1.0.0"));
+ JobId jobId = a.take(Message.Type.JOB_ACCEPTED, JobAccepted.class).jobId();
+
+ a.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(jobId, null, null));
+ a.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class);
+ b.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(jobId, null, null));
+ b.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class);
+ SessionHarness.await(() -> runtime.job(jobId).subscribers().size() == 2);
+
+ // Closing B unlinks only B's subscription; A (the job owner) keeps its own.
+ b.loop().shutdown("test");
+ SessionHarness.await(() -> runtime.job(jobId).subscribers().size() == 1);
+ assertThat(runtime.job(jobId).status().terminal()).isFalse();
+ gate.countDown();
+ a.take(Message.Type.JOB_RESULT, JobResult.class);
+ }
+ }
+
+ @Test
+ void idempotentRetryReclaimsAfterJobRemoval() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ JobSubmit submit =
+ new JobSubmit(
+ AgentRef.parse("echo@1.0.0"),
+ JsonNodeFactory.instance.objectNode().put("v", 1),
+ null,
+ null,
+ "key-90",
+ null);
+ h.send(Message.Type.JOB_SUBMIT, submit);
+ JobAccepted first = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+
+ // The job disappears (e.g. evicted) while the key is still claimed (#90).
+ runtime.removeJob(first.jobId());
+
+ h.send(Message.Type.JOB_SUBMIT, submit);
+ JobAccepted second = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(second.jobId()).isNotEqualTo(first.jobId());
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+ }
+ }
+
+ @Test
+ void zeroMaxRuntimeDoesNotScheduleTimeout() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("echo@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ null,
+ null,
+ null,
+ 0));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(h.take(Message.Type.JOB_RESULT, JobResult.class).finalStatus())
+ .isEqualTo(JobResult.SUCCESS);
+ }
+ }
+
+ @Test
+ void metricVariantsOnlyDecrementTrackedCostSpend() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "metrics",
+ "1.0.0",
+ (input, ctx) -> {
+ ctx.emit(new MetricEvent("cost.usd", new BigDecimal("2"), "usd", null));
+ ctx.emit(new MetricEvent("cost.usd", new BigDecimal("3"), null, null));
+ ctx.emit(new MetricEvent(null, new BigDecimal("3"), "usd", null));
+ ctx.emit(new MetricEvent("cost.usd", null, "usd", null));
+ ctx.emit(new MetricEvent("tokens.in", new BigDecimal("9"), "usd", null));
+ ctx.emit(
+ new MetricEvent("cost.budget.remaining", new BigDecimal("8"), "usd", null));
+ ctx.emit(new MetricEvent("cost.usd", new BigDecimal("1"), "eur", null));
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("metrics@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ Lease.builder().allow("cost.budget", "usd:10").build(),
+ null,
+ null,
+ null));
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+
+ // Only the first metric (cost.*, tracked unit, non-gauge) decremented the budget (#108).
+ assertThat(runtime.job(accepted.jobId()).budget().remaining("usd")).isEqualByComparingTo("8");
+ }
+ }
+
+ @Test
+ void emitAfterTerminalIsDroppedAndCancelledReflectsTerminalStatus() throws Exception {
+ CountDownLatch started = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(1);
+ AtomicBoolean sawCancelled = new AtomicBoolean();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "swallow",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ try {
+ new CountDownLatch(1).await(); // interrupted by cancel
+ } catch (InterruptedException ignored) {
+ // swallow: clears the interrupt flag
+ }
+ sawCancelled.set(ctx.cancelled());
+ ctx.emit(new LogEvent("info", "after terminal"));
+ done.countDown();
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(Message.Type.JOB_SUBMIT, submit("swallow@1.0.0"));
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+ h.sendJob(Message.Type.JOB_CANCEL, new JobCancel("stop"), accepted.jobId());
+ h.take(Message.Type.JOB_CANCELLED, JobCancelled.class);
+ assertThat(h.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.CANCELLED);
+
+ assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
+ // The interrupt flag was cleared, so cancelled() came from the terminal record status.
+ assertThat(sawCancelled).isTrue();
+
+ // Neither the post-terminal event nor a second terminal message leaked out.
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ h.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(h.probe().saw(Message.Type.JOB_EVENT)).isFalse();
+ assertThat(h.probe().saw(Message.Type.JOB_RESULT)).isFalse();
+ }
+ }
+
+ @Test
+ void agentThrowingInterruptedExceptionCancelsJob() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "selfinterrupt",
+ "1.0.0",
+ (input, ctx) -> {
+ throw new InterruptedException("self");
+ })
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(Message.Type.JOB_SUBMIT, submit("selfinterrupt@1.0.0"));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.CANCELLED);
+ assertThat(err.finalStatus()).isEqualTo(JobError.CANCELLED);
+ assertThat(err.message()).isEqualTo("interrupted");
+ }
+ }
+
+ @Test
+ void agentFailureWithoutMessageUsesExceptionClassName() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "blank",
+ "1.0.0",
+ (input, ctx) -> {
+ throw new IllegalStateException();
+ })
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(Message.Type.JOB_SUBMIT, submit("blank@1.0.0"));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ assertThat(err.message()).isEqualTo("IllegalStateException");
+ }
+ }
+
+ @Test
+ void shutdownWithEvictedJobLetsWorkerFinishSilently() throws Exception {
+ CountDownLatch started = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(1);
+ AtomicBoolean sawCancelled = new AtomicBoolean();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "evicted",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ awaitUninterruptibly(release);
+ sawCancelled.set(ctx.cancelled());
+ done.countDown();
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(Message.Type.JOB_SUBMIT, submit("evicted@1.0.0"));
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+ // The record disappears from the registry, then the session closes: teardown skips the
+ // unknown job and the still-running worker observes cancellation via the CLOSED phase.
+ var record = runtime.job(accepted.jobId());
+ runtime.removeJob(accepted.jobId());
+ h.loop().shutdown("test");
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.CLOSED);
+ release.countDown();
+ assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(sawCancelled).isTrue();
+ // The worker still won the SUCCESS transition; the send was dropped on the closed session.
+ SessionHarness.await(
+ () -> record.status() == dev.arcp.runtime.session.JobRecord.Status.SUCCESS);
+ }
+ }
+
+ private static void awaitUninterruptibly(CountDownLatch latch) {
+ boolean interrupted = false;
+ while (true) {
+ try {
+ latch.await();
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Test
+ void sendFailureWhileActiveShutsDownSession() throws Exception {
+ CountDownLatch started = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent(
+ "emit",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ awaitUninterruptibly(release);
+ ctx.emit(new LogEvent("info", "into the void"));
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(Message.Type.JOB_SUBMIT, submit("emit@1.0.0"));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+ // Kill the client endpoint: the next runtime send throws and the session shuts down.
+ h.client().close();
+ release.countDown();
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ @Test
+ void resumeWithWrongPrincipalRestoresParkedSession() throws Exception {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionHarness a = SessionHarness.connect(runtime);
+ SessionWelcome welcome = a.handshake(Auth.bearer("alice"), SessionHarness.DEFAULT_FEATURES);
+ String token = welcome.resumeToken();
+
+ a.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(a.loop(), SessionLoop.Phase.PARKED);
+
+ // A different principal presenting the token is rejected; the parked session is put back.
+ SessionHarness thief = SessionHarness.connect(runtime);
+ thief.hello(Auth.bearer("mallory"), SessionHarness.DEFAULT_FEATURES, token, 0L);
+ assertThat(thief.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.RESUME_WINDOW_EXPIRED);
+ SessionHarness.awaitPhase(thief.loop(), SessionLoop.Phase.CLOSED);
+ assertThat(a.loop().phase()).isEqualTo(SessionLoop.Phase.PARKED);
+
+ // The legitimate owner resumes without a last_event_seq; inbound is then delegated.
+ SessionHarness c = SessionHarness.connect(runtime);
+ c.hello(Auth.bearer("alice"), SessionHarness.DEFAULT_FEATURES, token, null);
+ SessionWelcome resumed = c.take(Message.Type.SESSION_WELCOME, SessionWelcome.class);
+ assertThat(resumed.resumeToken()).isEqualTo(token);
+ assertThat(a.loop().phase()).isEqualTo(SessionLoop.Phase.ACTIVE);
+
+ c.send(Message.Type.SESSION_PING, new SessionPing("via-delegate", Instant.now()));
+ assertThat(c.take(Message.Type.SESSION_PONG, SessionPong.class).pingNonce())
+ .isEqualTo("via-delegate");
+
+ // Dropping the forwarder's transport re-parks the resumed session.
+ c.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(a.loop(), SessionLoop.Phase.PARKED);
+
+ // A second drop on an already-parked session closes it for good.
+ a.loop().onComplete();
+ SessionHarness.awaitPhase(a.loop(), SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ @Test
+ void preHandshakeTransportDropClosesImmediately() throws Exception {
+ try (ArcpRuntime runtime = ArcpRuntime.builder().build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ @Test
+ void submitWithConstraintsButNoExpiryIsAccepted() throws Exception {
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("echo@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ null,
+ dev.arcp.core.lease.LeaseConstraints.none(),
+ null,
+ null));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(h.take(Message.Type.JOB_RESULT, JobResult.class).finalStatus())
+ .isEqualTo(JobResult.SUCCESS);
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopCredentialTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopCredentialTest.java
new file mode 100644
index 0000000..e58d61e
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopCredentialTest.java
@@ -0,0 +1,332 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.agents.AgentRef;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.credentials.Credential;
+import dev.arcp.core.credentials.CredentialId;
+import dev.arcp.core.credentials.CredentialScheme;
+import dev.arcp.core.error.ErrorCode;
+import dev.arcp.core.error.UpstreamBudgetExhaustedException;
+import dev.arcp.core.events.LogEvent;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.messages.JobAccepted;
+import dev.arcp.core.messages.JobError;
+import dev.arcp.core.messages.JobEvent;
+import dev.arcp.core.messages.JobResult;
+import dev.arcp.core.messages.JobSubmit;
+import dev.arcp.core.messages.JobSubscribe;
+import dev.arcp.core.messages.JobSubscribed;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.SessionPing;
+import dev.arcp.core.messages.SessionPong;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.Agent;
+import dev.arcp.runtime.agent.JobContext;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.credentials.CredentialProvisioner;
+import dev.arcp.runtime.credentials.InMemoryCredentialRevocationStore;
+import dev.arcp.runtime.credentials.IssuedCredential;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import org.junit.jupiter.api.Test;
+
+/** Provisioned-credential branches: issue failures, rotation, surplus revoke, redaction (#98). */
+class SessionLoopCredentialTest {
+
+ private static final Set WITH_CREDS =
+ EnumSet.of(
+ Feature.SUBSCRIBE,
+ Feature.LIST_JOBS,
+ Feature.COST_BUDGET,
+ Feature.ACK,
+ Feature.PROVISIONED_CREDENTIALS);
+
+ private static IssuedCredential cred(String id, String value, String handle) {
+ return new IssuedCredential(
+ new Credential(
+ CredentialId.of(id),
+ CredentialScheme.BEARER,
+ value,
+ "https://llm.example/v1",
+ null,
+ null),
+ handle);
+ }
+
+ private static JobSubmit submit() {
+ return new JobSubmit(
+ AgentRef.parse("worker@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ Lease.builder().allow("fs.read", "/workspace/**").build(),
+ null,
+ null,
+ null);
+ }
+
+ /** Provisioner whose issue behaviour is scripted per invocation. */
+ private static final class ScriptedProvisioner implements CredentialProvisioner {
+ volatile Function>> onIssue =
+ ctx -> CompletableFuture.completedFuture(List.of());
+ final List revoked = new CopyOnWriteArrayList<>();
+ final AtomicInteger issueCalls = new AtomicInteger();
+
+ @Override
+ public CompletableFuture> issue(
+ Lease lease, LeaseConstraints constraints, JobContext ctx) {
+ issueCalls.incrementAndGet();
+ return onIssue.apply(ctx);
+ }
+
+ @Override
+ public CompletableFuture revoke(CredentialId id) {
+ revoked.add(id);
+ return CompletableFuture.completedFuture(null);
+ }
+ }
+
+ private static ArcpRuntime runtime(ScriptedProvisioner provisioner, Agent agent) {
+ return ArcpRuntime.builder()
+ .credentialProvisioner(provisioner)
+ .credentialRevocationStore(new InMemoryCredentialRevocationStore())
+ .agent("worker", "1.0.0", agent)
+ .build();
+ }
+
+ @Test
+ void issueAttachesCredentialsAndExercisesIssueContext() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ AtomicBoolean cancelledDuringIssue = new AtomicBoolean(true);
+ provisioner.onIssue =
+ ctx -> {
+ ctx.emit(new LogEvent("info", "issuing"));
+ cancelledDuringIssue.set(ctx.cancelled());
+ try {
+ ctx.authorize("fs.read", "/workspace/a.txt");
+ } catch (Exception e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ assertThat(ctx.credentials()).isEmpty();
+ return CompletableFuture.completedFuture(List.of(cred("cred_1", "secret", "handle-1")));
+ };
+ try (ArcpRuntime runtime =
+ runtime(provisioner, (input, ctx) -> JobOutcome.Success.inline(input.payload()))) {
+ assertThat(runtime.advertised()).contains(Feature.PROVISIONED_CREDENTIALS, Feature.MODEL_USE);
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), WITH_CREDS);
+
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(accepted.credentials()).hasSize(1);
+ assertThat(accepted.credentials().getFirst().id()).isEqualTo(CredentialId.of("cred_1"));
+ assertThat(cancelledDuringIssue).isFalse();
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+ // Terminal success revokes the issued credential.
+ SessionHarness.await(() -> provisioner.revoked.contains(CredentialId.of("cred_1")));
+ }
+ }
+
+ @Test
+ void issueFailureWithUpstreamBudgetExhaustionMapsToBudgetError() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ provisioner.onIssue =
+ ctx ->
+ CompletableFuture.failedFuture(
+ new UpstreamBudgetExhaustedException("upstream balance empty", "{}"));
+ try (ArcpRuntime runtime =
+ runtime(provisioner, (input, ctx) -> JobOutcome.Success.inline(input.payload()))) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), WITH_CREDS);
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.BUDGET_EXHAUSTED);
+ assertThat(err.message()).isEqualTo("upstream balance empty");
+ assertThat(runtime.jobs()).isEmpty();
+ }
+ }
+
+ @Test
+ void issueFailureWithMessageMapsToInternalError() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ provisioner.onIssue =
+ ctx -> CompletableFuture.failedFuture(new IllegalStateException("issuer offline"));
+ try (ArcpRuntime runtime =
+ runtime(provisioner, (input, ctx) -> JobOutcome.Success.inline(input.payload()))) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), WITH_CREDS);
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ assertThat(err.message()).isEqualTo("issuer offline");
+ }
+ }
+
+ @Test
+ void issueFailureWithoutMessageUsesClassName() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ provisioner.onIssue =
+ ctx -> {
+ throw new IllegalStateException();
+ };
+ try (ArcpRuntime runtime =
+ runtime(provisioner, (input, ctx) -> JobOutcome.Success.inline(input.payload()))) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), WITH_CREDS);
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ assertThat(err.message()).isEqualTo("IllegalStateException");
+ }
+ }
+
+ @Test
+ void rotationRevokesSurplusRedactsSubscribersAndReplaysWithoutSecrets() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ provisioner.onIssue =
+ ctx -> {
+ if (provisioner.issueCalls.get() == 1) {
+ return CompletableFuture.completedFuture(List.of(cred("cred_1", "old", "h1")));
+ }
+ // Reissue during rotation returns a surplus credential too (#98).
+ return CompletableFuture.completedFuture(
+ List.of(cred("cred_2", "minted", "h2"), cred("cred_3", "surplus", "h3")));
+ };
+ CountDownLatch subscribed = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ runtime(
+ provisioner,
+ (input, ctx) -> {
+ ctx.emit(new LogEvent("info", "before"));
+ subscribed.await();
+ ctx.rotateCredential(CredentialId.of("cred_1"), "fresh-value");
+ ctx.emit(new LogEvent("info", "after"));
+ done.countDown();
+ return JobOutcome.Success.inline(input.payload());
+ })) {
+ SessionHarness owner = SessionHarness.connect(runtime);
+ owner.handshake(Auth.bearer("shared"), WITH_CREDS);
+ SessionHarness watcher = SessionHarness.connect(runtime);
+ watcher.handshake(Auth.bearer("shared"), WITH_CREDS);
+
+ owner.send(Message.Type.JOB_SUBMIT, submit());
+ JobAccepted accepted = owner.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ // Take "before" first so the watcher provably subscribes after it was emitted.
+ assertThat(owner.take(Message.Type.JOB_EVENT, JobEvent.class).eventKind()).isEqualTo("log");
+
+ watcher.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(accepted.jobId(), null, null));
+ watcher.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class);
+ subscribed.countDown();
+ assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
+
+ // Owner sees: credential_rotated, after; watcher never sees the rotation event.
+ JobEvent rotated = owner.take(Message.Type.JOB_EVENT, JobEvent.class);
+ assertThat(rotated.eventKind()).isEqualTo("status");
+ assertThat(rotated.body().path("phase").asText()).isEqualTo("credential_rotated");
+ assertThat(owner.take(Message.Type.JOB_EVENT, JobEvent.class).eventKind()).isEqualTo("log");
+ owner.take(Message.Type.JOB_RESULT, JobResult.class);
+
+ assertThat(
+ watcher.take(Message.Type.JOB_EVENT, JobEvent.class).body().path("message").asText())
+ .isEqualTo("after");
+ watcher.take(Message.Type.JOB_RESULT, JobResult.class);
+ watcher.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ watcher.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(watcher.probe().saw(Message.Type.JOB_EVENT)).isFalse();
+
+ // Surplus minted credential and the rotated-out original were both revoked.
+ assertThat(provisioner.revoked)
+ .contains(CredentialId.of("cred_1"), CredentialId.of("cred_3"));
+
+ // History replay to a non-owner session skips credential_rotated entirely.
+ SessionHarness late = SessionHarness.connect(runtime);
+ late.handshake(Auth.bearer("shared"), WITH_CREDS);
+ late.send(Message.Type.JOB_SUBSCRIBE, new JobSubscribe(accepted.jobId(), 0L, true));
+ assertThat(late.take(Message.Type.JOB_SUBSCRIBED, JobSubscribed.class).replayed()).isTrue();
+ assertThat(late.take(Message.Type.JOB_EVENT, JobEvent.class).body().path("message").asText())
+ .isEqualTo("before");
+ assertThat(late.take(Message.Type.JOB_EVENT, JobEvent.class).body().path("message").asText())
+ .isEqualTo("after");
+ late.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ late.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(late.probe().saw(Message.Type.JOB_EVENT)).isFalse();
+ }
+ }
+
+ @Test
+ void rotateUnknownCredentialIdFailsTheJob() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ provisioner.onIssue =
+ ctx -> CompletableFuture.completedFuture(List.of(cred("cred_1", "old", null)));
+ try (ArcpRuntime runtime =
+ runtime(
+ provisioner,
+ (input, ctx) -> {
+ ctx.rotateCredential(CredentialId.of("cred_unknown"), "v");
+ return JobOutcome.Success.inline(input.payload());
+ })) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), WITH_CREDS);
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ assertThat(err.message()).contains("unknown credential id");
+ }
+ }
+
+ @Test
+ void rotateWithEmptyReissueFailsLoudly() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ provisioner.onIssue =
+ ctx -> {
+ if (provisioner.issueCalls.get() == 1) {
+ return CompletableFuture.completedFuture(List.of(cred("cred_1", "old", "h1")));
+ }
+ return CompletableFuture.completedFuture(List.of());
+ };
+ try (ArcpRuntime runtime =
+ runtime(
+ provisioner,
+ (input, ctx) -> {
+ ctx.rotateCredential(CredentialId.of("cred_1"), "v");
+ return JobOutcome.Success.inline(input.payload());
+ })) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), WITH_CREDS);
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ assertThat(err.message()).contains("produced no credential");
+ }
+ }
+
+ @Test
+ void credentialsAreNotIssuedWhenFeatureNotNegotiated() throws Exception {
+ ScriptedProvisioner provisioner = new ScriptedProvisioner();
+ try (ArcpRuntime runtime =
+ runtime(provisioner, (input, ctx) -> JobOutcome.Success.inline(input.payload()))) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.anonymous(), SessionHarness.DEFAULT_FEATURES);
+ h.send(Message.Type.JOB_SUBMIT, submit());
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(accepted.credentials()).isNull();
+ assertThat(provisioner.issueCalls.get()).isZero();
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+ }
+ }
+}
diff --git a/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopSchedulerTest.java b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopSchedulerTest.java
new file mode 100644
index 0000000..47f731f
--- /dev/null
+++ b/arcp-runtime/src/test/java/dev/arcp/runtime/coverage/SessionLoopSchedulerTest.java
@@ -0,0 +1,519 @@
+package dev.arcp.runtime.coverage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import dev.arcp.core.agents.AgentRef;
+import dev.arcp.core.auth.Auth;
+import dev.arcp.core.capabilities.Feature;
+import dev.arcp.core.error.ErrorCode;
+import dev.arcp.core.lease.Lease;
+import dev.arcp.core.lease.LeaseConstraints;
+import dev.arcp.core.messages.JobAccepted;
+import dev.arcp.core.messages.JobCancel;
+import dev.arcp.core.messages.JobCancelled;
+import dev.arcp.core.messages.JobError;
+import dev.arcp.core.messages.JobResult;
+import dev.arcp.core.messages.JobSubmit;
+import dev.arcp.core.messages.Message;
+import dev.arcp.core.messages.SessionPing;
+import dev.arcp.core.messages.SessionPong;
+import dev.arcp.core.messages.SessionWelcome;
+import dev.arcp.runtime.ArcpRuntime;
+import dev.arcp.runtime.agent.JobOutcome;
+import dev.arcp.runtime.session.SessionLoop;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.junit.jupiter.api.Test;
+
+/** Deterministic heartbeat, park-expiry, watchdog, and eviction coverage via a manual scheduler. */
+class SessionLoopSchedulerTest {
+
+ private static final Set WITH_HEARTBEAT =
+ EnumSet.of(
+ Feature.SUBSCRIBE,
+ Feature.LIST_JOBS,
+ Feature.COST_BUDGET,
+ Feature.ACK,
+ Feature.HEARTBEAT);
+
+ private static JobSubmit submit(String agent, LeaseConstraints constraints, Integer maxRuntime) {
+ return new JobSubmit(
+ AgentRef.parse(agent),
+ JsonNodeFactory.instance.objectNode(),
+ Lease.builder().allow("fs.read", "/workspace/**").build(),
+ constraints,
+ null,
+ maxRuntime);
+ }
+
+ @Test
+ void heartbeatTicksPingPongAndCloseDeterministically() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .heartbeatIntervalSec(5)
+ .resumeWindowSec(60)
+ .build()) {
+ scheduler.drainNew(); // idempotency prune task
+
+ SessionHarness h = SessionHarness.connect(runtime);
+ SessionWelcome welcome = h.handshake(Auth.anonymous(), WITH_HEARTBEAT);
+ assertThat(welcome.heartbeatIntervalSec()).isEqualTo(5);
+
+ SessionHarness.await(() -> !scheduler.drainNew().isEmpty() || tickOf(scheduler) != null);
+ ManualScheduler.Task tick = tickOf(scheduler);
+ assertThat((Object) tick).isNotNull();
+
+ // No time has passed: neither ping nor close.
+ tick.run();
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence1", Instant.now()));
+ h.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(h.probe().saw(Message.Type.SESSION_PING)).isFalse();
+
+ // One interval elapsed: the runtime pings, the client answers.
+ clock.advance(Duration.ofSeconds(6));
+ tick.run();
+ SessionPing ping = h.take(Message.Type.SESSION_PING, SessionPing.class);
+ assertThat(ping.nonce()).startsWith("p_");
+ h.send(Message.Type.SESSION_PONG, new SessionPong(ping.nonce(), Instant.now()));
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence2", Instant.now()));
+ h.take(Message.Type.SESSION_PONG, SessionPong.class);
+
+ // Two intervals of silence: heartbeat lost, session closes.
+ clock.advance(Duration.ofSeconds(11));
+ tick.runIgnoringCancel();
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.CLOSED);
+
+ // A straggler tick on a closed session is a no-op.
+ tick.runIgnoringCancel();
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ private static ManualScheduler.Task tickOf(ManualScheduler scheduler) {
+ return scheduler.tasks().stream()
+ .skip(1) // idempotency prune
+ .filter(ManualScheduler.Task::periodic)
+ .findFirst()
+ .orElse(null);
+ }
+
+ @Test
+ void parkCancelsHeartbeatAndExpiresAfterResumeWindow() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .heartbeatIntervalSec(5)
+ .resumeWindowSec(60)
+ .build()) {
+ scheduler.drainNew();
+
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.bearer("parker"), WITH_HEARTBEAT);
+ SessionHarness.await(
+ () -> scheduler.tasks().stream().skip(1).anyMatch(ManualScheduler.Task::periodic));
+ scheduler.drainNew();
+
+ h.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.PARKED);
+ SessionHarness.await(
+ () ->
+ scheduler.drainNew().stream().anyMatch(t -> !t.periodic())
+ || expiryOf(scheduler) != null);
+ ManualScheduler.Task expiry = expiryOf(scheduler);
+ assertThat((Object) expiry).isNotNull();
+
+ expiry.run();
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.CLOSED);
+ }
+ }
+
+ private static ManualScheduler.Task expiryOf(ManualScheduler scheduler) {
+ return scheduler.tasks().stream()
+ .filter(t -> !t.periodic() && t.delayMillis() == 60_000L && !t.isCancelled())
+ .reduce((a, b) -> b)
+ .orElse(null);
+ }
+
+ @Test
+ void resumeWithHeartbeatCancelsParkExpiryAndReschedulesTicks() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .heartbeatIntervalSec(5)
+ .resumeWindowSec(60)
+ .build()) {
+ scheduler.drainNew();
+
+ SessionHarness h = SessionHarness.connect(runtime);
+ SessionWelcome welcome = h.handshake(Auth.bearer("carol"), WITH_HEARTBEAT);
+ String token = welcome.resumeToken();
+
+ h.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.PARKED);
+ SessionHarness.await(() -> expiryOfAnyState(scheduler) != null);
+ ManualScheduler.Task expiry = expiryOfAnyState(scheduler);
+
+ SessionHarness fresh = SessionHarness.connect(runtime);
+ fresh.hello(Auth.bearer("carol"), WITH_HEARTBEAT, token, 0L);
+ SessionWelcome resumed = fresh.take(Message.Type.SESSION_WELCOME, SessionWelcome.class);
+ assertThat(resumed.heartbeatIntervalSec()).isEqualTo(5);
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.ACTIVE);
+
+ // The stale expiry task firing after a successful resume must be a no-op.
+ expiry.runIgnoringCancel();
+ assertThat(h.loop().phase()).isEqualTo(SessionLoop.Phase.ACTIVE);
+
+ // The resumed session is reachable through the forwarder.
+ fresh.send(Message.Type.SESSION_PING, new SessionPing("post-resume", Instant.now()));
+ assertThat(fresh.take(Message.Type.SESSION_PONG, SessionPong.class).pingNonce())
+ .isEqualTo("post-resume");
+ }
+ }
+
+ private static ManualScheduler.Task expiryOfAnyState(ManualScheduler scheduler) {
+ return scheduler.tasks().stream()
+ .filter(t -> !t.periodic() && t.delayMillis() == 60_000L)
+ .reduce((a, b) -> b)
+ .orElse(null);
+ }
+
+ @Test
+ void parkWithRejectingSchedulerExpiresImmediately() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder().clock(clock).scheduler(scheduler).resumeWindowSec(60).build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake(Auth.bearer("doomed"), SessionHarness.DEFAULT_FEATURES);
+
+ scheduler.rejecting(true);
+ h.runtimeEndpoint().close();
+ SessionHarness.awaitPhase(h.loop(), SessionLoop.Phase.CLOSED);
+ scheduler.rejecting(false);
+ }
+ }
+
+ @Test
+ void leaseExpiryWatchdogTerminatesRunningJob() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ CountDownLatch started = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .resumeWindowSec(60)
+ .agent(
+ "block",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ new CountDownLatch(1).await(); // until interrupted
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ scheduler.drainNew();
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ submit("block@1.0.0", LeaseConstraints.of(clock.instant().plusSeconds(30)), null));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+ SessionHarness.await(() -> watchdog(scheduler, 30_000L) != null);
+
+ watchdog(scheduler, 30_000L).run();
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.LEASE_EXPIRED);
+ assertThat(err.finalStatus()).isEqualTo(JobError.ERROR);
+ }
+ }
+
+ private static ManualScheduler.Task watchdog(ManualScheduler scheduler, long delayMillis) {
+ return scheduler.tasks().stream()
+ .filter(t -> !t.periodic() && t.delayMillis() == delayMillis)
+ .findFirst()
+ .orElse(null);
+ }
+
+ @Test
+ void watchdogsOnTerminalJobAreNoopsAndEvictionRemovesJob() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .resumeWindowSec(60)
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ scheduler.drainNew();
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ submit("echo@1.0.0", LeaseConstraints.of(clock.instant().plusSeconds(30)), 45));
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ h.take(Message.Type.JOB_RESULT, JobResult.class);
+
+ // Expiry (30s), max-runtime (45s), and eviction (60s) tasks all exist once terminal.
+ SessionHarness.await(() -> watchdogAny(scheduler, 60_000L) != null);
+
+ watchdogAny(scheduler, 30_000L).runIgnoringCancel(); // lease expiry on terminal job
+ watchdogAny(scheduler, 45_000L).runIgnoringCancel(); // max-runtime on terminal job
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ h.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(h.probe().saw(Message.Type.JOB_ERROR)).isFalse();
+
+ assertThat(runtime.job(accepted.jobId())).isNotNull();
+ watchdogAny(scheduler, 60_000L).run(); // eviction
+ assertThat(runtime.job(accepted.jobId())).isNull();
+ }
+ }
+
+ private static ManualScheduler.Task watchdogAny(ManualScheduler scheduler, long delayMillis) {
+ return scheduler.tasks().stream()
+ .filter(t -> !t.periodic() && t.delayMillis() == delayMillis)
+ .findFirst()
+ .orElse(null);
+ }
+
+ @Test
+ void maxRuntimeWatchdogTimesOutRunningJob() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ CountDownLatch started = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .resumeWindowSec(60)
+ .agent(
+ "block",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ new CountDownLatch(1).await();
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ scheduler.drainNew();
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ h.send(Message.Type.JOB_SUBMIT, submit("block@1.0.0", null, 45));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+ SessionHarness.await(() -> watchdogAny(scheduler, 45_000L) != null);
+
+ watchdogAny(scheduler, 45_000L).run();
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.TIMEOUT);
+ assertThat(err.finalStatus()).isEqualTo(JobError.TIMED_OUT);
+ }
+ }
+
+ @Test
+ void leaseExpiryDuringAuthorizeFailsTheJob() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
+ ManualScheduler scheduler = new ManualScheduler();
+ CountDownLatch started = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .clock(clock)
+ .scheduler(scheduler)
+ .resumeWindowSec(60)
+ .agent(
+ "auth",
+ "1.0.0",
+ (input, ctx) -> {
+ started.countDown();
+ release.await();
+ ctx.authorize("fs.read", "/workspace/file.txt");
+ return JobOutcome.Success.inline(input.payload());
+ })
+ .build()) {
+ scheduler.drainNew();
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ submit("auth@1.0.0", LeaseConstraints.of(clock.instant().plusSeconds(30)), null));
+ h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+ clock.advance(Duration.ofSeconds(120));
+ release.countDown();
+ JobError err = h.take(Message.Type.JOB_ERROR, JobError.class);
+ assertThat(err.code()).isEqualTo(ErrorCode.LEASE_EXPIRED);
+ }
+ }
+
+ @Test
+ void cancelBeforeWorkerStartsPreventsTheRunEntirely() throws Exception {
+ GatedExecutor gated = new GatedExecutor();
+ try (ArcpRuntime runtime =
+ ArcpRuntime.builder()
+ .workerPool(gated)
+ .agent("echo", "1.0.0", (input, ctx) -> JobOutcome.Success.inline(input.payload()))
+ .build()) {
+ SessionHarness h = SessionHarness.connect(runtime);
+ h.handshake();
+
+ h.send(
+ Message.Type.JOB_SUBMIT,
+ new JobSubmit(
+ AgentRef.parse("echo@1.0.0"),
+ JsonNodeFactory.instance.objectNode(),
+ null,
+ null,
+ null,
+ null));
+ JobAccepted accepted = h.take(Message.Type.JOB_ACCEPTED, JobAccepted.class);
+ SessionHarness.await(() -> gated.submits.get() > 0);
+ SessionHarness.await(() -> runtime.job(accepted.jobId()).worker() != null);
+
+ // Cancel while the worker is still gated: the record turns terminal before runJob starts.
+ h.sendJob(Message.Type.JOB_CANCEL, new JobCancel("too soon"), accepted.jobId());
+ h.take(Message.Type.JOB_CANCELLED, JobCancelled.class);
+ assertThat(h.take(Message.Type.JOB_ERROR, JobError.class).code())
+ .isEqualTo(ErrorCode.CANCELLED);
+
+ gated.gate.countDown();
+ gated.lastDone.get(5, TimeUnit.SECONDS);
+
+ h.send(Message.Type.SESSION_PING, new SessionPing("fence", Instant.now()));
+ h.take(Message.Type.SESSION_PONG, SessionPong.class);
+ assertThat(h.probe().saw(Message.Type.JOB_RESULT)).isFalse();
+ assertThat(runtime.job(accepted.jobId()).status())
+ .isEqualTo(dev.arcp.runtime.session.JobRecord.Status.CANCELLED);
+ }
+ }
+
+ /**
+ * Executor whose submitted workers wait behind a gate; cancel(true) interrupts but never skips.
+ */
+ private static final class GatedExecutor extends AbstractExecutorService {
+ final CountDownLatch gate = new CountDownLatch(1);
+ final java.util.concurrent.atomic.AtomicInteger submits =
+ new java.util.concurrent.atomic.AtomicInteger();
+ volatile CompletableFuture lastDone = new CompletableFuture<>();
+ private final java.util.concurrent.ExecutorService real =
+ Executors.newVirtualThreadPerTaskExecutor();
+
+ @Override
+ public Future> submit(Runnable task) {
+ CompletableFuture done = new CompletableFuture<>();
+ lastDone = done;
+ submits.incrementAndGet();
+ Thread thread =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ boolean interrupted = false;
+ while (true) {
+ try {
+ gate.await();
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ try {
+ task.run();
+ done.complete(null);
+ } catch (Throwable t) {
+ done.completeExceptionally(t);
+ }
+ });
+ return new Future() {
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ if (mayInterruptIfRunning) {
+ thread.interrupt();
+ }
+ return true;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return false;
+ }
+
+ @Override
+ public boolean isDone() {
+ return done.isDone();
+ }
+
+ @Override
+ public Object get() throws InterruptedException, ExecutionException {
+ return done.get();
+ }
+
+ @Override
+ public Object get(long timeout, TimeUnit unit)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ return done.get(timeout, unit);
+ }
+ };
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ real.execute(command);
+ }
+
+ @Override
+ public void shutdown() {
+ real.shutdown();
+ }
+
+ @Override
+ public java.util.List shutdownNow() {
+ return real.shutdownNow();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return real.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return real.isTerminated();
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ return real.awaitTermination(timeout, unit);
+ }
+ }
+}
diff --git a/arcp-tck/pom.xml b/arcp-tck/pom.xml
index 175485b..c37aace 100644
--- a/arcp-tck/pom.xml
+++ b/arcp-tck/pom.xml
@@ -11,6 +11,10 @@
arcp-tck
+
+
+ false
+
arcp-tck
Conformance harness for ARCP runtime implementations.
diff --git a/arcp-tck/src/main/java/dev/arcp/tck/ConformanceSuite.java b/arcp-tck/src/main/java/dev/arcp/tck/ConformanceSuite.java
index 39ed73b..1a1476c 100644
--- a/arcp-tck/src/main/java/dev/arcp/tck/ConformanceSuite.java
+++ b/arcp-tck/src/main/java/dev/arcp/tck/ConformanceSuite.java
@@ -49,13 +49,29 @@ private ConformanceSuite() {}
/** Provider factory shape: each test invocation builds a fresh provider. */
@FunctionalInterface
public interface ProviderFactory {
+ /**
+ * Builds a fresh provider for a single conformance assertion.
+ *
+ * @return a provider owning the runtime under test; the suite closes it after the assertion
+ * @throws Exception if the provider cannot be constructed
+ */
TckProvider create() throws Exception;
}
+ /**
+ * Returns the conformance assertions as JUnit 5 dynamic tests, one per protocol behaviour:
+ * envelope round-trip via {@code session.welcome} (§5), capability intersection (§6.2), job
+ * submission and idempotency (§7.1, §7.2), agent versioning (§7.5), job events (§8.2), and
+ * provisioned credentials (§9.8). Each test builds a fresh provider from {@code factory} and
+ * closes both the provider and its client afterwards.
+ *
+ * @param factory supplies a fresh {@link TckProvider} per test invocation
+ * @return the dynamic test list to return from a JUnit 5 {@code @TestFactory} method
+ */
public static List dynamicTests(ProviderFactory factory) {
return List.of(
DynamicTest.dynamicTest(
- "§5.1 envelope round-trip via session.welcome",
+ "§5 envelope round-trip via session.welcome",
() -> runWith(factory, ConformanceSuite::handshakeReturnsWelcome)),
DynamicTest.dynamicTest(
"§6.2 capability intersection with feature subset",
diff --git a/arcp-tck/src/main/java/dev/arcp/tck/TckProvider.java b/arcp-tck/src/main/java/dev/arcp/tck/TckProvider.java
index 6c85671..045581b 100644
--- a/arcp-tck/src/main/java/dev/arcp/tck/TckProvider.java
+++ b/arcp-tck/src/main/java/dev/arcp/tck/TckProvider.java
@@ -11,10 +11,24 @@
*/
public interface TckProvider extends AutoCloseable {
- /** Construct and connect a fresh {@link ArcpClient} for one assertion. */
+ /**
+ * Constructs and connects a fresh {@link ArcpClient} for one assertion.
+ *
+ * @return a connected client whose lifecycle the harness owns
+ * @throws Exception if the runtime under test or the client cannot be brought up
+ */
ArcpClient connect() throws Exception;
- /** Construct a client against a runtime configured for provisioned credential assertions. */
+ /**
+ * Constructs a client against a runtime configured for provisioned credential assertions (§9.8).
+ * The default implementation throws {@link UnsupportedOperationException}, which makes {@link
+ * ConformanceSuite} skip the credential lifecycle test for this provider.
+ *
+ * @param provisioner issues credentials for jobs whose lease grants {@code model.use}
+ * @param store records issued credentials so the suite can assert they are revoked
+ * @return a connected client backed by the credential-provisioning runtime
+ * @throws Exception if the runtime under test or the client cannot be brought up
+ */
default ArcpClient connectWithProvisionedCredentials(
CredentialProvisioner provisioner, CredentialRevocationStore store) throws Exception {
throw new UnsupportedOperationException("provisioned credentials not supported by provider");
diff --git a/pom.xml b/pom.xml
index b591070..ad4b210 100644
--- a/pom.xml
+++ b/pom.xml
@@ -124,6 +124,17 @@
the Gradle setup pre-migration) and devs on newer JDKs pass
-Darcp.skip.spotless=true. -->
false
+
+ true
+
+ true
2.44.5
3.5.0
1.17.4
@@ -416,7 +427,13 @@
maven-javadoc-plugin
${maven-javadoc-plugin.version}
- none
+
+ all
+ ${arcp.javadoc.failOnWarnings}
true
UTF-8
UTF-8
@@ -461,6 +478,32 @@
report
+
+ check
+
+ check
+
+
+ ${arcp.skip.coverage.check}
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ 0.80
+
+
+ BRANCH
+ COVEREDRATIO
+ 0.80
+
+
+
+
+
+