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