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
|
switchCurrent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KanbanCapabilityStatus {
|
||||||
|
key: string
|
||||||
|
status: 'supported' | 'partial' | 'missing'
|
||||||
|
reason?: string
|
||||||
|
canonicalRoute?: string
|
||||||
|
canonicalCommand?: string
|
||||||
|
requiresBoard: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface KanbanCapabilities {
|
export interface KanbanCapabilities {
|
||||||
source: 'hermes-cli'
|
source: 'hermes-cli'
|
||||||
supports: Record<string, boolean>
|
supports: Record<string, boolean>
|
||||||
missing: string[]
|
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 {
|
export interface KanbanCreateRequest {
|
||||||
@@ -146,6 +165,39 @@ export interface KanbanListOptions extends KanbanBoardOptions {
|
|||||||
includeArchived?: boolean
|
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 {
|
function normalizedBoard(board?: string): string {
|
||||||
const trimmed = board?.trim()
|
const trimmed = board?.trim()
|
||||||
return trimmed || 'default'
|
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> {
|
export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
|
||||||
const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board)))
|
const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board)))
|
||||||
return res.stats
|
return res.stats
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ const localizedTaskStatus = computed(() => {
|
|||||||
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
|
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 sessionResults = ref<any[]>([])
|
||||||
const sessionLoading = ref(false)
|
const sessionLoading = ref(false)
|
||||||
const showSessions = ref(false)
|
const showSessions = ref(false)
|
||||||
@@ -243,8 +248,8 @@ async function handleAssign() {
|
|||||||
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
|
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions (only for non-completed tasks) -->
|
<!-- Actions (only for active, mutable tasks) -->
|
||||||
<div v-if="detail.task.status !== 'done'" class="detail-section">
|
<div v-if="canMutateTask" class="detail-section">
|
||||||
<div class="section-title">{{ t('kanban.action.title') }}</div>
|
<div class="section-title">{{ t('kanban.action.title') }}</div>
|
||||||
<div class="action-group">
|
<div class="action-group">
|
||||||
<template v-if="!showCompleteInput">
|
<template v-if="!showCompleteInput">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import * as kanbanApi from '@/api/hermes/kanban'
|
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 KANBAN_SELECTED_BOARD_STORAGE_KEY = 'hermes.kanban.selectedBoard'
|
||||||
export const DEFAULT_KANBAN_BOARD = 'default'
|
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 {
|
function safeStorageGet(key: string): string | null {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
@@ -27,7 +27,7 @@ function safeStorageSet(key: string, value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBoardSlug(board?: string | null): string {
|
export function normalizeBoardSlug(board?: string | null): string {
|
||||||
const trimmed = board?.trim()
|
const trimmed = board?.trim().toLowerCase()
|
||||||
if (!trimmed) return DEFAULT_KANBAN_BOARD
|
if (!trimmed) return DEFAULT_KANBAN_BOARD
|
||||||
return BOARD_SLUG_RE.test(trimmed) ? trimmed : DEFAULT_KANBAN_BOARD
|
return BOARD_SLUG_RE.test(trimmed) ? trimmed : DEFAULT_KANBAN_BOARD
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,20 @@ export const useKanbanStore = defineStore('kanban', () => {
|
|||||||
return visible
|
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 {
|
function boardExists(board: string): boolean {
|
||||||
return activeBoards.value.some(item => item.slug === board)
|
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) {
|
function setFilter(key: 'status' | 'assignee', value: string | null) {
|
||||||
if (key === 'status') filterStatus.value = value
|
if (key === 'status') filterStatus.value = value
|
||||||
else filterAssignee.value = value
|
else filterAssignee.value = value
|
||||||
@@ -273,6 +338,7 @@ export const useKanbanStore = defineStore('kanban', () => {
|
|||||||
boards,
|
boards,
|
||||||
capabilities,
|
capabilities,
|
||||||
activeBoards,
|
activeBoards,
|
||||||
|
isCapabilitySupported,
|
||||||
loading,
|
loading,
|
||||||
boardsLoading,
|
boardsLoading,
|
||||||
boardWarning,
|
boardWarning,
|
||||||
@@ -291,6 +357,13 @@ export const useKanbanStore = defineStore('kanban', () => {
|
|||||||
blockTask,
|
blockTask,
|
||||||
unblockTasks,
|
unblockTasks,
|
||||||
assignTask,
|
assignTask,
|
||||||
|
addComment,
|
||||||
|
getTaskLog,
|
||||||
|
getDiagnostics,
|
||||||
|
reclaimTask,
|
||||||
|
reassignTask,
|
||||||
|
specifyTask,
|
||||||
|
dispatch,
|
||||||
setFilter,
|
setFilter,
|
||||||
setSelectedBoard,
|
setSelectedBoard,
|
||||||
recoverSelectedBoard,
|
recoverSelectedBoard,
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ function firstQueryValue(value: string | string[] | undefined): string | undefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requestBoard(ctx: Context): string | null {
|
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 {
|
try {
|
||||||
return kanbanCli.normalizeBoardSlug(firstQueryValue(ctx.query.board as string | string[] | undefined))
|
return kanbanCli.normalizeBoardSlug(rawBoard)
|
||||||
} catch {
|
} catch {
|
||||||
ctx.status = 400
|
ctx.status = 400
|
||||||
ctx.body = { error: 'invalid board slug' }
|
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) {
|
export async function listBoards(ctx: Context) {
|
||||||
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
|
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
|
||||||
try {
|
try {
|
||||||
@@ -40,21 +130,25 @@ export async function listBoards(ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createBoard(ctx: Context) {
|
export async function createBoard(ctx: Context) {
|
||||||
const { slug, name, description, icon, color, switchCurrent } = ctx.request.body as {
|
const bodyResult = requestBody(ctx)
|
||||||
slug?: string
|
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||||
name?: string
|
const body = bodyResult.body
|
||||||
description?: string
|
const slug = requiredNonEmptyString(body.slug, 'slug')
|
||||||
icon?: string
|
const name = optionalString(body.name, 'name')
|
||||||
color?: string
|
const description = optionalString(body.description, 'description')
|
||||||
switchCurrent?: boolean
|
const icon = optionalString(body.icon, 'icon')
|
||||||
}
|
const color = optionalString(body.color, 'color')
|
||||||
if (!slug?.trim()) {
|
const switchCurrent = optionalBoolean(body.switchCurrent, 'switchCurrent')
|
||||||
ctx.status = 400
|
if (rejectBadRequest(ctx, slug.error || name.error || description.error || icon.error || color.error || switchCurrent.error)) return
|
||||||
ctx.body = { error: 'slug is required' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
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 }
|
ctx.body = { board }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500
|
ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500
|
||||||
@@ -115,8 +209,9 @@ export async function get(ctx: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For completed tasks, find related session from the worker's profile DB
|
// For terminal tasks, find related session from the worker's profile DB.
|
||||||
if (detail.task.status === 'done' && detail.runs.length > 0) {
|
// 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)
|
const profile = getLatestRunProfile(detail)
|
||||||
if (profile) {
|
if (profile) {
|
||||||
try {
|
try {
|
||||||
@@ -166,22 +261,19 @@ export async function get(ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function create(ctx: Context) {
|
export async function create(ctx: Context) {
|
||||||
const { title, body, assignee, priority, tenant } = ctx.request.body as {
|
const bodyResult = requestBody(ctx)
|
||||||
title?: string
|
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||||
body?: string
|
const payload = bodyResult.body
|
||||||
assignee?: string
|
const title = requiredNonEmptyString(payload.title, 'title')
|
||||||
priority?: number
|
const body = optionalString(payload.body, 'body')
|
||||||
tenant?: string
|
const assignee = optionalString(payload.assignee, 'assignee')
|
||||||
}
|
const priority = optionalInteger(payload.priority, 'priority')
|
||||||
if (!title) {
|
const tenant = optionalString(payload.tenant, 'tenant')
|
||||||
ctx.status = 400
|
if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return
|
||||||
ctx.body = { error: 'title is required' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const board = requestBoard(ctx)
|
const board = requestBoard(ctx)
|
||||||
if (!board) return
|
if (!board) return
|
||||||
try {
|
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 }
|
ctx.body = { task }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
@@ -190,19 +282,16 @@ export async function create(ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function complete(ctx: Context) {
|
export async function complete(ctx: Context) {
|
||||||
const { task_ids, summary } = ctx.request.body as {
|
const bodyResult = requestBody(ctx)
|
||||||
task_ids?: string[]
|
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||||
summary?: string
|
const payload = bodyResult.body
|
||||||
}
|
const taskIds = requiredNonEmptyStringArray(payload.task_ids, 'task_ids')
|
||||||
if (!task_ids?.length) {
|
const summary = optionalString(payload.summary, 'summary')
|
||||||
ctx.status = 400
|
if (rejectBadRequest(ctx, taskIds.error || summary.error)) return
|
||||||
ctx.body = { error: 'task_ids is required' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const board = requestBoard(ctx)
|
const board = requestBoard(ctx)
|
||||||
if (!board) return
|
if (!board) return
|
||||||
try {
|
try {
|
||||||
await kanbanCli.completeTasks(task_ids, summary, { board })
|
await kanbanCli.completeTasks(taskIds.value!, summary.value, { board })
|
||||||
ctx.body = { ok: true }
|
ctx.body = { ok: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
@@ -211,16 +300,14 @@ export async function complete(ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function block(ctx: Context) {
|
export async function block(ctx: Context) {
|
||||||
const { reason } = ctx.request.body as { reason?: string }
|
const bodyResult = requestBody(ctx)
|
||||||
if (!reason) {
|
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||||
ctx.status = 400
|
const reason = requiredNonEmptyString(bodyResult.body.reason, 'reason')
|
||||||
ctx.body = { error: 'reason is required' }
|
if (rejectBadRequest(ctx, reason.error)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const board = requestBoard(ctx)
|
const board = requestBoard(ctx)
|
||||||
if (!board) return
|
if (!board) return
|
||||||
try {
|
try {
|
||||||
await kanbanCli.blockTask(ctx.params.id, reason, { board })
|
await kanbanCli.blockTask(ctx.params.id, reason.value!, { board })
|
||||||
ctx.body = { ok: true }
|
ctx.body = { ok: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
@@ -229,16 +316,14 @@ export async function block(ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function unblock(ctx: Context) {
|
export async function unblock(ctx: Context) {
|
||||||
const { task_ids } = ctx.request.body as { task_ids?: string[] }
|
const bodyResult = requestBody(ctx)
|
||||||
if (!task_ids?.length) {
|
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||||
ctx.status = 400
|
const taskIds = requiredNonEmptyStringArray(bodyResult.body.task_ids, 'task_ids')
|
||||||
ctx.body = { error: 'task_ids is required' }
|
if (rejectBadRequest(ctx, taskIds.error)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const board = requestBoard(ctx)
|
const board = requestBoard(ctx)
|
||||||
if (!board) return
|
if (!board) return
|
||||||
try {
|
try {
|
||||||
await kanbanCli.unblockTasks(task_ids, { board })
|
await kanbanCli.unblockTasks(taskIds.value!, { board })
|
||||||
ctx.body = { ok: true }
|
ctx.body = { ok: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
@@ -247,16 +332,14 @@ export async function unblock(ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function assign(ctx: Context) {
|
export async function assign(ctx: Context) {
|
||||||
const { profile } = ctx.request.body as { profile?: string }
|
const bodyResult = requestBody(ctx)
|
||||||
if (!profile) {
|
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||||
ctx.status = 400
|
const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile')
|
||||||
ctx.body = { error: 'profile is required' }
|
if (rejectBadRequest(ctx, profile.error)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const board = requestBoard(ctx)
|
const board = requestBoard(ctx)
|
||||||
if (!board) return
|
if (!board) return
|
||||||
try {
|
try {
|
||||||
await kanbanCli.assignTask(ctx.params.id, profile, { board })
|
await kanbanCli.assignTask(ctx.params.id, profile.value!, { board })
|
||||||
ctx.body = { ok: true }
|
ctx.body = { ok: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
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) {
|
export async function stats(ctx: Context) {
|
||||||
const board = requestBoard(ctx)
|
const board = requestBoard(ctx)
|
||||||
if (!board) return
|
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/capabilities', ctrl.capabilities)
|
||||||
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
|
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
|
||||||
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
|
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/artifact', ctrl.readArtifact)
|
||||||
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
|
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
|
||||||
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
|
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/unblock', ctrl.unblock)
|
||||||
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
|
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
|
||||||
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
|
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 execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const execOpts = { windowsHide: true }
|
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 {
|
function resolveHermesBin(): string {
|
||||||
const envBin = process.env.HERMES_BIN?.trim()
|
const envBin = process.env.HERMES_BIN?.trim()
|
||||||
@@ -16,8 +20,9 @@ function resolveHermesBin(): string {
|
|||||||
const HERMES_BIN = resolveHermesBin()
|
const HERMES_BIN = resolveHermesBin()
|
||||||
|
|
||||||
export function normalizeBoardSlug(board?: string | null): string {
|
export function normalizeBoardSlug(board?: string | null): string {
|
||||||
const trimmed = board?.trim()
|
if (board === undefined || board === null) return 'default'
|
||||||
if (!trimmed) return 'default'
|
const trimmed = board.trim().toLowerCase()
|
||||||
|
if (!trimmed) throw new Error('Invalid kanban board slug')
|
||||||
if (!BOARD_SLUG_RE.test(trimmed)) {
|
if (!BOARD_SLUG_RE.test(trimmed)) {
|
||||||
throw new Error('Invalid kanban board slug')
|
throw new Error('Invalid kanban board slug')
|
||||||
}
|
}
|
||||||
@@ -125,6 +130,25 @@ export interface KanbanCapabilities {
|
|||||||
source: 'hermes-cli'
|
source: 'hermes-cli'
|
||||||
supports: Record<string, boolean>
|
supports: Record<string, boolean>
|
||||||
missing: string[]
|
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 {
|
export interface KanbanBoardOptions {
|
||||||
@@ -196,24 +220,186 @@ export async function archiveBoard(slugInput: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCapabilities(): Promise<KanbanCapabilities> {
|
export async function getCapabilities(): Promise<KanbanCapabilities> {
|
||||||
const supports = {
|
const capabilities: KanbanCapabilityStatus[] = [
|
||||||
explicitBoard: true,
|
{ key: 'explicitBoard', status: 'supported', canonicalCommand: '--board', requiresBoard: true },
|
||||||
boardsList: true,
|
{ key: 'boardsList', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards list', requiresBoard: false },
|
||||||
boardCreate: true,
|
{ key: 'boardCreate', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards create', requiresBoard: false },
|
||||||
boardArchive: true,
|
{ key: 'boardArchive', status: 'supported', canonicalRoute: '/boards/{slug}', canonicalCommand: 'boards rm', requiresBoard: false },
|
||||||
cliCurrentSwitch: true,
|
{ 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 },
|
||||||
taskCrudLite: true,
|
{ key: 'taskCrudLite', status: 'supported', canonicalRoute: '/tasks', canonicalCommand: 'list/show/create/complete/block/unblock/assign', requiresBoard: true },
|
||||||
commentsWrite: false,
|
{ key: 'commentsWrite', status: 'supported', canonicalRoute: '/tasks/{task_id}/comments', canonicalCommand: 'comment', requiresBoard: true },
|
||||||
taskLog: false,
|
{ key: 'commentsRead', status: 'supported', reason: 'Comments are returned on task detail responses', canonicalRoute: '/tasks/{task_id}', canonicalCommand: 'show --json', requiresBoard: true },
|
||||||
dispatch: false,
|
{ key: 'taskLog', status: 'supported', canonicalRoute: '/tasks/{task_id}/log', canonicalCommand: 'log', requiresBoard: true },
|
||||||
events: false,
|
{ key: 'diagnostics', status: 'supported', canonicalRoute: '/diagnostics', canonicalCommand: 'diagnostics', requiresBoard: true },
|
||||||
diagnostics: false,
|
{ key: 'reclaim', status: 'supported', canonicalRoute: '/tasks/{task_id}/reclaim', canonicalCommand: 'reclaim', requiresBoard: true },
|
||||||
bulk: false,
|
{ 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?: {
|
export async function listTasks(opts?: {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import {
|
|||||||
blockTask,
|
blockTask,
|
||||||
unblockTasks,
|
unblockTasks,
|
||||||
assignTask,
|
assignTask,
|
||||||
|
addComment,
|
||||||
|
getTaskLog,
|
||||||
|
getDiagnostics,
|
||||||
|
reclaimTask,
|
||||||
|
reassignTask,
|
||||||
|
specifyTask,
|
||||||
|
dispatch,
|
||||||
getStats,
|
getStats,
|
||||||
getAssignees,
|
getAssignees,
|
||||||
} from '../../packages/client/src/api/hermes/kanban'
|
} from '../../packages/client/src/api/hermes/kanban'
|
||||||
@@ -105,4 +112,33 @@ describe('Kanban API', () => {
|
|||||||
['/api/hermes/kanban/assignees?board=project-a'],
|
['/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(),
|
blockTask: vi.fn(),
|
||||||
unblockTasks: vi.fn(),
|
unblockTasks: vi.fn(),
|
||||||
assignTask: 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)
|
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', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
@@ -98,6 +116,64 @@ describe('Kanban store', () => {
|
|||||||
expect(store.tasks[1]).toMatchObject({ id: 'task-1', status: 'done' })
|
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 () => {
|
it('creates and archives boards without relying on CLI current board', async () => {
|
||||||
mockKanbanApi.listBoards.mockResolvedValue([
|
mockKanbanApi.listBoards.mockResolvedValue([
|
||||||
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
|
{ 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' } })
|
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 () => {
|
it('executes complete, block, unblock, and assign actions', async () => {
|
||||||
mockGetTask.mockResolvedValueOnce({
|
mockGetTask.mockResolvedValueOnce({
|
||||||
task: {
|
task: {
|
||||||
|
|||||||
@@ -45,11 +45,66 @@ describe('hermes kanban service', () => {
|
|||||||
it('exposes capability metadata for WUI/canonical parity gaps', async () => {
|
it('exposes capability metadata for WUI/canonical parity gaps', async () => {
|
||||||
await expect(service.getCapabilities()).resolves.toMatchObject({
|
await expect(service.getCapabilities()).resolves.toMatchObject({
|
||||||
source: 'hermes-cli',
|
source: 'hermes-cli',
|
||||||
supports: { boardsList: true, boardCreate: true, commentsWrite: false, dispatch: false },
|
supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true },
|
||||||
missing: expect.arrayContaining(['commentsWrite', 'dispatch']),
|
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 () => {
|
it('builds list/create/stats CLI calls with global --board before the action', async () => {
|
||||||
mockExecFileAsync
|
mockExecFileAsync
|
||||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) })
|
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) })
|
||||||
@@ -110,6 +165,44 @@ describe('hermes kanban service', () => {
|
|||||||
expect(mockExecFileAsync).not.toHaveBeenCalled()
|
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 () => {
|
it('wraps CLI failures with service-specific errors', async () => {
|
||||||
mockExecFileAsync.mockRejectedValue(new Error('boom'))
|
mockExecFileAsync.mockRejectedValue(new Error('boom'))
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const mockCompleteTasks = vi.hoisted(() => vi.fn())
|
|||||||
const mockBlockTask = vi.hoisted(() => vi.fn())
|
const mockBlockTask = vi.hoisted(() => vi.fn())
|
||||||
const mockUnblockTasks = vi.hoisted(() => vi.fn())
|
const mockUnblockTasks = vi.hoisted(() => vi.fn())
|
||||||
const mockAssignTask = 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 mockGetStats = vi.hoisted(() => vi.fn())
|
||||||
const mockGetAssignees = vi.hoisted(() => vi.fn())
|
const mockGetAssignees = vi.hoisted(() => vi.fn())
|
||||||
const mockSearchSessions = 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', () => ({
|
vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
|
||||||
normalizeBoardSlug: (board?: string | null) => {
|
normalizeBoardSlug: (board?: string | null) => {
|
||||||
const value = board?.trim() || 'default'
|
const value = board?.trim().toLowerCase() || 'default'
|
||||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value)) throw new Error('Invalid kanban board slug')
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error('Invalid kanban board slug')
|
||||||
return value
|
return value
|
||||||
},
|
},
|
||||||
listBoards: mockListBoards,
|
listBoards: mockListBoards,
|
||||||
@@ -44,6 +51,13 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
|
|||||||
blockTask: mockBlockTask,
|
blockTask: mockBlockTask,
|
||||||
unblockTasks: mockUnblockTasks,
|
unblockTasks: mockUnblockTasks,
|
||||||
assignTask: mockAssignTask,
|
assignTask: mockAssignTask,
|
||||||
|
addComment: mockAddComment,
|
||||||
|
getTaskLog: mockGetTaskLog,
|
||||||
|
getDiagnostics: mockGetDiagnostics,
|
||||||
|
reclaimTask: mockReclaimTask,
|
||||||
|
reassignTask: mockReassignTask,
|
||||||
|
specifyTask: mockSpecifyTask,
|
||||||
|
dispatch: mockDispatch,
|
||||||
getStats: mockGetStats,
|
getStats: mockGetStats,
|
||||||
getAssignees: mockGetAssignees,
|
getAssignees: mockGetAssignees,
|
||||||
}))
|
}))
|
||||||
@@ -109,6 +123,108 @@ describe('kanban controller', () => {
|
|||||||
expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false })
|
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 () => {
|
it('enriches completed task details using the latest run profile', async () => {
|
||||||
mockGetTask.mockResolvedValue({
|
mockGetTask.mockResolvedValue({
|
||||||
task: { id: 'task-1', status: 'done' },
|
task: { id: 'task-1', status: 'done' },
|
||||||
@@ -134,6 +250,31 @@ describe('kanban controller', () => {
|
|||||||
expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' })
|
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 () => {
|
it('prefers exact kanban-task session matches over later sessions that merely reference the task id', async () => {
|
||||||
mockGetTask.mockResolvedValue({
|
mockGetTask.mockResolvedValue({
|
||||||
task: { id: 't_348bfaaf', status: 'done' },
|
task: { id: 't_348bfaaf', status: 'done' },
|
||||||
@@ -165,6 +306,27 @@ describe('kanban controller', () => {
|
|||||||
const createCtx = ctx({ request: { body: {} } })
|
const createCtx = ctx({ request: { body: {} } })
|
||||||
await ctrl.create(createCtx)
|
await ctrl.create(createCtx)
|
||||||
expect(createCtx.status).toBe(400)
|
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' } })
|
const searchCtx = ctx({ query: { task_id: 'task-1' } })
|
||||||
await ctrl.searchSessions(searchCtx)
|
await ctrl.searchSessions(searchCtx)
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const handlers = {
|
|||||||
unblock: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
|
unblock: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
|
||||||
block: 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 } }),
|
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)
|
vi.mock('../../packages/server/src/controllers/hermes/kanban', () => handlers)
|
||||||
@@ -36,6 +43,8 @@ describe('kanban routes', () => {
|
|||||||
'/api/hermes/kanban/capabilities',
|
'/api/hermes/kanban/capabilities',
|
||||||
'/api/hermes/kanban/stats',
|
'/api/hermes/kanban/stats',
|
||||||
'/api/hermes/kanban/assignees',
|
'/api/hermes/kanban/assignees',
|
||||||
|
'/api/hermes/kanban/diagnostics',
|
||||||
|
'/api/hermes/kanban/dispatch',
|
||||||
'/api/hermes/kanban/artifact',
|
'/api/hermes/kanban/artifact',
|
||||||
'/api/hermes/kanban/search-sessions',
|
'/api/hermes/kanban/search-sessions',
|
||||||
'/api/hermes/kanban',
|
'/api/hermes/kanban',
|
||||||
@@ -44,6 +53,11 @@ describe('kanban routes', () => {
|
|||||||
'/api/hermes/kanban/unblock',
|
'/api/hermes/kanban/unblock',
|
||||||
'/api/hermes/kanban/:id/block',
|
'/api/hermes/kanban/:id/block',
|
||||||
'/api/hermes/kanban/:id/assign',
|
'/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