2025-12-09 17:11:25 +00:00
import ClawdisProtocol
2025-12-08 21:50:51 +01:00
import Foundation
import OSLog
2025-12-09 03:56:04 +01:00
import SwiftUI
2025-12-08 21:50:51 +01:00
struct ControlHeartbeatEvent : Codable {
let ts : Double
let status : String
let to : String ?
let preview : String ?
let durationMs : Double ?
let hasMedia : Bool ?
let reason : String ?
}
2025-12-09 15:38:22 +01:00
struct ControlAgentEvent : Codable , Sendable , Identifiable {
2025-12-09 17:11:25 +00:00
var id : String { " \( self . runId ) - \( self . seq ) " }
2025-12-09 00:28:03 +01:00
let runId : String
let seq : Int
let stream : String
let ts : Double
let data : [ String : AnyCodable ]
2025-12-09 15:38:22 +01:00
let summary : String ?
2025-12-09 00:28:03 +01:00
}
2025-12-08 21:50:51 +01:00
enum ControlChannelError : Error , LocalizedError {
case disconnected
case badResponse ( String )
var errorDescription : String ? {
switch self {
2025-12-09 02:25:01 +00:00
case . disconnected : " Control channel disconnected "
case let . badResponse ( msg ) : msg
2025-12-08 21:50:51 +01:00
}
}
}
@ MainActor
final class ControlChannel : ObservableObject {
static let shared = ControlChannel ( )
2025-12-09 15:01:13 +01:00
enum Mode {
case local
case remote ( target : String , identity : String )
}
2025-12-08 22:04:02 +01:00
enum ConnectionState : Equatable {
case disconnected
2025-12-09 03:33:16 +01:00
case connecting
2025-12-08 22:04:02 +01:00
case connected
case degraded ( String )
}
@ Published private ( set ) var state : ConnectionState = . disconnected
@ Published private ( set ) var lastPingMs : Double ?
2025-12-08 21:50:51 +01:00
2025-12-09 02:25:01 +00:00
private let logger = Logger ( subsystem : " com.steipete.clawdis " , category : " control " )
2025-12-09 14:41:41 +01:00
private let gateway = GatewayChannel ( )
2025-12-10 01:43:59 +01:00
private var gatewayPort : Int = GatewayEnvironment . gatewayPort ( )
private var gatewayURL : URL { URL ( string : " ws://127.0.0.1: \( self . gatewayPort ) " ) ! }
2025-12-09 15:01:13 +01:00
2025-12-09 14:41:41 +01:00
private var gatewayToken : String ? {
ProcessInfo . processInfo . environment [ " CLAWDIS_GATEWAY_TOKEN " ]
}
2025-12-09 15:01:13 +01:00
2025-12-09 14:41:41 +01:00
private var eventTokens : [ NSObjectProtocol ] = [ ]
2025-12-09 00:56:46 +01:00
2025-12-09 02:25:01 +00:00
func configure ( ) async {
2025-12-09 15:01:13 +01:00
self . state = . connecting
await self . gateway . configure ( url : self . gatewayURL , token : self . gatewayToken )
self . startEventStream ( )
self . state = . connected
PresenceReporter . shared . sendImmediate ( reason : " connect " )
2025-12-08 21:50:51 +01:00
}
2025-12-09 15:01:13 +01:00
func configure ( mode : Mode = . local ) async throws {
switch mode {
case . local :
2025-12-10 01:43:59 +01:00
self . gatewayPort = GatewayEnvironment . gatewayPort ( )
2025-12-09 15:01:13 +01:00
await self . configure ( )
case let . remote ( target , identity ) :
2025-12-10 01:43:59 +01:00
// C r e a t e / e n s u r e S S H t u n n e l , t h e n t a l k t o t h e f o r w a r d e d l o c a l p o r t .
2025-12-09 15:01:13 +01:00
_ = ( target , identity )
2025-12-10 01:43:59 +01:00
do {
let forwarded = try await RemoteTunnelManager . shared . ensureControlTunnel ( )
self . gatewayPort = Int ( forwarded )
await self . configure ( )
} catch {
self . state = . degraded ( error . localizedDescription )
throw error
}
2025-12-09 15:01:13 +01:00
}
}
2025-12-08 21:50:51 +01:00
func health ( timeout : TimeInterval ? = nil ) async throws -> Data {
2025-12-09 02:25:01 +00:00
do {
let start = Date ( )
2025-12-09 15:01:13 +01:00
var params : [ String : AnyHashable ] ?
2025-12-09 14:41:41 +01:00
if let timeout {
params = [ " timeout " : AnyHashable ( Int ( timeout * 1000 ) ) ]
}
2025-12-09 21:52:16 +00:00
let timeoutMs = ( timeout ? ? 15 ) * 1000
let payload = try await self . request ( method : " health " , params : params , timeoutMs : timeoutMs )
2025-12-09 02:25:01 +00:00
let ms = Date ( ) . timeIntervalSince ( start ) * 1000
self . lastPingMs = ms
self . state = . connected
return payload
} catch {
2025-12-09 18:01:15 +01:00
let message = self . friendlyGatewayMessage ( error )
self . state = . degraded ( message )
throw ControlChannelError . badResponse ( message )
2025-12-09 02:25:01 +00:00
}
2025-12-08 21:50:51 +01:00
}
2025-12-08 22:04:02 +01:00
func lastHeartbeat ( ) async throws -> ControlHeartbeatEvent ? {
2025-12-09 14:41:41 +01:00
// H e a r t b e a t r e m o v e d i n n e w p r o t o c o l
2025-12-09 15:01:13 +01:00
nil
2025-12-08 22:04:02 +01:00
}
2025-12-09 21:52:16 +00:00
func request (
method : String ,
params : [ String : AnyHashable ] ? = nil ,
timeoutMs : Double ? = nil ) async throws -> Data
{
2025-12-09 02:25:01 +00:00
do {
2025-12-09 15:01:13 +01:00
let rawParams = params ? . reduce ( into : [ String : AnyCodable ] ( ) ) { $0 [ $1 . key ] = AnyCodable ( $1 . value ) }
2025-12-09 21:52:16 +00:00
let data = try await self . gateway . request ( method : method , params : rawParams , timeoutMs : timeoutMs )
2025-12-09 02:25:01 +00:00
self . state = . connected
return data
} catch {
2025-12-09 18:01:15 +01:00
let message = self . friendlyGatewayMessage ( error )
self . state = . degraded ( message )
throw ControlChannelError . badResponse ( message )
2025-12-08 22:04:02 +01:00
}
2025-12-08 21:50:51 +01:00
}
2025-12-09 18:01:15 +01:00
private func friendlyGatewayMessage ( _ error : Error ) -> String {
// M a p U R L S e s s i o n / W S e r r o r s i n t o u s e r - f a c i n g , a c t i o n a b l e t e x t .
if let ctrlErr = error as ? ControlChannelError , let desc = ctrlErr . errorDescription {
return desc
}
2025-12-10 00:49:33 +01:00
// C o m m o n m i s f i r e : w e c o n n e c t e d t o l o c a l h o s t : 1 8 7 8 9 b u t t h e p o r t i s o c c u p i e d
// b y s o m e o t h e r p r o c e s s ( e . g . a l o c a l d e v g a t e w a y o r a s t u c k S S H f o r w a r d ) .
// T h e g a t e w a y h a n d s h a k e r e t u r n s s o m e t h i n g w e c a n ' t p a r s e , w h i c h c u r r e n t l y
// s u r f a c e s a s " h e l l o f a i l e d ( u n e x p e c t e d r e s p o n s e ) " . G i v e t h e u s e r a p o i n t e r
// t o f r e e t h e p o r t i n s t e a d o f a v a g u e m e s s a g e .
let nsError = error as NSError
if nsError . domain = = " Gateway " ,
nsError . localizedDescription . contains ( " hello failed (unexpected response) " ) {
let port = GatewayEnvironment . gatewayPort ( )
return " Gateway handshake got non-gateway data on localhost: \( port ) . Another process is using that port or the SSH forward failed. Stop the local gateway/port-forward on \( port ) and retry Remote mode. "
}
2025-12-09 18:01:15 +01:00
if let urlError = error as ? URLError {
2025-12-09 18:00:01 +00:00
let port = GatewayEnvironment . gatewayPort ( )
2025-12-09 18:01:15 +01:00
switch urlError . code {
case . cancelled :
2025-12-09 18:00:01 +00:00
return " Gateway connection was closed; start the gateway (localhost: \( port ) ) and retry. "
2025-12-09 18:01:15 +01:00
case . cannotFindHost , . cannotConnectToHost :
2025-12-09 18:00:01 +00:00
return " Cannot reach gateway at localhost: \( port ) ; ensure the gateway is running. "
2025-12-09 18:01:15 +01:00
case . networkConnectionLost :
2025-12-09 18:00:01 +00:00
return " Gateway connection dropped; gateway likely restarted—retry. "
2025-12-09 18:01:15 +01:00
case . timedOut :
2025-12-09 18:00:01 +00:00
return " Gateway request timed out; check gateway on localhost: \( port ) . "
2025-12-09 18:01:15 +01:00
case . notConnectedToInternet :
return " No network connectivity; cannot reach gateway. "
default :
break
}
}
2025-12-09 21:52:16 +00:00
if nsError . domain = = " Gateway " , nsError . code = = 5 {
return " Gateway request timed out; check the gateway process on localhost: \( GatewayEnvironment . gatewayPort ( ) ) . "
}
2025-12-09 18:01:15 +01:00
let detail = nsError . localizedDescription . isEmpty ? " unknown gateway error " : nsError . localizedDescription
return " Gateway error: \( detail ) "
}
2025-12-09 02:25:01 +00:00
func sendSystemEvent ( _ text : String ) async throws {
2025-12-09 14:41:41 +01:00
_ = try await self . request ( method : " system-event " , params : [ " text " : AnyHashable ( text ) ] )
}
private func startEventStream ( ) {
2025-12-09 15:01:13 +01:00
for tok in self . eventTokens {
NotificationCenter . default . removeObserver ( tok )
}
self . eventTokens . removeAll ( )
2025-12-09 14:41:41 +01:00
let ev = NotificationCenter . default . addObserver (
forName : . gatewayEvent ,
object : nil ,
2025-12-09 15:01:13 +01:00
queue : . main )
2025-12-09 15:26:31 +01:00
{ [ weak self ] note in
2025-12-09 15:01:13 +01:00
guard let self ,
2025-12-09 15:38:22 +01:00
let frame = note . object as ? GatewayFrame else { return }
switch frame {
case let . event ( evt ) where evt . event = = " agent " :
if let data = evt . payload ? . value ,
JSONSerialization . isValidJSONObject ( data ) ,
let blob = try ? JSONSerialization . data ( withJSONObject : data ) ,
2025-12-09 17:11:25 +00:00
let agent = try ? JSONDecoder ( ) . decode ( AgentEvent . self , from : blob )
{
2025-12-09 15:26:31 +01:00
Task { @ MainActor in
AgentEventStore . shared . append ( ControlAgentEvent (
2025-12-09 15:38:22 +01:00
runId : agent . runid ,
seq : agent . seq ,
stream : agent . stream ,
ts : Double ( agent . ts ) ,
data : agent . data . mapValues { Clawdis . AnyCodable ( $0 . value ) } ,
summary : nil ) )
2025-12-09 15:26:31 +01:00
}
2025-12-09 14:41:41 +01:00
}
2025-12-09 15:38:22 +01:00
case let . event ( evt ) where evt . event = = " shutdown " :
2025-12-09 15:26:31 +01:00
Task { @ MainActor in self . state = . degraded ( " gateway shutdown " ) }
2025-12-09 14:41:41 +01:00
default :
break
}
}
let tick = NotificationCenter . default . addObserver (
forName : . gatewaySnapshot ,
object : nil ,
2025-12-09 15:01:13 +01:00
queue : . main )
2025-12-09 15:26:31 +01:00
{ [ weak self ] _ in
Task { @ MainActor [ weak self ] in self ? . state = . connected }
2025-12-09 14:41:41 +01:00
}
2025-12-09 15:01:13 +01:00
self . eventTokens = [ ev , tick ]
2025-12-08 21:50:51 +01:00
}
}
extension Notification . Name {
static let controlHeartbeat = Notification . Name ( " clawdis.control.heartbeat " )
2025-12-09 02:25:01 +00:00
static let controlAgentEvent = Notification . Name ( " clawdis.control.agent " )
2025-12-08 21:50:51 +01:00
}