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:
@@ -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<KanbanBoard[]> {
|
||||
@@ -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))
|
||||
|
||||
@@ -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<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)
|
||||
@@ -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<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 })
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user