Kanban:补齐任务操作链路,明确能力边界 (#615)

* [verified] fix(kanban): harden WUI parity bridge

- Align board slug normalization with canonical underscore/lowercase/64-char rules
- Validate malformed Kanban action bodies before CLI shell-out
- Narrow task log no-log handling and expose phase-1 capabilities
- Extend client/server regression coverage for parity actions

* fix(kanban): guard archived task detail actions

---------

Co-authored-by: ekko <152005280+EKKOLearnAI@users.noreply.github.com>
This commit is contained in:
Zhicheng Han
2026-05-11 15:26:24 +02:00
committed by GitHub
parent 3a1893d401
commit 6ff1c18ee2
12 changed files with 1079 additions and 91 deletions
+104
View File
@@ -121,10 +121,29 @@ export interface KanbanBoardCreateRequest {
switchCurrent?: boolean
}
export interface KanbanCapabilityStatus {
key: string
status: 'supported' | 'partial' | 'missing'
reason?: string
canonicalRoute?: string
canonicalCommand?: string
requiresBoard: boolean
}
export interface KanbanCapabilities {
source: 'hermes-cli'
supports: Record<string, boolean>
missing: string[]
capabilities?: KanbanCapabilityStatus[]
}
export interface KanbanTaskLog {
task_id: string
path: string | null
exists: boolean
size_bytes: number
content: string
truncated: boolean
}
export interface KanbanCreateRequest {
@@ -146,6 +165,39 @@ export interface KanbanListOptions extends KanbanBoardOptions {
includeArchived?: boolean
}
export interface KanbanCommentCreateRequest {
body: string
author?: string
}
export interface KanbanTaskLogOptions extends KanbanBoardOptions {
tail?: number
}
export interface KanbanDiagnosticsOptions extends KanbanBoardOptions {
task?: string
severity?: 'warning' | 'error' | 'critical'
}
export interface KanbanReclaimOptions extends KanbanBoardOptions {
reason?: string
}
export interface KanbanReassignOptions extends KanbanBoardOptions {
reclaim?: boolean
reason?: string
}
export interface KanbanSpecifyOptions extends KanbanBoardOptions {
author?: string
}
export interface KanbanDispatchOptions extends KanbanBoardOptions {
dryRun?: boolean
max?: number
failureLimit?: number
}
function normalizedBoard(board?: string): string {
const trimmed = board?.trim()
return trimmed || 'default'
@@ -240,6 +292,58 @@ export async function assignTask(taskId: string, profile: string, opts?: KanbanB
})
}
export async function addComment(taskId: string, data: KanbanCommentCreateRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/comments`, 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))
return request<KanbanTaskLog>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/log`, params))
}
export async function getDiagnostics(opts?: KanbanDiagnosticsOptions): Promise<unknown[]> {
const params = boardParams(opts?.board)
if (opts?.task) params.set('task', opts.task)
if (opts?.severity) params.set('severity', opts.severity)
const res = await request<{ diagnostics: unknown[] }>(appendQuery('/api/hermes/kanban/diagnostics', params))
return res.diagnostics
}
export async function reclaimTask(taskId: string, opts?: KanbanReclaimOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reclaim`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ reason: opts?.reason }),
})
}
export async function reassignTask(taskId: string, profile: string, opts?: KanbanReassignOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reassign`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ profile, reclaim: opts?.reclaim, reason: opts?.reason }),
})
}
export async function specifyTask(taskId: string, opts?: KanbanSpecifyOptions): Promise<unknown[]> {
const res = await request<{ results: unknown[] }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/specify`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ author: opts?.author }),
})
return res.results
}
export async function dispatch(opts?: KanbanDispatchOptions): Promise<unknown> {
const params = boardParams(opts?.board)
const res = await request<{ result: unknown }>(appendQuery('/api/hermes/kanban/dispatch', params), {
method: 'POST',
body: JSON.stringify({ dryRun: opts?.dryRun, max: opts?.max, failureLimit: opts?.failureLimit }),
})
return res.result
}
export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board)))
return res.stats
@@ -43,6 +43,11 @@ const localizedTaskStatus = computed(() => {
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
})
const canMutateTask = computed(() => {
const status = detail.value?.task.status
return status !== 'done' && status !== 'archived'
})
const sessionResults = ref<any[]>([])
const sessionLoading = ref(false)
const showSessions = ref(false)
@@ -243,8 +248,8 @@ async function handleAssign() {
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
</div>
<!-- Actions (only for non-completed tasks) -->
<div v-if="detail.task.status !== 'done'" class="detail-section">
<!-- Actions (only for active, mutable tasks) -->
<div v-if="canMutateTask" class="detail-section">
<div class="section-title">{{ t('kanban.action.title') }}</div>
<div class="action-group">
<template v-if="!showCompleteInput">
+76 -3
View File
@@ -1,12 +1,12 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import * as kanbanApi from '@/api/hermes/kanban'
import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities } from '@/api/hermes/kanban'
import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities, KanbanDiagnosticsOptions, KanbanDispatchOptions } from '@/api/hermes/kanban'
export const KANBAN_SELECTED_BOARD_STORAGE_KEY = 'hermes.kanban.selectedBoard'
export const DEFAULT_KANBAN_BOARD = 'default'
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
function safeStorageGet(key: string): string | null {
if (typeof window === 'undefined') return null
@@ -27,7 +27,7 @@ function safeStorageSet(key: string, value: string) {
}
export function normalizeBoardSlug(board?: string | null): string {
const trimmed = board?.trim()
const trimmed = board?.trim().toLowerCase()
if (!trimmed) return DEFAULT_KANBAN_BOARD
return BOARD_SLUG_RE.test(trimmed) ? trimmed : DEFAULT_KANBAN_BOARD
}
@@ -72,6 +72,20 @@ export const useKanbanStore = defineStore('kanban', () => {
return visible
})
function isCapabilitySupported(key: string): boolean {
if (!capabilities.value) return false
const detail = capabilities.value.capabilities?.find(capability => capability.key === key)
if (detail) return detail.status === 'supported'
if (capabilities.value.missing?.includes(key)) return false
return capabilities.value.supports?.[key] === true
}
function assertCapability(key: string): void {
if (!isCapabilitySupported(key)) {
throw new Error(`Kanban capability "${key}" is not supported by the current Hermes backend`)
}
}
function boardExists(board: string): boolean {
return activeBoards.value.some(item => item.slug === board)
}
@@ -257,6 +271,57 @@ export const useKanbanStore = defineStore('kanban', () => {
}
}
async function addComment(taskId: string, body: string, author?: string) {
assertCapability('commentsWrite')
return kanbanApi.addComment(taskId, { body, author }, { board: selectedBoard.value })
}
async function getTaskLog(taskId: string, tail?: number) {
assertCapability('taskLog')
return kanbanApi.getTaskLog(taskId, { board: selectedBoard.value, tail })
}
async function getDiagnostics(opts: Omit<KanbanDiagnosticsOptions, 'board'> = {}) {
assertCapability('diagnostics')
return kanbanApi.getDiagnostics({ ...opts, board: selectedBoard.value })
}
async function reclaimTask(taskId: string, reason?: string) {
assertCapability('reclaim')
const board = selectedBoard.value
const result = await kanbanApi.reclaimTask(taskId, { board, reason })
if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()])
return result
}
async function reassignTask(taskId: string, profile: string, opts: { reclaim?: boolean; reason?: string } = {}) {
assertCapability('reassign')
const board = selectedBoard.value
const result = await kanbanApi.reassignTask(taskId, profile, { board, ...opts })
if (board === selectedBoard.value) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.assignee = profile === 'none' ? null : profile
await Promise.all([fetchStats(), fetchAssignees()])
}
return result
}
async function specifyTask(taskId: string, author?: string) {
assertCapability('specify')
const board = selectedBoard.value
const result = await kanbanApi.specifyTask(taskId, { board, author })
if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()])
return result
}
async function dispatch(opts: Omit<KanbanDispatchOptions, 'board'> = {}) {
assertCapability('dispatch')
const board = selectedBoard.value
const result = await kanbanApi.dispatch({ ...opts, board })
if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()])
return result
}
function setFilter(key: 'status' | 'assignee', value: string | null) {
if (key === 'status') filterStatus.value = value
else filterAssignee.value = value
@@ -273,6 +338,7 @@ export const useKanbanStore = defineStore('kanban', () => {
boards,
capabilities,
activeBoards,
isCapabilitySupported,
loading,
boardsLoading,
boardWarning,
@@ -291,6 +357,13 @@ export const useKanbanStore = defineStore('kanban', () => {
blockTask,
unblockTasks,
assignTask,
addComment,
getTaskLog,
getDiagnostics,
reclaimTask,
reassignTask,
specifyTask,
dispatch,
setFilter,
setSelectedBoard,
recoverSelectedBoard,