mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
Adds todo tracking infrastructure with TodoWrite tool integration to extract and persist todos in sessions. Refactors ToolCard component to support custom tool view rendering and displays todo progress in SessionList.
697 lines
23 KiB
TypeScript
697 lines
23 KiB
TypeScript
/**
|
|
* Sync Engine for Happy Telegram Bot (Direct Connect)
|
|
*
|
|
* In the direct-connect architecture:
|
|
* - happy-bot is the server (Socket.IO + REST)
|
|
* - happy-cli connects directly to the bot (no happy-server relay)
|
|
* - No E2E encryption; data is stored as JSON in SQLite
|
|
*/
|
|
|
|
import { z } from 'zod'
|
|
import type { Server } from 'socket.io'
|
|
import type { Store } from '../store'
|
|
import type { RpcRegistry } from '../socket/rpcRegistry'
|
|
import { extractTodoWriteTodosFromMessageContent, TodosSchema, type TodoItem } from './todos'
|
|
|
|
export type ConnectionStatus = 'disconnected' | 'connected'
|
|
|
|
export const MetadataSchema = z.object({
|
|
path: z.string(),
|
|
host: z.string(),
|
|
version: z.string().optional(),
|
|
name: z.string().optional(),
|
|
os: z.string().optional(),
|
|
summary: z.object({
|
|
text: z.string(),
|
|
updatedAt: z.number()
|
|
}).optional(),
|
|
machineId: z.string().optional(),
|
|
tools: z.array(z.string()).optional(),
|
|
flavor: z.string().nullish()
|
|
}).passthrough()
|
|
|
|
export type Metadata = z.infer<typeof MetadataSchema>
|
|
|
|
export const AgentStateSchema = z.object({
|
|
controlledByUser: z.boolean().nullish(),
|
|
requests: z.record(z.string(), z.object({
|
|
tool: z.string(),
|
|
arguments: z.unknown(),
|
|
createdAt: z.number().nullish()
|
|
}).passthrough()).nullish(),
|
|
completedRequests: z.record(z.string(), z.object({
|
|
tool: z.string(),
|
|
arguments: z.unknown(),
|
|
createdAt: z.number().nullish(),
|
|
completedAt: z.number().nullish(),
|
|
status: z.enum(['canceled', 'denied', 'approved']),
|
|
reason: z.string().optional(),
|
|
mode: z.string().optional(),
|
|
decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(),
|
|
allowTools: z.array(z.string()).optional()
|
|
}).passthrough()).nullish()
|
|
}).passthrough()
|
|
|
|
export type AgentState = z.infer<typeof AgentStateSchema>
|
|
|
|
const machineMetadataSchema = z.object({
|
|
host: z.string().optional(),
|
|
platform: z.string().optional(),
|
|
happyCliVersion: z.string().optional(),
|
|
displayName: z.string().optional()
|
|
}).passthrough()
|
|
|
|
export interface Session {
|
|
id: string
|
|
seq: number
|
|
createdAt: number
|
|
updatedAt: number
|
|
active: boolean
|
|
activeAt: number
|
|
metadata: Metadata | null
|
|
metadataVersion: number
|
|
agentState: AgentState | null
|
|
agentStateVersion: number
|
|
thinking: boolean
|
|
thinkingAt: number
|
|
todos?: TodoItem[]
|
|
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null
|
|
modelMode?: 'default' | 'sonnet' | 'opus' | null
|
|
}
|
|
|
|
export interface Machine {
|
|
id: string
|
|
seq: number
|
|
createdAt: number
|
|
updatedAt: number
|
|
active: boolean
|
|
activeAt: number
|
|
metadata: {
|
|
host: string
|
|
platform: string
|
|
happyCliVersion: string
|
|
displayName?: string
|
|
[key: string]: unknown
|
|
} | null
|
|
metadataVersion: number
|
|
daemonState: unknown | null
|
|
daemonStateVersion: number
|
|
}
|
|
|
|
export interface DecryptedMessage {
|
|
id: string
|
|
seq: number
|
|
localId: string | null
|
|
content: unknown
|
|
createdAt: number
|
|
}
|
|
|
|
export type FetchMessagesResult =
|
|
| { ok: true; messages: DecryptedMessage[] }
|
|
| { ok: false; status: number | null; error: string }
|
|
|
|
export type SyncEventType =
|
|
| 'session-added'
|
|
| 'session-updated'
|
|
| 'session-removed'
|
|
| 'message-received'
|
|
| 'machine-updated'
|
|
| 'connection-changed'
|
|
|
|
export interface SyncEvent {
|
|
type: SyncEventType
|
|
sessionId?: string
|
|
machineId?: string
|
|
data?: unknown
|
|
message?: DecryptedMessage
|
|
}
|
|
|
|
export type SyncEventListener = (event: SyncEvent) => void
|
|
|
|
function clampAliveTime(t: number): number | null {
|
|
if (!Number.isFinite(t)) return null
|
|
const now = Date.now()
|
|
if (t > now) return now
|
|
if (t < now - 1000 * 60 * 10) return null
|
|
return t
|
|
}
|
|
|
|
export class SyncEngine {
|
|
private sessions: Map<string, Session> = new Map()
|
|
private machines: Map<string, Machine> = new Map()
|
|
private sessionMessages: Map<string, DecryptedMessage[]> = new Map()
|
|
private listeners: Set<SyncEventListener> = new Set()
|
|
private connectionStatus: ConnectionStatus = 'connected'
|
|
|
|
private readonly lastBroadcastAtBySessionId: Map<string, number> = new Map()
|
|
private readonly lastBroadcastAtByMachineId: Map<string, number> = new Map()
|
|
private readonly todoBackfillAttemptedSessionIds: Set<string> = new Set()
|
|
private inactivityTimer: NodeJS.Timeout | null = null
|
|
|
|
constructor(
|
|
private readonly store: Store,
|
|
private readonly io: Server,
|
|
private readonly rpcRegistry: RpcRegistry
|
|
) {
|
|
this.reloadAll()
|
|
this.inactivityTimer = setInterval(() => this.expireInactive(), 5_000)
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.inactivityTimer) {
|
|
clearInterval(this.inactivityTimer)
|
|
this.inactivityTimer = null
|
|
}
|
|
}
|
|
|
|
start(): Promise<void> {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
subscribe(listener: SyncEventListener): () => void {
|
|
this.listeners.add(listener)
|
|
return () => this.listeners.delete(listener)
|
|
}
|
|
|
|
private emit(event: SyncEvent): void {
|
|
for (const listener of this.listeners) {
|
|
try {
|
|
listener(event)
|
|
} catch (error) {
|
|
console.error('[SyncEngine] Listener error:', error)
|
|
}
|
|
}
|
|
|
|
const webappEvent: SyncEvent = event.type === 'message-received'
|
|
? {
|
|
type: event.type,
|
|
sessionId: event.sessionId,
|
|
machineId: event.machineId,
|
|
message: event.message
|
|
}
|
|
: {
|
|
type: event.type,
|
|
sessionId: event.sessionId,
|
|
machineId: event.machineId
|
|
}
|
|
|
|
const rooms = new Set<string>()
|
|
if (webappEvent.sessionId) {
|
|
rooms.add(`session:${webappEvent.sessionId}`)
|
|
}
|
|
if (webappEvent.machineId) {
|
|
rooms.add(`machine:${webappEvent.machineId}`)
|
|
}
|
|
|
|
if (webappEvent.type !== 'message-received') {
|
|
rooms.add('webapp:all')
|
|
}
|
|
|
|
const webappNamespace = this.io.of('/webapp')
|
|
for (const room of rooms) {
|
|
webappNamespace.to(room).emit('update', webappEvent)
|
|
}
|
|
}
|
|
|
|
getConnectionStatus(): ConnectionStatus {
|
|
return this.connectionStatus
|
|
}
|
|
|
|
getSessions(): Session[] {
|
|
return Array.from(this.sessions.values())
|
|
}
|
|
|
|
getSession(sessionId: string): Session | undefined {
|
|
return this.sessions.get(sessionId)
|
|
}
|
|
|
|
getActiveSessions(): Session[] {
|
|
return this.getSessions().filter(s => s.active)
|
|
}
|
|
|
|
getMachines(): Machine[] {
|
|
return Array.from(this.machines.values())
|
|
}
|
|
|
|
getMachine(machineId: string): Machine | undefined {
|
|
return this.machines.get(machineId)
|
|
}
|
|
|
|
getOnlineMachines(): Machine[] {
|
|
return this.getMachines().filter(m => m.active)
|
|
}
|
|
|
|
getSessionMessages(sessionId: string): DecryptedMessage[] {
|
|
return this.sessionMessages.get(sessionId) || []
|
|
}
|
|
|
|
getMessagesPage(sessionId: string, options: { limit: number; beforeSeq: number | null }): {
|
|
messages: DecryptedMessage[]
|
|
page: {
|
|
limit: number
|
|
beforeSeq: number | null
|
|
nextBeforeSeq: number | null
|
|
hasMore: boolean
|
|
}
|
|
} {
|
|
const stored = this.store.getMessages(sessionId, options.limit, options.beforeSeq ?? undefined)
|
|
const messages: DecryptedMessage[] = stored.map((m) => ({
|
|
id: m.id,
|
|
seq: m.seq,
|
|
localId: m.localId,
|
|
content: m.content,
|
|
createdAt: m.createdAt
|
|
}))
|
|
|
|
let oldestSeq: number | null = null
|
|
for (const message of messages) {
|
|
if (typeof message.seq !== 'number') continue
|
|
if (oldestSeq === null || message.seq < oldestSeq) {
|
|
oldestSeq = message.seq
|
|
}
|
|
}
|
|
|
|
const nextBeforeSeq = oldestSeq
|
|
const hasMore = nextBeforeSeq !== null && this.store.getMessages(sessionId, 1, nextBeforeSeq).length > 0
|
|
|
|
return {
|
|
messages,
|
|
page: {
|
|
limit: options.limit,
|
|
beforeSeq: options.beforeSeq,
|
|
nextBeforeSeq,
|
|
hasMore
|
|
}
|
|
}
|
|
}
|
|
|
|
handleRealtimeEvent(event: SyncEvent): void {
|
|
if (event.type === 'session-updated' && event.sessionId) {
|
|
this.refreshSession(event.sessionId)
|
|
return
|
|
}
|
|
|
|
if (event.type === 'machine-updated' && event.machineId) {
|
|
this.refreshMachine(event.machineId)
|
|
return
|
|
}
|
|
|
|
this.emit(event)
|
|
}
|
|
|
|
handleSessionAlive(payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }): void {
|
|
const t = clampAliveTime(payload.time)
|
|
if (!t) return
|
|
|
|
const session = this.sessions.get(payload.sid) ?? this.refreshSession(payload.sid)
|
|
if (!session) return
|
|
|
|
const wasActive = session.active
|
|
const wasThinking = session.thinking
|
|
|
|
session.active = true
|
|
session.activeAt = Math.max(session.activeAt, t)
|
|
session.thinking = Boolean(payload.thinking)
|
|
session.thinkingAt = t
|
|
|
|
const now = Date.now()
|
|
const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0
|
|
const shouldBroadcast = (!wasActive && session.active)
|
|
|| (wasThinking !== session.thinking)
|
|
|| (now - lastBroadcastAt > 10_000)
|
|
|
|
if (shouldBroadcast) {
|
|
this.lastBroadcastAtBySessionId.set(session.id, now)
|
|
this.emit({ type: 'session-updated', sessionId: session.id, data: { activeAt: session.activeAt, thinking: session.thinking } })
|
|
}
|
|
}
|
|
|
|
handleSessionEnd(payload: { sid: string; time: number }): void {
|
|
const t = clampAliveTime(payload.time) ?? Date.now()
|
|
|
|
const session = this.sessions.get(payload.sid) ?? this.refreshSession(payload.sid)
|
|
if (!session) return
|
|
|
|
if (!session.active && !session.thinking) {
|
|
return
|
|
}
|
|
|
|
session.active = false
|
|
session.thinking = false
|
|
session.thinkingAt = t
|
|
|
|
this.emit({ type: 'session-updated', sessionId: session.id, data: { active: false, thinking: false } })
|
|
}
|
|
|
|
handleMachineAlive(payload: { machineId: string; time: number }): void {
|
|
const t = clampAliveTime(payload.time)
|
|
if (!t) return
|
|
|
|
const machine = this.machines.get(payload.machineId) ?? this.refreshMachine(payload.machineId)
|
|
if (!machine) return
|
|
|
|
const wasActive = machine.active
|
|
machine.active = true
|
|
machine.activeAt = Math.max(machine.activeAt, t)
|
|
|
|
const now = Date.now()
|
|
const lastBroadcastAt = this.lastBroadcastAtByMachineId.get(machine.id) ?? 0
|
|
const shouldBroadcast = (!wasActive && machine.active) || (now - lastBroadcastAt > 10_000)
|
|
if (shouldBroadcast) {
|
|
this.lastBroadcastAtByMachineId.set(machine.id, now)
|
|
this.emit({ type: 'machine-updated', machineId: machine.id, data: { activeAt: machine.activeAt } })
|
|
}
|
|
}
|
|
|
|
private expireInactive(): void {
|
|
const now = Date.now()
|
|
const sessionTimeoutMs = 30_000
|
|
const machineTimeoutMs = 45_000
|
|
|
|
for (const session of this.sessions.values()) {
|
|
if (!session.active) continue
|
|
if (now - session.activeAt <= sessionTimeoutMs) continue
|
|
session.active = false
|
|
session.thinking = false
|
|
this.emit({ type: 'session-updated', sessionId: session.id, data: { active: false } })
|
|
}
|
|
|
|
for (const machine of this.machines.values()) {
|
|
if (!machine.active) continue
|
|
if (now - machine.activeAt <= machineTimeoutMs) continue
|
|
machine.active = false
|
|
this.emit({ type: 'machine-updated', machineId: machine.id, data: { active: false } })
|
|
}
|
|
}
|
|
|
|
private refreshSession(sessionId: string): Session | null {
|
|
let stored = this.store.getSession(sessionId)
|
|
if (!stored) {
|
|
const existed = this.sessions.delete(sessionId)
|
|
if (existed) {
|
|
this.emit({ type: 'session-removed', sessionId })
|
|
}
|
|
return null
|
|
}
|
|
|
|
const existing = this.sessions.get(sessionId)
|
|
|
|
if (stored.todos === null && !this.todoBackfillAttemptedSessionIds.has(sessionId)) {
|
|
this.todoBackfillAttemptedSessionIds.add(sessionId)
|
|
const messages = this.store.getMessages(sessionId, 200)
|
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
const message = messages[i]
|
|
const todos = extractTodoWriteTodosFromMessageContent(message.content)
|
|
if (todos) {
|
|
const updated = this.store.setSessionTodos(sessionId, todos, message.createdAt)
|
|
if (updated) {
|
|
stored = this.store.getSession(sessionId) ?? stored
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const metadata = (() => {
|
|
const parsed = MetadataSchema.safeParse(stored.metadata)
|
|
return parsed.success ? parsed.data : null
|
|
})()
|
|
|
|
const agentState = (() => {
|
|
const parsed = AgentStateSchema.safeParse(stored.agentState)
|
|
return parsed.success ? parsed.data : null
|
|
})()
|
|
|
|
const todos = (() => {
|
|
if (stored.todos === null) return undefined
|
|
const parsed = TodosSchema.safeParse(stored.todos)
|
|
return parsed.success ? parsed.data : undefined
|
|
})()
|
|
|
|
const session: Session = {
|
|
id: stored.id,
|
|
seq: stored.seq,
|
|
createdAt: stored.createdAt,
|
|
updatedAt: stored.updatedAt,
|
|
active: existing?.active ?? stored.active,
|
|
activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt),
|
|
metadata,
|
|
metadataVersion: stored.metadataVersion,
|
|
agentState,
|
|
agentStateVersion: stored.agentStateVersion,
|
|
thinking: existing?.thinking ?? false,
|
|
thinkingAt: existing?.thinkingAt ?? 0,
|
|
todos,
|
|
permissionMode: existing?.permissionMode ?? null,
|
|
modelMode: existing?.modelMode ?? null
|
|
}
|
|
|
|
this.sessions.set(sessionId, session)
|
|
this.emit({ type: existing ? 'session-updated' : 'session-added', sessionId, data: session })
|
|
return session
|
|
}
|
|
|
|
private refreshMachine(machineId: string): Machine | null {
|
|
const stored = this.store.getMachine(machineId)
|
|
if (!stored) {
|
|
const existed = this.machines.delete(machineId)
|
|
if (existed) {
|
|
this.emit({ type: 'machine-updated', machineId, data: null })
|
|
}
|
|
return null
|
|
}
|
|
|
|
const existing = this.machines.get(machineId)
|
|
|
|
const metadata = (() => {
|
|
const parsed = machineMetadataSchema.safeParse(stored.metadata)
|
|
if (!parsed.success) return null
|
|
const data = parsed.data as Record<string, unknown>
|
|
const host = typeof data.host === 'string' ? data.host : 'unknown'
|
|
const platform = typeof data.platform === 'string' ? data.platform : 'unknown'
|
|
const happyCliVersion = typeof data.happyCliVersion === 'string' ? data.happyCliVersion : 'unknown'
|
|
const displayName = typeof data.displayName === 'string' ? data.displayName : undefined
|
|
return { host, platform, happyCliVersion, displayName, ...data }
|
|
})()
|
|
|
|
const machine: Machine = {
|
|
id: stored.id,
|
|
seq: stored.seq,
|
|
createdAt: stored.createdAt,
|
|
updatedAt: stored.updatedAt,
|
|
active: existing?.active ?? stored.active,
|
|
activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt),
|
|
metadata,
|
|
metadataVersion: stored.metadataVersion,
|
|
daemonState: stored.daemonState,
|
|
daemonStateVersion: stored.daemonStateVersion
|
|
}
|
|
|
|
this.machines.set(machineId, machine)
|
|
this.emit({ type: 'machine-updated', machineId, data: machine })
|
|
return machine
|
|
}
|
|
|
|
private reloadAll(): void {
|
|
const sessions = this.store.getSessions()
|
|
for (const s of sessions) {
|
|
this.refreshSession(s.id)
|
|
}
|
|
|
|
const machines = this.store.getMachines()
|
|
for (const m of machines) {
|
|
this.refreshMachine(m.id)
|
|
}
|
|
}
|
|
|
|
getOrCreateSession(tag: string, metadata: unknown, agentState: unknown): Session {
|
|
const stored = this.store.getOrCreateSession(tag, metadata, agentState)
|
|
return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })()
|
|
}
|
|
|
|
getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown): Machine {
|
|
const stored = this.store.getOrCreateMachine(id, metadata, daemonState)
|
|
return this.refreshMachine(stored.id) ?? (() => { throw new Error('Failed to load machine') })()
|
|
}
|
|
|
|
async fetchMessages(sessionId: string): Promise<FetchMessagesResult> {
|
|
try {
|
|
const stored = this.store.getMessages(sessionId, 200)
|
|
const messages: DecryptedMessage[] = stored.map((m) => ({
|
|
id: m.id,
|
|
seq: m.seq,
|
|
localId: m.localId,
|
|
content: m.content,
|
|
createdAt: m.createdAt
|
|
}))
|
|
this.sessionMessages.set(sessionId, messages)
|
|
return { ok: true, messages }
|
|
} catch (error) {
|
|
return { ok: false, status: null, error: error instanceof Error ? error.message : 'Failed to load messages' }
|
|
}
|
|
}
|
|
|
|
async sendMessage(sessionId: string, payload: { text: string; localId?: string | null; sentFrom?: 'telegram-bot' | 'webapp' }): Promise<void> {
|
|
const session = this.sessions.get(sessionId)
|
|
const sentFrom = payload.sentFrom ?? 'webapp'
|
|
|
|
const content = {
|
|
role: 'user',
|
|
content: {
|
|
type: 'text',
|
|
text: payload.text
|
|
},
|
|
meta: {
|
|
sentFrom,
|
|
permissionMode: session?.permissionMode || 'default',
|
|
model: session?.modelMode === 'default' ? null : session?.modelMode ?? undefined
|
|
}
|
|
}
|
|
|
|
const msg = this.store.addMessage(sessionId, content, payload.localId ?? undefined)
|
|
|
|
const update = {
|
|
id: msg.id,
|
|
seq: Date.now(),
|
|
createdAt: msg.createdAt,
|
|
body: {
|
|
t: 'new-message' as const,
|
|
sid: sessionId,
|
|
message: {
|
|
id: msg.id,
|
|
seq: msg.seq,
|
|
createdAt: msg.createdAt,
|
|
localId: msg.localId,
|
|
content: msg.content
|
|
}
|
|
}
|
|
}
|
|
this.io.of('/cli').to(`session:${sessionId}`).emit('update', update)
|
|
|
|
// Keep a small in-memory cache for Telegram rendering.
|
|
const cached = this.sessionMessages.get(sessionId) ?? []
|
|
cached.push({ id: msg.id, seq: msg.seq, localId: msg.localId, content: msg.content, createdAt: msg.createdAt })
|
|
this.sessionMessages.set(sessionId, cached.slice(-200))
|
|
|
|
this.emit({
|
|
type: 'message-received',
|
|
sessionId,
|
|
message: {
|
|
id: msg.id,
|
|
seq: msg.seq,
|
|
localId: msg.localId,
|
|
content: msg.content,
|
|
createdAt: msg.createdAt
|
|
}
|
|
})
|
|
}
|
|
|
|
async approvePermission(
|
|
sessionId: string,
|
|
requestId: string,
|
|
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan',
|
|
allowTools?: string[],
|
|
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
|
|
): Promise<void> {
|
|
await this.sessionRpc(sessionId, 'permission', {
|
|
id: requestId,
|
|
approved: true,
|
|
mode,
|
|
allowTools,
|
|
decision
|
|
})
|
|
}
|
|
|
|
async denyPermission(
|
|
sessionId: string,
|
|
requestId: string,
|
|
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
|
|
): Promise<void> {
|
|
await this.sessionRpc(sessionId, 'permission', {
|
|
id: requestId,
|
|
approved: false,
|
|
decision
|
|
})
|
|
}
|
|
|
|
async abortSession(sessionId: string): Promise<void> {
|
|
await this.sessionRpc(sessionId, 'abort', { reason: 'User aborted via Telegram Bot' })
|
|
}
|
|
|
|
async setPermissionMode(
|
|
sessionId: string,
|
|
mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
|
|
): Promise<void> {
|
|
const session = this.sessions.get(sessionId)
|
|
if (session) {
|
|
session.permissionMode = mode
|
|
this.emit({ type: 'session-updated', sessionId, data: session })
|
|
}
|
|
}
|
|
|
|
async setModelMode(sessionId: string, model: 'default' | 'sonnet' | 'opus'): Promise<void> {
|
|
const session = this.sessions.get(sessionId)
|
|
if (session) {
|
|
session.modelMode = model
|
|
this.emit({ type: 'session-updated', sessionId, data: session })
|
|
}
|
|
}
|
|
|
|
async spawnSession(
|
|
machineId: string,
|
|
directory: string,
|
|
agent: 'claude' | 'codex' = 'claude'
|
|
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
|
try {
|
|
const result = await this.machineRpc(machineId, 'spawn-happy-session', { type: 'spawn-in-directory', directory, agent })
|
|
if (result && typeof result === 'object') {
|
|
const obj = result as Record<string, unknown>
|
|
if (obj.type === 'success' && typeof obj.sessionId === 'string') {
|
|
return { type: 'success', sessionId: obj.sessionId }
|
|
}
|
|
if (obj.type === 'error' && typeof obj.errorMessage === 'string') {
|
|
return { type: 'error', message: obj.errorMessage }
|
|
}
|
|
}
|
|
return { type: 'error', message: 'Unexpected spawn result' }
|
|
} catch (error) {
|
|
return { type: 'error', message: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|
|
|
|
private async sessionRpc(sessionId: string, method: string, params: unknown): Promise<unknown> {
|
|
return await this.rpcCall(`${sessionId}:${method}`, params)
|
|
}
|
|
|
|
private async machineRpc(machineId: string, method: string, params: unknown): Promise<unknown> {
|
|
return await this.rpcCall(`${machineId}:${method}`, params)
|
|
}
|
|
|
|
private async rpcCall(method: string, params: unknown): Promise<unknown> {
|
|
const socketId = this.rpcRegistry.getSocketIdForMethod(method)
|
|
if (!socketId) {
|
|
throw new Error(`RPC handler not registered: ${method}`)
|
|
}
|
|
|
|
const socket = this.io.of('/cli').sockets.get(socketId)
|
|
if (!socket) {
|
|
throw new Error(`RPC socket disconnected: ${method}`)
|
|
}
|
|
|
|
const response = await socket.timeout(30_000).emitWithAck('rpc-request', {
|
|
method,
|
|
params: JSON.stringify(params)
|
|
}) as unknown
|
|
|
|
if (typeof response !== 'string') {
|
|
return response
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(response) as unknown
|
|
} catch {
|
|
return response
|
|
}
|
|
}
|
|
}
|