Kanban:补齐看板事件、链接与批量操作闭环 (#634)
* feat(kanban): add board-scoped event stream bridge * test(kanban): align event refresh expectation * feat(kanban): add links and partial bulk bridge * test(kanban): align links bulk refresh expectation * fix(kanban): treat mutation stderr as failed
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { WebSocket } from 'ws'
|
||||
import type { Server as HttpServer, IncomingMessage } from 'http'
|
||||
import { getToken } from '../../services/auth'
|
||||
import { logger } from '../../services/logger'
|
||||
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||||
|
||||
interface KanbanEventsRequest extends IncomingMessage {
|
||||
kanbanBoard?: string
|
||||
}
|
||||
|
||||
function sendJson(ws: WebSocket, payload: Record<string, unknown>) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function streamLines(onLine: (line: string) => void) {
|
||||
let buffer = ''
|
||||
return (chunk: Buffer | string) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[]) {
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
|
||||
|
||||
servers.forEach((httpServer) => {
|
||||
httpServer.on('upgrade', async (req: KanbanEventsRequest, socket, head) => {
|
||||
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
||||
if (url.pathname !== '/api/hermes/kanban/events') return
|
||||
|
||||
const authToken = await getToken()
|
||||
if (authToken) {
|
||||
const token = url.searchParams.get('token') || ''
|
||||
if (token !== authToken) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
req.kanbanBoard = kanbanCli.normalizeBoardSlug(url.searchParams.get('board'))
|
||||
} catch {
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
wss.on('connection', (ws, req: KanbanEventsRequest) => {
|
||||
const board = req.kanbanBoard || 'default'
|
||||
const child = kanbanCli.watchEvents({ board, interval: 0.5 })
|
||||
let closed = false
|
||||
|
||||
sendJson(ws, { type: 'connected', board })
|
||||
|
||||
const closeChild = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (!child.killed) child.kill()
|
||||
}
|
||||
|
||||
child.stdout?.on('data', streamLines((line) => {
|
||||
if (line.toLowerCase().startsWith('watching kanban events')) return
|
||||
sendJson(ws, { type: 'event', board, line })
|
||||
}))
|
||||
|
||||
child.stderr?.on('data', streamLines((line) => {
|
||||
sendJson(ws, { type: 'error', board, message: line })
|
||||
}))
|
||||
|
||||
child.on('error', (err) => {
|
||||
logger.error(err, 'Hermes CLI: kanban watch failed')
|
||||
sendJson(ws, { type: 'error', board, message: err.message })
|
||||
if (ws.readyState === ws.OPEN) ws.close()
|
||||
})
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
sendJson(ws, { type: 'stopped', board, code, signal })
|
||||
if (ws.readyState === ws.OPEN) ws.close()
|
||||
})
|
||||
|
||||
ws.on('close', closeChild)
|
||||
ws.on('error', closeChild)
|
||||
})
|
||||
|
||||
logger.info('WebSocket ready at /api/hermes/kanban/events (kanban watch bridge)')
|
||||
}
|
||||
@@ -13,6 +13,9 @@ kanbanRoutes.get('/api/hermes/kanban/diagnostics', ctrl.diagnostics)
|
||||
kanbanRoutes.post('/api/hermes/kanban/dispatch', ctrl.dispatch)
|
||||
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
|
||||
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
|
||||
kanbanRoutes.post('/api/hermes/kanban/links', ctrl.linkTasks)
|
||||
kanbanRoutes.delete('/api/hermes/kanban/links', ctrl.unlinkTasks)
|
||||
kanbanRoutes.post('/api/hermes/kanban/tasks/bulk', ctrl.bulkUpdateTasks)
|
||||
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
|
||||
kanbanRoutes.get('/api/hermes/kanban/:id', ctrl.get)
|
||||
kanbanRoutes.post('/api/hermes/kanban', ctrl.create)
|
||||
|
||||
Reference in New Issue
Block a user