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

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

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

* fix(kanban): guard archived task detail actions

---------

Co-authored-by: ekko <152005280+EKKOLearnAI@users.noreply.github.com>
This commit is contained in:
Zhicheng Han
2026-05-11 15:26:24 +02:00
committed by GitHub
parent 3a1893d401
commit 6ff1c18ee2
12 changed files with 1079 additions and 91 deletions
+95 -2
View File
@@ -45,11 +45,66 @@ describe('hermes kanban service', () => {
it('exposes capability metadata for WUI/canonical parity gaps', async () => {
await expect(service.getCapabilities()).resolves.toMatchObject({
source: 'hermes-cli',
supports: { boardsList: true, boardCreate: true, commentsWrite: false, dispatch: false },
missing: expect.arrayContaining(['commentsWrite', 'dispatch']),
supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true },
missing: expect.arrayContaining(['cliCurrentSwitch', 'links', 'bulk', 'events', 'homeSubscriptions']),
capabilities: expect.arrayContaining([
expect.objectContaining({ key: 'commentsWrite', status: 'supported', canonicalCommand: 'comment', requiresBoard: true }),
expect.objectContaining({ key: 'events', status: 'missing', canonicalRoute: '/events', requiresBoard: true }),
]),
})
})
it('builds comment/log/diagnostics commands with explicit board', async () => {
mockExecFileAsync
.mockResolvedValueOnce({ stdout: 'comment added\n' })
.mockResolvedValueOnce({ stdout: 'worker log\n' })
.mockResolvedValueOnce({ stdout: JSON.stringify([{ task_id: 'task-1', severity: 'warning' }]) })
await expect(service.addComment('task-1', '--not-an-option', { board: 'default', author: 'han' })).resolves.toEqual({ ok: true, output: 'comment added\n' })
await expect(service.getTaskLog('task-1', { board: 'default', tail: 4000 })).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 11, content: 'worker log\n', truncated: false })
await expect(service.getDiagnostics({ board: 'default', task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1', severity: 'warning' }])
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'comment', 'task-1', '--not-an-option', '--author', 'han'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'log', 'task-1', '--tail', '4000'])
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'diagnostics', '--json', '--task', 'task-1', '--severity', 'warning'])
})
it('maps no-log task logs to canonical empty-log shape', async () => {
mockExecFileAsync
.mockRejectedValueOnce({ code: 1, stderr: '(no log for task-1 — task may not have spawned yet)' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
await expect(service.getTaskLog('task-1', { board: 'default' })).resolves.toEqual({
task_id: 'task-1',
path: null,
exists: false,
size_bytes: 0,
content: '',
truncated: false,
})
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'log', 'task-1'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'show', 'task-1', '--json'])
})
it('builds recovery and dispatch commands with explicit board', async () => {
mockExecFileAsync
.mockResolvedValueOnce({ stdout: 'reclaimed\n' })
.mockResolvedValueOnce({ stdout: 'reassigned\n' })
.mockResolvedValueOnce({ stdout: '{"task_id":"task-1","created":true}\n' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ spawned: 1 }) })
await expect(service.reclaimTask('task-1', { board: 'project-a', reason: 'stale lock' })).resolves.toEqual({ ok: true, output: 'reclaimed\n' })
await expect(service.reassignTask('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })).resolves.toEqual({ ok: true, output: 'reassigned\n' })
await expect(service.specifyTask('task-1', { board: 'project-a', author: 'han' })).resolves.toEqual([{ task_id: 'task-1', created: true }])
await expect(service.dispatch({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'reclaim', 'task-1', '--reason', 'stale lock'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'reassign', 'task-1', 'bob', '--reclaim', '--reason', 'handoff'])
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'specify', 'task-1', '--json', '--author', 'han'])
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'dispatch', '--json', '--dry-run', '--max', '2', '--failure-limit', '3'])
})
it('builds list/create/stats CLI calls with global --board before the action', async () => {
mockExecFileAsync
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) })
@@ -110,6 +165,44 @@ describe('hermes kanban service', () => {
expect(mockExecFileAsync).not.toHaveBeenCalled()
})
it('normalizes board slugs using canonical upstream-compatible rules', async () => {
const sixtyFourChars = 'a'.repeat(64)
mockExecFileAsync
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
await service.listTasks({ board: 'Team_Alpha' })
await service.listTasks({ board: sixtyFourChars })
await service.listTasks({ board: 'default' })
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'team_alpha', 'list', '--json'])
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', sixtyFourChars, 'list', '--json'])
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'list', '--json'])
await expect(service.listTasks({ board: 'bad/slug' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: 'bad.slug' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: '..' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: 'bad slug' })).rejects.toThrow('Invalid kanban board slug')
await expect(service.listTasks({ board: ' ' })).rejects.toThrow('Invalid kanban board slug')
})
it('does not hide non-no-log failures from the kanban log command', async () => {
mockExecFileAsync
.mockRejectedValueOnce({ code: 1, stderr: 'permission denied', message: 'permission denied' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
await expect(service.getTaskLog('task-1', { board: 'default' })).rejects.toThrow('Failed to read kanban task log: permission denied')
expect(mockLoggerError).toHaveBeenCalled()
})
it('does not treat misleading no-log fragments as canonical no-log messages', async () => {
mockExecFileAsync
.mockRejectedValueOnce({ code: 1, stderr: 'permission denied: no log for diagnostic file', message: 'permission denied' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
await expect(service.getTaskLog('task-1', { board: 'default' })).rejects.toThrow('Failed to read kanban task log: permission denied')
})
it('wraps CLI failures with service-specific errors', async () => {
mockExecFileAsync.mockRejectedValue(new Error('boom'))
+164 -2
View File
@@ -12,6 +12,13 @@ const mockCompleteTasks = vi.hoisted(() => vi.fn())
const mockBlockTask = vi.hoisted(() => vi.fn())
const mockUnblockTasks = vi.hoisted(() => vi.fn())
const mockAssignTask = vi.hoisted(() => vi.fn())
const mockAddComment = vi.hoisted(() => vi.fn())
const mockGetTaskLog = vi.hoisted(() => vi.fn())
const mockGetDiagnostics = vi.hoisted(() => vi.fn())
const mockReclaimTask = vi.hoisted(() => vi.fn())
const mockReassignTask = vi.hoisted(() => vi.fn())
const mockSpecifyTask = vi.hoisted(() => vi.fn())
const mockDispatch = vi.hoisted(() => vi.fn())
const mockGetStats = vi.hoisted(() => vi.fn())
const mockGetAssignees = vi.hoisted(() => vi.fn())
const mockSearchSessions = vi.hoisted(() => vi.fn())
@@ -29,8 +36,8 @@ vi.mock('os', () => ({
vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
normalizeBoardSlug: (board?: string | null) => {
const value = board?.trim() || 'default'
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value)) throw new Error('Invalid kanban board slug')
const value = board?.trim().toLowerCase() || 'default'
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error('Invalid kanban board slug')
return value
},
listBoards: mockListBoards,
@@ -44,6 +51,13 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
blockTask: mockBlockTask,
unblockTasks: mockUnblockTasks,
assignTask: mockAssignTask,
addComment: mockAddComment,
getTaskLog: mockGetTaskLog,
getDiagnostics: mockGetDiagnostics,
reclaimTask: mockReclaimTask,
reassignTask: mockReassignTask,
specifyTask: mockSpecifyTask,
dispatch: mockDispatch,
getStats: mockGetStats,
getAssignees: mockGetAssignees,
}))
@@ -109,6 +123,108 @@ describe('kanban controller', () => {
expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false })
})
it('proxies comment/log/diagnostics with explicit board context', async () => {
const taskLog = { task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false }
mockAddComment.mockResolvedValue({ ok: true, output: 'commented' })
mockGetTaskLog.mockResolvedValue(taskLog)
mockGetDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
const commentCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { body: 'needs review', author: 'han' } } })
await ctrl.addComment(commentCtx)
expect(mockAddComment).toHaveBeenCalledWith('task-1', 'needs review', { board: 'project-a', author: 'han' })
expect(commentCtx.body).toEqual({ ok: true, output: 'commented' })
const logCtx = ctx({ query: { board: 'default', tail: '4000' }, params: { id: 'task-1' } })
await ctrl.taskLog(logCtx)
expect(mockGetTaskLog).toHaveBeenCalledWith('task-1', { board: 'default', tail: 4000 })
expect(logCtx.body).toEqual(taskLog)
const diagnosticsCtx = ctx({ query: { board: 'default', task: 'task-1', severity: 'warning' } })
await ctrl.diagnostics(diagnosticsCtx)
expect(mockGetDiagnostics).toHaveBeenCalledWith({ board: 'default', task: 'task-1', severity: 'warning' })
expect(diagnosticsCtx.body).toEqual({ diagnostics: [{ task_id: 'task-1' }] })
})
it('validates canonical parity endpoint inputs before shelling out', async () => {
const invalidTailCtx = ctx({ query: { board: 'default', tail: '0' }, params: { id: 'task-1' } })
await ctrl.taskLog(invalidTailCtx)
expect(invalidTailCtx.status).toBe(400)
expect(mockGetTaskLog).not.toHaveBeenCalled()
const oversizedTailCtx = ctx({ query: { board: 'default', tail: '1000001' }, params: { id: 'task-1' } })
await ctrl.taskLog(oversizedTailCtx)
expect(oversizedTailCtx.status).toBe(400)
expect(mockGetTaskLog).not.toHaveBeenCalled()
const invalidSeverityCtx = ctx({ query: { board: 'default', severity: 'info' } })
await ctrl.diagnostics(invalidSeverityCtx)
expect(invalidSeverityCtx.status).toBe(400)
expect(mockGetDiagnostics).not.toHaveBeenCalled()
const emptyBoardCtx = ctx({ query: { board: ' ' } })
await ctrl.list(emptyBoardCtx)
expect(emptyBoardCtx.status).toBe(400)
expect(mockListTasks).not.toHaveBeenCalled()
const invalidDispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: 'yes', max: -1, failureLimit: 0 } } })
await ctrl.dispatch(invalidDispatchCtx)
expect(invalidDispatchCtx.status).toBe(400)
expect(mockDispatch).not.toHaveBeenCalled()
const oversizedDispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: false, max: 999999999 } } })
await ctrl.dispatch(oversizedDispatchCtx)
expect(oversizedDispatchCtx.status).toBe(400)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('rejects malformed parity action bodies before shelling out', async () => {
const cases: Array<{ name: string; invoke: (c: any) => Promise<void>; context: any; mock: ReturnType<typeof vi.fn> }> = [
{ name: 'comment body object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: {}, author: 'han' } } }), mock: mockAddComment },
{ name: 'comment request body array', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: [] } }), mock: mockAddComment },
{ name: 'comment author object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: 'ok', author: {} } } }), mock: mockAddComment },
{ name: 'reclaim request body string', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 'bad' } }), mock: mockReclaimTask },
{ name: 'reclaim reason array', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { reason: [] } } }), mock: mockReclaimTask },
{ name: 'reassign reclaim string', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 'false' } } }), mock: mockReassignTask },
{ name: 'reassign reclaim number', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 1 } } }), mock: mockReassignTask },
{ name: 'reassign profile number', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 123 } } }), mock: mockReassignTask },
{ name: 'specify request body number', invoke: ctrl.specify, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 123 } }), mock: mockSpecifyTask },
{ name: 'specify author object', invoke: ctrl.specify, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { author: {} } } }), mock: mockSpecifyTask },
{ name: 'dispatch request body array', invoke: ctrl.dispatch, context: ctx({ query: { board: 'default' }, request: { body: [] } }), mock: mockDispatch },
]
for (const testCase of cases) {
vi.clearAllMocks()
await testCase.invoke(testCase.context)
expect(testCase.context.status, testCase.name).toBe(400)
expect(testCase.mock, testCase.name).not.toHaveBeenCalled()
}
})
it('proxies recovery and dispatch actions with explicit board context', async () => {
mockReclaimTask.mockResolvedValue({ ok: true, output: 'reclaimed' })
mockReassignTask.mockResolvedValue({ ok: true, output: 'reassigned' })
mockSpecifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockDispatch.mockResolvedValue({ spawned: 1 })
const reclaimCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { reason: 'stale' } } })
await ctrl.reclaim(reclaimCtx)
expect(mockReclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
const reassignCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: true, reason: 'handoff' } } })
await ctrl.reassign(reassignCtx)
expect(mockReassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
const specifyCtx = ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { author: 'han' } } })
await ctrl.specify(specifyCtx)
expect(mockSpecifyTask).toHaveBeenCalledWith('task-1', { board: 'default', author: 'han' })
expect(specifyCtx.body).toEqual({ results: [{ task_id: 'task-1' }] })
const dispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: true, max: 2, failureLimit: 3 } } })
await ctrl.dispatch(dispatchCtx)
expect(mockDispatch).toHaveBeenCalledWith({ board: 'default', dryRun: true, max: 2, failureLimit: 3 })
expect(dispatchCtx.body).toEqual({ result: { spawned: 1 } })
})
it('enriches completed task details using the latest run profile', async () => {
mockGetTask.mockResolvedValue({
task: { id: 'task-1', status: 'done' },
@@ -134,6 +250,31 @@ describe('kanban controller', () => {
expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' })
})
it('enriches archived task details using the latest run profile', async () => {
mockGetTask.mockResolvedValue({
task: { id: 'task-archived', status: 'archived' },
runs: [{ profile: 'reviewer' }],
comments: [],
events: [],
})
mockFindLatestExactSessionId.mockResolvedValue('session-archived')
mockGetExactSessionDetail.mockResolvedValue({
title: 'Archived session',
source: 'codex',
model: 'gpt-5.5',
started_at: 1,
ended_at: 2,
messages: [],
})
const c = ctx({ params: { id: 'task-archived' }, query: { board: 'project-a' } })
await ctrl.get(c)
expect(mockFindLatestExactSessionId).toHaveBeenCalledWith('task-archived', 'reviewer')
expect(mockGetExactSessionDetail).toHaveBeenCalledWith('session-archived', 'reviewer')
expect(c.body.session).toMatchObject({ id: 'session-archived', title: 'Archived session' })
})
it('prefers exact kanban-task session matches over later sessions that merely reference the task id', async () => {
mockGetTask.mockResolvedValue({
task: { id: 't_348bfaaf', status: 'done' },
@@ -165,6 +306,27 @@ describe('kanban controller', () => {
const createCtx = ctx({ request: { body: {} } })
await ctrl.create(createCtx)
expect(createCtx.status).toBe(400)
expect(mockCreateTask).not.toHaveBeenCalled()
const invalidCompleteCtx = ctx({ request: { body: { task_ids: ['task-1', 123] } } })
await ctrl.complete(invalidCompleteCtx)
expect(invalidCompleteCtx.status).toBe(400)
expect(mockCompleteTasks).not.toHaveBeenCalled()
const invalidBlockCtx = ctx({ params: { id: 'task-1' }, request: { body: { reason: [] } } })
await ctrl.block(invalidBlockCtx)
expect(invalidBlockCtx.status).toBe(400)
expect(mockBlockTask).not.toHaveBeenCalled()
const invalidUnblockCtx = ctx({ request: { body: [] } })
await ctrl.unblock(invalidUnblockCtx)
expect(invalidUnblockCtx.status).toBe(400)
expect(mockUnblockTasks).not.toHaveBeenCalled()
const invalidAssignCtx = ctx({ params: { id: 'task-1' }, request: { body: { profile: 123 } } })
await ctrl.assign(invalidAssignCtx)
expect(invalidAssignCtx.status).toBe(400)
expect(mockAssignTask).not.toHaveBeenCalled()
const searchCtx = ctx({ query: { task_id: 'task-1' } })
await ctrl.searchSessions(searchCtx)
+14
View File
@@ -16,6 +16,13 @@ const handlers = {
unblock: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
block: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
assign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
addComment: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
taskLog: vi.fn(async (ctx: any) => { ctx.body = { log: '' } }),
diagnostics: vi.fn(async (ctx: any) => { ctx.body = { diagnostics: [] } }),
reclaim: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
reassign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
specify: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
dispatch: vi.fn(async (ctx: any) => { ctx.body = { result: {} } }),
}
vi.mock('../../packages/server/src/controllers/hermes/kanban', () => handlers)
@@ -36,6 +43,8 @@ describe('kanban routes', () => {
'/api/hermes/kanban/capabilities',
'/api/hermes/kanban/stats',
'/api/hermes/kanban/assignees',
'/api/hermes/kanban/diagnostics',
'/api/hermes/kanban/dispatch',
'/api/hermes/kanban/artifact',
'/api/hermes/kanban/search-sessions',
'/api/hermes/kanban',
@@ -44,6 +53,11 @@ describe('kanban routes', () => {
'/api/hermes/kanban/unblock',
'/api/hermes/kanban/:id/block',
'/api/hermes/kanban/:id/assign',
'/api/hermes/kanban/:id/comments',
'/api/hermes/kanban/:id/log',
'/api/hermes/kanban/:id/reclaim',
'/api/hermes/kanban/:id/reassign',
'/api/hermes/kanban/:id/specify',
]))
})