Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion packages/client/src/api/hermes/kanban.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { request } from '../client'
import { request, getApiKey, getBaseUrlValue } from '../client'

// ─── Types ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -84,6 +84,8 @@ export interface KanbanTaskDetail {
comments: KanbanComment[]
events: KanbanEvent[]
runs: KanbanRun[]
parents?: string[]
children?: string[]
}

export interface KanbanStats {
Expand Down Expand Up @@ -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'
Expand All @@ -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<KanbanBoard[]> {
Expand Down Expand Up @@ -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<KanbanTaskLog> {
const params = boardParams(opts?.board)
if (opts?.tail !== undefined) params.set('tail', String(opts.tail))
Expand Down
141 changes: 140 additions & 1 deletion packages/client/src/stores/hermes/kanban.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null
let eventRefreshTimer: ReturnType<typeof setTimeout> | null = null
let eventStreamEnabled = false

const activeBoards = computed(() => {
const visible = boards.value.filter(board => !board.archived)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -111,6 +220,7 @@ export const useKanbanStore = defineStore('kanban', () => {
if (changed) {
clearBoardScopedState()
boardGeneration++
restartEventStreamIfActive()
}
return resolved
}
Expand Down Expand Up @@ -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<KanbanBulkUpdateRequest, 'ids'> & { 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 })
Expand Down Expand Up @@ -358,6 +492,9 @@ export const useKanbanStore = defineStore('kanban', () => {
unblockTasks,
assignTask,
addComment,
linkTasks,
unlinkTasks,
bulkUpdateTasks,
getTaskLog,
getDiagnostics,
reclaimTask,
Expand All @@ -366,6 +503,8 @@ export const useKanbanStore = defineStore('kanban', () => {
dispatch,
setFilter,
setSelectedBoard,
startEventStream,
stopEventStream,
recoverSelectedBoard,
resolveAvailableBoard,
clearBoardScopedState,
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/views/hermes/KanbanView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -122,6 +123,7 @@ onMounted(async () => {
})

onUnmounted(() => {
kanbanStore.stopEventStream()
if (refreshTimer.value) clearInterval(refreshTimer.value)
})

Expand Down
Loading
Loading