diff --git a/packages/client/src/api/hermes/kanban.ts b/packages/client/src/api/hermes/kanban.ts index 11fd40c0..2481d43a 100644 --- a/packages/client/src/api/hermes/kanban.ts +++ b/packages/client/src/api/hermes/kanban.ts @@ -1,4 +1,4 @@ -import { request } from '../client' +import { request, getApiKey, getBaseUrlValue } from '../client' // ─── Types ────────────────────────────────────────────────────── @@ -84,6 +84,8 @@ export interface KanbanTaskDetail { comments: KanbanComment[] events: KanbanEvent[] runs: KanbanRun[] + parents?: string[] + children?: string[] } export interface KanbanStats { @@ -198,6 +200,26 @@ export interface KanbanDispatchOptions extends KanbanBoardOptions { failureLimit?: number } +export interface KanbanLinkRequest { + parent_id: string + child_id: string +} + +export interface KanbanBulkUpdateRequest { + ids: string[] + status?: KanbanTaskStatus + assignee?: string | null + archive?: boolean + summary?: string + reason?: string +} + +export interface KanbanBulkTaskResult { + id: string + ok: boolean + error?: string +} + function normalizedBoard(board?: string): string { const trimmed = board?.trim() return trimmed || 'default' @@ -214,6 +236,37 @@ function boardParams(board?: string): URLSearchParams { return params } +function websocketProtocol(base?: string): string { + if (base) return base.startsWith('https') ? 'wss:' : 'ws:' + return location.protocol === 'https:' ? 'wss:' : 'ws:' +} + +function formatHostForPort(hostname: string, port: number): string { + if (hostname.startsWith('[') && hostname.endsWith(']')) return `${hostname}:${port}` + return hostname.includes(':') ? `[${hostname}]:${port}` : `${hostname}:${port}` +} + +export function buildKanbanEventsWebSocketUrl(opts?: KanbanBoardOptions): string { + const base = getBaseUrlValue() + const params = boardParams(opts?.board) + const token = getApiKey() + if (token) params.set('token', token) + const path = `/api/hermes/kanban/events?${params.toString()}` + + if (base) { + return `${websocketProtocol(base)}//${new URL(base).host}${path}` + } + + const host = import.meta.env.DEV + ? formatHostForPort(location.hostname, 8648) + : location.host + return `${websocketProtocol()}//${host}${path}` +} + +export function openKanbanEventStream(opts?: KanbanBoardOptions): WebSocket { + return new WebSocket(buildKanbanEventsWebSocketUrl(opts)) +} + // ─── API functions ─────────────────────────────────────────────── export async function listBoards(opts?: { includeArchived?: boolean }): Promise { @@ -299,6 +352,29 @@ export async function addComment(taskId: string, data: KanbanCommentCreateReques }) } +export async function linkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> { + return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function unlinkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> { + const params = boardParams(opts?.board) + params.set('parent_id', data.parent_id) + params.set('child_id', data.child_id) + return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', params), { + method: 'DELETE', + }) +} + +export async function bulkUpdateTasks(data: KanbanBulkUpdateRequest, opts?: KanbanBoardOptions): Promise<{ results: KanbanBulkTaskResult[] }> { + return request<{ results: KanbanBulkTaskResult[] }>(appendQuery('/api/hermes/kanban/tasks/bulk', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify(data), + }) +} + export async function getTaskLog(taskId: string, opts?: KanbanTaskLogOptions): Promise { const params = boardParams(opts?.board) if (opts?.tail !== undefined) params.set('tail', String(opts.tail)) diff --git a/packages/client/src/stores/hermes/kanban.ts b/packages/client/src/stores/hermes/kanban.ts index 9a02b1ed..6f2cbae1 100644 --- a/packages/client/src/stores/hermes/kanban.ts +++ b/packages/client/src/stores/hermes/kanban.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import * as kanbanApi from '@/api/hermes/kanban' -import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities, KanbanDiagnosticsOptions, KanbanDispatchOptions } from '@/api/hermes/kanban' +import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities, KanbanDiagnosticsOptions, KanbanDispatchOptions, KanbanBulkUpdateRequest } from '@/api/hermes/kanban' export const KANBAN_SELECTED_BOARD_STORAGE_KEY = 'hermes.kanban.selectedBoard' export const DEFAULT_KANBAN_BOARD = 'default' @@ -53,6 +53,11 @@ export const useKanbanStore = defineStore('kanban', () => { let statsRequestSeq = 0 let assigneesRequestSeq = 0 let loadingRequestSeq = 0 + let eventStreamSeq = 0 + let eventSocket: WebSocket | null = null + let eventReconnectTimer: ReturnType | null = null + let eventRefreshTimer: ReturnType | null = null + let eventStreamEnabled = false const activeBoards = computed(() => { const visible = boards.value.filter(board => !board.archived) @@ -86,6 +91,19 @@ export const useKanbanStore = defineStore('kanban', () => { } } + function hasCapabilityStatus(key: string, statuses: Array<'supported' | 'partial' | 'missing'>): boolean { + const detail = capabilities.value?.capabilities?.find(capability => capability.key === key) + if (detail) return statuses.includes(detail.status) + if (statuses.includes('supported')) return isCapabilitySupported(key) + return false + } + + function assertCapabilityStatus(key: string, statuses: Array<'supported' | 'partial' | 'missing'>): void { + if (!hasCapabilityStatus(key, statuses)) { + throw new Error(`Kanban capability "${key}" is not available with the required status`) + } + } + function boardExists(board: string): boolean { return activeBoards.value.some(item => item.slug === board) } @@ -102,6 +120,97 @@ export const useKanbanStore = defineStore('kanban', () => { assignees.value = [] } + function clearEventTimers() { + if (eventReconnectTimer) clearTimeout(eventReconnectTimer) + if (eventRefreshTimer) clearTimeout(eventRefreshTimer) + eventReconnectTimer = null + eventRefreshTimer = null + } + + function closeEventSocket() { + if (!eventSocket) return + const socket = eventSocket + eventSocket = null + socket.onclose = null + socket.onerror = null + socket.onmessage = null + try { socket.close() } catch { } + } + + function stopEventStream() { + eventStreamEnabled = false + eventStreamSeq++ + clearEventTimers() + closeEventSocket() + } + + function scheduleEventRefresh(board: string, generation: number, seq: number) { + if (eventRefreshTimer) clearTimeout(eventRefreshTimer) + eventRefreshTimer = setTimeout(() => { + if (!eventStreamEnabled || seq !== eventStreamSeq || generation !== boardGeneration || board !== selectedBoard.value) return + void Promise.all([fetchBoards(), fetchTasks(true), fetchStats(), fetchAssignees()]) + }, 100) + } + + function scheduleEventReconnect(board: string, generation: number, seq: number) { + if (eventReconnectTimer) clearTimeout(eventReconnectTimer) + eventReconnectTimer = setTimeout(() => { + if (!eventStreamEnabled || seq !== eventStreamSeq || generation !== boardGeneration || board !== selectedBoard.value) return + connectEventStream(board, generation, seq) + }, 3000) + } + + function connectEventStream(board: string, generation: number, seq: number) { + closeEventSocket() + let socket: WebSocket + try { + socket = kanbanApi.openKanbanEventStream({ board }) + } catch (err) { + console.error('Failed to open kanban event stream:', err) + scheduleEventReconnect(board, generation, seq) + return + } + eventSocket = socket + socket.onmessage = (event) => { + if (!eventStreamEnabled || seq !== eventStreamSeq || generation !== boardGeneration || board !== selectedBoard.value) return + try { + const payload = JSON.parse(String(event.data)) + if (payload?.type === 'event') scheduleEventRefresh(board, generation, seq) + } catch { + scheduleEventRefresh(board, generation, seq) + } + } + socket.onerror = () => { + if (eventSocket === socket) console.error('Kanban event stream error') + } + socket.onclose = () => { + if (eventSocket === socket) { + eventSocket = null + scheduleEventReconnect(board, generation, seq) + } + } + } + + function hasEventStreamCapability(): boolean { + const status = capabilities.value?.capabilities?.find(capability => capability.key === 'events')?.status + return status === 'supported' || status === 'partial' || isCapabilitySupported('events') + } + + function startEventStream() { + if (!hasEventStreamCapability()) return false + eventStreamEnabled = true + const seq = ++eventStreamSeq + const generation = boardGeneration + const board = selectedBoard.value + clearEventTimers() + connectEventStream(board, generation, seq) + return true + } + + function restartEventStreamIfActive() { + if (eventStreamEnabled) startEventStream() + } + function setSelectedBoard(board?: string | null): string { const resolved = resolveAvailableBoard(board) const changed = selectedBoard.value !== resolved @@ -111,6 +220,7 @@ export const useKanbanStore = defineStore('kanban', () => { if (changed) { clearBoardScopedState() boardGeneration++ + restartEventStreamIfActive() } return resolved } @@ -276,6 +386,30 @@ export const useKanbanStore = defineStore('kanban', () => { return kanbanApi.addComment(taskId, { body, author }, { board: selectedBoard.value }) } + async function linkTasks(parentId: string, childId: string) { + assertCapability('links') + const board = selectedBoard.value + const result = await kanbanApi.linkTasks({ parent_id: parentId, child_id: childId }, { board }) + if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()]) + return result + } + + async function unlinkTasks(parentId: string, childId: string) { + assertCapability('links') + const board = selectedBoard.value + const result = await kanbanApi.unlinkTasks({ parent_id: parentId, child_id: childId }, { board }) + if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()]) + return result + } + + async function bulkUpdateTasks(data: Omit & { ids: string[] }) { + assertCapabilityStatus('bulk', ['supported', 'partial']) + const board = selectedBoard.value + const result = await kanbanApi.bulkUpdateTasks(data, { board }) + if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards(), fetchAssignees()]) + return result + } + async function getTaskLog(taskId: string, tail?: number) { assertCapability('taskLog') return kanbanApi.getTaskLog(taskId, { board: selectedBoard.value, tail }) @@ -358,6 +492,9 @@ export const useKanbanStore = defineStore('kanban', () => { unblockTasks, assignTask, addComment, + linkTasks, + unlinkTasks, + bulkUpdateTasks, getTaskLog, getDiagnostics, reclaimTask, @@ -366,6 +503,8 @@ export const useKanbanStore = defineStore('kanban', () => { dispatch, setFilter, setSelectedBoard, + startEventStream, + stopEventStream, recoverSelectedBoard, resolveAvailableBoard, clearBoardScopedState, diff --git a/packages/client/src/views/hermes/KanbanView.vue b/packages/client/src/views/hermes/KanbanView.vue index 4cc22e2e..f1da3e7c 100644 --- a/packages/client/src/views/hermes/KanbanView.vue +++ b/packages/client/src/views/hermes/KanbanView.vue @@ -113,6 +113,7 @@ watch(() => route.query.board, async () => { onMounted(async () => { await Promise.all([kanbanStore.fetchBoards(), kanbanStore.fetchCapabilities()]) await applyBoardSelection(routeBoard(), true, true) + kanbanStore.startEventStream() routeReady.value = true refreshTimer.value = setInterval(() => { if (document.visibilityState === 'visible') { @@ -122,6 +123,7 @@ onMounted(async () => { }) onUnmounted(() => { + kanbanStore.stopEventStream() if (refreshTimer.value) clearInterval(refreshTimer.value) }) diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts index 5cde926e..a7463b9c 100644 --- a/packages/server/src/controllers/hermes/kanban.ts +++ b/packages/server/src/controllers/hermes/kanban.ts @@ -41,6 +41,7 @@ function validSeverity(value?: string): value is 'warning' | 'error' | 'critical const MAX_LOG_TAIL_BYTES = 1_000_000 const MAX_DISPATCH_TASKS = 100 const MAX_DISPATCH_FAILURE_LIMIT = 100 +const MAX_BULK_TASKS = 100 type PositiveIntegerResult = { value?: number; error?: string } type StringResult = { value?: string; error?: string } @@ -85,6 +86,25 @@ function optionalString(value: unknown, name: string): StringResult { return { value } } +function optionalNullableString(value: unknown, name: string): { value?: string | null; error?: string } { + if (value === undefined) return {} + if (value === null) return { value: null } + if (typeof value !== 'string') return { error: `${name} must be a string` } + return { value } +} + +function hasOwn(body: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(body, key) +} + +function optionalTaskStatus(value: unknown, name: string): { value?: kanbanCli.KanbanTaskStatus; error?: string } { + if (value === undefined || value === null) return {} + if (value !== 'triage' && value !== 'todo' && value !== 'ready' && value !== 'running' && value !== 'blocked' && value !== 'done' && value !== 'archived') { + return { error: `${name} must be a valid kanban task status` } + } + return { value } +} + function requiredNonEmptyString(value: unknown, name: string): StringResult { if (typeof value !== 'string' || !value.trim()) return { error: `${name} is required` } return { value } @@ -364,6 +384,80 @@ export async function addComment(ctx: Context) { } } +export async function linkTasks(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const parentId = requiredNonEmptyString(bodyResult.body.parent_id, 'parent_id') + const childId = requiredNonEmptyString(bodyResult.body.child_id, 'child_id') + if (rejectBadRequest(ctx, parentId.error || childId.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.linkTasks(parentId.value!.trim(), childId.value!.trim(), { board }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function unlinkTasks(ctx: Context) { + const parentId = requiredNonEmptyString(firstQueryValue(ctx.query.parent_id as string | string[] | undefined), 'parent_id') + const childId = requiredNonEmptyString(firstQueryValue(ctx.query.child_id as string | string[] | undefined), 'child_id') + if (rejectBadRequest(ctx, parentId.error || childId.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.unlinkTasks(parentId.value!.trim(), childId.value!.trim(), { board }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function bulkUpdateTasks(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const ids = requiredNonEmptyStringArray(body.ids, 'ids') + const status = optionalTaskStatus(body.status, 'status') + const assignee = optionalNullableString(body.assignee, 'assignee') + const archive = optionalBoolean(body.archive, 'archive') + const summary = optionalString(body.summary, 'summary') + const reason = optionalString(body.reason, 'reason') + if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return + if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) { + ctx.status = 400 + ctx.body = { error: 'at least one bulk action is required' } + return + } + if (ids.value!.length > MAX_BULK_TASKS) { + ctx.status = 400 + ctx.body = { error: `ids must contain <= ${MAX_BULK_TASKS} tasks` } + return + } + if (archive.value && status.value !== undefined) { + ctx.status = 400 + ctx.body = { error: 'archive cannot be combined with status' } + return + } + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.bulkUpdateTasks({ + board, + ids: ids.value!.map(id => id.trim()), + status: status.value, + assignee: assignee.value, + archive: archive.value, + summary: summary.value, + reason: reason.value, + }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + export async function taskLog(ctx: Context) { const board = requestBoard(ctx) if (!board) return diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 018daca2..a3ebcfd1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -13,6 +13,7 @@ import { initLoginLimiter } from './services/login-limiter' import { initGatewayManager, getGatewayManagerInstance } from './services/gateway-bootstrap' import { bindShutdown } from './services/shutdown' import { setupTerminalWebSocket } from './routes/hermes/terminal' +import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events' import { startVersionCheck } from './routes/health' import { registerRoutes } from './routes' import { setGroupChatServer } from './routes/hermes/group-chat' @@ -136,7 +137,8 @@ export async function bootstrap() { console.log('[bootstrap] app.listen called') setupTerminalWebSocket(servers) - console.log('[bootstrap] terminal websocket setup') + setupKanbanEventsWebSocket(servers) + console.log('[bootstrap] terminal + kanban websocket setup') // Group chat Socket.IO (must be after server is created) const groupChatServer = new GroupChatServer(servers) @@ -159,7 +161,7 @@ export async function bootstrap() { servers.forEach((httpServer) => { httpServer.on('upgrade', (req: any, socket: any) => { const url = new URL(req.url || '', `http://${req.headers.host}`) - if (url.pathname !== '/api/hermes/terminal' && !url.pathname.startsWith('/socket.io/')) { + if (url.pathname !== '/api/hermes/terminal' && url.pathname !== '/api/hermes/kanban/events' && !url.pathname.startsWith('/socket.io/')) { socket.destroy() } }) diff --git a/packages/server/src/routes/hermes/kanban-events.ts b/packages/server/src/routes/hermes/kanban-events.ts new file mode 100644 index 00000000..6b46762c --- /dev/null +++ b/packages/server/src/routes/hermes/kanban-events.ts @@ -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) { + 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)') +} diff --git a/packages/server/src/routes/hermes/kanban.ts b/packages/server/src/routes/hermes/kanban.ts index ce181335..254b117c 100644 --- a/packages/server/src/routes/hermes/kanban.ts +++ b/packages/server/src/routes/hermes/kanban.ts @@ -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) diff --git a/packages/server/src/services/hermes/hermes-kanban.ts b/packages/server/src/services/hermes/hermes-kanban.ts index 28b1b995..d6b0fa40 100644 --- a/packages/server/src/services/hermes/hermes-kanban.ts +++ b/packages/server/src/services/hermes/hermes-kanban.ts @@ -1,4 +1,5 @@ -import { execFile } from 'child_process' +import { execFile, spawn } from 'child_process' +import type { ChildProcess } from 'child_process' import { promisify } from 'util' import { logger } from '../logger' @@ -155,6 +156,29 @@ export interface KanbanBoardOptions { board?: string } +export interface KanbanWatchOptions extends KanbanBoardOptions { + interval?: number +} + +export interface KanbanBulkTaskUpdateOptions extends KanbanBoardOptions { + ids: string[] + status?: KanbanTaskStatus + assignee?: string | null + archive?: boolean + summary?: string + reason?: string +} + +export interface KanbanBulkTaskResult { + id: string + ok: boolean + error?: string +} + +export interface KanbanBulkTaskUpdateResult { + results: KanbanBulkTaskResult[] +} + // ─── CLI wrappers ─────────────────────────────────────────────── export async function listBoards(opts?: { includeArchived?: boolean }): Promise { @@ -235,9 +259,9 @@ export async function getCapabilities(): Promise { { key: 'reassign', status: 'supported', canonicalRoute: '/tasks/{task_id}/reassign', canonicalCommand: 'reassign', requiresBoard: true }, { key: 'specify', status: 'supported', canonicalRoute: '/tasks/{task_id}/specify', canonicalCommand: 'specify', requiresBoard: true }, { key: 'dispatch', status: 'supported', canonicalRoute: '/dispatch', canonicalCommand: 'dispatch', requiresBoard: true }, - { key: 'links', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true }, - { key: 'bulk', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/tasks/bulk', canonicalCommand: 'bulk-equivalent', requiresBoard: true }, - { key: 'events', status: 'missing', reason: 'Streaming strategy not selected for WUI yet', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true }, + { key: 'links', status: 'supported', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true }, + { key: 'bulk', status: 'partial', reason: 'WUI applies supported bulk-equivalent CLI transitions per id and returns per-task outcomes; direct priority/status patch parity remains deferred', canonicalRoute: '/tasks/bulk', canonicalCommand: 'bulk-equivalent via complete/block/unblock/archive/assign', requiresBoard: true }, + { key: 'events', status: 'partial', reason: 'WUI exposes a board-scoped WebSocket bridge backed by the canonical `kanban watch` stream; payload is currently a refresh invalidation signal, not a typed event model', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true }, { key: 'homeSubscriptions', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/home-channels and subscription routes', canonicalCommand: 'notify-*', requiresBoard: true }, ] const supports = Object.fromEntries(capabilities.map(capability => [capability.key, capability.status === 'supported'])) as Record @@ -266,6 +290,58 @@ function pushOptional(args: string[], flag: string, value?: string | number | nu if (value !== undefined && value !== null && String(value).trim() !== '') args.push(flag, String(value)) } +function textFromExecValue(value: unknown): string { + if (Buffer.isBuffer(value)) return value.toString('utf8') + return value === undefined || value === null ? '' : String(value) +} + +async function execKanbanMutation(args: string[], logMessage: string, errorPrefix: string): Promise { + try { + const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + const stderrText = textFromExecValue(stderr).trim() + if (stderrText) throw new Error(stderrText) + return textFromExecValue(stdout) + } catch (err: any) { + logger.error(err, logMessage) + throw new Error(`${errorPrefix}: ${err.message}`) + } +} + +export function buildWatchArgs(opts?: KanbanWatchOptions): string[] { + const args = [...boardArgs(opts?.board), 'watch'] + pushOptional(args, '--interval', opts?.interval ?? 0.5) + return args +} + +export function watchEvents(opts?: KanbanWatchOptions): ChildProcess { + return spawn(HERMES_BIN, buildWatchArgs(opts), { + stdio: ['ignore', 'pipe', 'pipe'], + ...execOpts, + }) +} + +export async function linkTasks(parentId: string, childId: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output: string }> { + const output = await execKanbanMutation( + [...boardArgs(opts?.board), 'link', parentId, childId], + 'Hermes CLI: kanban link failed', + 'Failed to link kanban tasks', + ) + return { ok: true, output } +} + +export async function unlinkTasks(parentId: string, childId: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output: string }> { + const output = await execKanbanMutation( + [...boardArgs(opts?.board), 'unlink', parentId, childId], + 'Hermes CLI: kanban unlink failed', + 'Failed to unlink kanban tasks', + ) + return { ok: true, output } +} + export async function addComment(taskId: string, body: string, opts?: KanbanBoardOptions & { author?: string }): Promise<{ ok: boolean; output: string }> { const args = [...boardArgs(opts?.board), 'comment', taskId, body] pushOptional(args, '--author', opts?.author) @@ -476,55 +552,72 @@ export async function completeTasks(taskIds: string[], summary?: string, opts?: const args = [...boardArgs(opts?.board), 'complete', ...taskIds] if (summary) args.push('--summary', summary) - try { - await execFileAsync(HERMES_BIN, args, { - maxBuffer: 50 * 1024 * 1024, - timeout: 30000, - ...execOpts, - }) - } catch (err: any) { - logger.error(err, 'Hermes CLI: kanban complete failed') - throw new Error(`Failed to complete kanban tasks: ${err.message}`) - } + await execKanbanMutation(args, 'Hermes CLI: kanban complete failed', 'Failed to complete kanban tasks') } export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise { - try { - await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'block', taskId, reason], { - maxBuffer: 50 * 1024 * 1024, - timeout: 30000, - ...execOpts, - }) - } catch (err: any) { - logger.error(err, 'Hermes CLI: kanban block failed') - throw new Error(`Failed to block kanban task: ${err.message}`) - } + await execKanbanMutation( + [...boardArgs(opts?.board), 'block', taskId, reason], + 'Hermes CLI: kanban block failed', + 'Failed to block kanban task', + ) } export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise { - try { - await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'unblock', ...taskIds], { - maxBuffer: 50 * 1024 * 1024, - timeout: 30000, - ...execOpts, - }) - } catch (err: any) { - logger.error(err, 'Hermes CLI: kanban unblock failed') - throw new Error(`Failed to unblock kanban tasks: ${err.message}`) - } + await execKanbanMutation( + [...boardArgs(opts?.board), 'unblock', ...taskIds], + 'Hermes CLI: kanban unblock failed', + 'Failed to unblock kanban tasks', + ) } export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise { - try { - await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'assign', taskId, profile], { - maxBuffer: 50 * 1024 * 1024, - timeout: 30000, - ...execOpts, - }) - } catch (err: any) { - logger.error(err, 'Hermes CLI: kanban assign failed') - throw new Error(`Failed to assign kanban task: ${err.message}`) + await execKanbanMutation( + [...boardArgs(opts?.board), 'assign', taskId, profile], + 'Hermes CLI: kanban assign failed', + 'Failed to assign kanban task', + ) +} + +export async function archiveTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise { + await execKanbanMutation( + [...boardArgs(opts?.board), 'archive', ...taskIds], + 'Hermes CLI: kanban archive failed', + 'Failed to archive kanban tasks', + ) +} + +async function applyBulkStatus(taskId: string, opts: KanbanBulkTaskUpdateOptions): Promise { + switch (opts.status) { + case undefined: + return + case 'done': + return completeTasks([taskId], opts.summary, opts) + case 'blocked': + return blockTask(taskId, opts.reason?.trim() || 'Bulk update', opts) + case 'ready': + return unblockTasks([taskId], opts) + case 'archived': + return archiveTasks([taskId], opts) + default: + throw new Error(`Bulk status ${opts.status} is not supported by the CLI bridge`) + } +} + +export async function bulkUpdateTasks(opts: KanbanBulkTaskUpdateOptions): Promise { + const ids = opts.ids.map(id => id.trim()).filter(Boolean) + const results: KanbanBulkTaskResult[] = [] + for (const id of ids) { + try { + if (opts.archive) await archiveTasks([id], opts) + else await applyBulkStatus(id, opts) + if (opts.assignee !== undefined) await assignTask(id, opts.assignee?.trim() || 'none', opts) + results.push({ id, ok: true }) + } catch (err: any) { + results.push({ id, ok: false, error: err?.message || String(err) }) + } } + return { results } } export async function getStats(opts?: KanbanBoardOptions): Promise { diff --git a/tests/client/kanban-api.test.ts b/tests/client/kanban-api.test.ts index 9f463181..2c3ca311 100644 --- a/tests/client/kanban-api.test.ts +++ b/tests/client/kanban-api.test.ts @@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockRequest = vi.hoisted(() => vi.fn()) +const mockGetApiKey = vi.hoisted(() => vi.fn(() => '')) +const mockGetBaseUrlValue = vi.hoisted(() => vi.fn(() => '')) vi.mock('../../packages/client/src/api/client', () => ({ request: mockRequest, + getApiKey: mockGetApiKey, + getBaseUrlValue: mockGetBaseUrlValue, })) import { @@ -20,6 +24,9 @@ import { unblockTasks, assignTask, addComment, + linkTasks, + unlinkTasks, + bulkUpdateTasks, getTaskLog, getDiagnostics, reclaimTask, @@ -28,11 +35,23 @@ import { dispatch, getStats, getAssignees, + buildKanbanEventsWebSocketUrl, } from '../../packages/client/src/api/hermes/kanban' describe('Kanban API', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() + mockGetApiKey.mockReturnValue('') + mockGetBaseUrlValue.mockReturnValue('') + }) + + it('builds board-scoped kanban event websocket URLs with auth token', () => { + mockGetBaseUrlValue.mockReturnValue('https://wui.example.test') + mockGetApiKey.mockReturnValue('token value') + + expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value') + expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value') }) it('serializes board, list filters, and archived inclusion into query params', async () => { @@ -116,6 +135,9 @@ describe('Kanban API', () => { it('calls parity-gap APIs with explicit board query params', async () => { mockRequest .mockResolvedValueOnce({ ok: true, output: 'commented' }) + .mockResolvedValueOnce({ ok: true, output: 'linked' }) + .mockResolvedValueOnce({ ok: true, output: 'unlinked' }) + .mockResolvedValueOnce({ results: [{ id: 'task-1', ok: true }] }) .mockResolvedValueOnce({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false }) .mockResolvedValueOnce({ diagnostics: [{ task_id: 'task-1' }] }) .mockResolvedValueOnce({ ok: true, output: 'reclaimed' }) @@ -124,6 +146,9 @@ describe('Kanban API', () => { .mockResolvedValueOnce({ result: { spawned: 1 } }) await addComment('task-1', { body: 'needs review', author: 'han' }, { board: 'default' }) + await linkTasks({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' }) + await unlinkTasks({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' }) + await expect(bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] }) await expect(getTaskLog('task-1', { board: 'default', tail: 4000 })).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false }) await expect(getDiagnostics({ board: 'default', task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }]) await reclaimTask('task-1', { board: 'project-a', reason: 'stale' }) @@ -133,6 +158,9 @@ describe('Kanban API', () => { expect(mockRequest.mock.calls).toEqual([ ['/api/hermes/kanban/task-1/comments?board=default', { method: 'POST', body: JSON.stringify({ body: 'needs review', author: 'han' }) }], + ['/api/hermes/kanban/links?board=project-a', { method: 'POST', body: JSON.stringify({ parent_id: 'task-1', child_id: 'task-2' }) }], + ['/api/hermes/kanban/links?board=project-a&parent_id=task-1&child_id=task-2', { method: 'DELETE' }], + ['/api/hermes/kanban/tasks/bulk?board=project-a', { method: 'POST', body: JSON.stringify({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }) }], ['/api/hermes/kanban/task-1/log?board=default&tail=4000'], ['/api/hermes/kanban/diagnostics?board=default&task=task-1&severity=warning'], ['/api/hermes/kanban/task-1/reclaim?board=project-a', { method: 'POST', body: JSON.stringify({ reason: 'stale' }) }], diff --git a/tests/client/kanban-store.test.ts b/tests/client/kanban-store.test.ts index 9254cf81..5a0a8809 100644 --- a/tests/client/kanban-store.test.ts +++ b/tests/client/kanban-store.test.ts @@ -16,12 +16,16 @@ const mockKanbanApi = vi.hoisted(() => ({ unblockTasks: vi.fn(), assignTask: vi.fn(), addComment: vi.fn(), + linkTasks: vi.fn(), + unlinkTasks: vi.fn(), + bulkUpdateTasks: vi.fn(), getTaskLog: vi.fn(), getDiagnostics: vi.fn(), reclaimTask: vi.fn(), reassignTask: vi.fn(), specifyTask: vi.fn(), dispatch: vi.fn(), + openKanbanEventStream: vi.fn(), })) vi.mock('@/api/hermes/kanban', () => mockKanbanApi) @@ -49,6 +53,7 @@ describe('Kanban store', () => { { slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 1 }, total: 1 }, ]) mockKanbanApi.getCapabilities.mockResolvedValue({ source: 'hermes-cli', supports: { boardsList: true }, missing: [] }) + mockKanbanApi.openKanbanEventStream.mockReturnValue({ close: vi.fn(), onmessage: null, onclose: null, onerror: null }) }) it('persists selected board, including default, and falls back to default for missing boards', async () => { @@ -137,6 +142,75 @@ describe('Kanban store', () => { expect(mockKanbanApi.dispatch).not.toHaveBeenCalled() }) + it('passes selected board to link and partial bulk parity actions', async () => { + mockKanbanApi.getCapabilities.mockResolvedValue({ + source: 'hermes-cli', + supports: { links: true, bulk: false }, + missing: ['bulk'], + capabilities: [ + { key: 'links', status: 'supported', requiresBoard: true }, + { key: 'bulk', status: 'partial', requiresBoard: true }, + ], + }) + mockKanbanApi.linkTasks.mockResolvedValue({ ok: true }) + mockKanbanApi.unlinkTasks.mockResolvedValue({ ok: true }) + mockKanbanApi.bulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] }) + mockKanbanApi.listTasks.mockResolvedValue([]) + mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} }) + mockKanbanApi.getAssignees.mockResolvedValue([]) + + const store = useKanbanStore() + store.setSelectedBoard('project-a') + await store.fetchCapabilities() + + await store.linkTasks('task-1', 'task-2') + await store.unlinkTasks('task-1', 'task-2') + await expect(store.bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] }) + + expect(mockKanbanApi.linkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' }) + expect(mockKanbanApi.unlinkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' }) + expect(mockKanbanApi.bulkUpdateTasks).toHaveBeenCalledWith({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' }) + expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true }) + }) + + it('opens board-scoped event streams, refreshes on events, and reconnects on board switch', async () => { + vi.useFakeTimers() + const socketA = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null } + const socketB = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null } + mockKanbanApi.openKanbanEventStream + .mockReturnValueOnce(socketA) + .mockReturnValueOnce(socketB) + mockKanbanApi.getCapabilities.mockResolvedValue({ + source: 'hermes-cli', + supports: {}, + missing: [], + capabilities: [{ key: 'events', status: 'partial', requiresBoard: true }], + }) + mockKanbanApi.listTasks.mockResolvedValue([]) + mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} }) + mockKanbanApi.getAssignees.mockResolvedValue([]) + + const store = useKanbanStore() + store.setSelectedBoard('project-a') + await store.fetchCapabilities() + + expect(store.startEventStream()).toBe(true) + expect(mockKanbanApi.openKanbanEventStream).toHaveBeenCalledWith({ board: 'project-a' }) + + socketA.onmessage?.({ data: JSON.stringify({ type: 'event', line: 'changed' }) }) + await vi.advanceTimersByTimeAsync(100) + expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true }) + expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' }) + expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' }) + + store.setSelectedBoard('default') + expect(socketA.close).toHaveBeenCalled() + expect(mockKanbanApi.openKanbanEventStream).toHaveBeenLastCalledWith({ board: 'default' }) + store.stopEventStream() + expect(socketB.close).toHaveBeenCalled() + vi.useRealTimers() + }) + it('passes selected board to parity actions and refreshes affected board state', async () => { mockKanbanApi.getCapabilities.mockResolvedValue({ source: 'hermes-cli', diff --git a/tests/client/kanban-view.test.ts b/tests/client/kanban-view.test.ts index c99a70ea..cb66d96f 100644 --- a/tests/client/kanban-view.test.ts +++ b/tests/client/kanban-view.test.ts @@ -32,6 +32,8 @@ const mockSetFilter = vi.hoisted(() => vi.fn()) const mockRecoverSelectedBoard = vi.hoisted(() => vi.fn()) const mockCreateBoard = vi.hoisted(() => vi.fn()) const mockArchiveSelectedBoard = vi.hoisted(() => vi.fn()) +const mockStartEventStream = vi.hoisted(() => vi.fn()) +const mockStopEventStream = vi.hoisted(() => vi.fn()) vi.mock('vue-router', () => ({ useRoute: () => routeState, @@ -57,6 +59,8 @@ vi.mock('@/stores/hermes/kanban', () => ({ recoverSelectedBoard: mockRecoverSelectedBoard, createBoard: mockCreateBoard, archiveSelectedBoard: mockArchiveSelectedBoard, + startEventStream: mockStartEventStream, + stopEventStream: mockStopEventStream, }), })) diff --git a/tests/server/hermes-kanban-service.test.ts b/tests/server/hermes-kanban-service.test.ts index 1bb3cdee..467ad9fd 100644 --- a/tests/server/hermes-kanban-service.test.ts +++ b/tests/server/hermes-kanban-service.test.ts @@ -45,15 +45,70 @@ describe('hermes kanban service', () => { it('exposes capability metadata for WUI/canonical parity gaps', async () => { await expect(service.getCapabilities()).resolves.toMatchObject({ source: 'hermes-cli', - supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true }, - missing: expect.arrayContaining(['cliCurrentSwitch', 'links', 'bulk', 'events', 'homeSubscriptions']), + supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true, links: true }, + missing: expect.arrayContaining(['cliCurrentSwitch', 'bulk', 'homeSubscriptions']), capabilities: expect.arrayContaining([ expect.objectContaining({ key: 'commentsWrite', status: 'supported', canonicalCommand: 'comment', requiresBoard: true }), - expect.objectContaining({ key: 'events', status: 'missing', canonicalRoute: '/events', requiresBoard: true }), + expect.objectContaining({ key: 'links', status: 'supported', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true }), + expect.objectContaining({ key: 'bulk', status: 'partial', canonicalRoute: '/tasks/bulk', requiresBoard: true }), + expect.objectContaining({ key: 'events', status: 'partial', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true }), ]), }) }) + it('builds board-scoped watch args for the kanban event bridge', () => { + expect(service.buildWatchArgs({ board: 'Project_A', interval: 0.25 })).toEqual(['kanban', '--board', 'project_a', 'watch', '--interval', '0.25']) + expect(service.buildWatchArgs()).toEqual(['kanban', '--board', 'default', 'watch', '--interval', '0.5']) + }) + + it('builds link/unlink and bulk-equivalent task commands with explicit board', async () => { + mockExecFileAsync + .mockResolvedValueOnce({ stdout: 'linked\n' }) + .mockResolvedValueOnce({ stdout: 'unlinked\n' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockRejectedValueOnce(new Error('cannot complete task-2')) + + await expect(service.linkTasks('task-1', 'task-2', { board: 'project-a' })).resolves.toEqual({ ok: true, output: 'linked\n' }) + await expect(service.unlinkTasks('task-1', 'task-2', { board: 'project-a' })).resolves.toEqual({ ok: true, output: 'unlinked\n' }) + await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1', 'task-2'], status: 'done', assignee: 'alice', summary: 'closed' })).resolves.toEqual({ + results: [ + { id: 'task-1', ok: true }, + { id: 'task-2', ok: false, error: 'Failed to complete kanban tasks: cannot complete task-2' }, + ], + }) + + expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'link', 'task-1', 'task-2']) + expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'unlink', 'task-1', 'task-2']) + expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'complete', 'task-1', '--summary', 'closed']) + expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'assign', 'task-1', 'alice']) + expect(mockExecFileAsync.mock.calls[4][1]).toEqual(['kanban', '--board', 'project-a', 'complete', 'task-2', '--summary', 'closed']) + }) + + it('treats zero-exit stderr from mutation CLI calls as failures', async () => { + mockExecFileAsync + .mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): missing-a, missing-b\n' }) + .mockResolvedValueOnce({ stdout: '', stderr: 'No such link: missing-a -> missing-b\n' }) + .mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): task-1\n' }) + .mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): task-2\n' }) + + await expect(service.linkTasks('missing-a', 'missing-b', { board: 'project-a' })).rejects.toThrow('Failed to link kanban tasks: kanban: unknown task(s): missing-a, missing-b') + await expect(service.unlinkTasks('missing-a', 'missing-b', { board: 'project-a' })).rejects.toThrow('Failed to unlink kanban tasks: No such link: missing-a -> missing-b') + await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1', 'task-2'], status: 'done' })).resolves.toEqual({ + results: [ + { id: 'task-1', ok: false, error: 'Failed to complete kanban tasks: kanban: unknown task(s): task-1' }, + { id: 'task-2', ok: false, error: 'Failed to complete kanban tasks: kanban: unknown task(s): task-2' }, + ], + }) + }) + + it('returns per-task bulk errors for unsupported direct status patches before shelling out', async () => { + await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1'], status: 'running' })).resolves.toEqual({ + results: [{ id: 'task-1', ok: false, error: 'Bulk status running is not supported by the CLI bridge' }], + }) + expect(mockExecFileAsync).not.toHaveBeenCalled() + }) + it('builds comment/log/diagnostics commands with explicit board', async () => { mockExecFileAsync .mockResolvedValueOnce({ stdout: 'comment added\n' }) diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts index 71ce67fb..048d3fe8 100644 --- a/tests/server/kanban-controller.test.ts +++ b/tests/server/kanban-controller.test.ts @@ -13,6 +13,9 @@ const mockBlockTask = vi.hoisted(() => vi.fn()) const mockUnblockTasks = vi.hoisted(() => vi.fn()) const mockAssignTask = vi.hoisted(() => vi.fn()) const mockAddComment = vi.hoisted(() => vi.fn()) +const mockLinkTasks = vi.hoisted(() => vi.fn()) +const mockUnlinkTasks = vi.hoisted(() => vi.fn()) +const mockBulkUpdateTasks = vi.hoisted(() => vi.fn()) const mockGetTaskLog = vi.hoisted(() => vi.fn()) const mockGetDiagnostics = vi.hoisted(() => vi.fn()) const mockReclaimTask = vi.hoisted(() => vi.fn()) @@ -52,6 +55,9 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({ unblockTasks: mockUnblockTasks, assignTask: mockAssignTask, addComment: mockAddComment, + linkTasks: mockLinkTasks, + unlinkTasks: mockUnlinkTasks, + bulkUpdateTasks: mockBulkUpdateTasks, getTaskLog: mockGetTaskLog, getDiagnostics: mockGetDiagnostics, reclaimTask: mockReclaimTask, @@ -145,6 +151,27 @@ describe('kanban controller', () => { expect(diagnosticsCtx.body).toEqual({ diagnostics: [{ task_id: 'task-1' }] }) }) + it('proxies links and bulk actions with explicit board context', async () => { + mockLinkTasks.mockResolvedValue({ ok: true, output: 'linked' }) + mockUnlinkTasks.mockResolvedValue({ ok: true, output: 'unlinked' }) + mockBulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] }) + + const linkCtx = ctx({ query: { board: 'project-a' }, request: { body: { parent_id: 'task-1', child_id: 'task-2' } } }) + await ctrl.linkTasks(linkCtx) + expect(mockLinkTasks).toHaveBeenCalledWith('task-1', 'task-2', { board: 'project-a' }) + expect(linkCtx.body).toEqual({ ok: true, output: 'linked' }) + + const unlinkCtx = ctx({ query: { board: 'project-a', parent_id: 'task-1', child_id: 'task-2' } }) + await ctrl.unlinkTasks(unlinkCtx) + expect(mockUnlinkTasks).toHaveBeenCalledWith('task-1', 'task-2', { board: 'project-a' }) + expect(unlinkCtx.body).toEqual({ ok: true, output: 'unlinked' }) + + const bulkCtx = ctx({ query: { board: 'project-a' }, request: { body: { ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' } } }) + await ctrl.bulkUpdateTasks(bulkCtx) + expect(mockBulkUpdateTasks).toHaveBeenCalledWith({ board: 'project-a', ids: ['task-1'], status: 'done', assignee: null, archive: undefined, summary: 'closed', reason: undefined }) + expect(bulkCtx.body).toEqual({ results: [{ id: 'task-1', ok: true }] }) + }) + it('validates canonical parity endpoint inputs before shelling out', async () => { const invalidTailCtx = ctx({ query: { board: 'default', tail: '0' }, params: { id: 'task-1' } }) await ctrl.taskLog(invalidTailCtx) @@ -182,6 +209,12 @@ describe('kanban controller', () => { { name: 'comment body object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: {}, author: 'han' } } }), mock: mockAddComment }, { name: 'comment request body array', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: [] } }), mock: mockAddComment }, { name: 'comment author object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: 'ok', author: {} } } }), mock: mockAddComment }, + { name: 'link missing child', invoke: ctrl.linkTasks, context: ctx({ query: { board: 'default' }, request: { body: { parent_id: 'task-1' } } }), mock: mockLinkTasks }, + { name: 'unlink missing parent', invoke: ctrl.unlinkTasks, context: ctx({ query: { board: 'default', child_id: 'task-2' } }), mock: mockUnlinkTasks }, + { name: 'bulk empty ids', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: [], status: 'done' } } }), mock: mockBulkUpdateTasks }, + { name: 'bulk invalid status', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'], status: 'invalid' } } }), mock: mockBulkUpdateTasks }, + { name: 'bulk archive with status', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'], archive: true, status: 'done' } } }), mock: mockBulkUpdateTasks }, + { name: 'bulk no action', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'] } } }), mock: mockBulkUpdateTasks }, { name: 'reclaim request body string', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 'bad' } }), mock: mockReclaimTask }, { name: 'reclaim reason array', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { reason: [] } } }), mock: mockReclaimTask }, { name: 'reassign reclaim string', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 'false' } } }), mock: mockReassignTask }, diff --git a/tests/server/kanban-routes.test.ts b/tests/server/kanban-routes.test.ts index 258ea59e..8aeadf24 100644 --- a/tests/server/kanban-routes.test.ts +++ b/tests/server/kanban-routes.test.ts @@ -9,6 +9,9 @@ const handlers = { assignees: vi.fn(async (ctx: any) => { ctx.body = { assignees: [] } }), readArtifact: vi.fn(async (ctx: any) => { ctx.body = { content: 'x' } }), searchSessions: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }), + linkTasks: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }), + unlinkTasks: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }), + bulkUpdateTasks: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }), list: vi.fn(async (ctx: any) => { ctx.body = { tasks: [] } }), get: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }), create: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }), @@ -47,6 +50,8 @@ describe('kanban routes', () => { '/api/hermes/kanban/dispatch', '/api/hermes/kanban/artifact', '/api/hermes/kanban/search-sessions', + '/api/hermes/kanban/links', + '/api/hermes/kanban/tasks/bulk', '/api/hermes/kanban', '/api/hermes/kanban/:id', '/api/hermes/kanban/complete',