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,
+264 -61
View File
@@ -19,8 +19,14 @@ function firstQueryValue(value: string | string[] | undefined): string | undefin
}
function requestBoard(ctx: Context): string | null {
const rawBoard = firstQueryValue(ctx.query.board as string | string[] | undefined)
if (rawBoard !== undefined && !rawBoard.trim()) {
ctx.status = 400
ctx.body = { error: 'invalid board slug' }
return null
}
try {
return kanbanCli.normalizeBoardSlug(firstQueryValue(ctx.query.board as string | string[] | undefined))
return kanbanCli.normalizeBoardSlug(rawBoard)
} catch {
ctx.status = 400
ctx.body = { error: 'invalid board slug' }
@@ -28,6 +34,90 @@ function requestBoard(ctx: Context): string | null {
}
}
function validSeverity(value?: string): value is 'warning' | 'error' | 'critical' {
return value === undefined || value === 'warning' || value === 'error' || value === 'critical'
}
const MAX_LOG_TAIL_BYTES = 1_000_000
const MAX_DISPATCH_TASKS = 100
const MAX_DISPATCH_FAILURE_LIMIT = 100
type PositiveIntegerResult = { value?: number; error?: string }
type StringResult = { value?: string; error?: string }
type BooleanResult = { value?: boolean; error?: string }
type BodyResult = { body: Record<string, unknown>; error?: string }
function optionalPositiveInteger(value: unknown, name: string, max: number): PositiveIntegerResult {
if (value === undefined || value === null || value === '') return {}
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return { error: `${name} must be a positive integer` }
}
if (value > max) {
return { error: `${name} must be <= ${max}` }
}
return { value }
}
function optionalPositiveIntegerQuery(value: string | undefined, name: string, max: number): PositiveIntegerResult {
if (value === undefined || value === '') return {}
const numeric = Number(value)
if (!Number.isInteger(numeric) || numeric <= 0) {
return { error: `${name} must be a positive integer` }
}
if (numeric > max) {
return { error: `${name} must be <= ${max}` }
}
return { value: numeric }
}
function requestBody(ctx: Context): BodyResult {
const body = ctx.request.body
if (body === undefined || body === null) return { body: {} }
if (typeof body !== 'object' || Array.isArray(body)) {
return { body: {}, error: 'request body must be an object' }
}
return { body: body as Record<string, unknown> }
}
function optionalString(value: unknown, name: string): StringResult {
if (value === undefined || value === null) return {}
if (typeof value !== 'string') return { error: `${name} must be a string` }
return { value }
}
function requiredNonEmptyString(value: unknown, name: string): StringResult {
if (typeof value !== 'string' || !value.trim()) return { error: `${name} is required` }
return { value }
}
function requiredNonEmptyStringArray(value: unknown, name: string): { value?: string[]; error?: string } {
if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.trim())) {
return { error: `${name} is required` }
}
return { value }
}
function optionalBoolean(value: unknown, name: string): BooleanResult {
if (value === undefined || value === null) return {}
if (typeof value !== 'boolean') return { error: `${name} must be boolean` }
return { value }
}
function optionalInteger(value: unknown, name: string): PositiveIntegerResult {
if (value === undefined || value === null || value === '') return {}
if (typeof value !== 'number' || !Number.isInteger(value)) {
return { error: `${name} must be an integer` }
}
return { value }
}
function rejectBadRequest(ctx: Context, error?: string): boolean {
if (!error) return false
ctx.status = 400
ctx.body = { error }
return true
}
export async function listBoards(ctx: Context) {
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
try {
@@ -40,21 +130,25 @@ export async function listBoards(ctx: Context) {
}
export async function createBoard(ctx: Context) {
const { slug, name, description, icon, color, switchCurrent } = ctx.request.body as {
slug?: string
name?: string
description?: string
icon?: string
color?: string
switchCurrent?: boolean
}
if (!slug?.trim()) {
ctx.status = 400
ctx.body = { error: 'slug is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const slug = requiredNonEmptyString(body.slug, 'slug')
const name = optionalString(body.name, 'name')
const description = optionalString(body.description, 'description')
const icon = optionalString(body.icon, 'icon')
const color = optionalString(body.color, 'color')
const switchCurrent = optionalBoolean(body.switchCurrent, 'switchCurrent')
if (rejectBadRequest(ctx, slug.error || name.error || description.error || icon.error || color.error || switchCurrent.error)) return
try {
const board = await kanbanCli.createBoard({ slug, name, description, icon, color, switchCurrent })
const board = await kanbanCli.createBoard({
slug: slug.value!,
name: name.value,
description: description.value,
icon: icon.value,
color: color.value,
switchCurrent: switchCurrent.value,
})
ctx.body = { board }
} catch (err: any) {
ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500
@@ -115,8 +209,9 @@ export async function get(ctx: Context) {
return
}
// For completed tasks, find related session from the worker's profile DB
if (detail.task.status === 'done' && detail.runs.length > 0) {
// For terminal tasks, find related session from the worker's profile DB.
// Archived tasks can still carry the worker result/session users need to inspect.
if ((detail.task.status === 'done' || detail.task.status === 'archived') && detail.runs.length > 0) {
const profile = getLatestRunProfile(detail)
if (profile) {
try {
@@ -166,22 +261,19 @@ export async function get(ctx: Context) {
}
export async function create(ctx: Context) {
const { title, body, assignee, priority, tenant } = ctx.request.body as {
title?: string
body?: string
assignee?: string
priority?: number
tenant?: string
}
if (!title) {
ctx.status = 400
ctx.body = { error: 'title is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const payload = bodyResult.body
const title = requiredNonEmptyString(payload.title, 'title')
const body = optionalString(payload.body, 'body')
const assignee = optionalString(payload.assignee, 'assignee')
const priority = optionalInteger(payload.priority, 'priority')
const tenant = optionalString(payload.tenant, 'tenant')
if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const task = await kanbanCli.createTask(title, { board, body, assignee, priority, tenant })
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: assignee.value, priority: priority.value, tenant: tenant.value })
ctx.body = { task }
} catch (err: any) {
ctx.status = 500
@@ -190,19 +282,16 @@ export async function create(ctx: Context) {
}
export async function complete(ctx: Context) {
const { task_ids, summary } = ctx.request.body as {
task_ids?: string[]
summary?: string
}
if (!task_ids?.length) {
ctx.status = 400
ctx.body = { error: 'task_ids is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const payload = bodyResult.body
const taskIds = requiredNonEmptyStringArray(payload.task_ids, 'task_ids')
const summary = optionalString(payload.summary, 'summary')
if (rejectBadRequest(ctx, taskIds.error || summary.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.completeTasks(task_ids, summary, { board })
await kanbanCli.completeTasks(taskIds.value!, summary.value, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -211,16 +300,14 @@ export async function complete(ctx: Context) {
}
export async function block(ctx: Context) {
const { reason } = ctx.request.body as { reason?: string }
if (!reason) {
ctx.status = 400
ctx.body = { error: 'reason is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const reason = requiredNonEmptyString(bodyResult.body.reason, 'reason')
if (rejectBadRequest(ctx, reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.blockTask(ctx.params.id, reason, { board })
await kanbanCli.blockTask(ctx.params.id, reason.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -229,16 +316,14 @@ export async function block(ctx: Context) {
}
export async function unblock(ctx: Context) {
const { task_ids } = ctx.request.body as { task_ids?: string[] }
if (!task_ids?.length) {
ctx.status = 400
ctx.body = { error: 'task_ids is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const taskIds = requiredNonEmptyStringArray(bodyResult.body.task_ids, 'task_ids')
if (rejectBadRequest(ctx, taskIds.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.unblockTasks(task_ids, { board })
await kanbanCli.unblockTasks(taskIds.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -247,16 +332,14 @@ export async function unblock(ctx: Context) {
}
export async function assign(ctx: Context) {
const { profile } = ctx.request.body as { profile?: string }
if (!profile) {
ctx.status = 400
ctx.body = { error: 'profile is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile')
if (rejectBadRequest(ctx, profile.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.assignTask(ctx.params.id, profile, { board })
await kanbanCli.assignTask(ctx.params.id, profile.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -264,6 +347,126 @@ export async function assign(ctx: Context) {
}
}
export async function addComment(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const bodyPayload = bodyResult.body
const body = requiredNonEmptyString(bodyPayload.body, 'body')
const author = optionalString(bodyPayload.author, 'author')
if (rejectBadRequest(ctx, body.error || author.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.addComment(ctx.params.id, body.value!, { board, author: author.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
const tailRaw = firstQueryValue(ctx.query.tail as string | string[] | undefined)
const tail = optionalPositiveIntegerQuery(tailRaw, 'tail', MAX_LOG_TAIL_BYTES)
if (rejectBadRequest(ctx, tail.error)) return
try {
ctx.body = await kanbanCli.getTaskLog(ctx.params.id, { board, tail: tail.value })
} catch (err: any) {
ctx.status = err.message?.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
}
export async function diagnostics(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
const task = firstQueryValue(ctx.query.task as string | string[] | undefined)
const severity = firstQueryValue(ctx.query.severity as string | string[] | undefined)
if (!validSeverity(severity)) {
ctx.status = 400
ctx.body = { error: 'severity must be warning, error, or critical' }
return
}
try {
const diagnostics = await kanbanCli.getDiagnostics({ board, task, severity })
ctx.body = { diagnostics }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function reclaim(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.reclaimTask(ctx.params.id, { board, reason: reason.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function reassign(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const profile = requiredNonEmptyString(body.profile, 'profile')
const reclaim = optionalBoolean(body.reclaim, 'reclaim')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.reassignTask(ctx.params.id, profile.value!, { board, reclaim: reclaim.value, reason: reason.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function specify(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const author = optionalString(body.author, 'author')
if (rejectBadRequest(ctx, author.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const results = await kanbanCli.specifyTask(ctx.params.id, { board, author: author.value })
ctx.body = { results }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function dispatch(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const dryRun = optionalBoolean(body.dryRun, 'dryRun')
const max = optionalPositiveInteger(body.max, 'max', MAX_DISPATCH_TASKS)
const failureLimit = optionalPositiveInteger(body.failureLimit, 'failureLimit', MAX_DISPATCH_FAILURE_LIMIT)
if (rejectBadRequest(ctx, dryRun.error || max.error || failureLimit.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const result = await kanbanCli.dispatch({ board, dryRun: dryRun.value, max: max.value, failureLimit: failureLimit.value })
ctx.body = { result }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function stats(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
@@ -9,6 +9,8 @@ kanbanRoutes.delete('/api/hermes/kanban/boards/:slug', ctrl.archiveBoard)
kanbanRoutes.get('/api/hermes/kanban/capabilities', ctrl.capabilities)
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
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.get('/api/hermes/kanban', ctrl.list)
@@ -18,3 +20,8 @@ kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete)
kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock)
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
kanbanRoutes.post('/api/hermes/kanban/:id/comments', ctrl.addComment)
kanbanRoutes.get('/api/hermes/kanban/:id/log', ctrl.taskLog)
kanbanRoutes.post('/api/hermes/kanban/:id/reclaim', ctrl.reclaim)
kanbanRoutes.post('/api/hermes/kanban/:id/reassign', ctrl.reassign)
kanbanRoutes.post('/api/hermes/kanban/:id/specify', ctrl.specify)
@@ -5,7 +5,11 @@ import { logger } from '../logger'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
const NO_WORKER_LOG_PATTERNS = [
/^\(no log for [^)]+?\s+—\s+task may not have spawned yet\)$/i,
/^no worker log(?: for [^\n]+)?$/i,
]
function resolveHermesBin(): string {
const envBin = process.env.HERMES_BIN?.trim()
@@ -16,8 +20,9 @@ function resolveHermesBin(): string {
const HERMES_BIN = resolveHermesBin()
export function normalizeBoardSlug(board?: string | null): string {
const trimmed = board?.trim()
if (!trimmed) return 'default'
if (board === undefined || board === null) return 'default'
const trimmed = board.trim().toLowerCase()
if (!trimmed) throw new Error('Invalid kanban board slug')
if (!BOARD_SLUG_RE.test(trimmed)) {
throw new Error('Invalid kanban board slug')
}
@@ -125,6 +130,25 @@ 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 KanbanCapabilityStatus {
key: string
status: 'supported' | 'partial' | 'missing'
reason?: string
canonicalRoute?: string
canonicalCommand?: string
requiresBoard: boolean
}
export interface KanbanBoardOptions {
@@ -196,24 +220,186 @@ export async function archiveBoard(slugInput: string): Promise<void> {
}
export async function getCapabilities(): Promise<KanbanCapabilities> {
const supports = {
explicitBoard: true,
boardsList: true,
boardCreate: true,
boardArchive: true,
cliCurrentSwitch: true,
taskCrudLite: true,
commentsWrite: false,
taskLog: false,
dispatch: false,
events: false,
diagnostics: false,
bulk: false,
const capabilities: KanbanCapabilityStatus[] = [
{ key: 'explicitBoard', status: 'supported', canonicalCommand: '--board', requiresBoard: true },
{ key: 'boardsList', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards list', requiresBoard: false },
{ key: 'boardCreate', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards create', requiresBoard: false },
{ key: 'boardArchive', status: 'supported', canonicalRoute: '/boards/{slug}', canonicalCommand: 'boards rm', requiresBoard: false },
{ key: 'cliCurrentSwitch', status: 'partial', reason: 'Backend keeps explicit board context and does not expose a WUI route for mutating canonical CLI current board', canonicalRoute: '/boards/{slug}/switch', canonicalCommand: 'boards switch', requiresBoard: false },
{ key: 'taskCrudLite', status: 'supported', canonicalRoute: '/tasks', canonicalCommand: 'list/show/create/complete/block/unblock/assign', requiresBoard: true },
{ key: 'commentsWrite', status: 'supported', canonicalRoute: '/tasks/{task_id}/comments', canonicalCommand: 'comment', requiresBoard: true },
{ key: 'commentsRead', status: 'supported', reason: 'Comments are returned on task detail responses', canonicalRoute: '/tasks/{task_id}', canonicalCommand: 'show --json', requiresBoard: true },
{ key: 'taskLog', status: 'supported', canonicalRoute: '/tasks/{task_id}/log', canonicalCommand: 'log', requiresBoard: true },
{ key: 'diagnostics', status: 'supported', canonicalRoute: '/diagnostics', canonicalCommand: 'diagnostics', requiresBoard: true },
{ key: 'reclaim', status: 'supported', canonicalRoute: '/tasks/{task_id}/reclaim', canonicalCommand: 'reclaim', requiresBoard: true },
{ 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: '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<string, boolean>
const missing = capabilities
.filter(capability => capability.status !== 'supported')
.map(capability => capability.key)
return { source: 'hermes-cli', supports, missing, capabilities }
}
function parseJsonPayload(stdout: string): unknown[] {
const trimmed = stdout.trim()
if (!trimmed) return []
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed
return [parsed]
}
function isNoWorkerLogError(err: any): boolean {
const lines = [err?.stderr, err?.stdout, err?.message]
.filter(Boolean)
.flatMap(value => String(value).split(/\r?\n/).map(line => line.trim()).filter(Boolean))
return lines.some(line => NO_WORKER_LOG_PATTERNS.some(pattern => pattern.test(line)))
}
function pushOptional(args: string[], flag: string, value?: string | number | null): void {
if (value !== undefined && value !== null && String(value).trim() !== '') args.push(flag, String(value))
}
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)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return { ok: true, output: stdout }
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban comment failed')
throw new Error(`Failed to comment on kanban task: ${err.message}`)
}
}
export async function getTaskLog(taskId: string, opts?: KanbanBoardOptions & { tail?: number }): Promise<KanbanTaskLog> {
const args = [...boardArgs(opts?.board), 'log', taskId]
pushOptional(args, '--tail', opts?.tail)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
const sizeBytes = Buffer.byteLength(stdout, 'utf8')
return {
task_id: taskId,
path: null,
exists: true,
size_bytes: sizeBytes,
content: stdout,
truncated: opts?.tail !== undefined && sizeBytes >= opts.tail,
}
} catch (err: any) {
const detail = await getTask(taskId, opts)
if (!detail) throw new Error('Kanban task not found')
if ((err.code === 1 || err.status === 1) && isNoWorkerLogError(err)) {
return {
task_id: taskId,
path: null,
exists: false,
size_bytes: 0,
content: '',
truncated: false,
}
}
logger.error(err, 'Hermes CLI: kanban log failed')
throw new Error(`Failed to read kanban task log: ${err.message}`)
}
}
export async function getDiagnostics(opts?: KanbanBoardOptions & { task?: string; severity?: string }): Promise<unknown[]> {
const args = [...boardArgs(opts?.board), 'diagnostics', '--json']
pushOptional(args, '--task', opts?.task)
pushOptional(args, '--severity', opts?.severity)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return JSON.parse(stdout)
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban diagnostics failed')
throw new Error(`Failed to get kanban diagnostics: ${err.message}`)
}
}
export async function reclaimTask(taskId: string, opts?: KanbanBoardOptions & { reason?: string }): Promise<{ ok: boolean; output: string }> {
const args = [...boardArgs(opts?.board), 'reclaim', taskId]
pushOptional(args, '--reason', opts?.reason)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return { ok: true, output: stdout }
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban reclaim failed')
throw new Error(`Failed to reclaim kanban task: ${err.message}`)
}
}
export async function reassignTask(taskId: string, profile: string, opts?: KanbanBoardOptions & { reclaim?: boolean; reason?: string }): Promise<{ ok: boolean; output: string }> {
const args = [...boardArgs(opts?.board), 'reassign', taskId, profile]
if (opts?.reclaim) args.push('--reclaim')
pushOptional(args, '--reason', opts?.reason)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return { ok: true, output: stdout }
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban reassign failed')
throw new Error(`Failed to reassign kanban task: ${err.message}`)
}
}
export async function specifyTask(taskId: string, opts?: KanbanBoardOptions & { author?: string }): Promise<unknown[]> {
const args = [...boardArgs(opts?.board), 'specify', taskId, '--json']
pushOptional(args, '--author', opts?.author)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return parseJsonPayload(stdout)
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban specify failed')
throw new Error(`Failed to specify kanban task: ${err.message}`)
}
}
export async function dispatch(opts?: KanbanBoardOptions & { dryRun?: boolean; max?: number; failureLimit?: number }): Promise<unknown> {
const args = [...boardArgs(opts?.board), 'dispatch', '--json']
if (opts?.dryRun) args.push('--dry-run')
pushOptional(args, '--max', opts?.max)
pushOptional(args, '--failure-limit', opts?.failureLimit)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return JSON.parse(stdout)
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban dispatch failed')
throw new Error(`Failed to dispatch kanban tasks: ${err.message}`)
}
const missing = Object.entries(supports)
.filter(([, supported]) => !supported)
.map(([name]) => name)
return { source: 'hermes-cli', supports, missing }
}
export async function listTasks(opts?: {
+36
View File
@@ -19,6 +19,13 @@ import {
blockTask,
unblockTasks,
assignTask,
addComment,
getTaskLog,
getDiagnostics,
reclaimTask,
reassignTask,
specifyTask,
dispatch,
getStats,
getAssignees,
} from '../../packages/client/src/api/hermes/kanban'
@@ -105,4 +112,33 @@ describe('Kanban API', () => {
['/api/hermes/kanban/assignees?board=project-a'],
])
})
it('calls parity-gap APIs with explicit board query params', async () => {
mockRequest
.mockResolvedValueOnce({ ok: true, output: 'commented' })
.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' })
.mockResolvedValueOnce({ ok: true, output: 'reassigned' })
.mockResolvedValueOnce({ results: [{ task_id: 'task-1' }] })
.mockResolvedValueOnce({ result: { spawned: 1 } })
await addComment('task-1', { body: 'needs review', author: 'han' }, { board: 'default' })
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' })
await reassignTask('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
await expect(specifyTask('task-1', { board: 'default', author: 'han' })).resolves.toEqual([{ task_id: 'task-1' }])
await expect(dispatch({ board: 'default', dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
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/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' }) }],
['/api/hermes/kanban/task-1/reassign?board=project-a', { method: 'POST', body: JSON.stringify({ profile: 'bob', reclaim: true, reason: 'handoff' }) }],
['/api/hermes/kanban/task-1/specify?board=default', { method: 'POST', body: JSON.stringify({ author: 'han' }) }],
['/api/hermes/kanban/dispatch?board=default', { method: 'POST', body: JSON.stringify({ dryRun: true, max: 2, failureLimit: 3 }) }],
])
})
})
+77 -1
View File
@@ -15,13 +15,31 @@ const mockKanbanApi = vi.hoisted(() => ({
blockTask: vi.fn(),
unblockTasks: vi.fn(),
assignTask: vi.fn(),
addComment: vi.fn(),
getTaskLog: vi.fn(),
getDiagnostics: vi.fn(),
reclaimTask: vi.fn(),
reassignTask: vi.fn(),
specifyTask: vi.fn(),
dispatch: vi.fn(),
}))
vi.mock('@/api/hermes/kanban', () => mockKanbanApi)
import { KANBAN_SELECTED_BOARD_STORAGE_KEY, useKanbanStore } from '@/stores/hermes/kanban'
import { KANBAN_SELECTED_BOARD_STORAGE_KEY, normalizeBoardSlug, useKanbanStore } from '@/stores/hermes/kanban'
describe('Kanban store', () => {
it('normalizes board slugs with canonical underscore, uppercase, and length rules', () => {
const sixtyFour = 'a'.repeat(64)
expect(normalizeBoardSlug(' Team_Alpha ')).toBe('team_alpha')
expect(normalizeBoardSlug(sixtyFour)).toBe(sixtyFour)
expect(normalizeBoardSlug('default')).toBe('default')
expect(normalizeBoardSlug('bad/slug')).toBe('default')
expect(normalizeBoardSlug('bad.slug')).toBe('default')
expect(normalizeBoardSlug('bad slug')).toBe('default')
})
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
@@ -98,6 +116,64 @@ describe('Kanban store', () => {
expect(store.tasks[1]).toMatchObject({ id: 'task-1', status: 'done' })
})
it('uses capability metadata before calling parity APIs', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { commentsWrite: true, dispatch: false },
missing: ['dispatch'],
})
mockKanbanApi.addComment.mockResolvedValue({ ok: true })
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
expect(store.isCapabilitySupported('commentsWrite')).toBe(true)
expect(store.isCapabilitySupported('dispatch')).toBe(false)
await store.addComment('task-1', 'needs review', 'han')
await expect(store.dispatch({ dryRun: true })).rejects.toThrow('dispatch')
expect(mockKanbanApi.addComment).toHaveBeenCalledWith('task-1', { body: 'needs review', author: 'han' }, { board: 'project-a' })
expect(mockKanbanApi.dispatch).not.toHaveBeenCalled()
})
it('passes selected board to parity actions and refreshes affected board state', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { taskLog: true, diagnostics: true, reclaim: true, reassign: true, specify: true, dispatch: true },
missing: [],
})
mockKanbanApi.getTaskLog.mockResolvedValue({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
mockKanbanApi.getDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
mockKanbanApi.reclaimTask.mockResolvedValue({ ok: true })
mockKanbanApi.reassignTask.mockResolvedValue({ ok: true })
mockKanbanApi.specifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockKanbanApi.dispatch.mockResolvedValue({ spawned: 1 })
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: {} }])
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1', assignee: 'bob' }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.tasks = [{ id: 'task-1', status: 'running', assignee: 'alice' }] as any
await store.fetchCapabilities()
await expect(store.getTaskLog('task-1', 4000)).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
await expect(store.getDiagnostics({ task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }])
await store.reclaimTask('task-1', 'stale')
await store.reassignTask('task-1', 'bob', { reclaim: true, reason: 'handoff' })
await expect(store.specifyTask('task-1', 'han')).resolves.toEqual([{ task_id: 'task-1' }])
await expect(store.dispatch({ dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
expect(mockKanbanApi.getTaskLog).toHaveBeenCalledWith('task-1', { board: 'project-a', tail: 4000 })
expect(mockKanbanApi.getDiagnostics).toHaveBeenCalledWith({ board: 'project-a', task: 'task-1', severity: 'warning' })
expect(mockKanbanApi.reclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
expect(mockKanbanApi.reassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
expect(mockKanbanApi.specifyTask).toHaveBeenCalledWith('task-1', { board: 'project-a', author: 'han' })
expect(mockKanbanApi.dispatch).toHaveBeenCalledWith({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })
expect(store.tasks[0]).toMatchObject({ id: 'task-1', assignee: 'bob' })
})
it('creates and archives boards without relying on CLI current board', async () => {
mockKanbanApi.listBoards.mockResolvedValue([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
+29
View File
@@ -204,6 +204,35 @@ describe('KanbanTaskDrawer', () => {
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'hermes.chat', query: { session: 'session-2' } })
})
it('does not expose mutation actions for archived tasks', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-archived',
title: 'Archived task',
body: null,
assignee: 'alice',
status: 'archived',
priority: 1,
created_at: 100,
started_at: 110,
completed_at: 120,
tenant: null,
result: 'Archived summary',
},
latest_summary: 'Archived summary',
comments: [],
events: [],
runs: [],
})
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-archived' } })
await flushPromises()
expect(wrapper.text()).not.toContain('kanban.action.complete')
expect(wrapper.text()).not.toContain('kanban.action.block')
expect(wrapper.text()).not.toContain('kanban.action.assign')
})
it('executes complete, block, unblock, and assign actions', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
+95 -2
View File
@@ -45,11 +45,66 @@ 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: false, dispatch: false },
missing: expect.arrayContaining(['commentsWrite', 'dispatch']),
supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true },
missing: expect.arrayContaining(['cliCurrentSwitch', 'links', 'bulk', 'events', 'homeSubscriptions']),
capabilities: expect.arrayContaining([
expect.objectContaining({ key: 'commentsWrite', status: 'supported', canonicalCommand: 'comment', requiresBoard: true }),
expect.objectContaining({ key: 'events', status: 'missing', canonicalRoute: '/events', requiresBoard: true }),
]),
})
})
it('builds comment/log/diagnostics commands with explicit board', async () => {
mockExecFileAsync
.mockResolvedValueOnce({ stdout: 'comment added\n' })
.mockResolvedValueOnce({ stdout: 'worker log\n' })
.mockResolvedValueOnce({ stdout: JSON.stringify([{ task_id: 'task-1', severity: 'warning' }]) })
await expect(service.addComment('task-1', '--not-an-option', { board: 'default', author: 'han' })).resolves.toEqual({ ok: true, output: 'comment added\n' })
await expect(service.getTaskLog('task-1', { board: 'default', tail: 4000 })).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 11, content: 'worker log\n', truncated: false })
await expect(service.getDiagnostics({ board: 'default', task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1', severity: 'warning' }])
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'comment', 'task-1', '--not-an-option', '--author', 'han'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'log', 'task-1', '--tail', '4000'])
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'diagnostics', '--json', '--task', 'task-1', '--severity', 'warning'])
})
it('maps no-log task logs to canonical empty-log shape', async () => {
mockExecFileAsync
.mockRejectedValueOnce({ code: 1, stderr: '(no log for task-1 — task may not have spawned yet)' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
await expect(service.getTaskLog('task-1', { board: 'default' })).resolves.toEqual({
task_id: 'task-1',
path: null,
exists: false,
size_bytes: 0,
content: '',
truncated: false,
})
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'log', 'task-1'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'show', 'task-1', '--json'])
})
it('builds recovery and dispatch commands with explicit board', async () => {
mockExecFileAsync
.mockResolvedValueOnce({ stdout: 'reclaimed\n' })
.mockResolvedValueOnce({ stdout: 'reassigned\n' })
.mockResolvedValueOnce({ stdout: '{"task_id":"task-1","created":true}\n' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ spawned: 1 }) })
await expect(service.reclaimTask('task-1', { board: 'project-a', reason: 'stale lock' })).resolves.toEqual({ ok: true, output: 'reclaimed\n' })
await expect(service.reassignTask('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })).resolves.toEqual({ ok: true, output: 'reassigned\n' })
await expect(service.specifyTask('task-1', { board: 'project-a', author: 'han' })).resolves.toEqual([{ task_id: 'task-1', created: true }])
await expect(service.dispatch({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'reclaim', 'task-1', '--reason', 'stale lock'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'reassign', 'task-1', 'bob', '--reclaim', '--reason', 'handoff'])
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'specify', 'task-1', '--json', '--author', 'han'])
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'dispatch', '--json', '--dry-run', '--max', '2', '--failure-limit', '3'])
})
it('builds list/create/stats CLI calls with global --board before the action', async () => {
mockExecFileAsync
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) })
@@ -110,6 +165,44 @@ describe('hermes kanban service', () => {
expect(mockExecFileAsync).not.toHaveBeenCalled()
})
it('normalizes board slugs using canonical upstream-compatible rules', async () => {
const sixtyFourChars = 'a'.repeat(64)
mockExecFileAsync
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
await service.listTasks({ board: 'Team_Alpha' })
await service.listTasks({ board: sixtyFourChars })
await service.listTasks({ board: 'default' })
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'team_alpha', 'list', '--json'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', sixtyFourChars, 'list', '--json'])
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'list', '--json'])
await expect(service.listTasks({ board: 'bad/slug' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: 'bad.slug' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: '..' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: 'bad slug' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: ' ' })).rejects.toThrow('Invalid kanban board slug')
})
it('does not hide non-no-log failures from the kanban log command', async () => {
mockExecFileAsync
.mockRejectedValueOnce({ code: 1, stderr: 'permission denied', message: 'permission denied' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
await expect(service.getTaskLog('task-1', { board: 'default' })).rejects.toThrow('Failed to read kanban task log: permission denied')
expect(mockLoggerError).toHaveBeenCalled()
})
it('does not treat misleading no-log fragments as canonical no-log messages', async () => {
mockExecFileAsync
.mockRejectedValueOnce({ code: 1, stderr: 'permission denied: no log for diagnostic file', message: 'permission denied' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
await expect(service.getTaskLog('task-1', { board: 'default' })).rejects.toThrow('Failed to read kanban task log: permission denied')
})
it('wraps CLI failures with service-specific errors', async () => {
mockExecFileAsync.mockRejectedValue(new Error('boom'))
+164 -2
View File
@@ -12,6 +12,13 @@ const mockCompleteTasks = vi.hoisted(() => vi.fn())
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 mockGetTaskLog = vi.hoisted(() => vi.fn())
const mockGetDiagnostics = vi.hoisted(() => vi.fn())
const mockReclaimTask = vi.hoisted(() => vi.fn())
const mockReassignTask = vi.hoisted(() => vi.fn())
const mockSpecifyTask = vi.hoisted(() => vi.fn())
const mockDispatch = vi.hoisted(() => vi.fn())
const mockGetStats = vi.hoisted(() => vi.fn())
const mockGetAssignees = vi.hoisted(() => vi.fn())
const mockSearchSessions = vi.hoisted(() => vi.fn())
@@ -29,8 +36,8 @@ vi.mock('os', () => ({
vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
normalizeBoardSlug: (board?: string | null) => {
const value = board?.trim() || 'default'
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value)) throw new Error('Invalid kanban board slug')
const value = board?.trim().toLowerCase() || 'default'
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error('Invalid kanban board slug')
return value
},
listBoards: mockListBoards,
@@ -44,6 +51,13 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
blockTask: mockBlockTask,
unblockTasks: mockUnblockTasks,
assignTask: mockAssignTask,
addComment: mockAddComment,
getTaskLog: mockGetTaskLog,
getDiagnostics: mockGetDiagnostics,
reclaimTask: mockReclaimTask,
reassignTask: mockReassignTask,
specifyTask: mockSpecifyTask,
dispatch: mockDispatch,
getStats: mockGetStats,
getAssignees: mockGetAssignees,
}))
@@ -109,6 +123,108 @@ describe('kanban controller', () => {
expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false })
})
it('proxies comment/log/diagnostics with explicit board context', async () => {
const taskLog = { task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false }
mockAddComment.mockResolvedValue({ ok: true, output: 'commented' })
mockGetTaskLog.mockResolvedValue(taskLog)
mockGetDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
const commentCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { body: 'needs review', author: 'han' } } })
await ctrl.addComment(commentCtx)
expect(mockAddComment).toHaveBeenCalledWith('task-1', 'needs review', { board: 'project-a', author: 'han' })
expect(commentCtx.body).toEqual({ ok: true, output: 'commented' })
const logCtx = ctx({ query: { board: 'default', tail: '4000' }, params: { id: 'task-1' } })
await ctrl.taskLog(logCtx)
expect(mockGetTaskLog).toHaveBeenCalledWith('task-1', { board: 'default', tail: 4000 })
expect(logCtx.body).toEqual(taskLog)
const diagnosticsCtx = ctx({ query: { board: 'default', task: 'task-1', severity: 'warning' } })
await ctrl.diagnostics(diagnosticsCtx)
expect(mockGetDiagnostics).toHaveBeenCalledWith({ board: 'default', task: 'task-1', severity: 'warning' })
expect(diagnosticsCtx.body).toEqual({ diagnostics: [{ task_id: 'task-1' }] })
})
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)
expect(invalidTailCtx.status).toBe(400)
expect(mockGetTaskLog).not.toHaveBeenCalled()
const oversizedTailCtx = ctx({ query: { board: 'default', tail: '1000001' }, params: { id: 'task-1' } })
await ctrl.taskLog(oversizedTailCtx)
expect(oversizedTailCtx.status).toBe(400)
expect(mockGetTaskLog).not.toHaveBeenCalled()
const invalidSeverityCtx = ctx({ query: { board: 'default', severity: 'info' } })
await ctrl.diagnostics(invalidSeverityCtx)
expect(invalidSeverityCtx.status).toBe(400)
expect(mockGetDiagnostics).not.toHaveBeenCalled()
const emptyBoardCtx = ctx({ query: { board: ' ' } })
await ctrl.list(emptyBoardCtx)
expect(emptyBoardCtx.status).toBe(400)
expect(mockListTasks).not.toHaveBeenCalled()
const invalidDispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: 'yes', max: -1, failureLimit: 0 } } })
await ctrl.dispatch(invalidDispatchCtx)
expect(invalidDispatchCtx.status).toBe(400)
expect(mockDispatch).not.toHaveBeenCalled()
const oversizedDispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: false, max: 999999999 } } })
await ctrl.dispatch(oversizedDispatchCtx)
expect(oversizedDispatchCtx.status).toBe(400)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('rejects malformed parity action bodies before shelling out', async () => {
const cases: Array<{ name: string; invoke: (c: any) => Promise<void>; context: any; mock: ReturnType<typeof vi.fn> }> = [
{ 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: '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 },
{ name: 'reassign reclaim number', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 1 } } }), mock: mockReassignTask },
{ name: 'reassign profile number', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 123 } } }), mock: mockReassignTask },
{ name: 'specify request body number', invoke: ctrl.specify, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 123 } }), mock: mockSpecifyTask },
{ name: 'specify author object', invoke: ctrl.specify, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { author: {} } } }), mock: mockSpecifyTask },
{ name: 'dispatch request body array', invoke: ctrl.dispatch, context: ctx({ query: { board: 'default' }, request: { body: [] } }), mock: mockDispatch },
]
for (const testCase of cases) {
vi.clearAllMocks()
await testCase.invoke(testCase.context)
expect(testCase.context.status, testCase.name).toBe(400)
expect(testCase.mock, testCase.name).not.toHaveBeenCalled()
}
})
it('proxies recovery and dispatch actions with explicit board context', async () => {
mockReclaimTask.mockResolvedValue({ ok: true, output: 'reclaimed' })
mockReassignTask.mockResolvedValue({ ok: true, output: 'reassigned' })
mockSpecifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockDispatch.mockResolvedValue({ spawned: 1 })
const reclaimCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { reason: 'stale' } } })
await ctrl.reclaim(reclaimCtx)
expect(mockReclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
const reassignCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: true, reason: 'handoff' } } })
await ctrl.reassign(reassignCtx)
expect(mockReassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
const specifyCtx = ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { author: 'han' } } })
await ctrl.specify(specifyCtx)
expect(mockSpecifyTask).toHaveBeenCalledWith('task-1', { board: 'default', author: 'han' })
expect(specifyCtx.body).toEqual({ results: [{ task_id: 'task-1' }] })
const dispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: true, max: 2, failureLimit: 3 } } })
await ctrl.dispatch(dispatchCtx)
expect(mockDispatch).toHaveBeenCalledWith({ board: 'default', dryRun: true, max: 2, failureLimit: 3 })
expect(dispatchCtx.body).toEqual({ result: { spawned: 1 } })
})
it('enriches completed task details using the latest run profile', async () => {
mockGetTask.mockResolvedValue({
task: { id: 'task-1', status: 'done' },
@@ -134,6 +250,31 @@ describe('kanban controller', () => {
expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' })
})
it('enriches archived task details using the latest run profile', async () => {
mockGetTask.mockResolvedValue({
task: { id: 'task-archived', status: 'archived' },
runs: [{ profile: 'reviewer' }],
comments: [],
events: [],
})
mockFindLatestExactSessionId.mockResolvedValue('session-archived')
mockGetExactSessionDetail.mockResolvedValue({
title: 'Archived session',
source: 'codex',
model: 'gpt-5.5',
started_at: 1,
ended_at: 2,
messages: [],
})
const c = ctx({ params: { id: 'task-archived' }, query: { board: 'project-a' } })
await ctrl.get(c)
expect(mockFindLatestExactSessionId).toHaveBeenCalledWith('task-archived', 'reviewer')
expect(mockGetExactSessionDetail).toHaveBeenCalledWith('session-archived', 'reviewer')
expect(c.body.session).toMatchObject({ id: 'session-archived', title: 'Archived session' })
})
it('prefers exact kanban-task session matches over later sessions that merely reference the task id', async () => {
mockGetTask.mockResolvedValue({
task: { id: 't_348bfaaf', status: 'done' },
@@ -165,6 +306,27 @@ describe('kanban controller', () => {
const createCtx = ctx({ request: { body: {} } })
await ctrl.create(createCtx)
expect(createCtx.status).toBe(400)
expect(mockCreateTask).not.toHaveBeenCalled()
const invalidCompleteCtx = ctx({ request: { body: { task_ids: ['task-1', 123] } } })
await ctrl.complete(invalidCompleteCtx)
expect(invalidCompleteCtx.status).toBe(400)
expect(mockCompleteTasks).not.toHaveBeenCalled()
const invalidBlockCtx = ctx({ params: { id: 'task-1' }, request: { body: { reason: [] } } })
await ctrl.block(invalidBlockCtx)
expect(invalidBlockCtx.status).toBe(400)
expect(mockBlockTask).not.toHaveBeenCalled()
const invalidUnblockCtx = ctx({ request: { body: [] } })
await ctrl.unblock(invalidUnblockCtx)
expect(invalidUnblockCtx.status).toBe(400)
expect(mockUnblockTasks).not.toHaveBeenCalled()
const invalidAssignCtx = ctx({ params: { id: 'task-1' }, request: { body: { profile: 123 } } })
await ctrl.assign(invalidAssignCtx)
expect(invalidAssignCtx.status).toBe(400)
expect(mockAssignTask).not.toHaveBeenCalled()
const searchCtx = ctx({ query: { task_id: 'task-1' } })
await ctrl.searchSessions(searchCtx)
+14
View File
@@ -16,6 +16,13 @@ const handlers = {
unblock: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
block: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
assign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
addComment: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
taskLog: vi.fn(async (ctx: any) => { ctx.body = { log: '' } }),
diagnostics: vi.fn(async (ctx: any) => { ctx.body = { diagnostics: [] } }),
reclaim: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
reassign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
specify: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
dispatch: vi.fn(async (ctx: any) => { ctx.body = { result: {} } }),
}
vi.mock('../../packages/server/src/controllers/hermes/kanban', () => handlers)
@@ -36,6 +43,8 @@ describe('kanban routes', () => {
'/api/hermes/kanban/capabilities',
'/api/hermes/kanban/stats',
'/api/hermes/kanban/assignees',
'/api/hermes/kanban/diagnostics',
'/api/hermes/kanban/dispatch',
'/api/hermes/kanban/artifact',
'/api/hermes/kanban/search-sessions',
'/api/hermes/kanban',
@@ -44,6 +53,11 @@ describe('kanban routes', () => {
'/api/hermes/kanban/unblock',
'/api/hermes/kanban/:id/block',
'/api/hermes/kanban/:id/assign',
'/api/hermes/kanban/:id/comments',
'/api/hermes/kanban/:id/log',
'/api/hermes/kanban/:id/reclaim',
'/api/hermes/kanban/:id/reassign',
'/api/hermes/kanban/:id/specify',
]))
})