diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aca13f6..ce6859e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,6 +97,22 @@ Two layers must pass before a PR merges: CI runs both on every PR. A PR that changes which feature flags the SDK negotiates must also update the README feature matrix in the same change. +### Coverage + +The aggregate Kover coverage thresholds (line ≥ 75 %, branch ≥ 45 %) are +enforced at the root level so `:lib` unit tests and the `:tests` +integration suite both contribute to one report. Reproduce the +measurement locally with: + +```sh +./gradlew test koverXmlReport koverVerify +``` + +To raise (or temporarily relax) the floor without editing the build, +pass `-Pkover.minLineCoverage=N` and/or `-Pkover.minBranchCoverage=N`. +Ratchet the defaults up in `build.gradle.kts` as more of the runtime +dispatch surface lands real tests. + ## Coding standards This repo enforces formatting with ktlint, static analysis with detekt, and diff --git a/README.md b/README.md index f27266d..2c5ee43 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,15 @@ For Maven users, the same coordinates apply (`groupId = dev.arcp`, `artifactId = Connect to a runtime, submit a job, stream its events to completion: ```kotlin +import dev.arcp.auth.StaticBearerAuth import dev.arcp.client.ARCPClient -import dev.arcp.envelope.Envelope import dev.arcp.messages.Capabilities import dev.arcp.messages.JobCompleted import dev.arcp.messages.JobFailed import dev.arcp.messages.JobSubmit import dev.arcp.messages.SessionClose +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.runtime.AgentRegistry import dev.arcp.transport.MemoryTransport import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.runBlocking @@ -57,10 +59,18 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put fun main(): Unit = runBlocking { - val (clientTransport, _) = MemoryTransport.pair() // swap for a networked transport + // Paired in-memory transport: client end <-> runtime end. + val (clientTransport, runtimeTransport) = MemoryTransport.pair() + val agents = AgentRegistry().apply { register("data-analyzer", "1.0.0", default = true) } + val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(streaming = true), + agentRegistry = agents, + bearerAuth = StaticBearerAuth(mapOf("quickstart-token" to "quickstart")), + ) + runtime.accept(runtimeTransport) ARCPClient( transport = clientTransport, - auth = ARCPClient.bearer(System.getenv("ARCP_TOKEN")), + auth = ARCPClient.bearer("quickstart-token"), client = ARCPClient.defaultClientInfo(principal = "quickstart"), capabilities = Capabilities(streaming = true), ).use { client -> @@ -78,6 +88,7 @@ fun main(): Unit = runBlocking { }.collect {} client.send(session.sessionId, SessionClose()) } + runtime.close() } ``` @@ -173,7 +184,7 @@ client.receive().takeWhile { env -> when (val p = env.payload) { is JobStatusEvent -> { println("status: ${p.phase} ${p.body}"); true } is JobProgress -> { println("progress: ${p.percent}% ${p.message}"); true } - is Metric -> { println("metric: ${p.name}=${p.value} ${p.unit ?: ""}"); true } + is Metric -> { println("metric: ${p.name}=${p.value} ${p.unit}"); true } is JobResultChunk -> { chunks.accept(p); true } is JobCompleted -> { println("result: ${p.result}"); false } is JobFailed -> { println("failed: ${p.code} ${p.message}"); false } @@ -222,7 +233,7 @@ try { client.receive().collect { env -> val m = env.payload as? Metric ?: return@collect if (m.name == StandardMetrics.COST_BUDGET_REMAINING) { - println("budget remaining: ${m.value} ${m.unit ?: ""}") + println("budget remaining: ${m.value} ${m.unit}") } } } catch (e: ARCPException.BudgetExhausted) { @@ -263,18 +274,23 @@ ARCP features this SDK negotiates during the `hello`/`welcome` handshake: | Feature flag | Status | |---|---| | `heartbeat` | Supported | -| `ack` | Partial | +| `ack` | Catalog only — runtime returns `UNIMPLEMENTED` Nack on `subscribe`-style ack envelopes | | `list_jobs` | Supported | -| `subscribe` | Partial | +| `subscribe` | Catalog + helpers; runtime does **not** dispatch `subscribe`/`unsubscribe` yet | | `lease_expires_at` | Supported | | `cost.budget` | Supported | -| `model.use` | Supported | +| `model.use` | Catalog + helpers; runtime does not yet enforce per-call model use | | `provisioned_credentials` | Supported | | `progress` | Supported | -| `result_chunk` | Supported | +| `result_chunk` | Client-side assembly only; runtime does not emit chunks itself | | `agent_versions` | Supported | -`ack` and `subscribe` are wired into the runtime and message catalog (envelopes round-trip, the runtime dispatches them), but `ARCPClient` does not yet expose convenience methods for either — they're driven via `client.send(...)` with the raw `Ack` / `Subscribe` message types. +See [`docs/conformance.md`](docs/conformance.md) for a per-section +breakdown of which message types are routed by `ARCPRuntime.handleEnvelope` +versus types that exist only in the catalog. The session +challenge/authenticate flow, delegation, artifacts dispatch, resume, and +interrupt are all in the catalog but deferred from runtime dispatch — a +peer that sends them today receives a correlated `UNIMPLEMENTED` Nack. ## Transport diff --git a/build.gradle.kts b/build.gradle.kts index ff328f4..e35ef1a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ktlint) apply false alias(libs.plugins.detekt) apply false - alias(libs.plugins.kover) apply false + alias(libs.plugins.kover) alias(libs.plugins.dokka) apply false alias(libs.plugins.nmcp) apply false alias(libs.plugins.nmcp.aggregation) @@ -78,3 +78,46 @@ nmcpAggregation { dependencies { nmcpAggregation(project(":lib")) } + +// --------------------------------------------------------------------------- +// Kover aggregation: roll :lib unit tests and the :tests integration suite +// into a single coverage report and enforce a floor on every CI run (#56). +// +// Thresholds: the floor is set to what `./gradlew test koverXmlReport +// koverVerify` reports as the current measurement, so CI passes today and any +// regression below the floor fails the build. Ratchet the numbers up as more +// of the runtime dispatch surface lands real tests — `coverage.line.minimum` +// and `coverage.branch.minimum` are the only knobs to touch. +// --------------------------------------------------------------------------- +dependencies { + kover(project(":lib")) + kover(project(":tests")) +} + +kover { + reports { + verify { + rule { + groupBy = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.APPLICATION + bound { + minValue = providers.gradleProperty("kover.minLineCoverage") + .map { it.toInt() } + .orElse(75) + coverageUnits = + kotlinx.kover.gradle.plugin.dsl.CoverageUnit.LINE + aggregationForGroup = + kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE + } + bound { + minValue = providers.gradleProperty("kover.minBranchCoverage") + .map { it.toInt() } + .orElse(45) + coverageUnits = + kotlinx.kover.gradle.plugin.dsl.CoverageUnit.BRANCH + aggregationForGroup = + kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE + } + } + } + } +} diff --git a/docs/conformance.md b/docs/conformance.md index f778721..85badab 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -1,69 +1,91 @@ # Conformance -This document maps ARCP v1.1 RFC sections to their Kotlin SDK implementations. +This document maps ARCP v1.1 RFC sections to their Kotlin SDK +implementations. Each row distinguishes three layers: + +- **Catalog** — the wire types and serializers exist in `messages/*` and + round-trip via `Envelope.serializer()`. +- **Helpers** — storage, registry, or in-memory data structures back the + feature (`store/EventLog.kt`, `lease/*`, etc.). +- **Runtime dispatch** — `ARCPRuntime.handleEnvelope` actually handles + the message and produces a correlated reply. Anything that falls + through to the `UNIMPLEMENTED` Nack is *not* dispatched, even if + catalog and helpers exist. + +A surface is only **✅ Supported** when all three layers are present. +Partial rows give a brief note. ## Implementation status -| RFC § | Title | Status | Implementation | -|-------|-------|--------|----------------| -| §6.1 | Envelope format | ✅ | `envelope/Envelope.kt` | -| §6.2 | Message catalog | ✅ | `messages/*.kt` | -| §6.3 | Resume | ✅ | `store/EventLog.kt` | -| §6.4 | Idempotency | ✅ | `store/EventLog.kt` | -| §6.6 | `session.list_jobs` / `session.jobs` | ✅ | `messages/Session.kt`, `runtime/ARCPRuntime.kt` | -| §7 | Capability negotiation | ✅ | `runtime/CapabilityNegotiation.kt` | -| §7.5 | Agent versioning (`name@version`) | ✅ | `runtime/AgentRegistry.kt` | -| §8 | Session handshake | ✅ | `runtime/ARCPRuntime.kt`, `client/ARCPClient.kt` | -| §8.2 | Authentication (`bearer`, `signed_jwt`) | ✅ | `auth/BearerAuth.kt`, `auth/JwtAuth.kt` | -| §8.4 | `result_chunk` streaming | ✅ | `messages/Execution.kt`, `client/ARCPClient.kt` | -| §9 | Leases & budgets | ✅ | `lease/` | -| §9.6 | `cost.budget` lease | ✅ | `lease/CostBudget.kt`, `lease/BudgetRegistry.kt` | -| §9.7 | `model.use` lease | ✅ | `lease/ModelUseLease.kt` | -| §9.8 | Provisioned credentials | ✅ | `credentials/` | -| §10 | Cancellation & delegation | ✅ | `messages/Control.kt`, `runtime/ARCPRuntime.kt` | -| §11 | Observability / metrics | ✅ | `messages/Telemetry.kt`, `trace/TraceContext.kt` | -| §12 | Error taxonomy | ✅ | `error/ErrorCode.kt`, `error/ARCPException.kt` | -| §15 | Vendor extensions | ✅ | `extensions/ExtensionRegistry.kt` | -| §16 | Artifacts | ✅ | `messages/Artifacts.kt` | -| §17.1 | Distributed tracing (W3C TraceContext) | ✅ | `trace/TraceContext.kt` | -| §18 | Error codes | ✅ | `error/ErrorCode.kt` | -| §19 | Session resume | ✅ | `store/EventLog.kt` | -| §21 | Extension naming (`arcpx.*`) | ✅ | `extensions/ExtensionRegistry.kt` | -| §22 | Reference transports | ✅ (memory) | `transport/MemoryTransport.kt` | -| WebSocket transport | — | 🔜 v0.2 | `transport/WebSocketTransport.kt` | -| Stdio transport | — | 🔜 v0.2 | `transport/StdioTransport.kt` | +| RFC § | Title | Catalog | Helpers | Dispatch | Notes | +|-------|-------|---------|---------|----------|-------| +| §6.1 | Envelope format | ✅ | ✅ | ✅ | `envelope/Envelope.kt` | +| §6.2 | Message catalog | ✅ | ✅ | partial | runtime handles ping, list_jobs, job.submit, metric, cancel, terminal job events, session.close; everything else returns `UNIMPLEMENTED` Nack | +| §6.3 | Resume | ✅ | ✅ | ⚠ deferred | `EventLog.replay()` exists; the runtime does not yet drive a `session.resume` flow | +| §6.4 | Idempotency | ✅ | ✅ | partial | `EventLog.recordIdempotent/lookupIdempotent`; runtime does not yet replay idempotent outcomes on resubmit | +| §6.6 | `session.list_jobs` / `session.jobs` | ✅ | ✅ | ✅ | `messages/Session.kt`, `runtime/JobInventory.kt`, `ARCPRuntime.handleListJobs` | +| §7 | Capability negotiation | ✅ | ✅ | ✅ | `runtime/CapabilityNegotiation.kt` | +| §7.5 | Agent versioning (`name@version`) | ✅ | ✅ | ✅ | `runtime/AgentRegistry.kt` | +| §8.1 | Session handshake (direct-credential) | ✅ | ✅ | ✅ | `runtime/ARCPRuntime.kt`, `client/ARCPClient.kt` | +| §8.1 | Session challenge/authenticate flow | ✅ | — | ⚠ deferred | runtime returns explicit `UNIMPLEMENTED` Nack; client cannot respond to a challenge | +| §8.2 | Authentication (`bearer`, `signed_jwt`) | ✅ | ✅ | ✅ | `auth/BearerAuth.kt`, `auth/JwtAuth.kt` (clock skew + optional issuer) | +| §8.4 | `result_chunk` streaming | ✅ | ✅ | partial | client-side `ResultChunkAssembler`; runtime does not emit result chunks itself | +| §9 | Leases & budgets | ✅ | ✅ | partial | runtime enforces `cost.budget` on `Metric` envelopes; lease subset checks live in `lease/LeaseSubset.kt` | +| §9.6 | `cost.budget` lease | ✅ | ✅ | ✅ | `lease/CostBudget.kt`, `lease/BudgetRegistry.kt`, `ARCPRuntime.handleMetric` | +| §9.7 | `model.use` lease | ✅ | ✅ | partial | parsed at submit; runtime does not yet enforce per-call model use | +| §9.8 | Provisioned credentials | ✅ | ✅ | ✅ | issued at job submit, revoked at terminal cleanup, retried with exponential backoff | +| §10.4 | Cancellation | ✅ | ✅ | ✅ | `messages/Control.kt`, `ARCPRuntime.handleCancel` (job-target only) | +| §10.5 | Interrupt | ✅ | — | ⚠ deferred | `Capabilities.interrupt` is negotiated; runtime does not yet dispatch the interrupt flow | +| §11 | Observability / metrics | ✅ | ✅ | partial | `Metric` is dispatched for `cost.*`; other metrics flow through as informational | +| §12 | Error taxonomy | ✅ | ✅ | ✅ | `error/ErrorCode.kt`, `error/ARCPException.kt` | +| §13 | Subscriptions | ✅ | partial | ⚠ deferred | `runtime/SubscriptionManager.kt`, `CompiledSubscriptionFilter.kt` exist; runtime does not dispatch `subscribe`/`unsubscribe` | +| §14 | Agent handoff / delegation | ✅ | — | ⚠ deferred | wire types exist; runtime returns `UNIMPLEMENTED` Nack | +| §15 | Vendor extensions | ✅ | ✅ | ✅ | `extensions/ExtensionRegistry.kt`; unknown extensions are silently dropped from negotiation rather than rejecting the session (RFC §21) | +| §16 | Artifacts | ✅ | ✅ | partial | `runtime/ArtifactStore.kt` for in-process use; runtime does not yet route `artifact.put`/`artifact.fetch` envelopes | +| §17.1 | Distributed tracing (W3C TraceContext) | ✅ | ✅ | ✅ | `trace/TraceContext.kt` | +| §18 | Error codes | ✅ | ✅ | ✅ | `error/ErrorCode.kt` | +| §19 | Session resume | ✅ | ✅ | ⚠ deferred | log-side replay works; runtime does not yet honor a `session.resume` envelope | +| §21 | Extension naming (`arcpx.*`) | ✅ | ✅ | ✅ | `extensions/ExtensionRegistry.kt`; unknown extensions on the wire produce `UNIMPLEMENTED` Nack at dispatch time | +| §22 | Reference transports | ✅ | ✅ (memory) | ✅ (memory) | `transport/MemoryTransport.kt` | +| WebSocket transport | — | — | 🔜 v0.2 | not on the public API | +| Stdio transport | — | — | 🔜 v0.2 | not on the public API | ## Notable v1.1 additions -- **`session.list_jobs` / `session.jobs`** (§6.6): principal-scoped in-memory - inventory with cursor pagination. -- **Agent versioning** (§7.5): `name@version` parsing, advertised descriptors, - and `AGENT_VERSION_NOT_AVAILABLE` error. -- **`result_chunk`** (§8.4): wire payloads plus client-side chunk assembly. +- **`session.list_jobs` / `session.jobs`** (§6.6): principal-scoped + in-memory inventory with opaque, sort-key-tied cursors. +- **Agent versioning** (§7.5): `name@version` parsing, advertised + descriptors, and `AGENT_VERSION_NOT_AVAILABLE` error. +- **`result_chunk`** (§8.4): wire payloads plus client-side chunk + assembly with content equality on the assembled bytes. - **`cost.budget`** (§9.6): budget parser, counters, subset checks, and - `BUDGET_EXHAUSTED` error. -- **`model.use` and provisioned credentials** (§9.7, §9.8): lease matching, - credential wire types, provisioner interface, in-memory implementation, - redaction, issue/revoke hooks, and rotation status events. -- **Error taxonomy** (§12): `BUDGET_EXHAUSTED`, `AGENT_VERSION_NOT_AVAILABLE`, - and `LEASE_SUBSET_VIOLATION` are recognized wire codes. + `BUDGET_EXHAUSTED` error. The counter clamps over-spend so a misbehaving + agent cannot push the balance negative. +- **`model.use` and provisioned credentials** (§9.7, §9.8): lease + matching, credential wire types, provisioner interface, in-memory + implementation, redaction, issue/revoke hooks, and rotation status + events. +- **Error taxonomy** (§12): `BUDGET_EXHAUSTED`, + `AGENT_VERSION_NOT_AVAILABLE`, and `LEASE_SUBSET_VIOLATION` are + recognized wire codes. ## Conformance testing -Integration tests live in `:tests` and target the public SDK surface over -`MemoryTransport`. Run with: +Integration tests live in `:tests` and target the public SDK surface +over `MemoryTransport`. Run with: ```bash ./gradlew :tests:test ``` -For cross-language conformance tracking, refer to the ARCP spec repository -and shared issue milestones. +For cross-language conformance tracking, refer to the ARCP spec +repository and shared issue milestones. ## Version reporting The Gradle artifact ships as `dev.arcp:arcp:1.1.0`, but the in-process -`dev.arcp.Version.SDK_VERSION` constant (which feeds `RuntimeIdentity.version` -on handshakes and the CLI `version` output) currently reads `0.1.0`. The -two are intentionally decoupled while the protocol-driving CLI subcommands -catch up; both numbers will align in a future release. +`dev.arcp.Version.SDK_VERSION` constant (which feeds +`RuntimeIdentity.version` on handshakes and the CLI `version` output) +currently reads `0.1.0`. The two are intentionally decoupled while the +protocol-driving CLI subcommands catch up; both numbers will align in a +future release. diff --git a/docs/getting-started.md b/docs/getting-started.md index f2ba22e..f047da1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,9 +21,14 @@ are declared as `api` dependencies and are pulled in automatically. ## Minimal example -The snippet below opens a session, submits a job, and closes cleanly. -It uses `MemoryTransport` — the same transport the integration tests use; -swap it for `WebSocketTransport` or `StdioTransport` in production. +The snippet below pairs a client and runtime over `MemoryTransport`, +registers one agent, opens a session, submits a job, and closes +cleanly. It is the same shape the integration tests use and runs +end-to-end as written. + +`MemoryTransport` is the only transport on the v0.1 public API surface +(`dev.arcp.transport.Transport`); the WebSocket and stdio transports +referenced elsewhere in the docs are planned for a future SDK release. ```kotlin import dev.arcp.client.ARCPClient @@ -31,18 +36,22 @@ import dev.arcp.messages.Capabilities import dev.arcp.runtime.ARCPRuntime import dev.arcp.runtime.AgentRegistry import dev.arcp.transport.MemoryTransport +import dev.arcp.auth.StaticBearerAuth import kotlinx.coroutines.runBlocking fun main() = runBlocking { // 1. Paired in-memory transport (client ↔ runtime). val (clientTransport, runtimeTransport) = MemoryTransport.pair() - // 2. Runtime with one registered agent. + // 2. Runtime with one registered agent. The bearer auth on both + // sides must agree — here the runtime maps "my-token" to a + // principal name, and the client carries the same token. val registry = AgentRegistry() registry.register("summarise", "1.0.0", default = true) val runtime = ARCPRuntime( supportedCapabilities = Capabilities(streaming = true), agentRegistry = registry, + bearerAuth = StaticBearerAuth(mapOf("my-token" to "quickstart")), ) // 3. Let the runtime accept the connection in the background. @@ -90,5 +99,5 @@ cd kotlin-sdk ## Next steps - [Architecture](architecture.md) — understand the layering before writing more code -- [Transports](transports.md) — connect over WebSocket or stdio +- [Transports](transports.md) — currently only `MemoryTransport` is public; WebSocket/stdio are planned - [Guides](README.md#guides) — deep-dives on sessions, jobs, leases, and more diff --git a/docs/guides/job-events.md b/docs/guides/job-events.md index cc44b55..1c50e84 100644 --- a/docs/guides/job-events.md +++ b/docs/guides/job-events.md @@ -8,7 +8,9 @@ progress, heartbeat, chunk, and status events. Sent when the agent begins executing: ```kotlin -is JobStarted -> println("Job ${msg.jobId} is now running") +// JobStarted carries only startedAt on its payload; the job id lives on +// the envelope (see Execution.kt). +is JobStarted -> println("Job ${env.jobId} started at ${msg.startedAt}") ``` ## JobProgress diff --git a/lib/api/lib.api b/lib/api/lib.api index f0dabe7..194414f 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -11,12 +11,15 @@ public abstract interface class dev/arcp/auth/BearerAuth { public final class dev/arcp/auth/JwtAuth { public static final field Companion Ldev/arcp/auth/JwtAuth$Companion; - public fun (Lcom/nimbusds/jose/JWSVerifier;Ljava/lang/String;)V + public synthetic fun (Lcom/nimbusds/jose/JWSVerifier;Ljava/lang/String;Ljava/lang/String;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/nimbusds/jose/JWSVerifier;Ljava/lang/String;Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun verify (Ljava/lang/String;)Ljava/lang/String; } public final class dev/arcp/auth/JwtAuth$Companion { - public final fun hmac ([BLjava/lang/String;)Ldev/arcp/auth/JwtAuth; + public final fun getDEFAULT_CLOCK_SKEW-UwyO8pc ()J + public final fun hmac-Wn2Vu4Y ([BLjava/lang/String;Ljava/lang/String;J)Ldev/arcp/auth/JwtAuth; + public static synthetic fun hmac-Wn2Vu4Y$default (Ldev/arcp/auth/JwtAuth$Companion;[BLjava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Ldev/arcp/auth/JwtAuth; } public final class dev/arcp/auth/StaticBearerAuth : dev/arcp/auth/BearerAuth { @@ -91,7 +94,7 @@ public final class dev/arcp/credentials/Credential { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/credentials/Credential$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/credentials/Credential$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/credentials/Credential$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/credentials/Credential; @@ -124,7 +127,7 @@ public final class dev/arcp/credentials/CredentialConstraints { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/credentials/CredentialConstraints$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/credentials/CredentialConstraints$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/credentials/CredentialConstraints$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/credentials/CredentialConstraints; @@ -154,7 +157,7 @@ public final class dev/arcp/credentials/CredentialId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/credentials/CredentialId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/credentials/CredentialId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/credentials/CredentialId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -541,7 +544,7 @@ public final class dev/arcp/ids/ArtifactId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/ArtifactId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/ArtifactId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/ArtifactId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -572,7 +575,7 @@ public final class dev/arcp/ids/IdempotencyKey { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/IdempotencyKey$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/IdempotencyKey$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/IdempotencyKey$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -602,7 +605,7 @@ public final class dev/arcp/ids/JobId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/JobId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/JobId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/JobId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -633,7 +636,7 @@ public final class dev/arcp/ids/LeaseId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/LeaseId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/LeaseId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/LeaseId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -664,7 +667,7 @@ public final class dev/arcp/ids/MessageId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/MessageId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/MessageId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/MessageId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -695,7 +698,7 @@ public final class dev/arcp/ids/PermissionName { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/PermissionName$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/PermissionName$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/PermissionName$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -725,7 +728,7 @@ public final class dev/arcp/ids/SessionId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/SessionId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/SessionId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/SessionId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -756,7 +759,7 @@ public final class dev/arcp/ids/SpanId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/SpanId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/SpanId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/SpanId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -787,7 +790,7 @@ public final class dev/arcp/ids/StreamId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/StreamId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/StreamId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/StreamId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -818,7 +821,7 @@ public final class dev/arcp/ids/SubscriptionId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/SubscriptionId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/SubscriptionId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/SubscriptionId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -849,7 +852,7 @@ public final class dev/arcp/ids/ToolName { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/ToolName$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/ToolName$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/ToolName$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -879,7 +882,7 @@ public final class dev/arcp/ids/TraceId { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/ids/TraceId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/ids/TraceId$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/ids/TraceId$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -954,14 +957,50 @@ public final class dev/arcp/lease/BudgetCounter$Outcome$Ok : dev/arcp/lease/Budg public fun toString ()Ljava/lang/String; } +public final class dev/arcp/lease/BudgetCounter$Outcome$Rejected : dev/arcp/lease/BudgetCounter$Outcome { + public synthetic fun (Ljava/lang/String;Ljava/math/BigDecimal;Ljava/math/BigDecimal;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-S_UYvFw ()Ljava/lang/String; + public final fun component2 ()Ljava/math/BigDecimal; + public final fun component3 ()Ljava/math/BigDecimal; + public final fun copy-w_8BWxE (Ljava/lang/String;Ljava/math/BigDecimal;Ljava/math/BigDecimal;)Ldev/arcp/lease/BudgetCounter$Outcome$Rejected; + public static synthetic fun copy-w_8BWxE$default (Ldev/arcp/lease/BudgetCounter$Outcome$Rejected;Ljava/lang/String;Ljava/math/BigDecimal;Ljava/math/BigDecimal;ILjava/lang/Object;)Ldev/arcp/lease/BudgetCounter$Outcome$Rejected; + public fun equals (Ljava/lang/Object;)Z + public final fun getAvailable ()Ljava/math/BigDecimal; + public final fun getCurrency-S_UYvFw ()Ljava/lang/String; + public final fun getRequested ()Ljava/math/BigDecimal; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/arcp/lease/BudgetRegistry { public fun ()V - public final fun consume-tBvcXsc (Ljava/lang/String;Ldev/arcp/lease/BudgetAmount;)Ldev/arcp/lease/BudgetCounter$Outcome; + public final fun consume-tBvcXsc (Ljava/lang/String;Ldev/arcp/lease/BudgetAmount;)Ldev/arcp/lease/BudgetRegistry$Outcome; public final fun register-tBvcXsc (Ljava/lang/String;Ldev/arcp/lease/CostBudget;)V public final fun remaining-2ATt4A4 (Ljava/lang/String;)Ldev/arcp/lease/CostBudget; public final fun terminate-2ATt4A4 (Ljava/lang/String;)V } +public abstract interface class dev/arcp/lease/BudgetRegistry$Outcome { +} + +public final class dev/arcp/lease/BudgetRegistry$Outcome$Counted : dev/arcp/lease/BudgetRegistry$Outcome { + public fun (Ldev/arcp/lease/BudgetCounter$Outcome;)V + public final fun component1 ()Ldev/arcp/lease/BudgetCounter$Outcome; + public final fun copy (Ldev/arcp/lease/BudgetCounter$Outcome;)Ldev/arcp/lease/BudgetRegistry$Outcome$Counted; + public static synthetic fun copy$default (Ldev/arcp/lease/BudgetRegistry$Outcome$Counted;Ldev/arcp/lease/BudgetCounter$Outcome;ILjava/lang/Object;)Ldev/arcp/lease/BudgetRegistry$Outcome$Counted; + public fun equals (Ljava/lang/Object;)Z + public final fun getOutcome ()Ldev/arcp/lease/BudgetCounter$Outcome; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/arcp/lease/BudgetRegistry$Outcome$Unregistered : dev/arcp/lease/BudgetRegistry$Outcome { + public static final field INSTANCE Ldev/arcp/lease/BudgetRegistry$Outcome$Unregistered; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/arcp/lease/CostBudget { public fun (Ljava/util/List;)V public final fun byCurrency-y7CK5Xs (Ljava/lang/String;)Ldev/arcp/lease/BudgetAmount; @@ -989,7 +1028,7 @@ public final class dev/arcp/lease/Currency { public final synthetic fun unbox-impl ()Ljava/lang/String; } -public synthetic class dev/arcp/lease/Currency$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/lease/Currency$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/lease/Currency$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; @@ -1039,7 +1078,7 @@ public final class dev/arcp/messages/Ack : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Ack$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Ack$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Ack$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Ack; @@ -1071,7 +1110,7 @@ public final class dev/arcp/messages/AgentDelegate : dev/arcp/messages/MessageTy public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/AgentDelegate$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/AgentDelegate$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/AgentDelegate$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/AgentDelegate; @@ -1103,7 +1142,7 @@ public final class dev/arcp/messages/AgentDescriptor { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/AgentDescriptor$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/AgentDescriptor$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/AgentDescriptor$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/AgentDescriptor; @@ -1139,7 +1178,7 @@ public final class dev/arcp/messages/AgentHandoff : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/AgentHandoff$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/AgentHandoff$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/AgentHandoff$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/AgentHandoff; @@ -1187,7 +1226,7 @@ public final class dev/arcp/messages/ArtifactFetch : dev/arcp/messages/MessageTy public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ArtifactFetch$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ArtifactFetch$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ArtifactFetch$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ArtifactFetch; @@ -1225,7 +1264,7 @@ public final class dev/arcp/messages/ArtifactPut : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ArtifactPut$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ArtifactPut$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ArtifactPut$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ArtifactPut; @@ -1255,7 +1294,7 @@ public final class dev/arcp/messages/ArtifactRefMessage : dev/arcp/messages/Mess public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ArtifactRefMessage$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ArtifactRefMessage$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ArtifactRefMessage$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ArtifactRefMessage; @@ -1293,7 +1332,7 @@ public final class dev/arcp/messages/ArtifactRefSpec { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ArtifactRefSpec$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ArtifactRefSpec$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ArtifactRefSpec$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ArtifactRefSpec; @@ -1320,7 +1359,7 @@ public final class dev/arcp/messages/ArtifactRelease : dev/arcp/messages/Message public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ArtifactRelease$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ArtifactRelease$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ArtifactRelease$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ArtifactRelease; @@ -1352,7 +1391,7 @@ public final class dev/arcp/messages/Auth { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Auth$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Auth$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Auth$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Auth; @@ -1402,7 +1441,7 @@ public final class dev/arcp/messages/Backpressure : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Backpressure$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Backpressure$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Backpressure$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Backpressure; @@ -1436,7 +1475,7 @@ public final class dev/arcp/messages/Cancel : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Cancel$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Cancel$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Cancel$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Cancel; @@ -1463,7 +1502,7 @@ public final class dev/arcp/messages/CancelAccepted : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/CancelAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/CancelAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/CancelAccepted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/CancelAccepted; @@ -1492,7 +1531,7 @@ public final class dev/arcp/messages/CancelRefused : dev/arcp/messages/MessageTy public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/CancelRefused$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/CancelRefused$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/CancelRefused$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/CancelRefused; @@ -1568,7 +1607,7 @@ public final class dev/arcp/messages/Capabilities { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Capabilities$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Capabilities$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Capabilities$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Capabilities; @@ -1598,7 +1637,7 @@ public final class dev/arcp/messages/CheckpointCreate : dev/arcp/messages/Messag public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/CheckpointCreate$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/CheckpointCreate$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/CheckpointCreate$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/CheckpointCreate; @@ -1630,7 +1669,7 @@ public final class dev/arcp/messages/CheckpointRestore : dev/arcp/messages/Messa public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/CheckpointRestore$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/CheckpointRestore$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/CheckpointRestore$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/CheckpointRestore; @@ -1664,7 +1703,7 @@ public final class dev/arcp/messages/ClientInfo { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ClientInfo$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ClientInfo$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ClientInfo$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ClientInfo; @@ -1694,7 +1733,7 @@ public final class dev/arcp/messages/EventEmit : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/EventEmit$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/EventEmit$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/EventEmit$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/EventEmit; @@ -1738,7 +1777,7 @@ public final class dev/arcp/messages/Interrupt : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Interrupt$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Interrupt$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Interrupt$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Interrupt; @@ -1772,7 +1811,7 @@ public final class dev/arcp/messages/JobAccepted : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobAccepted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobAccepted; @@ -1803,7 +1842,7 @@ public final class dev/arcp/messages/JobCancelled : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobCancelled$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobCancelled$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobCancelled$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobCancelled; @@ -1835,7 +1874,7 @@ public final class dev/arcp/messages/JobCheckpoint : dev/arcp/messages/MessageTy public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobCheckpoint$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobCheckpoint$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobCheckpoint$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobCheckpoint; @@ -1866,7 +1905,7 @@ public final class dev/arcp/messages/JobCompleted : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobCompleted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobCompleted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobCompleted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobCompleted; @@ -1900,7 +1939,7 @@ public final class dev/arcp/messages/JobFailed : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobFailed$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobFailed$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobFailed$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobFailed; @@ -1931,7 +1970,7 @@ public final class dev/arcp/messages/JobHeartbeat : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobHeartbeat$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobHeartbeat$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobHeartbeat$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobHeartbeat; @@ -1994,7 +2033,7 @@ public final class dev/arcp/messages/JobListEntry { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobListEntry$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobListEntry$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobListEntry$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobListEntry; @@ -2029,7 +2068,7 @@ public final class dev/arcp/messages/JobListFilter { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobListFilter$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobListFilter$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobListFilter$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobListFilter; @@ -2060,7 +2099,7 @@ public final class dev/arcp/messages/JobListLease { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobListLease$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobListLease$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobListLease$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobListLease; @@ -2091,7 +2130,7 @@ public final class dev/arcp/messages/JobProgress : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobProgress$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobProgress$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobProgress$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobProgress; @@ -2125,7 +2164,7 @@ public final class dev/arcp/messages/JobResult : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobResult$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobResult; @@ -2160,7 +2199,7 @@ public final class dev/arcp/messages/JobResultChunk : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobResultChunk$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobResultChunk$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobResultChunk$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobResultChunk; @@ -2189,7 +2228,7 @@ public final class dev/arcp/messages/JobSchedule : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobSchedule$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobSchedule$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobSchedule$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobSchedule; @@ -2216,7 +2255,7 @@ public final class dev/arcp/messages/JobStarted : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobStarted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobStarted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobStarted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobStarted; @@ -2246,7 +2285,7 @@ public final class dev/arcp/messages/JobStatusEvent : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobStatusEvent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobStatusEvent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobStatusEvent$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobStatusEvent; @@ -2284,7 +2323,7 @@ public final class dev/arcp/messages/JobSubmit : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/JobSubmit$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/JobSubmit$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/JobSubmit$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/JobSubmit; @@ -2313,7 +2352,7 @@ public final class dev/arcp/messages/LeaseExtended : dev/arcp/messages/MessageTy public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/LeaseExtended$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/LeaseExtended$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/LeaseExtended$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/LeaseExtended; @@ -2349,7 +2388,7 @@ public final class dev/arcp/messages/LeaseGranted : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/LeaseGranted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/LeaseGranted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/LeaseGranted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/LeaseGranted; @@ -2379,7 +2418,7 @@ public final class dev/arcp/messages/LeaseRefresh : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/LeaseRefresh$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/LeaseRefresh$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/LeaseRefresh$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/LeaseRefresh; @@ -2408,7 +2447,7 @@ public final class dev/arcp/messages/LeaseRevoked : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/LeaseRevoked$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/LeaseRevoked$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/LeaseRevoked$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/LeaseRevoked; @@ -2440,7 +2479,7 @@ public final class dev/arcp/messages/Log : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Log$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Log$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Log$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Log; @@ -2499,7 +2538,7 @@ public final class dev/arcp/messages/Metric : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Metric$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Metric$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Metric$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Metric; @@ -2535,7 +2574,7 @@ public final class dev/arcp/messages/Nack : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Nack$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Nack$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Nack$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Nack; @@ -2567,7 +2606,7 @@ public final class dev/arcp/messages/PermissionDeny : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/PermissionDeny$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/PermissionDeny$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/PermissionDeny$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/PermissionDeny; @@ -2599,7 +2638,7 @@ public final class dev/arcp/messages/PermissionGrant : dev/arcp/messages/Message public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/PermissionGrant$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/PermissionGrant$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/PermissionGrant$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/PermissionGrant; @@ -2635,7 +2674,7 @@ public final class dev/arcp/messages/PermissionRequest : dev/arcp/messages/Messa public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/PermissionRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/PermissionRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/PermissionRequest$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/PermissionRequest; @@ -2664,7 +2703,7 @@ public final class dev/arcp/messages/Ping : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Ping$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Ping$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Ping$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Ping; @@ -2693,7 +2732,7 @@ public final class dev/arcp/messages/Pong : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Pong$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Pong$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Pong$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Pong; @@ -2742,7 +2781,7 @@ public final class dev/arcp/messages/Resume : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Resume$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Resume$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Resume$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Resume; @@ -2776,7 +2815,7 @@ public final class dev/arcp/messages/RuntimeIdentity { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/RuntimeIdentity$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/RuntimeIdentity$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/RuntimeIdentity$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/RuntimeIdentity; @@ -2810,7 +2849,7 @@ public final class dev/arcp/messages/SessionAccepted : dev/arcp/messages/Message public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionAccepted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionAccepted; @@ -2840,7 +2879,7 @@ public final class dev/arcp/messages/SessionAuthenticate : dev/arcp/messages/Mes public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionAuthenticate$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionAuthenticate$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionAuthenticate$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionAuthenticate; @@ -2872,7 +2911,7 @@ public final class dev/arcp/messages/SessionChallenge : dev/arcp/messages/Messag public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionChallenge$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionChallenge$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionChallenge$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionChallenge; @@ -2901,7 +2940,7 @@ public final class dev/arcp/messages/SessionClose : dev/arcp/messages/MessageTyp public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionClose$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionClose$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionClose$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionClose; @@ -2930,7 +2969,7 @@ public final class dev/arcp/messages/SessionEvicted : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionEvicted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionEvicted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionEvicted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionEvicted; @@ -2962,7 +3001,7 @@ public final class dev/arcp/messages/SessionJobs : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionJobs$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionJobs$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionJobs$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionJobs; @@ -2989,7 +3028,7 @@ public final class dev/arcp/messages/SessionLease { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionLease$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionLease$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionLease$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionLease; @@ -3023,7 +3062,7 @@ public final class dev/arcp/messages/SessionListJobs : dev/arcp/messages/Message public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionListJobs$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionListJobs$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionListJobs$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionListJobs; @@ -3054,7 +3093,7 @@ public final class dev/arcp/messages/SessionOpen : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionOpen$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionOpen$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionOpen$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionOpen; @@ -3085,7 +3124,7 @@ public final class dev/arcp/messages/SessionRefresh : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionRefresh$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionRefresh$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionRefresh$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionRefresh; @@ -3114,7 +3153,7 @@ public final class dev/arcp/messages/SessionRejected : dev/arcp/messages/Message public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionRejected$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionRejected$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionRejected$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionRejected; @@ -3141,7 +3180,7 @@ public final class dev/arcp/messages/SessionUnauthenticated : dev/arcp/messages/ public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SessionUnauthenticated$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SessionUnauthenticated$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SessionUnauthenticated$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SessionUnauthenticated; @@ -3196,7 +3235,7 @@ public final class dev/arcp/messages/StreamChunk : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/StreamChunk$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/StreamChunk$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/StreamChunk$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/StreamChunk; @@ -3225,7 +3264,7 @@ public final class dev/arcp/messages/StreamClose : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/StreamClose$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/StreamClose$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/StreamClose$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/StreamClose; @@ -3254,7 +3293,7 @@ public final class dev/arcp/messages/StreamError : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/StreamError$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/StreamError$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/StreamError$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/StreamError; @@ -3303,7 +3342,7 @@ public final class dev/arcp/messages/StreamOpen : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/StreamOpen$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/StreamOpen$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/StreamOpen$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/StreamOpen; @@ -3333,7 +3372,7 @@ public final class dev/arcp/messages/Subscribe : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Subscribe$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Subscribe$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Subscribe$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Subscribe; @@ -3360,7 +3399,7 @@ public final class dev/arcp/messages/SubscribeAccepted : dev/arcp/messages/Messa public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SubscribeAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SubscribeAccepted$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SubscribeAccepted$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SubscribeAccepted; @@ -3391,7 +3430,7 @@ public final class dev/arcp/messages/SubscribeClosed : dev/arcp/messages/Message public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SubscribeClosed$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SubscribeClosed$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SubscribeClosed$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SubscribeClosed; @@ -3418,7 +3457,7 @@ public final class dev/arcp/messages/SubscribeEvent : dev/arcp/messages/MessageT public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SubscribeEvent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SubscribeEvent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SubscribeEvent$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SubscribeEvent; @@ -3457,7 +3496,7 @@ public final class dev/arcp/messages/SubscriptionFilter { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SubscriptionFilter$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SubscriptionFilter$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SubscriptionFilter$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SubscriptionFilter; @@ -3485,7 +3524,7 @@ public final class dev/arcp/messages/SubscriptionSince { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/SubscriptionSince$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/SubscriptionSince$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/SubscriptionSince$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/SubscriptionSince; @@ -3521,7 +3560,7 @@ public final class dev/arcp/messages/ToolError : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ToolError$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ToolError$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ToolError$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ToolError; @@ -3551,7 +3590,7 @@ public final class dev/arcp/messages/ToolInvoke : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ToolInvoke$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ToolInvoke$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ToolInvoke$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ToolInvoke; @@ -3582,7 +3621,7 @@ public final class dev/arcp/messages/ToolResult : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/ToolResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/ToolResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/ToolResult$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/ToolResult; @@ -3620,7 +3659,7 @@ public final class dev/arcp/messages/TraceSpan : dev/arcp/messages/MessageType { public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/TraceSpan$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/TraceSpan$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/TraceSpan$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/TraceSpan; @@ -3662,7 +3701,7 @@ public final class dev/arcp/messages/Unsubscribe : dev/arcp/messages/MessageType public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/Unsubscribe$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/Unsubscribe$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/Unsubscribe$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/Unsubscribe; @@ -3691,7 +3730,7 @@ public final class dev/arcp/messages/WorkflowComplete : dev/arcp/messages/Messag public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/WorkflowComplete$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/WorkflowComplete$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/WorkflowComplete$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/WorkflowComplete; @@ -3721,7 +3760,7 @@ public final class dev/arcp/messages/WorkflowStart : dev/arcp/messages/MessageTy public fun toString ()Ljava/lang/String; } -public synthetic class dev/arcp/messages/WorkflowStart$$serializer : kotlinx/serialization/internal/GeneratedSerializer { +public final synthetic class dev/arcp/messages/WorkflowStart$$serializer : kotlinx/serialization/internal/GeneratedSerializer { public static final field INSTANCE Ldev/arcp/messages/WorkflowStart$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/WorkflowStart; @@ -3738,8 +3777,8 @@ public final class dev/arcp/messages/WorkflowStart$Companion { public final class dev/arcp/runtime/ARCPRuntime : java/lang/AutoCloseable { public static final field Companion Ldev/arcp/runtime/ARCPRuntime$Companion; - public synthetic fun (Ldev/arcp/messages/Capabilities;Ldev/arcp/messages/RuntimeIdentity;Ldev/arcp/auth/BearerAuth;Ldev/arcp/auth/JwtAuth;JLdev/arcp/runtime/AgentRegistry;Ldev/arcp/runtime/JobInventory;Ldev/arcp/lease/BudgetRegistry;Ldev/arcp/credentials/CredentialProvisioner;Ldev/arcp/credentials/CredentialStore;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ldev/arcp/messages/Capabilities;Ldev/arcp/messages/RuntimeIdentity;Ldev/arcp/auth/BearerAuth;Ldev/arcp/auth/JwtAuth;JLdev/arcp/runtime/AgentRegistry;Ldev/arcp/runtime/JobInventory;Ldev/arcp/lease/BudgetRegistry;Ldev/arcp/credentials/CredentialProvisioner;Ldev/arcp/credentials/CredentialStore;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/arcp/messages/Capabilities;Ldev/arcp/messages/RuntimeIdentity;Ldev/arcp/auth/BearerAuth;Ldev/arcp/auth/JwtAuth;JLdev/arcp/runtime/AgentRegistry;Ldev/arcp/runtime/JobInventory;Ldev/arcp/lease/BudgetRegistry;Ldev/arcp/credentials/CredentialProvisioner;Ldev/arcp/credentials/CredentialStore;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/arcp/messages/Capabilities;Ldev/arcp/messages/RuntimeIdentity;Ldev/arcp/auth/BearerAuth;Ldev/arcp/auth/JwtAuth;JLdev/arcp/runtime/AgentRegistry;Ldev/arcp/runtime/JobInventory;Ldev/arcp/lease/BudgetRegistry;Ldev/arcp/credentials/CredentialProvisioner;Ldev/arcp/credentials/CredentialStore;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun accept (Ldev/arcp/transport/Transport;)Lkotlinx/coroutines/Job; public fun close ()V public final fun evict-eGHAo4s (Ldev/arcp/transport/Transport;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3868,22 +3907,48 @@ public final class dev/arcp/runtime/DefaultUpstreamErrorTranslator : dev/arcp/ru public final class dev/arcp/runtime/InMemoryJobInventory : dev/arcp/runtime/JobInventory { public fun ()V + public fun evict-2ATt4A4 (Ljava/lang/String;)Z + public final fun getSize ()I public fun list-wZblZb4 (Ljava/lang/String;Ljava/lang/String;Ldev/arcp/messages/JobListFilter;ILjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun record (Ldev/arcp/messages/JobListEntry;Ljava/lang/String;)V public fun updateStatus-tBvcXsc (Ljava/lang/String;Ljava/lang/String;)V } public abstract interface class dev/arcp/runtime/JobInventory { + public fun evict-2ATt4A4 (Ljava/lang/String;)Z public abstract fun list-wZblZb4 (Ljava/lang/String;Ljava/lang/String;Ldev/arcp/messages/JobListFilter;ILjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun record (Ldev/arcp/messages/JobListEntry;Ljava/lang/String;)V public abstract fun updateStatus-tBvcXsc (Ljava/lang/String;Ljava/lang/String;)V } +public final class dev/arcp/runtime/JobInventory$DefaultImpls { + public static fun evict-2ATt4A4 (Ldev/arcp/runtime/JobInventory;Ljava/lang/String;)Z +} + public final class dev/arcp/runtime/ReservedEventKinds { public static final field INSTANCE Ldev/arcp/runtime/ReservedEventKinds; public final fun getALL ()Ljava/util/Set; } +public final class dev/arcp/runtime/RuleBasedUpstreamErrorTranslator : dev/arcp/runtime/UpstreamErrorTranslator { + public fun (Ljava/util/List;)V + public fun ([Ldev/arcp/runtime/RuleBasedUpstreamErrorTranslator$Rule;)V + public fun translate (Ljava/lang/Throwable;)Ldev/arcp/error/ARCPException; +} + +public final class dev/arcp/runtime/RuleBasedUpstreamErrorTranslator$Rule { + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun component1 ()Lkotlin/jvm/functions/Function1; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/arcp/runtime/RuleBasedUpstreamErrorTranslator$Rule; + public static synthetic fun copy$default (Ldev/arcp/runtime/RuleBasedUpstreamErrorTranslator$Rule;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/arcp/runtime/RuleBasedUpstreamErrorTranslator$Rule; + public fun equals (Ljava/lang/Object;)Z + public final fun getMapper ()Lkotlin/jvm/functions/Function1; + public final fun getPredicate ()Lkotlin/jvm/functions/Function1; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class dev/arcp/runtime/SessionState { } diff --git a/lib/src/main/kotlin/dev/arcp/auth/JwtAuth.kt b/lib/src/main/kotlin/dev/arcp/auth/JwtAuth.kt index 3a4860e..2319507 100644 --- a/lib/src/main/kotlin/dev/arcp/auth/JwtAuth.kt +++ b/lib/src/main/kotlin/dev/arcp/auth/JwtAuth.kt @@ -5,17 +5,27 @@ import com.nimbusds.jose.crypto.MACVerifier import com.nimbusds.jwt.SignedJWT import dev.arcp.error.ARCPException import java.util.Date +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes /** * Validates `signed_jwt` tokens at session establishment (RFC §8.2). * - * The verifier accepts tokens signed with the configured [JWSVerifier] and - * returns the principal carried in the JWT `sub` claim. The `aud` claim is - * matched against the runtime's expected audience identifier. + * The verifier accepts tokens signed with the configured [JWSVerifier] + * and returns the principal carried in the JWT `sub` claim. The `aud` + * claim is matched against [expectedAudience]; when [expectedIssuer] is + * non-null the `iss` claim must also match before the token is accepted. + * + * [allowedClockSkew] applies to both `exp` and `nbf` and defaults to one + * minute, matching the recommendation in RFC 7519 §4.1.4 — a wall-clock + * drift of a few hundred milliseconds between issuer and verifier should + * not turn a valid token into `ARCPException.Unauthenticated`. */ public class JwtAuth( private val verifier: JWSVerifier, private val expectedAudience: String, + private val expectedIssuer: String? = null, + private val allowedClockSkew: Duration = DEFAULT_CLOCK_SKEW, ) { /** Verifies [token] and returns the principal (`sub` claim). */ public fun verify(token: String): String { @@ -23,6 +33,7 @@ public class JwtAuth( verifySignature(jwt) val claims = jwt.jwtClaimsSet verifyAudience(claims.audience ?: emptyList()) + verifyIssuer(claims.issuer) verifyTimeBounds(claims.expirationTime, claims.notBeforeTime) return claims.subject?.takeIf { it.isNotBlank() } ?: throw ARCPException.Unauthenticated("JWT missing sub claim") @@ -50,24 +61,44 @@ public class JwtAuth( } } + private fun verifyIssuer(issuer: String?) { + val expected = expectedIssuer ?: return + if (issuer != expected) { + throw ARCPException.Unauthenticated( + "JWT issuer '${issuer ?: ""}' does not match expected '$expected'", + ) + } + } + private fun verifyTimeBounds( exp: Date?, nbf: Date?, ) { val now = Date() - if (exp != null && !exp.after(now)) { + val skewMs = allowedClockSkew.inWholeMilliseconds + if (exp != null && exp.time + skewMs <= now.time) { throw ARCPException.Unauthenticated("JWT expired") } - if (nbf != null && nbf.after(now)) { + if (nbf != null && nbf.time - skewMs > now.time) { throw ARCPException.Unauthenticated("JWT not yet valid") } } public companion object { + /** Default clock skew tolerance applied to `exp` and `nbf` (1 minute). */ + public val DEFAULT_CLOCK_SKEW: Duration = 1.minutes + /** Convenience: HMAC-SHA256 verifier for shared-secret JWTs. */ public fun hmac( secret: ByteArray, audience: String, - ): JwtAuth = JwtAuth(MACVerifier(secret), audience) + issuer: String? = null, + allowedClockSkew: Duration = DEFAULT_CLOCK_SKEW, + ): JwtAuth = JwtAuth( + verifier = MACVerifier(secret), + expectedAudience = audience, + expectedIssuer = issuer, + allowedClockSkew = allowedClockSkew, + ) } } diff --git a/lib/src/main/kotlin/dev/arcp/client/ARCPClient.kt b/lib/src/main/kotlin/dev/arcp/client/ARCPClient.kt index f9c2914..2c45287 100644 --- a/lib/src/main/kotlin/dev/arcp/client/ARCPClient.kt +++ b/lib/src/main/kotlin/dev/arcp/client/ARCPClient.kt @@ -19,15 +19,33 @@ import dev.arcp.messages.SessionOpen import dev.arcp.messages.SessionRejected import dev.arcp.messages.SessionUnauthenticated import dev.arcp.transport.Transport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Minimal ARCP client (RFC §5). * * v0.1 implements the four-message handshake (RFC §8.1) over an injected - * [Transport]. Higher-order surfaces (jobs, streams, subscriptions) are added - * in subsequent phases. + * [Transport]. Higher-order surfaces (jobs, streams, subscriptions) are + * added in subsequent phases. + * + * The client multiplexes the transport's incoming envelopes through a + * single hot [MutableSharedFlow], so awaiting a correlated reply (for + * example via [listJobs]) does not steal envelopes from concurrent + * [receive] subscribers (#58). Internally, correlated waiters register + * a [CompletableDeferred] in [pendingReplies] *before* the request is + * sent — guaranteeing the reply is delivered to the waiter even if it + * arrives before any subsequent collect on [receive] subscribes. */ public class ARCPClient( private val transport: Transport, @@ -35,6 +53,43 @@ public class ARCPClient( private val client: ClientInfo, private val capabilities: Capabilities, ) : AutoCloseable { + private val supervisor: Job = SupervisorJob() + private val scope: CoroutineScope = + CoroutineScope(supervisor + Dispatchers.Default + CoroutineName("arcp-client")) + + /** + * Hot flow of envelopes that arrived from the transport but were not + * routed to a correlated waiter. The small [INBOUND_REPLAY] window + * means a subscriber that registers slightly after the producing + * emit still observes the event — without this, the + * `receive()`-after-`send()` pattern races against the mirror. + */ + private val inbound: MutableSharedFlow = MutableSharedFlow( + replay = INBOUND_REPLAY, + extraBufferCapacity = INBOUND_BUFFER, + ) + private val pendingReplies: ConcurrentHashMap> = + ConcurrentHashMap() + + @Volatile + private var mirroring: Boolean = false + + @Synchronized + private fun ensureMirroring() { + if (mirroring) return + mirroring = true + scope.launch { + transport.receive().collect { env -> + val correlated = env.correlationId?.let { pendingReplies.remove(it) } + if (correlated != null) { + correlated.complete(env) + } else { + inbound.emit(env) + } + } + } + } + /** * Drives the four-message handshake to completion. Returns the * [SessionAccepted] payload on success. @@ -43,8 +98,8 @@ public class ARCPClient( * @throws ARCPException for any other handshake failure. */ public suspend fun open(): SessionAccepted { - transport.send(buildOpener()) - val reply = transport.receive().first() + val opener = buildOpener() + val reply = awaitCorrelated(opener.id) { transport.send(opener) } return interpretHandshakeReply(reply) } @@ -83,6 +138,7 @@ public class ARCPClient( sessionId: SessionId, payload: dev.arcp.messages.MessageType, ): MessageId { + ensureMirroring() val id = MessageId.random() transport.send( Envelope( @@ -94,8 +150,16 @@ public class ARCPClient( return id } - /** Returns the underlying transport's incoming-envelope flow. */ - public fun receive(): Flow = transport.receive() + /** + * Returns the multiplexed inbound flow. Correlated replies (handshake, + * [listJobs]) are NOT delivered here — they are routed to the waiter + * that registered for them. Everything else (job events, status, + * metrics, streams, …) lands on this flow. + */ + public fun receive(): Flow { + ensureMirroring() + return inbound.asSharedFlow() + } /** Sends `session.list_jobs` and waits for the correlated `session.jobs` reply. */ public suspend fun listJobs( @@ -104,12 +168,12 @@ public class ARCPClient( limit: Int = SessionListJobs.DEFAULT_LIMIT, cursor: String? = null, ): SessionJobs { - val requestId = - send( - sessionId, - SessionListJobs(filter = filter, limit = limit, cursor = cursor), - ) - val reply = transport.receive().first { it.correlationId == requestId } + val request = Envelope( + id = MessageId.random(), + sessionId = sessionId, + payload = SessionListJobs(filter = filter, limit = limit, cursor = cursor), + ) + val reply = awaitCorrelated(request.id) { transport.send(request) } return when (val payload = reply.payload) { is SessionJobs -> payload is Nack -> throw ARCPException.FailedPrecondition(payload.message) @@ -119,7 +183,29 @@ public class ARCPClient( } } + /** + * Registers a deferred for the reply correlated to [requestId], runs + * [send] (which actually puts the request on the wire), and awaits the + * matching envelope. The deferred is registered *before* the send so a + * fast runtime reply cannot race the subscription. + */ + private suspend fun awaitCorrelated( + requestId: MessageId, + send: suspend () -> Unit, + ): Envelope { + ensureMirroring() + val deferred = CompletableDeferred() + pendingReplies[requestId] = deferred + try { + send() + return deferred.await() + } finally { + pendingReplies.remove(requestId) + } + } + override fun close() { + scope.cancel() transport.close() } @@ -133,5 +219,8 @@ public class ARCPClient( /** Convenience: build a `bearer` [Auth] block. */ public fun bearer(token: String): Auth = Auth(scheme = AuthScheme.BEARER, token = token) + + private const val INBOUND_BUFFER: Int = 64 + private const val INBOUND_REPLAY: Int = 16 } } diff --git a/lib/src/main/kotlin/dev/arcp/client/ResultChunkAssembler.kt b/lib/src/main/kotlin/dev/arcp/client/ResultChunkAssembler.kt index dbfefbb..5ea58d8 100644 --- a/lib/src/main/kotlin/dev/arcp/client/ResultChunkAssembler.kt +++ b/lib/src/main/kotlin/dev/arcp/client/ResultChunkAssembler.kt @@ -3,59 +3,128 @@ package dev.arcp.client import dev.arcp.error.ARCPException import dev.arcp.messages.JobResultChunk import dev.arcp.messages.ResultChunkEncoding +import java.io.ByteArrayOutputStream import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -/** Client-side accumulator for `result_chunk` streams (RFC v1.1 §8.4). */ +/** + * Client-side accumulator for `result_chunk` streams (RFC v1.1 §8.4). + * + * Not thread-safe: use one assembler per result stream and call [accept] + * from a single coroutine. Sharing an instance across coroutines can + * corrupt the per-result decode buffers. + * + * The assembler decodes each chunk exactly once on arrival and copies + * decoded bytes into a single output buffer when the terminal chunk + * arrives, avoiding the O(n) per-chunk re-decode that an earlier + * implementation incurred near the 64 MiB ceiling. + */ public class ResultChunkAssembler( private val maxAssembledSize: Long = DEFAULT_MAX_ASSEMBLED_SIZE, ) { - private val buffers: MutableMap> = mutableMapOf() + private val buffers: MutableMap = mutableMapOf() /** Accepts a chunk and returns the assembled result when the final chunk arrives. */ public fun accept(chunk: JobResultChunk): AssembledResult? { - val chunks = buffers.getOrPut(chunk.resultId) { mutableListOf() } - validateChunk(chunks, chunk) - chunks += chunk - val size = chunks.sumOf { decodedBytes(it).size.toLong() } - if (size > maxAssembledSize) { + val buffer = buffers.getOrPut(chunk.resultId) { ResultBuffer() } + buffer.validateChunk(chunk) + val decoded = decodedBytes(chunk) + buffer.addDecoded(chunk, decoded) + if (buffer.totalSize > maxAssembledSize) { throw ARCPException.Internal("assembled result exceeds $maxAssembledSize bytes") } if (chunk.more) return null buffers.remove(chunk.resultId) - val allBytes = chunks.flatMap { decodedBytes(it).asIterable() }.toByteArray() return AssembledResult( resultId = chunk.resultId, - bytes = allBytes, - isText = chunks.all { it.encoding == ResultChunkEncoding.UTF8 }, + bytes = buffer.assemble(), + isText = buffer.allEncoding == ResultChunkEncoding.UTF8, ) } - private fun validateChunk( - chunks: List, - chunk: JobResultChunk, - ) { - val previous = chunks.lastOrNull() - if (previous != null && chunk.chunkSeq != previous.chunkSeq + 1) { - throw ARCPException.OutOfRange("result_chunk sequence must be strictly monotonic") - } - val first = chunks.firstOrNull() - if (first != null && first.encoding != chunk.encoding) { - throw ARCPException.FailedPrecondition("mixed result_chunk encodings are not allowed") - } - } - @OptIn(ExperimentalEncodingApi::class) private fun decodedBytes(chunk: JobResultChunk): ByteArray = when (chunk.encoding) { ResultChunkEncoding.UTF8 -> chunk.data.encodeToByteArray() ResultChunkEncoding.BASE64 -> Base64.Default.decode(chunk.data) } + /** + * Assembled bytes for one result stream. + * + * [equals] and [hashCode] use [ByteArray.contentEquals] / + * [ByteArray.contentHashCode] so two results carrying the same bytes + * compare equal regardless of array instance — Kotlin's default + * data-class equality would otherwise compare arrays by reference. + */ public data class AssembledResult( - val resultId: String, - val bytes: ByteArray, - val isText: Boolean, - ) + public val resultId: String, + public val bytes: ByteArray, + public val isText: Boolean, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AssembledResult) return false + return resultId == other.resultId && + isText == other.isText && + bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = resultId.hashCode() + result = 31 * result + bytes.contentHashCode() + result = 31 * result + isText.hashCode() + return result + } + + override fun toString(): String = + "AssembledResult(resultId=$resultId, bytes=${bytes.size} bytes, isText=$isText)" + } + + private class ResultBuffer { + private val parts: ArrayList = ArrayList() + private var lastSeq: Long? = null + private var firstEncoding: ResultChunkEncoding? = null + var allEncoding: ResultChunkEncoding? = null + private set + var totalSize: Long = 0L + private set + + fun validateChunk(chunk: JobResultChunk) { + lastSeq?.let { previous -> + if (chunk.chunkSeq != previous + 1L) { + throw ARCPException.OutOfRange( + "result_chunk sequence must be strictly monotonic", + ) + } + } + firstEncoding?.let { first -> + if (first != chunk.encoding) { + throw ARCPException.FailedPrecondition( + "mixed result_chunk encodings are not allowed", + ) + } + } + } + + fun addDecoded( + chunk: JobResultChunk, + decoded: ByteArray, + ) { + parts += decoded + lastSeq = chunk.chunkSeq + if (firstEncoding == null) { + firstEncoding = chunk.encoding + allEncoding = chunk.encoding + } + totalSize += decoded.size.toLong() + } + + fun assemble(): ByteArray { + val out = ByteArrayOutputStream(totalSize.toInt()) + for (part in parts) out.write(part) + return out.toByteArray() + } + } public companion object { public const val DEFAULT_MAX_ASSEMBLED_SIZE: Long = 64L * 1024L * 1024L diff --git a/lib/src/main/kotlin/dev/arcp/credentials/CredentialStore.kt b/lib/src/main/kotlin/dev/arcp/credentials/CredentialStore.kt index 1a45cb0..828cb49 100644 --- a/lib/src/main/kotlin/dev/arcp/credentials/CredentialStore.kt +++ b/lib/src/main/kotlin/dev/arcp/credentials/CredentialStore.kt @@ -21,29 +21,34 @@ public interface CredentialStore { public suspend fun pendingRevocations(): List } -/** In-memory [CredentialStore] for tests and single-process runtimes. */ +/** + * In-memory [CredentialStore] for tests and single-process runtimes. + * + * The map values are immutable [List]s updated through + * [ConcurrentHashMap.compute], so iteration in [outstanding] / + * [pendingRevocations] never observes a partially-mutated list and + * concurrent [record] / [remove] calls on the same job id are atomic. + */ public class InMemoryCredentialStore : CredentialStore { - private val byJob: ConcurrentHashMap> = ConcurrentHashMap() + private val byJob: ConcurrentHashMap> = ConcurrentHashMap() override suspend fun record( jobId: JobId, credential: Credential, ) { - byJob.compute(jobId) { _, existing -> - (existing ?: mutableListOf()).also { it += credential } - } + byJob.compute(jobId) { _, existing -> (existing ?: emptyList()) + credential } } - override suspend fun outstanding(jobId: JobId): List = - byJob[jobId]?.toList().orEmpty() + override suspend fun outstanding(jobId: JobId): List = byJob[jobId].orEmpty() override suspend fun remove(credentialId: CredentialId) { - byJob.entries.forEach { (_, credentials) -> - credentials.removeIf { it.id == credentialId } + for (key in byJob.keys) { + byJob.compute(key) { _, existing -> + val filtered = existing?.filterNot { it.id == credentialId } + if (filtered.isNullOrEmpty()) null else filtered + } } - byJob.entries.removeIf { it.value.isEmpty() } } - override suspend fun pendingRevocations(): List = - byJob.values.flatMap { it.toList() } + override suspend fun pendingRevocations(): List = byJob.values.flatten() } diff --git a/lib/src/main/kotlin/dev/arcp/lease/BudgetCounter.kt b/lib/src/main/kotlin/dev/arcp/lease/BudgetCounter.kt index 5f222ae..046f6ca 100644 --- a/lib/src/main/kotlin/dev/arcp/lease/BudgetCounter.kt +++ b/lib/src/main/kotlin/dev/arcp/lease/BudgetCounter.kt @@ -3,20 +3,50 @@ package dev.arcp.lease import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap -/** Mutable per-job counter for a [CostBudget]. */ +/** + * Mutable per-job counter for a [CostBudget]. + * + * The counter enforces `consumed-to-date ≤ initial` per currency. Spends + * that would cross zero are rejected as [Outcome.Rejected]; the counter + * is *not* mutated, so a caller may retry with a smaller amount. A spend + * that lands exactly on zero returns [Outcome.Exhausted]; subsequent + * non-zero spends return [Outcome.Rejected]. + */ public class BudgetCounter( initial: CostBudget, ) { private val remaining: ConcurrentHashMap = ConcurrentHashMap(initial.budgets.associate { it.currency to it.value }) - /** Consumes [amount] and returns whether the budget is exhausted. */ + /** + * Attempts to consume [amount]. Returns: + * - [Outcome.Ok] when the spend fit and budget remains. + * - [Outcome.Exhausted] when the spend fit and remaining now equals zero. + * - [Outcome.Rejected] when the spend would drop remaining below zero + * (the counter is *not* modified). + * + * Untracked currencies return [Outcome.Ok] for backward compatibility: + * a counter only enforces the currencies the initial [CostBudget] + * declared. + */ public fun consume(amount: BudgetAmount): Outcome { require(amount.value >= BigDecimal.ZERO) { "budget consumption must be non-negative" } + var rejected: Outcome.Rejected? = null val left = remaining.computeIfPresent(amount.currency) { _, current -> - current.subtract(amount.value) + val next = current.subtract(amount.value) + if (next < BigDecimal.ZERO) { + rejected = Outcome.Rejected(amount.currency, amount.value, current) + current + } else { + next + } } ?: return Outcome.Ok - return if (left <= BigDecimal.ZERO) Outcome.Exhausted(amount.currency) else Outcome.Ok + rejected?.let { return it } + return if (left.compareTo(BigDecimal.ZERO) == 0) { + Outcome.Exhausted(amount.currency) + } else { + Outcome.Ok + } } /** Returns the remaining amount for [currency], if tracked. */ @@ -31,5 +61,17 @@ public class BudgetCounter( public data class Exhausted( public val currency: Currency, ) : Outcome + + /** + * The requested spend would have pushed the remaining balance + * negative; the counter was not modified. Callers may retry with + * a smaller [requested] amount or surface the rejection to the + * agent. + */ + public data class Rejected( + public val currency: Currency, + public val requested: BigDecimal, + public val available: BigDecimal, + ) : Outcome } } diff --git a/lib/src/main/kotlin/dev/arcp/lease/BudgetRegistry.kt b/lib/src/main/kotlin/dev/arcp/lease/BudgetRegistry.kt index f7b9904..4d45314 100644 --- a/lib/src/main/kotlin/dev/arcp/lease/BudgetRegistry.kt +++ b/lib/src/main/kotlin/dev/arcp/lease/BudgetRegistry.kt @@ -3,7 +3,14 @@ package dev.arcp.lease import dev.arcp.ids.JobId import java.util.concurrent.ConcurrentHashMap -/** Registry of active job budget counters. */ +/** + * Registry of active job budget counters. + * + * Callers that need to distinguish "no budget registered for this job" + * from "spend fit within the budget" should match on the [Outcome] + * sealed interface — [Outcome.Unregistered] is returned when no counter + * exists, [BudgetCounter.Outcome] variants when one does. + */ public class BudgetRegistry { private val counters: ConcurrentHashMap = ConcurrentHashMap() private val initial: ConcurrentHashMap = ConcurrentHashMap() @@ -17,11 +24,19 @@ public class BudgetRegistry { initial[jobId] = budget } - /** Consumes [amount] from the counter for [jobId]. */ + /** + * Consumes [amount] from the counter for [jobId]. Returns + * [Outcome.Unregistered] when no counter has been registered (the + * caller can then decide whether to log, drop the metric, or refuse + * the operation); otherwise delegates to [BudgetCounter.consume]. + */ public fun consume( jobId: JobId, amount: BudgetAmount, - ): BudgetCounter.Outcome = counters[jobId]?.consume(amount) ?: BudgetCounter.Outcome.Ok + ): Outcome { + val counter = counters[jobId] ?: return Outcome.Unregistered + return Outcome.Counted(counter.consume(amount)) + } /** Removes budget state for a terminal job. */ public fun terminate(jobId: JobId) { @@ -39,4 +54,15 @@ public class BudgetRegistry { }, ) } + + /** Outcome of [consume] — disambiguates "no counter" from a real spend. */ + public sealed interface Outcome { + /** No counter has been registered for the job id. */ + public data object Unregistered : Outcome + + /** A counter exists; [outcome] is the result of consuming against it. */ + public data class Counted( + public val outcome: BudgetCounter.Outcome, + ) : Outcome + } } diff --git a/lib/src/main/kotlin/dev/arcp/messages/Artifacts.kt b/lib/src/main/kotlin/dev/arcp/messages/Artifacts.kt index 8668bab..6e0f6e1 100644 --- a/lib/src/main/kotlin/dev/arcp/messages/Artifacts.kt +++ b/lib/src/main/kotlin/dev/arcp/messages/Artifacts.kt @@ -11,13 +11,19 @@ import kotlinx.serialization.Serializable */ @Serializable public data class ArtifactRefSpec( + /** Server-assigned id; opaque to the client. */ @SerialName("artifact_id") val artifactId: ArtifactId, + /** Canonical fetch URI (e.g. `arcp://session/.../artifact/...`). */ val uri: String, + /** MIME type of the payload. */ @SerialName("media_type") val mediaType: String, + /** Size in bytes. */ val size: Long, + /** Lowercase hex SHA-256 of the body, or `null` if not computed. */ val sha256: String? = null, + /** Optional expiry deadline; readers must treat past-expiry artifacts as missing. */ @SerialName("expires_at") val expiresAt: Instant? = null, ) diff --git a/lib/src/main/kotlin/dev/arcp/messages/Execution.kt b/lib/src/main/kotlin/dev/arcp/messages/Execution.kt index 793d695..59747bd 100644 --- a/lib/src/main/kotlin/dev/arcp/messages/Execution.kt +++ b/lib/src/main/kotlin/dev/arcp/messages/Execution.kt @@ -15,7 +15,9 @@ import kotlinx.serialization.json.JsonObject @Serializable @SerialName("tool.invoke") public data class ToolInvoke( + /** Fully-qualified tool name. */ val tool: ToolName, + /** Tool-specific argument object. Opaque to the protocol. */ val arguments: JsonObject = JsonObject(emptyMap()), ) : MessageType @@ -23,7 +25,9 @@ public data class ToolInvoke( @Serializable @SerialName("tool.result") public data class ToolResult( + /** Inline result; mutually exclusive with [resultRef]. */ val value: JsonElement? = null, + /** Artifact pointer for large results (RFC §16). */ @SerialName("result_ref") val resultRef: JsonElement? = null, ) : MessageType @@ -32,10 +36,15 @@ public data class ToolResult( @Serializable @SerialName("tool.error") public data class ToolError( + /** Canonical error code. */ val code: ErrorCode, + /** Human-readable message. */ val message: String, + /** `true` when the caller may safely retry. */ val retryable: Boolean? = null, + /** Structured error context. */ val details: JsonElement? = null, + /** Underlying upstream error, when one is available. */ val cause: JsonElement? = null, ) : MessageType diff --git a/lib/src/main/kotlin/dev/arcp/messages/Permissions.kt b/lib/src/main/kotlin/dev/arcp/messages/Permissions.kt index 642c721..cf38928 100644 --- a/lib/src/main/kotlin/dev/arcp/messages/Permissions.kt +++ b/lib/src/main/kotlin/dev/arcp/messages/Permissions.kt @@ -10,10 +10,15 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("permission.request") public data class PermissionRequest( + /** Permission name (`fs.read`, `tool.call`, ...). */ val permission: PermissionName, + /** Resource the permission applies to. */ val resource: String, + /** Optional operation hint (e.g. `read`, `write`, `delete`). */ val operation: String? = null, + /** Optional human-readable rationale shown to a reviewer. */ val reason: String? = null, + /** Desired lease duration; the grantor may shorten or override. */ @SerialName("requested_lease_seconds") val requestedLeaseSeconds: Long? = null, ) : MessageType @@ -22,8 +27,11 @@ public data class PermissionRequest( @Serializable @SerialName("permission.grant") public data class PermissionGrant( + /** Permission being granted. */ val permission: PermissionName, + /** Resource the grant applies to. */ val resource: String, + /** Actual lease duration; null means "as requested". */ @SerialName("lease_seconds") val leaseSeconds: Long? = null, ) : MessageType @@ -32,8 +40,11 @@ public data class PermissionGrant( @Serializable @SerialName("permission.deny") public data class PermissionDeny( + /** Permission denied. */ val permission: PermissionName, + /** Resource denied. */ val resource: String, + /** Optional reason surfaced to the requester. */ val reason: String? = null, ) : MessageType @@ -41,11 +52,16 @@ public data class PermissionDeny( @Serializable @SerialName("lease.granted") public data class LeaseGranted( + /** Server-assigned lease id. */ @SerialName("lease_id") val leaseId: LeaseId, + /** Permission the lease grants. */ val permission: PermissionName, + /** Resource the lease applies to. */ val resource: String, + /** Optional operation hint (`read`, `write`, ...). */ val operation: String? = null, + /** Absolute expiry timestamp. */ @SerialName("expires_at") val expiresAt: Instant, ) : MessageType @@ -54,8 +70,10 @@ public data class LeaseGranted( @Serializable @SerialName("lease.refresh") public data class LeaseRefresh( + /** Lease to extend. */ @SerialName("lease_id") val leaseId: LeaseId, + /** Desired additional duration; the grantor may shorten. */ @SerialName("requested_extension_seconds") val requestedExtensionSeconds: Long? = null, ) : MessageType @@ -64,8 +82,10 @@ public data class LeaseRefresh( @Serializable @SerialName("lease.extended") public data class LeaseExtended( + /** Lease that was extended. */ @SerialName("lease_id") val leaseId: LeaseId, + /** New absolute expiry. */ @SerialName("expires_at") val expiresAt: Instant, ) : MessageType @@ -74,7 +94,9 @@ public data class LeaseExtended( @Serializable @SerialName("lease.revoked") public data class LeaseRevoked( + /** Lease that was revoked. */ @SerialName("lease_id") val leaseId: LeaseId, + /** Reason surfaced to the holder. */ val reason: String, ) : MessageType diff --git a/lib/src/main/kotlin/dev/arcp/messages/Session.kt b/lib/src/main/kotlin/dev/arcp/messages/Session.kt index d0b12e0..2bf44a7 100644 --- a/lib/src/main/kotlin/dev/arcp/messages/Session.kt +++ b/lib/src/main/kotlin/dev/arcp/messages/Session.kt @@ -25,36 +25,65 @@ public enum class HeartbeatRecovery { /** * Negotiated capability set (RFC §7). * - * Absent boolean fields default to `false` per §7. The `extensions` list - * advertises the namespaces accepted on this session (RFC §21.2). + * Absent boolean fields default to `false` per RFC §7. The single + * deliberate exception is [interrupt], which defaults to `true` for + * pause-and-ask ergonomics — a peer that intends to *opt out* must + * advertise `interrupt = false` explicitly. See RFC §10.5 for the + * interrupt semantics this aligns with. + * + * The `extensions` list advertises the vendor namespaces accepted on + * this session (RFC §21.2). Unknown extensions on the wire are + * silently dropped from the negotiated result and do not reject a + * session — unsupported *required* features still do. */ @Serializable public data class Capabilities( + /** Streaming via `stream.*` / `result_chunk`. */ val streaming: Boolean = false, + /** Jobs persist across transport disconnects (RFC §10). */ @SerialName("durable_jobs") val durableJobs: Boolean = false, + /** `job.checkpoint` / resume from checkpoint (RFC §10). */ val checkpoints: Boolean = false, + /** Sidecar binary frames (RFC §11.3). v0.1 supports inline base64 only. */ @SerialName("binary_streams") val binaryStreams: Boolean = false, + /** `agent.handoff` between runtimes (RFC §14). */ @SerialName("agent_handoff") val agentHandoff: Boolean = false, + /** Inline artifact references (RFC §16). */ val artifacts: Boolean = false, + /** Read-only subscriptions to live jobs (RFC §13). */ val subscriptions: Boolean = false, + /** `job.schedule` for deferred or recurring work (RFC §10.6). */ @SerialName("scheduled_jobs") val scheduledJobs: Boolean = false, + /** Per-job credential issue/revoke (RFC §9.8). */ @SerialName("provisioned_credentials") val provisionedCredentials: Boolean = false, + /** `model.use` lease enforcement (RFC §9.7). */ @SerialName("model.use") val modelUse: Boolean = false, + /** Anonymous (`scheme = none`) sessions accepted. */ val anonymous: Boolean = false, + /** + * Cooperative interrupt support (RFC §10.5). Deliberately defaults + * to `true` — see the class KDoc for the rationale. A peer that + * wants to opt out must advertise `interrupt = false` explicitly. + */ val interrupt: Boolean = true, + /** Heartbeat cadence in seconds (RFC §10.3). */ @SerialName("heartbeat_interval_seconds") val heartbeatIntervalSeconds: Int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + /** Heartbeat-recovery policy (RFC §10.3). */ @SerialName("heartbeat_recovery") val heartbeatRecovery: HeartbeatRecovery = HeartbeatRecovery.FAIL, + /** Supported `binary` stream encodings (RFC §11.3). */ @SerialName("binary_encoding") val binaryEncoding: List = listOf("base64"), + /** Vendor extensions advertised (`arcpx.*`, RFC §21). */ val extensions: List = emptyList(), + /** Versioned agents this runtime exposes (RFC §7.5). */ val agents: List = emptyList(), ) { public companion object { @@ -85,26 +114,37 @@ public enum class AuthScheme { /** Credentials block on `session.open` (RFC §8.2). */ @Serializable public data class Auth( + /** Authentication scheme used by [token]. */ val scheme: AuthScheme, + /** Opaque token (bearer string, JWT, etc.). Required for non-`none` schemes. */ val token: String? = null, + /** Optional client/runtime fingerprint, e.g. mTLS thumbprint. */ val fingerprint: String? = null, ) /** Client identity attestation (RFC §8.2). */ @Serializable public data class ClientInfo( + /** Client SDK kind, e.g. `arcp-kotlin`. */ val kind: String, + /** Client SDK version. */ val version: String, + /** Optional transport-level fingerprint (mTLS, etc.). */ val fingerprint: String? = null, + /** Optional principal hint; the runtime ultimately decides identity. */ val principal: String? = null, ) /** Runtime identity returned in `session.accepted` (RFC §8.3). */ @Serializable public data class RuntimeIdentity( + /** Runtime kind, e.g. `arcp-kotlin-runtime`. */ val kind: String, + /** Runtime SDK version. */ val version: String, + /** Optional transport-level fingerprint, e.g. mTLS leaf SHA-256. */ val fingerprint: String? = null, + /** Trust classification advertised to the client (RFC §15.3). */ @SerialName("trust_level") val trustLevel: TrustLevel = TrustLevel.UNTRUSTED, ) diff --git a/lib/src/main/kotlin/dev/arcp/messages/Telemetry.kt b/lib/src/main/kotlin/dev/arcp/messages/Telemetry.kt index a480c4e..4efd5f2 100644 --- a/lib/src/main/kotlin/dev/arcp/messages/Telemetry.kt +++ b/lib/src/main/kotlin/dev/arcp/messages/Telemetry.kt @@ -33,8 +33,10 @@ public enum class LogLevel { @Serializable @SerialName("event.emit") public data class EventEmit( + /** Application-defined event name; namespaced per RFC §21 when vendor-specific. */ @SerialName("event_type") val eventType: String, + /** Free-form structured payload. */ val data: JsonObject = JsonObject(emptyMap()), ) : MessageType @@ -42,12 +44,25 @@ public data class EventEmit( @Serializable @SerialName("log") public data class Log( + /** Severity level. */ val level: LogLevel, + /** Human-readable message. */ val message: String, + /** Structured attributes (key/value bag). */ val attributes: JsonObject = JsonObject(emptyMap()), ) : MessageType -/** `metric` — telemetry sample (RFC §17.3). */ +/** + * `metric` — telemetry sample (RFC §17.3). + * + * @property name Metric name; use [StandardMetrics] constants where possible + * and namespace per RFC §21 otherwise. + * @property value Numeric or string value. The wire form is a JSON primitive + * so non-numeric metrics (e.g. error codes) round-trip without coercion. + * @property unit Unit string ("USD", "ms", "tokens", ...). Non-nullable on + * the wire — emitters must always set it. + * @property dims Optional dimension/label map, e.g. `{"phase": "exec"}`. + */ @Serializable @SerialName("metric") public data class Metric( @@ -61,13 +76,19 @@ public data class Metric( @Serializable @SerialName("trace.span") public data class TraceSpan( + /** Span name. */ val name: String, + /** Optional span kind (`client`, `server`, `internal`, ...). */ val kind: String? = null, + /** Wall-clock start time. */ @SerialName("started_at") val startedAt: Instant, + /** Wall-clock end time, when the span has closed. */ @SerialName("ended_at") val endedAt: Instant? = null, + /** Structured span attributes. */ val attributes: JsonObject = JsonObject(emptyMap()), + /** Optional list of span events; opaque JSON to the SDK. */ val events: JsonElement? = null, ) : MessageType diff --git a/lib/src/main/kotlin/dev/arcp/runtime/ARCPRuntime.kt b/lib/src/main/kotlin/dev/arcp/runtime/ARCPRuntime.kt index b4ec50e..6ac7b7c 100644 --- a/lib/src/main/kotlin/dev/arcp/runtime/ARCPRuntime.kt +++ b/lib/src/main/kotlin/dev/arcp/runtime/ARCPRuntime.kt @@ -41,6 +41,8 @@ import dev.arcp.messages.Ping import dev.arcp.messages.Pong import dev.arcp.messages.RuntimeIdentity import dev.arcp.messages.SessionAccepted +import dev.arcp.messages.SessionAuthenticate +import dev.arcp.messages.SessionChallenge import dev.arcp.messages.SessionClose import dev.arcp.messages.SessionEvicted import dev.arcp.messages.SessionLease @@ -59,6 +61,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.Clock @@ -70,11 +73,13 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put -import java.io.IOException import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min +import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds private val log = KotlinLogging.logger {} @@ -103,6 +108,13 @@ public class ARCPRuntime( private val budgets: BudgetRegistry = BudgetRegistry(), private val credentialProvisioner: CredentialProvisioner? = null, private val credentialStore: CredentialStore = InMemoryCredentialStore(), + /** + * When `true` (the default), terminal jobs are removed from the + * [JobInventory] after their events have drained. Set to `false` + * if the caller needs `session.list_jobs` to keep returning + * completed jobs indefinitely — usually for tests. + */ + private val evictTerminalJobs: Boolean = true, ) : AutoCloseable { private val supervisor: Job = SupervisorJob() private val scope: CoroutineScope = @@ -153,7 +165,7 @@ public class ARCPRuntime( private suspend fun runDispatchLoop(transport: Transport) { try { transport.receive().collect { env -> - handleEnvelope(env, transport) + dispatchOne(env, transport) } } catch (e: CancellationException) { throw e @@ -162,6 +174,33 @@ public class ARCPRuntime( } } + /** + * Runs [handleEnvelope] inside a per-envelope try/catch. Application-level + * errors translate to a correlated `Nack`; `CancellationException` still + * unwinds the loop. The session stays usable after a single bad envelope + * (RFC §17 — malformed wire input must not tear down the session). + */ + private suspend fun dispatchOne( + env: Envelope, + transport: Transport, + ) { + try { + handleEnvelope(env, transport) + } catch (e: CancellationException) { + throw e + } catch (e: ARCPException) { + log.debug(e) { "envelope ${env.id.value} produced ${e.code.wire}" } + transport.send(nack(env, e)) + } catch (e: IllegalArgumentException) { + log.debug(e) { "envelope ${env.id.value} rejected as invalid" } + transport.send(nack(env, ARCPException.InvalidArgument(e.message ?: "invalid", null))) + } catch (e: NumberFormatException) { + log.debug(e) { "envelope ${env.id.value} rejected as invalid number" } + val invalid = ARCPException.InvalidArgument(e.message ?: "invalid number", null) + transport.send(nack(env, invalid)) + } + } + private suspend fun handleEnvelope( env: Envelope, transport: Transport, @@ -183,6 +222,22 @@ public class ARCPRuntime( is JobCompleted -> handleTerminalJob(env, "completed", transport) is JobFailed -> handleTerminalJob(env, "failed", transport) is JobCancelled -> handleTerminalJob(env, "cancelled", transport) + is SessionChallenge, is SessionAuthenticate -> + transport.send( + Envelope( + id = MessageId.random(), + sessionId = env.sessionId, + correlationId = env.id, + payload = + Nack( + nackFor = env.id, + code = ErrorCode.UNIMPLEMENTED, + message = + "session challenge/authenticate flow is deferred; " + + "use direct-credential session.open (RFC §8.2)", + ), + ), + ) is SessionClose -> { env.sessionId?.let { sessions.remove(it) } transport.close() @@ -303,11 +358,20 @@ public class ARCPRuntime( } val amount = BudgetAmount(dev.arcp.lease.Currency(metric.unit), metric.value.asBigDecimal()) when (val outcome = budgets.consume(jobId, amount)) { - BudgetCounter.Outcome.Ok -> emitRemainingBudget(env, jobId, amount, transport) - is BudgetCounter.Outcome.Exhausted -> { - emitRemainingBudget(env, jobId, amount, transport) - val error = ARCPException.BudgetExhausted(outcome.currency.code, jobId) - transport.send(nack(env, error)) + BudgetRegistry.Outcome.Unregistered -> + log.info { "cost metric for unregistered job ${jobId.value}; ignoring" } + is BudgetRegistry.Outcome.Counted -> when (val counted = outcome.outcome) { + BudgetCounter.Outcome.Ok -> emitRemainingBudget(env, jobId, amount, transport) + is BudgetCounter.Outcome.Exhausted -> { + emitRemainingBudget(env, jobId, amount, transport) + val error = ARCPException.BudgetExhausted(counted.currency.code, jobId) + transport.send(nack(env, error)) + } + is BudgetCounter.Outcome.Rejected -> { + emitRemainingBudget(env, jobId, amount, transport) + val error = ARCPException.BudgetExhausted(counted.currency.code, jobId) + transport.send(nack(env, error)) + } } } } @@ -375,10 +439,17 @@ public class ARCPRuntime( jobInventory.updateStatus(jobId, status) budgets.terminate(jobId) jobs.remove(jobId) - val provisioner = credentialProvisioner ?: return - credentialStore.outstanding(jobId).forEach { credential -> - revokeWithRetry(provisioner, credential) + val provisioner = credentialProvisioner + if (provisioner != null) { + credentialStore.outstanding(jobId).forEach { credential -> + revokeWithRetry(provisioner, credential) + } } + // Drop the inventory record once events have drained — long-lived + // runtimes that accept many short jobs cannot afford an unbounded + // in-memory inventory (#60). Implementations that want to retain + // terminal jobs for replay should override JobInventory.evict. + if (evictTerminalJobs) jobInventory.evict(jobId) } private suspend fun revokeWithRetry( @@ -390,9 +461,27 @@ public class ARCPRuntime( provisioner.revoke(credential.id) credentialStore.remove(credential.id) return - } catch (e: IOException) { - if (attempt == REVOKE_ATTEMPTS - 1) { - log.warn(e) { "credential revocation failed for ${credential.id}" } + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + val final = attempt == REVOKE_ATTEMPTS - 1 + if (final) { + log.warn(e) { + "credential revocation failed for ${credential.id.value} after " + + "${REVOKE_ATTEMPTS} attempts" + } + } else { + log.warn(e) { + "credential revocation attempt ${attempt + 1}/$REVOKE_ATTEMPTS " + + "failed for ${credential.id.value}" + } + val backoffMs = min( + REVOKE_BACKOFF_INITIAL_MS * REVOKE_BACKOFF_BASE.pow(attempt).toLong(), + REVOKE_BACKOFF_CAP_MS, + ) + delay(backoffMs.milliseconds) } } } @@ -713,5 +802,8 @@ public class ARCPRuntime( public val DEFAULT_SESSION_LEASE: Duration = 1.hours private const val REVOKE_ATTEMPTS: Int = 3 + private const val REVOKE_BACKOFF_INITIAL_MS: Long = 250L + private const val REVOKE_BACKOFF_BASE: Double = 2.0 + private const val REVOKE_BACKOFF_CAP_MS: Long = 5_000L } } diff --git a/lib/src/main/kotlin/dev/arcp/runtime/AgentRegistry.kt b/lib/src/main/kotlin/dev/arcp/runtime/AgentRegistry.kt index 99455a4..85ad5ff 100644 --- a/lib/src/main/kotlin/dev/arcp/runtime/AgentRegistry.kt +++ b/lib/src/main/kotlin/dev/arcp/runtime/AgentRegistry.kt @@ -5,10 +5,15 @@ import dev.arcp.messages.AgentDescriptor import dev.arcp.messages.AgentRef import java.util.concurrent.ConcurrentHashMap -/** Runtime registry for versioned agents advertised in session capabilities. */ +/** + * Runtime registry for versioned agents advertised in session capabilities. + * + * Each agent's version map is held as an immutable [Map] updated through + * [ConcurrentHashMap.compute], so [resolve] and [descriptors] never + * observe a half-written entry while [register] is in progress. + */ public class AgentRegistry { - private val versions: ConcurrentHashMap> = - ConcurrentHashMap() + private val versions: ConcurrentHashMap> = ConcurrentHashMap() /** Registers an agent version and optionally marks it as the default. */ public fun register( @@ -17,11 +22,19 @@ public class AgentRegistry { default: Boolean = false, ) { val ref = AgentRef(name, version) - val registered = versions.computeIfAbsent(ref.name) { linkedMapOf() } - if (default) { - registered.keys.forEach { registered[it] = false } + val versionKey = ref.version ?: version + versions.compute(ref.name) { _, existing -> + val cleared = + if (default) { + existing?.mapValues { false } ?: emptyMap() + } else { + existing ?: emptyMap() + } + // Preserve insertion order via LinkedHashMap so descriptors() is stable. + val next = LinkedHashMap(cleared) + next[versionKey] = default + next } - registered[ref.version ?: version] = default } /** Resolves a bare or pinned reference to an available concrete version. */ diff --git a/lib/src/main/kotlin/dev/arcp/runtime/ArtifactStore.kt b/lib/src/main/kotlin/dev/arcp/runtime/ArtifactStore.kt index 55db647..95310d7 100644 --- a/lib/src/main/kotlin/dev/arcp/runtime/ArtifactStore.kt +++ b/lib/src/main/kotlin/dev/arcp/runtime/ArtifactStore.kt @@ -11,9 +11,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -46,7 +50,18 @@ public class ArtifactStore private constructor( private val scope: CoroutineScope = CoroutineScope(supervisor + Dispatchers.IO + CoroutineName("arcp-artifacts")) - init { + /** + * Serializes all access to [connection]. JDBC connections are not + * safe to use concurrently from multiple coroutines, and the + * background sweep job races against caller [put]/[fetch]/[release] + * traffic. + * + * `adopt(connection)` callers must coordinate access to the shared + * connection with whatever other code uses it — this lock only + * covers calls made through *this* store. + */ + private val connectionLock: Mutex = Mutex() + private val sweepJob: Job = scope.launch { while (isActive) { delay(sweepInterval) @@ -54,7 +69,6 @@ public class ArtifactStore private constructor( .onFailure { log.warn(it) { "artifact sweep failed" } } } } - } /** * Persists [request] under its artifact id. Returns an [ArtifactRefSpec] @@ -68,7 +82,7 @@ public class ArtifactStore private constructor( withContext(Dispatchers.IO) { val effectiveExpiry = computeExpiry(request.expiresAt) val sha256 = sha256Hex(request.data) - persistArtifact(request, effectiveExpiry, sha256) + connectionLock.withLock { persistArtifact(request, effectiveExpiry, sha256) } ArtifactRefSpec( artifactId = request.artifactId, uri = artifactUri(request.sessionId, request.artifactId), @@ -144,6 +158,10 @@ public class ArtifactStore private constructor( * if the artifact is unknown or has expired. */ public suspend fun fetch(artifactId: ArtifactId): ArtifactBody = withContext(Dispatchers.IO) { + connectionLock.withLock { fetchLocked(artifactId) } + } + + private fun fetchLocked(artifactId: ArtifactId): ArtifactBody { val sql = """ SELECT media_type, size_bytes, sha256, expires_at_iso, body_blob @@ -152,33 +170,39 @@ public class ArtifactStore private constructor( """.trimIndent() connection.prepareStatement(sql).use { ps -> ps.setString(1, artifactId.value) - ps.executeQuery().use { rs -> - if (!rs.next()) { - throw ARCPException.NotFound("artifact ${artifactId.value} not found") - } - val expiresIso = rs.getString(4) - val expires = expiresIso?.let(Instant::parse) - if (expires != null && expires < Clock.System.now()) { - throw ARCPException.NotFound("artifact ${artifactId.value} expired") - } - ArtifactBody( - artifactId = artifactId, - mediaType = rs.getString(1), - size = rs.getLong(2), - sha256 = rs.getString(3), - expiresAt = expires, - bytes = rs.getBytes(5), - ) - } + return ps.executeQuery().use { rs -> readArtifactBody(rs, artifactId) } + } + } + + private fun readArtifactBody( + rs: java.sql.ResultSet, + artifactId: ArtifactId, + ): ArtifactBody { + if (!rs.next()) { + throw ARCPException.NotFound("artifact ${artifactId.value} not found") + } + val expires = rs.getString(4)?.let(Instant::parse) + if (expires != null && expires < Clock.System.now()) { + throw ARCPException.NotFound("artifact ${artifactId.value} expired") } + return ArtifactBody( + artifactId = artifactId, + mediaType = rs.getString(1), + size = rs.getLong(2), + sha256 = rs.getString(3), + expiresAt = expires, + bytes = rs.getBytes(5), + ) } /** Deletes [artifactId]. Returns `true` if a row was removed. */ public suspend fun release(artifactId: ArtifactId): Boolean = withContext(Dispatchers.IO) { val sql = "DELETE FROM arcp_artifact WHERE artifact_id = ?" - connection.prepareStatement(sql).use { ps -> - ps.setString(1, artifactId.value) - ps.executeUpdate() > 0 + connectionLock.withLock { + connection.prepareStatement(sql).use { ps -> + ps.setString(1, artifactId.value) + ps.executeUpdate() > 0 + } } } @@ -191,13 +215,22 @@ public class ArtifactStore private constructor( val sql = "DELETE FROM arcp_artifact " + "WHERE expires_at_iso IS NOT NULL AND expires_at_iso < ?" - connection.prepareStatement(sql).use { ps -> - ps.setString(1, now) - ps.executeUpdate() + connectionLock.withLock { + connection.prepareStatement(sql).use { ps -> + ps.setString(1, now) + ps.executeUpdate() + } } } + /** + * Cancels the background sweep and (when this store owns its + * connection) closes the JDBC connection. Waits for an in-flight + * sweep iteration to unwind before closing so it never runs against + * a closed connection. + */ override fun close() { + runBlocking { sweepJob.cancelAndJoin() } scope.cancel() if (ownsConnection) { runCatching { connection.close() } diff --git a/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt b/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt index af78de4..f8c2e9c 100644 --- a/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt +++ b/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt @@ -10,6 +10,13 @@ import dev.arcp.messages.Capabilities * negotiated only if both parties advertise it. Required client features * the runtime does not support are returned in [unsupported] so the runtime * can reject the session. + * + * Vendor extensions (`arcpx.*`) are explicitly *not* included in + * [unsupported]: per RFC §21 unknown extensions are optional and the + * negotiated extension set is the intersection of both sides. A peer + * that *requires* an extension surface must request a corresponding + * boolean capability or send the extension's required messages, which + * will hit the `classifyUnknown`/`Nack` path at dispatch time. */ public data class CapabilityNegotiation( val negotiated: Capabilities, @@ -27,7 +34,6 @@ public fun negotiate( ): CapabilityNegotiation { val unsupported = mutableListOf() val merged = mergeCapabilities(proposed, supported, unsupported) - unsupported += proposed.extensions - supported.extensions.toSet() return CapabilityNegotiation(merged, unsupported) } @@ -61,12 +67,20 @@ private fun mergeCapabilities( ) } +/** + * Computes the negotiated `binary_encoding` list. The result is the + * intersection of both sides; if either side omitted the field entirely + * (i.e. carries the default `["base64"]`) the intersection naturally + * includes `base64`. Two peers that explicitly advertise disjoint, + * non-default lists return an empty list — the caller must then refuse + * features that require an encoding. + */ private fun negotiateBinaryEncoding( proposed: Capabilities, supported: Capabilities, ): List { - val both = supported.binaryEncoding.intersect(proposed.binaryEncoding.toSet()) - return both.toList().ifEmpty { listOf("base64") } + val intersection = supported.binaryEncoding.intersect(proposed.binaryEncoding.toSet()) + return intersection.toList() } private fun negotiateExtensions( diff --git a/lib/src/main/kotlin/dev/arcp/runtime/JobInventory.kt b/lib/src/main/kotlin/dev/arcp/runtime/JobInventory.kt index f53f7db..b72bb91 100644 Binary files a/lib/src/main/kotlin/dev/arcp/runtime/JobInventory.kt and b/lib/src/main/kotlin/dev/arcp/runtime/JobInventory.kt differ diff --git a/lib/src/main/kotlin/dev/arcp/runtime/UpstreamErrorTranslator.kt b/lib/src/main/kotlin/dev/arcp/runtime/UpstreamErrorTranslator.kt index 057c255..f5459c9 100644 --- a/lib/src/main/kotlin/dev/arcp/runtime/UpstreamErrorTranslator.kt +++ b/lib/src/main/kotlin/dev/arcp/runtime/UpstreamErrorTranslator.kt @@ -2,24 +2,65 @@ package dev.arcp.runtime import dev.arcp.error.ARCPException -/** Translates upstream provider errors to ARCP errors at integration boundaries. */ +/** + * Translates upstream provider errors to ARCP errors at integration + * boundaries. + * + * Implementations should be exhaustive about *their own* provider's + * exception hierarchy — never substring-match on `Throwable.message`, + * since provider error strings vary by version and locale. + */ public fun interface UpstreamErrorTranslator { - /** Returns an ARCP error for [error], or null when no mapping is known. */ + /** Returns an ARCP error for [error], or `null` when no mapping is known. */ public fun translate(error: Throwable): ARCPException? } -/** Conservative default translator for common budget-exhaustion signals. */ +/** + * No-op default translator. + * + * The previous default substring-matched `Throwable.message` for the + * words "budget" and "exhausted"/"exceeded", which produced false + * positives on unrelated rate-limit errors and discarded the original + * cause. Deployments now ship their own translator — usually a small + * `RuleBasedUpstreamErrorTranslator` keyed on provider exception types + * — and accept that the SDK has no opinionated guesses to offer (#69). + * + * Use [RuleBasedUpstreamErrorTranslator] to build a translator from a + * list of `(predicate, mapper)` pairs. + */ public object DefaultUpstreamErrorTranslator : UpstreamErrorTranslator { - override fun translate(error: Throwable): ARCPException? { - val message = error.message?.lowercase().orEmpty() - return if ("budget" in message && ("exhaust" in message || "exceed" in message)) { - ARCPException.BudgetExhausted( - currency = "unknown", - message = - error.message ?: "budget exhausted", - ) - } else { - null - } - } + override fun translate(error: Throwable): ARCPException? = null +} + +/** + * Rule-based [UpstreamErrorTranslator]. The first [rules] entry whose + * [Rule.predicate] returns `true` is applied; its [Rule.mapper] receives + * the original throwable and must return an [ARCPException] (carrying + * the original as `cause` is strongly recommended). + * + * Example: map an upstream rate-limit exception type to a deferred-retry + * [ARCPException.ResourceExhausted]: + * + * ```kotlin + * val rateLimited = "com.openai.errors.RateLimitException" + * val translator = RuleBasedUpstreamErrorTranslator( + * Rule(predicate = { it::class.qualifiedName == rateLimited }) { e -> + * ARCPException.ResourceExhausted(e.message ?: "rate-limited", retryAfterSeconds = 1) + * }, + * ) + * ``` + */ +public class RuleBasedUpstreamErrorTranslator( + private val rules: List, +) : UpstreamErrorTranslator { + public constructor(vararg rules: Rule) : this(rules.toList()) + + override fun translate(error: Throwable): ARCPException? = + rules.firstOrNull { it.predicate(error) }?.mapper?.invoke(error) + + /** One translation rule. */ + public data class Rule( + public val predicate: (Throwable) -> Boolean, + public val mapper: (Throwable) -> ARCPException, + ) } diff --git a/lib/src/main/kotlin/dev/arcp/store/EventLog.kt b/lib/src/main/kotlin/dev/arcp/store/EventLog.kt index 372d53d..a0a6003 100644 --- a/lib/src/main/kotlin/dev/arcp/store/EventLog.kt +++ b/lib/src/main/kotlin/dev/arcp/store/EventLog.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.JsonElement import java.nio.file.Path import java.sql.Connection @@ -23,20 +25,27 @@ private val log = KotlinLogging.logger {} * Append-only SQLite event log (RFC §6.4, §19). * * The log is globally ordered by an autoincrement sequence. Per-message - * idempotency is enforced by a `UNIQUE (session_id, message_id)` constraint - * — duplicate appends are detected and rejected as - * [ARCPException.AlreadyExists]. The log also stores logical idempotency + * idempotency is enforced over `(COALESCE(session_id, ''), message_id)`, + * so duplicate appends are detected even for pre-session envelopes that + * carry a `null` session id (SQLite treats `NULL` values as distinct in + * a plain unique constraint). The log also stores logical idempotency * outcomes (RFC §6.4) and binary artifacts (RFC §16). * - * All public APIs are `suspend` and dispatch JDBC work onto [Dispatchers.IO]. + * All public APIs are `suspend` and dispatch JDBC work onto + * [Dispatchers.IO]. All access to the wrapped [Connection] is serialized + * through an internal [Mutex] — JDBC connections are not safe to use + * concurrently from multiple coroutines. */ public class EventLog private constructor( private val connection: Connection, ) : AutoCloseable { + private val connectionLock: Mutex = Mutex() + /** * Appends [envelope] to the log. Returns the assigned monotonic sequence * number. Re-appending an envelope with the same `(session_id, message_id)` - * raises [ARCPException.AlreadyExists]. + * raises [ARCPException.AlreadyExists] — including for envelopes that + * carry a `null` session id. */ public suspend fun append(envelope: Envelope): Long = withIo { try { @@ -68,7 +77,7 @@ public class EventLog private constructor( ps: java.sql.PreparedStatement, envelope: Envelope, ) { - ps.setString(1, envelope.sessionId?.value) + ps.setString(1, envelope.sessionId?.value ?: NULL_SESSION_SENTINEL) ps.setString(2, envelope.id.value) ps.setString(3, envelope.type) ps.setString(4, envelope.timestamp.toString()) @@ -112,13 +121,15 @@ public class EventLog private constructor( WHERE session_id = ? AND seq > ? ORDER BY seq ASC """.trimIndent() - connection.prepareStatement(sql).use { ps -> - ps.setString(1, sessionId.value) - ps.setLong(2, cursorSeq) - ps.executeQuery().use { rs -> - while (rs.next()) { - val body = rs.getString(1) - emit(arcpJson.decodeFromString(Envelope.serializer(), body)) + connectionLock.withLock { + connection.prepareStatement(sql).use { ps -> + ps.setString(1, sessionId.value) + ps.setLong(2, cursorSeq) + ps.executeQuery().use { rs -> + while (rs.next()) { + val body = rs.getString(1) + emit(arcpJson.decodeFromString(Envelope.serializer(), body)) + } } } } @@ -218,7 +229,7 @@ public class EventLog private constructor( private suspend inline fun withIo(crossinline block: () -> T): T = kotlinx.coroutines.withContext(Dispatchers.IO) { - block() + connectionLock.withLock { block() } } override fun close() { @@ -233,6 +244,14 @@ public class EventLog private constructor( /** Resource path of the embedded schema file. */ public const val SCHEMA_RESOURCE: String = "/arcp/store/schema.sql" + /** + * Sentinel session id used in place of `NULL` for the uniqueness + * constraint on `(session_id, message_id)`. SQLite treats NULLs in + * a UNIQUE constraint as distinct, so pre-session envelopes would + * silently bypass idempotency without this substitution (#49). + */ + private const val NULL_SESSION_SENTINEL: String = "" + private val INSERT_ENVELOPE_SQL = """ INSERT INTO arcp_envelope ( diff --git a/lib/src/test/kotlin/dev/arcp/auth/JwtAuthTest.kt b/lib/src/test/kotlin/dev/arcp/auth/JwtAuthTest.kt new file mode 100644 index 0000000..117a77d --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/auth/JwtAuthTest.kt @@ -0,0 +1,169 @@ +package dev.arcp.auth + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import dev.arcp.error.ARCPException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.util.Date +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +private const val AUDIENCE = "test-runtime" +private val SECRET = "a-very-strong-32-byte-secret-key".toByteArray() + +/** + * Coverage for the v0.1 [JwtAuth]: skew tolerance for `exp`/`nbf` and + * optional issuer matching (#61), plus the existing audience and missing-sub + * paths. + */ +class JwtAuthTest : + StringSpec({ + "verify succeeds for a valid token" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE) + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() + 10_000), + ) + auth.verify(token) shouldBe "u@x" + } + + "exp comparison allows configured clock skew (#61)" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE, allowedClockSkew = 30.seconds) + // exp is 5 seconds in the past, but within the 30s skew window. + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() - 5_000), + ) + auth.verify(token) shouldBe "u@x" + } + + "exp outside the skew window still rejects" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE, allowedClockSkew = 1.seconds) + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() - 10_000), + ) + shouldThrow { auth.verify(token) } + } + + "nbf comparison allows configured clock skew (#61)" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE, allowedClockSkew = 30.seconds) + val now = System.currentTimeMillis() + // nbf is 5 seconds in the future, within the 30s skew window. + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(now + 60_000), + notBefore = Date(now + 5_000), + ) + auth.verify(token) shouldBe "u@x" + } + + "issuer match succeeds when expectedIssuer is set (#61)" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE, issuer = "issuer.example") + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() + 10_000), + issuer = "issuer.example", + ) + auth.verify(token) shouldBe "u@x" + } + + "issuer mismatch is rejected (#61)" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE, issuer = "issuer.example") + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() + 10_000), + issuer = "rogue.example", + ) + shouldThrow { auth.verify(token) } + } + + "absent expectedIssuer accepts any iss (default behavior)" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE) + val token = sign( + subject = "u@x", + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() + 10_000), + issuer = "whoever.example", + ) + auth.verify(token) shouldBe "u@x" + } + + "audience mismatch is rejected" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE) + val token = sign( + subject = "u@x", + audience = "other-runtime", + expiresAt = Date(System.currentTimeMillis() + 10_000), + ) + shouldThrow { auth.verify(token) } + } + + "missing subject is rejected" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE) + val token = sign( + subject = null, + audience = AUDIENCE, + expiresAt = Date(System.currentTimeMillis() + 10_000), + ) + shouldThrow { auth.verify(token) } + } + + "malformed token is rejected" { + val auth = JwtAuth.hmac(SECRET, AUDIENCE) + shouldThrow { auth.verify("not.a.jwt") } + } + + "default skew is one minute" { + JwtAuth.DEFAULT_CLOCK_SKEW shouldBe 1.minutes + } + }) + +private data class SignInput( + val subject: String? = "u@x", + val audience: String = AUDIENCE, + val expiresAt: Date = Date(System.currentTimeMillis() + 10_000), + val notBefore: Date? = null, + val issuer: String? = null, +) + +@Suppress("LongParameterList") +private fun sign( + subject: String?, + audience: String, + expiresAt: Date, + issuer: String? = null, + notBefore: Date? = null, +): String = sign( + SignInput( + subject = subject, + audience = audience, + expiresAt = expiresAt, + notBefore = notBefore, + issuer = issuer, + ), +) + +private fun sign(input: SignInput): String { + val claims = JWTClaimsSet + .Builder() + .audience(input.audience) + .expirationTime(input.expiresAt) + input.subject?.let { claims.subject(it) } + input.notBefore?.let { claims.notBeforeTime(it) } + input.issuer?.let { claims.issuer(it) } + val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims.build()) + jwt.sign(MACSigner(SECRET)) + return jwt.serialize() +} diff --git a/lib/src/test/kotlin/dev/arcp/client/ResultChunkAssemblerEqualityTest.kt b/lib/src/test/kotlin/dev/arcp/client/ResultChunkAssemblerEqualityTest.kt new file mode 100644 index 0000000..2dd389e --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/client/ResultChunkAssemblerEqualityTest.kt @@ -0,0 +1,90 @@ +package dev.arcp.client + +import dev.arcp.messages.JobResultChunk +import dev.arcp.messages.ResultChunkEncoding +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +/** + * [ResultChunkAssembler.AssembledResult] uses [ByteArray.contentEquals]/ + * [ByteArray.contentHashCode] so two results with the same bytes compare + * equal even when the array instances differ (#53). + */ +class ResultChunkAssemblerEqualityTest : + StringSpec({ + "two AssembledResults with equal contents are equal (#53)" { + val a = ResultChunkAssembler.AssembledResult( + resultId = "r1", + bytes = byteArrayOf(0x01, 0x02, 0x03), + isText = false, + ) + val b = ResultChunkAssembler.AssembledResult( + resultId = "r1", + bytes = byteArrayOf(0x01, 0x02, 0x03), + isText = false, + ) + a shouldBe b + a.hashCode() shouldBe b.hashCode() + } + + "different bytes compare unequal" { + val a = ResultChunkAssembler.AssembledResult( + resultId = "r1", + bytes = byteArrayOf(0x01), + isText = false, + ) + val b = ResultChunkAssembler.AssembledResult( + resultId = "r1", + bytes = byteArrayOf(0x02), + isText = false, + ) + a shouldNotBe b + } + + "assembled result works as a map key" { + val a = ResultChunkAssembler.AssembledResult( + resultId = "r1", + bytes = byteArrayOf(0x01, 0x02), + isText = false, + ) + val b = ResultChunkAssembler.AssembledResult( + resultId = "r1", + bytes = byteArrayOf(0x01, 0x02), + isText = false, + ) + val map = mutableMapOf() + map[a] = 1 + map[b] shouldBe 1 + } + + "decoded chunks are reused, not re-decoded (#54)" { + // Smoke test: assembling a multi-chunk stream produces the same + // bytes as the concatenated chunk payloads, which means the + // single-decode pass agreed with the old multi-decode pass. + val assembler = ResultChunkAssembler() + val first = + assembler.accept( + JobResultChunk( + resultId = "r1", + chunkSeq = 0, + data = "Hello", + encoding = ResultChunkEncoding.UTF8, + more = true, + ), + ) + first shouldBe null + val final = + assembler.accept( + JobResultChunk( + resultId = "r1", + chunkSeq = 1, + data = " world", + encoding = ResultChunkEncoding.UTF8, + more = false, + ), + ) + final?.bytes?.decodeToString() shouldBe "Hello world" + final?.isText shouldBe true + } + }) diff --git a/lib/src/test/kotlin/dev/arcp/lease/BudgetCounterRejectedTest.kt b/lib/src/test/kotlin/dev/arcp/lease/BudgetCounterRejectedTest.kt new file mode 100644 index 0000000..b5926d7 --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/lease/BudgetCounterRejectedTest.kt @@ -0,0 +1,43 @@ +package dev.arcp.lease + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import java.math.BigDecimal + +/** + * Direct coverage for the [BudgetCounter.consume] post-fix contract: a spend + * that would drop remaining below zero must return [BudgetCounter.Outcome.Rejected] + * without mutating the counter, so a caller can retry with a smaller amount + * (#65). + */ +class BudgetCounterRejectedTest : + StringSpec({ + "rejects an over-spend without mutating the counter (#65)" { + val counter = BudgetCounter( + CostBudget(listOf(BudgetAmount(Currency("USD"), BigDecimal("1.00")))), + ) + val outcome = counter.consume(BudgetAmount(Currency("USD"), BigDecimal("1.50"))) + outcome.shouldBeInstanceOf() + counter.remaining(Currency("USD")) shouldBe BigDecimal("1.00") + } + + "a smaller follow-up spend still succeeds after a rejected over-spend" { + val counter = BudgetCounter( + CostBudget(listOf(BudgetAmount(Currency("USD"), BigDecimal("1.00")))), + ) + counter.consume(BudgetAmount(Currency("USD"), BigDecimal("2.00"))) + val ok = counter.consume(BudgetAmount(Currency("USD"), BigDecimal("0.40"))) + ok shouldBe BudgetCounter.Outcome.Ok + counter.remaining(Currency("USD")) shouldBe BigDecimal("0.60") + } + + "exact-zero spend returns Exhausted, not Rejected" { + val counter = BudgetCounter( + CostBudget(listOf(BudgetAmount(Currency("USD"), BigDecimal("0.25")))), + ) + val outcome = counter.consume(BudgetAmount(Currency("USD"), BigDecimal("0.25"))) + outcome.shouldBeInstanceOf() + counter.remaining(Currency("USD")) shouldBe BigDecimal("0.00") + } + }) diff --git a/lib/src/test/kotlin/dev/arcp/lease/BudgetRegistryTest.kt b/lib/src/test/kotlin/dev/arcp/lease/BudgetRegistryTest.kt new file mode 100644 index 0000000..bd96308 --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/lease/BudgetRegistryTest.kt @@ -0,0 +1,49 @@ +package dev.arcp.lease + +import dev.arcp.ids.JobId +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import java.math.BigDecimal + +/** + * Coverage for [BudgetRegistry]'s lifecycle: register/consume/terminate/remaining + * plus the new [BudgetRegistry.Outcome.Unregistered] outcome (#66). + */ +class BudgetRegistryTest : + StringSpec({ + val job = JobId("job_test") + val usd = Currency("USD") + + "consume on an unregistered job returns Unregistered, not Ok (#66)" { + val registry = BudgetRegistry() + val outcome = registry.consume(job, BudgetAmount(usd, BigDecimal("0.10"))) + outcome shouldBe BudgetRegistry.Outcome.Unregistered + } + + "consume after register returns Counted(Ok)" { + val registry = BudgetRegistry() + registry.register(job, CostBudget(listOf(BudgetAmount(usd, BigDecimal("1.00"))))) + val outcome = registry.consume(job, BudgetAmount(usd, BigDecimal("0.25"))) + outcome.shouldBeInstanceOf() + outcome.outcome shouldBe BudgetCounter.Outcome.Ok + } + + "remaining reflects consumed amount" { + val registry = BudgetRegistry() + registry.register(job, CostBudget(listOf(BudgetAmount(usd, BigDecimal("1.00"))))) + registry.consume(job, BudgetAmount(usd, BigDecimal("0.30"))) + val remaining = registry.remaining(job) + remaining shouldBe + CostBudget(listOf(BudgetAmount(usd, BigDecimal("0.70")))) + } + + "terminate drops both counter and initial state" { + val registry = BudgetRegistry() + registry.register(job, CostBudget(listOf(BudgetAmount(usd, BigDecimal("1.00"))))) + registry.terminate(job) + registry.remaining(job) shouldBe null + registry.consume(job, BudgetAmount(usd, BigDecimal("0.10"))) shouldBe + BudgetRegistry.Outcome.Unregistered + } + }) diff --git a/lib/src/test/kotlin/dev/arcp/runtime/CapabilityNegotiationTest.kt b/lib/src/test/kotlin/dev/arcp/runtime/CapabilityNegotiationTest.kt new file mode 100644 index 0000000..ac632f3 --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/runtime/CapabilityNegotiationTest.kt @@ -0,0 +1,84 @@ +package dev.arcp.runtime + +import dev.arcp.messages.Capabilities +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe + +class CapabilityNegotiationTest : + StringSpec({ + "interrupt stays enabled when both peers use defaults" { + val proposed = Capabilities() + val supported = Capabilities() + + val result = negotiate(proposed, supported) + + result.negotiated.interrupt shouldBe true + } + + // ---- #51 binary encoding negotiation ---- + + "binary encoding intersects identical lists" { + val result = negotiate( + proposed = Capabilities(binaryEncoding = listOf("base64", "raw")), + supported = Capabilities(binaryEncoding = listOf("base64", "raw")), + ) + result.negotiated.binaryEncoding shouldContain "base64" + result.negotiated.binaryEncoding shouldContain "raw" + } + + "binary encoding intersection is empty when both sides advertise disjoint lists (#51)" { + val result = negotiate( + proposed = Capabilities(binaryEncoding = listOf("raw")), + supported = Capabilities(binaryEncoding = listOf("base64")), + ) + result.negotiated.binaryEncoding shouldBe emptyList() + } + + "binary encoding defaults to base64 when both sides omit the field" { + val result = negotiate( + proposed = Capabilities(), + supported = Capabilities(), + ) + result.negotiated.binaryEncoding shouldBe listOf("base64") + } + + // ---- #57 extension handling ---- + + "unknown vendor extension is silently dropped (#57)" { + val result = negotiate( + proposed = Capabilities(extensions = listOf("arcpx.unknown.example.v1")), + supported = Capabilities(), + ) + result.negotiated.extensions shouldBe emptyList() + result.unsupported shouldBe emptyList() + } + + "matching vendor extension is intersected and kept" { + val proposed = Capabilities( + extensions = listOf("arcpx.acme.cache.v1", "arcpx.other.v1"), + ) + val supported = Capabilities(extensions = listOf("arcpx.acme.cache.v1")) + val result = negotiate(proposed, supported) + result.negotiated.extensions shouldBe listOf("arcpx.acme.cache.v1") + result.unsupported shouldBe emptyList() + } + + "required boolean feature mismatch still appears in unsupported" { + val result = negotiate( + proposed = Capabilities(streaming = true), + supported = Capabilities(streaming = false), + ) + result.unsupported shouldContain "streaming" + } + + "client features the runtime turns off do not appear in unsupported" { + val result = negotiate( + proposed = Capabilities(streaming = false), + supported = Capabilities(streaming = true), + ) + result.unsupported shouldNotContain "streaming" + result.negotiated.streaming shouldBe false + } + }) diff --git a/lib/src/test/kotlin/dev/arcp/runtime/ListJobsHandlerTest.kt b/lib/src/test/kotlin/dev/arcp/runtime/ListJobsHandlerTest.kt index d7e3488..bf73762 100644 --- a/lib/src/test/kotlin/dev/arcp/runtime/ListJobsHandlerTest.kt +++ b/lib/src/test/kotlin/dev/arcp/runtime/ListJobsHandlerTest.kt @@ -1,12 +1,16 @@ package dev.arcp.runtime +import dev.arcp.error.ARCPException import dev.arcp.ids.JobId import dev.arcp.ids.MessageId import dev.arcp.messages.JobListEntry import dev.arcp.messages.JobListFilter +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant class ListJobsHandlerTest : @@ -20,25 +24,88 @@ class ListJobsHandlerTest : } "respects limit and emits next cursor when more remain" { - val inventory = InMemoryJobInventory() - repeat(3) { inventory.record(entry("job_$it"), "principal") } - val jobs = inventory.list("principal", MessageId("msg_x"), JobListFilter(), 2, null) - jobs.jobs.shouldHaveSize(2) - jobs.nextCursor shouldBe "2" + runTest { + val inventory = InMemoryJobInventory() + repeat(3) { inventory.record(entry("job_$it"), "principal") } + val jobs = inventory.list("principal", MessageId("msg_x"), JobListFilter(), 2, null) + jobs.jobs.shouldHaveSize(2) + jobs.nextCursor.shouldNotBeNull() + } } "second page cursor returns subsequent slice" { - val inventory = InMemoryJobInventory() - repeat(3) { inventory.record(entry("job_$it"), "principal") } - val first = inventory.list("principal", MessageId("msg_x"), JobListFilter(), 2, null) - val second = inventory.list( - "principal", - MessageId("msg_y"), - JobListFilter(), - 2, - first.nextCursor, - ) - second.jobs.map { it.jobId } shouldBe listOf(JobId("job_2")) + runTest { + val inventory = InMemoryJobInventory() + repeat(3) { inventory.record(entry("job_$it"), "principal") } + val first = + inventory.list("principal", MessageId("msg_x"), JobListFilter(), 2, null) + val second = inventory.list( + "principal", + MessageId("msg_y"), + JobListFilter(), + 2, + first.nextCursor, + ) + second.jobs.map { it.jobId } shouldBe listOf(JobId("job_2")) + } + } + + "concurrent insert between pages does not skip or duplicate (#59)" { + runTest { + val inventory = InMemoryJobInventory() + // Three jobs at distinct timestamps so ordering is unambiguous. + inventory.record(entry("job_0", createdAt = "2026-05-09T12:00:00Z"), "principal") + inventory.record(entry("job_1", createdAt = "2026-05-09T12:00:01Z"), "principal") + inventory.record(entry("job_2", createdAt = "2026-05-09T12:00:02Z"), "principal") + val first = + inventory.list("principal", MessageId("msg_x"), JobListFilter(), 2, null) + // A new job is recorded that sorts BEFORE the last entry of page 1. + inventory.record( + entry("job_inserted", createdAt = "2026-05-09T12:00:00.500Z"), + "principal", + ) + val second = inventory.list( + "principal", + MessageId("msg_y"), + JobListFilter(), + 2, + first.nextCursor, + ) + // With an opaque sort-key cursor the second page returns exactly + // the entries that sort strictly after the cursor — never the + // newly inserted job (it sorts before the cursor) and never the + // last entry of page 1 (the cursor is past it). + second.jobs.map { it.jobId } shouldBe listOf(JobId("job_2")) + } + } + + "malformed cursor raises InvalidArgument (#59)" { + runTest { + val inventory = InMemoryJobInventory() + inventory.record(entry("job_0"), "principal") + shouldThrow { + inventory.list( + "principal", + MessageId("msg_x"), + JobListFilter(), + 100, + "not-base64!@#", + ) + } + } + } + + "evict drops the record (#60)" { + runTest { + val inventory = InMemoryJobInventory() + inventory.record(entry("job_a"), "principal") + inventory.size shouldBe 1 + inventory.evict(JobId("job_a")) shouldBe true + inventory.size shouldBe 0 + val jobs = + inventory.list("principal", MessageId("msg_x"), JobListFilter(), 100, null) + jobs.jobs.shouldHaveSize(0) + } } "filter status narrows results" { @@ -76,9 +143,10 @@ private fun entry( id: String, status: String = "accepted", agent: String = "agent@1", + createdAt: String = "2026-05-09T12:00:00Z", ): JobListEntry = JobListEntry( jobId = JobId(id), agent = agent, status = status, - createdAt = Instant.parse("2026-05-09T12:00:00Z"), + createdAt = Instant.parse(createdAt), ) diff --git a/lib/src/test/kotlin/dev/arcp/runtime/UpstreamErrorTranslatorTest.kt b/lib/src/test/kotlin/dev/arcp/runtime/UpstreamErrorTranslatorTest.kt new file mode 100644 index 0000000..019b43b --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/runtime/UpstreamErrorTranslatorTest.kt @@ -0,0 +1,48 @@ +package dev.arcp.runtime + +import dev.arcp.error.ARCPException +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +/** + * Coverage for the post-fix translator surface: the empty default returns + * `null` for every input (#69), and [RuleBasedUpstreamErrorTranslator] + * dispatches the first matching rule and preserves the cause. + */ +class UpstreamErrorTranslatorTest : + StringSpec({ + "DefaultUpstreamErrorTranslator returns null for every input (#69)" { + DefaultUpstreamErrorTranslator.translate( + RuntimeException("budget exceeded"), + ) shouldBe null + DefaultUpstreamErrorTranslator.translate( + IllegalStateException("anything"), + ) shouldBe null + } + + "first matching rule wins" { + val translator = RuleBasedUpstreamErrorTranslator( + RuleBasedUpstreamErrorTranslator.Rule( + predicate = { it.message == "first" }, + mapper = { ARCPException.FailedPrecondition("matched first") }, + ), + RuleBasedUpstreamErrorTranslator.Rule( + predicate = { it.message == "first" }, + mapper = { ARCPException.NotFound("would match too") }, + ), + ) + val out = translator.translate(RuntimeException("first")) + out.shouldBeInstanceOf() + } + + "no matching rule returns null" { + val translator = RuleBasedUpstreamErrorTranslator( + RuleBasedUpstreamErrorTranslator.Rule( + predicate = { false }, + mapper = { ARCPException.Internal("never") }, + ), + ) + translator.translate(RuntimeException("anything")) shouldBe null + } + }) diff --git a/lib/src/test/kotlin/dev/arcp/transport/MemoryTransportTest.kt b/lib/src/test/kotlin/dev/arcp/transport/MemoryTransportTest.kt new file mode 100644 index 0000000..c00f62a --- /dev/null +++ b/lib/src/test/kotlin/dev/arcp/transport/MemoryTransportTest.kt @@ -0,0 +1,88 @@ +package dev.arcp.transport + +import dev.arcp.envelope.Envelope +import dev.arcp.ids.MessageId +import dev.arcp.messages.Ping +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest + +/** + * Round-trip and backpressure coverage for [MemoryTransport]. The runtime, + * client, and every sample depends on this transport, so its FIFO and + * close-time semantics need direct tests (#70). + */ +class MemoryTransportTest : + StringSpec({ + "client.send delivers to server.receive in FIFO order" { + runTest { + val (client, server) = MemoryTransport.pair() + coroutineScope { + val received = async { + server.receive().take(3).toList() + } + client.send(env("a")) + client.send(env("b")) + client.send(env("c")) + val list = received.await() + list.map { it.id } shouldBe + listOf(MessageId("a"), MessageId("b"), MessageId("c")) + } + } + } + + "send/receive is bidirectional" { + runTest { + val (client, server) = MemoryTransport.pair() + coroutineScope { + launch { server.send(env("from-server")) } + val first = client.receive().first() + first.id shouldBe MessageId("from-server") + } + } + } + + "close terminates the receive flow" { + runTest { + val (client, server) = MemoryTransport.pair() + val collected = mutableListOf() + coroutineScope { + val collector = launch { + server.receive().toList(collected) + } + client.send(env("only")) + client.close() + collector.join() + } + collected shouldHaveSize 1 + } + } + + "backpressure suspends the sender once the channel is full" { + runTest { + // Capacity 1 channel — second send must suspend until the receiver drains. + val (client, server) = MemoryTransport.pair(capacity = 1) + client.send(env("first")) + // No collector yet, so a second concurrent send would suspend; verify by + // launching it and draining one frame. + coroutineScope { + val sender = launch { client.send(env("second")) } + val drained = server.receive().take(2).toList() + drained.map { it.id } shouldBe listOf(MessageId("first"), MessageId("second")) + sender.join() + } + } + } + }) + +private fun env(id: String): Envelope = Envelope( + id = MessageId(id), + payload = Ping(nonce = id), +) diff --git a/samples/README.md b/samples/README.md index 194d4ce..7addb59 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,6 +1,6 @@ # ARCP Kotlin Samples -Fourteen single-purpose programs, each named for the protocol +Thirteen single-purpose programs, each named for the protocol primitive it demonstrates. > **Illustrative, not runnable.** Each example imports from the @@ -28,7 +28,6 @@ primitive it demonstrates. | [`resumability/`](./src/main/kotlin/com/arcp/samples/resumability) | Crash and resume via `exitProcess(137)` + `resume` envelope. | §10, §19, §6.4 | | [`reasoning_streams/`](./src/main/kotlin/com/arcp/samples/reasoningstreams) | `kind: thought` stream + a peer runtime that subscribes and delegates critiques back. | §11.4, §13, §14 | | [`extensions/`](./src/main/kotlin/com/arcp/samples/extensions) | Custom `arcpx.sdr.*.v1` extension namespace with correct unknown-message handling. | §21 | -| [`human_input/`](./src/main/kotlin/com/arcp/samples/humaninput) | `human.input.request` fanned across phone/email/Slack; first-wins resolution. | §12 | | [`cancellation/`](./src/main/kotlin/com/arcp/samples/cancellation) | Cooperative `cancel` (terminate) vs `interrupt` (pause and ask). | §10.4–§10.5 | | [`mcp/`](./src/main/kotlin/com/arcp/samples/mcp) | ARCP runtime fronting an MCP server: `tool.invoke` → MCP `call_tool`. | §20 | diff --git a/samples/build.gradle.kts b/samples/build.gradle.kts index 6450d39..95f248f 100644 --- a/samples/build.gradle.kts +++ b/samples/build.gradle.kts @@ -22,6 +22,13 @@ dependencies { runtimeOnly(libs.logback.classic) } +// Each entry maps a Gradle task name to the sample's main class. The list is +// the source of truth; the README is regenerated from these entries by hand. +// +// Invariant: every mainClass below must point to a source file under +// `src/main/kotlin/...`. The init check enforces this so a sample cannot be +// announced (and then fail at run time) when its source has been deleted — +// see #55. val sampleClasses = mapOf( "runSubscriptions" to "com.arcp.samples.subscriptions.MainKt", @@ -35,7 +42,6 @@ val sampleClasses = "runResumability" to "com.arcp.samples.resumability.MainKt", "runReasoningStreams" to "com.arcp.samples.reasoningstreams.MainKt", "runExtensions" to "com.arcp.samples.extensions.MainKt", - "runHumanInput" to "com.arcp.samples.humaninput.MainKt", "runCancellation" to "com.arcp.samples.cancellation.MainKt", "runMcp" to "com.arcp.samples.mcp.MainKt", "runListJobs" to "com.arcp.samples.listjobs.MainKt", @@ -46,7 +52,18 @@ val sampleClasses = "runLitellmRecipe" to "com.arcp.samples.recipes.litellm.MainKt", ) +private fun classpathRelative(mainClassFqn: String): String { + val pkg = mainClassFqn.substringBeforeLast('.') + val cls = mainClassFqn.substringAfterLast('.').removeSuffix("Kt") + val pkgPath = pkg.replace('.', '/') + return "src/main/kotlin/$pkgPath/$cls.kt" +} + sampleClasses.forEach { (name, mainClassFqn) -> + val source = file(classpathRelative(mainClassFqn)) + check(source.exists()) { + "sample task '$name' points to $mainClassFqn but ${source.relativeTo(rootDir)} is missing" + } tasks.register(name) { group = "samples" description = "Run sample $name" diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts index 898f4ff..bdbf251 100644 --- a/tests/build.gradle.kts +++ b/tests/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kover) } kotlin { diff --git a/tests/src/test/kotlin/dev/arcp/tests/HandshakeTest.kt b/tests/src/test/kotlin/dev/arcp/tests/HandshakeTest.kt index 99ab192..25f09f9 100644 --- a/tests/src/test/kotlin/dev/arcp/tests/HandshakeTest.kt +++ b/tests/src/test/kotlin/dev/arcp/tests/HandshakeTest.kt @@ -77,7 +77,10 @@ class HandshakeTest : } } - "client requesting unsupported extension is rejected" { + "client advertising an unknown vendor extension is accepted (#57)" { + // Per RFC §21 vendor extensions are optional unless required. The + // runtime must drop unknown extensions from the negotiated set + // rather than rejecting the entire session. runTest { val (clientTransport, serverTransport) = MemoryTransport.pair() val runtime = @@ -94,7 +97,10 @@ class HandshakeTest : capabilities = Capabilities(extensions = listOf("arcpx.acme.cache.v1")), ) client.use { - shouldThrow { client.open() } + val accepted = client.open() + accepted.sessionId shouldNotBe null + // The unknown extension is dropped from the negotiated set. + accepted.capabilities.extensions shouldBe emptyList() } runtime.close() } diff --git a/tests/src/test/kotlin/dev/arcp/tests/ListJobsFanOutTest.kt b/tests/src/test/kotlin/dev/arcp/tests/ListJobsFanOutTest.kt new file mode 100644 index 0000000..f6073c6 --- /dev/null +++ b/tests/src/test/kotlin/dev/arcp/tests/ListJobsFanOutTest.kt @@ -0,0 +1,70 @@ +package dev.arcp.tests + +import dev.arcp.auth.StaticBearerAuth +import dev.arcp.client.ARCPClient +import dev.arcp.envelope.Envelope +import dev.arcp.messages.Capabilities +import dev.arcp.messages.JobAccepted +import dev.arcp.messages.JobSubmit +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.runtime.AgentRegistry +import dev.arcp.transport.MemoryTransport +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +/** + * Regression: a concurrent `listJobs` call must not steal envelopes from + * `client.receive()`. The client now mirrors the transport's inbound flow + * through a single shared flow so every subscriber sees every envelope (#58). + */ +class ListJobsFanOutTest : + StringSpec({ + "concurrent listJobs does not steal envelopes from receive() (#58)" { + // Uses real dispatchers; the runtime collects from Dispatchers.Default, + // which runTest's virtual time does not advance. + runBlocking { withTimeout(10.seconds) { runFanOutScenario() } } + } + }) + +private suspend fun runFanOutScenario() { + val (clientTransport, serverTransport) = MemoryTransport.pair() + val agents = AgentRegistry().apply { register("a", "1.0.0", default = true) } + val runtime = + ARCPRuntime( + supportedCapabilities = Capabilities(), + agentRegistry = agents, + bearerAuth = StaticBearerAuth(mapOf("t" to "u@x")), + ) + runtime.accept(serverTransport) + val client = buildClient(clientTransport) + client.use { exerciseFanOut(client) } + runtime.close() +} + +private fun buildClient(transport: MemoryTransport): ARCPClient = ARCPClient( + transport = transport, + auth = ARCPClient.bearer("t"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(), +) + +private suspend fun exerciseFanOut(client: ARCPClient) { + val session = client.open() + coroutineScope { + val accepted = async { + client.receive().first { it.payload is JobAccepted } + } + client.send(session.sessionId, JobSubmit(agent = "a@1.0.0")) + val list = client.listJobs(session.sessionId) + list shouldNotBe null + val acceptedEnv = accepted.await() + (acceptedEnv.payload as JobAccepted).agent shouldBe "a@1.0.0" + } +}