Control: route health/heartbeat over RPC stdio
This commit is contained in:
@@ -15,12 +15,58 @@ actor AgentRPC {
|
||||
}
|
||||
|
||||
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
|
||||
static let agentEventNotification = Notification.Name("clawdis.rpc.agent")
|
||||
|
||||
private struct ControlResponse: Decodable {
|
||||
let type: String
|
||||
let id: String
|
||||
let ok: Bool
|
||||
let payload: AnyCodable?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
default:
|
||||
let context = EncodingError.Context(
|
||||
codingPath: encoder.codingPath,
|
||||
debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var process: Process?
|
||||
private var stdinHandle: FileHandle?
|
||||
private var stdoutHandle: FileHandle?
|
||||
private var buffer = Data()
|
||||
private var waiters: [CheckedContinuation<String, Error>] = []
|
||||
private var controlWaiters: [String: CheckedContinuation<Data, Error>] = [:]
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
|
||||
private var starting = false
|
||||
|
||||
@@ -127,6 +173,22 @@ actor AgentRPC {
|
||||
}
|
||||
}
|
||||
|
||||
func controlRequest(method: String, params: [String: Any]? = nil) async throws -> Data {
|
||||
if self.process?.isRunning != true {
|
||||
try await self.start()
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
var frame: [String: Any] = ["type": "control-request", "id": id, "method": method]
|
||||
if let params { frame["params"] = params }
|
||||
let data = try JSONSerialization.data(withJSONObject: frame)
|
||||
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
|
||||
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
||||
self.controlWaiters[id] = cont
|
||||
stdinHandle.write(data)
|
||||
stdinHandle.write(Data([0x0A]))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Process lifecycle
|
||||
|
||||
func start() async throws {
|
||||
@@ -180,6 +242,11 @@ actor AgentRPC {
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
|
||||
}
|
||||
let control = self.controlWaiters
|
||||
self.controlWaiters.removeAll()
|
||||
for (_, waiter) in control {
|
||||
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
|
||||
}
|
||||
}
|
||||
|
||||
private func ingest(data: Data) {
|
||||
@@ -189,11 +256,11 @@ actor AgentRPC {
|
||||
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
|
||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||
|
||||
// Event frames are pushed without request/response pairing (e.g., heartbeats).
|
||||
if let event = self.parseHeartbeatEvent(from: line) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: Self.heartbeatNotification, object: event)
|
||||
}
|
||||
// Event frames are pushed without request/response pairing (e.g., heartbeats/agent).
|
||||
if self.handleEventLine(line) {
|
||||
continue
|
||||
}
|
||||
if self.handleControlResponse(line) {
|
||||
continue
|
||||
}
|
||||
if let waiter = waiters.first {
|
||||
@@ -221,6 +288,62 @@ actor AgentRPC {
|
||||
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
|
||||
}
|
||||
|
||||
private func parseAgentEvent(from line: String) -> ControlAgentEvent? {
|
||||
guard let data = line.data(using: .utf8) else { return nil }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String,
|
||||
type == "event",
|
||||
let evt = obj["event"] as? String,
|
||||
evt == "agent",
|
||||
let payload = obj["payload"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
return try? JSONDecoder().decode(ControlAgentEvent.self, from: payloadData)
|
||||
}
|
||||
|
||||
private func handleEventLine(_ line: String) -> Bool {
|
||||
if let hb = self.parseHeartbeatEvent(from: line) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: Self.heartbeatNotification, object: hb)
|
||||
NotificationCenter.default.post(name: .controlHeartbeat, object: hb)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if let agent = self.parseAgentEvent(from: line) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: Self.agentEventNotification, object: agent)
|
||||
NotificationCenter.default.post(name: .controlAgentEvent, object: agent)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func handleControlResponse(_ line: String) -> Bool {
|
||||
guard let data = line.data(using: .utf8) else { return false }
|
||||
guard let parsed = try? JSONDecoder().decode(ControlResponse.self, from: data) else { return false }
|
||||
guard parsed.type == "control-response" else { return false }
|
||||
guard let waiter = self.controlWaiters.removeValue(forKey: parsed.id) else {
|
||||
self.logger.debug("control response with no waiter id=\(parsed.id, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
if parsed.ok {
|
||||
let payloadData: Data = if let payload = parsed.payload {
|
||||
(try? JSONEncoder().encode(payload)) ?? Data()
|
||||
} else {
|
||||
Data()
|
||||
}
|
||||
waiter.resume(returning: payloadData)
|
||||
} else {
|
||||
waiter.resume(throwing: RpcError(message: parsed.error ?? "control error"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func nextLine() async throws -> String {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
||||
self.waiters.append(cont)
|
||||
|
||||
Reference in New Issue
Block a user