diff --git a/arcp-client/pom.xml b/arcp-client/pom.xml index ede90c3..40d30d3 100644 --- a/arcp-client/pom.xml +++ b/arcp-client/pom.xml @@ -11,6 +11,10 @@ arcp-client + + + false + arcp-client ARCP client SDK. diff --git a/arcp-client/src/main/java/dev/arcp/client/ArcpClient.java b/arcp-client/src/main/java/dev/arcp/client/ArcpClient.java index 7a7587b..ef1720b 100644 --- a/arcp-client/src/main/java/dev/arcp/client/ArcpClient.java +++ b/arcp-client/src/main/java/dev/arcp/client/ArcpClient.java @@ -144,11 +144,22 @@ private ArcpClient(Builder b) { this.lastEventSeq = b.lastEventSeq; } + /** + * Starts building a client over the given transport. + * + * @param transport connected transport the client will own for the session's lifetime + * @return a new {@link Builder} with default client info, auth, and feature set + */ public static Builder builder(Transport transport) { return new Builder(transport); } - /** Send hello and return a future completing with the negotiated {@link Session}. */ + /** + * Sends {@code session.hello} and returns a future completing with the negotiated {@link Session} + * once {@code session.welcome} arrives (§6.2). + * + * @return a future completing with the session, or exceptionally if the handshake is rejected + */ public CompletableFuture connect() { transport.incoming().subscribe(this); SessionHello hello = @@ -157,6 +168,17 @@ public CompletableFuture 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 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 requireSubscriber() { + Flow.Subscriber 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 + + + + + +