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:
@@ -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'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user