213 lines
9.7 KiB
Swift
213 lines
9.7 KiB
Swift
import Foundation
|
|
|
|
/// A small JSON-RPC 2.0 client layered over a `ProcessHandle`'s stdio (ADAPTERS §0.2), shared by
|
|
/// the two duplex-stdio backends:
|
|
///
|
|
/// - **`codex app-server`** ([`CodexAppServerBackend`](Codex/CodexAppServerBackend.swift)) —
|
|
/// `includeVersionHeader: false`: codex omits the `"jsonrpc":"2.0"` header on the wire (its
|
|
/// source-confirmed dialect — it neither emits nor requires it), so we match that exactly.
|
|
/// - **`grok agent stdio`** ([`GrokACPBackend`](Grok/GrokACPBackend.swift)) —
|
|
/// `includeVersionHeader: true`: the Agent Client Protocol is *standards* JSON-RPC 2.0, so the
|
|
/// header is present on every message.
|
|
///
|
|
/// Three message classes flow over one newline-delimited (JSONL) channel:
|
|
///
|
|
/// - outbound **requests** (`id` + `method`) → correlated to an `await`ed continuation by `id`;
|
|
/// - inbound **notifications** (`method`, no `id`) → published on `notifications`;
|
|
/// - inbound **server→client requests** (`id` + `method`) → dispatched to the handler registered
|
|
/// in `start`, which must reply with a `result` keyed by the same `id`. **Approvals arrive this
|
|
/// way** — codex's `…/requestApproval` and ACP's `session/request_permission` alike (ADAPTERS §2.4).
|
|
public actor JSONRPCConnection {
|
|
public struct Notification: Sendable, Equatable {
|
|
public let method: String
|
|
public let params: JSONValue
|
|
public init(method: String, params: JSONValue) {
|
|
self.method = method
|
|
self.params = params
|
|
}
|
|
}
|
|
|
|
/// A server→client request we must answer (e.g. an approval). `id` is echoed
|
|
/// verbatim into the reply — it may be a number or a string on the wire.
|
|
public struct InboundRequest: Sendable, Equatable {
|
|
public let id: JSONValue
|
|
public let method: String
|
|
public let params: JSONValue
|
|
public init(id: JSONValue, method: String, params: JSONValue) {
|
|
self.id = id
|
|
self.method = method
|
|
self.params = params
|
|
}
|
|
}
|
|
|
|
public enum RPCError: Error, Sendable, Equatable {
|
|
/// The peer returned an `error` object for one of our requests.
|
|
///
|
|
/// `kind` is `error.data.kind` when the peer sent one, and nil otherwise. The wslc broker
|
|
/// sets it on every failure (docs/WINDOWS_PORT.md §3.3) so hostd can branch on a stable
|
|
/// token — `not_running`, `already_exists`, `unsupported` — instead of guessing from which
|
|
/// method failed or, worse, matching on message text that is often empty or unhelpful
|
|
/// (§13.3 has a real example: a COM message reading "The text associated with this error
|
|
/// code could not be found").
|
|
case server(code: Int, message: String, kind: String?)
|
|
/// The connection closed before the response arrived.
|
|
case connectionClosed
|
|
}
|
|
|
|
private let handle: any ProcessHandle
|
|
/// Emit the `"jsonrpc":"2.0"` header on outbound messages (standards JSON-RPC, e.g. ACP).
|
|
/// Off for codex app-server, whose confirmed dialect omits it.
|
|
private let includeVersionHeader: Bool
|
|
private var nextID = 0
|
|
private var pending: [Int: CheckedContinuation<JSONValue, Error>] = [:]
|
|
private var notificationContinuation: AsyncStream<Notification>.Continuation?
|
|
/// Buffered (unbounded) so notifications that arrive during `initialize`/`session/new`
|
|
/// — before the backend begins draining — are never dropped.
|
|
public nonisolated let notifications: AsyncStream<Notification>
|
|
private var requestHandler: (@Sendable (InboundRequest) async -> Void)?
|
|
private var readTask: Task<Void, Never>?
|
|
private var closed = false
|
|
|
|
public init(handle: any ProcessHandle, includeVersionHeader: Bool = false) {
|
|
self.handle = handle
|
|
self.includeVersionHeader = includeVersionHeader
|
|
var captured: AsyncStream<Notification>.Continuation!
|
|
self.notifications = AsyncStream(bufferingPolicy: .unbounded) { captured = $0 }
|
|
self.notificationContinuation = captured
|
|
}
|
|
|
|
/// Begin reading the peer's stdout. `onRequest` handles inbound server→client
|
|
/// requests; it runs detached so a suspended approval handler can't block the
|
|
/// read loop (and thus block our own request responses or further notifications).
|
|
public func start(onRequest: @escaping @Sendable (InboundRequest) async -> Void) {
|
|
requestHandler = onRequest
|
|
guard readTask == nil else { return }
|
|
let handle = self.handle
|
|
readTask = Task { [weak self] in
|
|
do {
|
|
for try await line in handle.stdoutLines {
|
|
await self?.ingest(line)
|
|
}
|
|
} catch {
|
|
await self?.terminate(reason: error)
|
|
return
|
|
}
|
|
await self?.terminate(reason: RPCError.connectionClosed)
|
|
}
|
|
}
|
|
|
|
// MARK: - Outbound
|
|
|
|
/// Send a request and suspend until the correlated response arrives.
|
|
public func request(_ method: String, params: JSONValue? = nil) async throws -> JSONValue {
|
|
if closed { throw RPCError.connectionClosed }
|
|
nextID += 1
|
|
let id = nextID
|
|
var fields: [String: JSONValue] = ["id": .number(Double(id)), "method": .string(method)]
|
|
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
|
|
if let params { fields["params"] = params }
|
|
let payload = JSONValue.object(fields)
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
pending[id] = continuation
|
|
do {
|
|
try handle.writeLine(payload.encodedData())
|
|
} catch {
|
|
pending.removeValue(forKey: id)
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fire a notification (no response expected).
|
|
public func notify(_ method: String, params: JSONValue? = nil) throws {
|
|
var fields: [String: JSONValue] = ["method": .string(method)]
|
|
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
|
|
if let params { fields["params"] = params }
|
|
try handle.writeLine(JSONValue.object(fields).encodedData())
|
|
}
|
|
|
|
/// Answer an inbound server→client request with a `result`.
|
|
public func reply(to id: JSONValue, result: JSONValue) throws {
|
|
var fields: [String: JSONValue] = ["id": id, "result": result]
|
|
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
|
|
try handle.writeLine(JSONValue.object(fields).encodedData())
|
|
}
|
|
|
|
/// Answer an inbound server→client request with an `error`.
|
|
public func replyError(to id: JSONValue, code: Int, message: String) throws {
|
|
let error = JSONValue.object(["code": .number(Double(code)), "message": .string(message)])
|
|
var fields: [String: JSONValue] = ["id": id, "error": error]
|
|
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
|
|
try handle.writeLine(JSONValue.object(fields).encodedData())
|
|
}
|
|
|
|
// MARK: - Inbound
|
|
|
|
private func ingest(_ line: Data) {
|
|
guard !line.isEmpty, let root = try? JSONValue(parsing: line),
|
|
let object = root.objectValue
|
|
else { return } // non-JSON noise on stdout is ignored, never fatal
|
|
|
|
let idValue = object["id"]
|
|
let hasID = (idValue != nil && idValue != .null)
|
|
|
|
if let method = object["method"]?.stringValue {
|
|
let params = object["params"] ?? .object([:])
|
|
if hasID, let idValue {
|
|
// Server→client request — dispatch detached so a suspended handler
|
|
// never stalls the read loop.
|
|
let request = InboundRequest(id: idValue, method: method, params: params)
|
|
if let requestHandler {
|
|
Task { await requestHandler(request) }
|
|
}
|
|
} else {
|
|
notificationContinuation?.yield(Notification(method: method, params: params))
|
|
}
|
|
return
|
|
}
|
|
|
|
// No method → a response to one of our requests.
|
|
guard hasID, let id = idValue?.intValue,
|
|
let continuation = pending.removeValue(forKey: id)
|
|
else { return }
|
|
if let error = object["error"] {
|
|
continuation.resume(
|
|
throwing: RPCError.server(
|
|
code: error["code"]?.intValue ?? -1,
|
|
message: error["message"]?.stringValue ?? "RPC error",
|
|
kind: error["data"]?["kind"]?.stringValue))
|
|
} else {
|
|
continuation.resume(returning: object["result"] ?? .null)
|
|
}
|
|
}
|
|
|
|
/// Finish the notification stream *without* failing in-flight requests or stopping the read
|
|
/// loop. The ACP turn terminal is the `session/prompt` *response*, not a notification, so the
|
|
/// backend awaits that response and then calls this — the unbounded buffer guarantees every
|
|
/// already-yielded `session/update` is still delivered to the drain loop before it ends, so no
|
|
/// trailing chunk is dropped. Idempotent.
|
|
public func endNotifications() {
|
|
notificationContinuation?.finish()
|
|
notificationContinuation = nil
|
|
}
|
|
|
|
/// Stream closed (peer exited or read error): fail every in-flight request and
|
|
/// end the notification stream so the backend's drain loop terminates.
|
|
private func terminate(reason: Error) {
|
|
guard !closed else { return }
|
|
closed = true
|
|
for (_, continuation) in pending {
|
|
continuation.resume(throwing: reason)
|
|
}
|
|
pending.removeAll()
|
|
notificationContinuation?.finish()
|
|
notificationContinuation = nil
|
|
}
|
|
|
|
/// Stop reading and release continuations (called on backend shutdown).
|
|
public func close() {
|
|
readTask?.cancel()
|
|
terminate(reason: RPCError.connectionClosed)
|
|
}
|
|
}
|