From b0e03ae838dbb74f54b4c03022548fc8d050e442 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Fri, 8 May 2026 11:32:47 +0800 Subject: [PATCH] add hermes kanban board (#534) --- docs/openapi.json | 152 ++++ packages/client/src/api/hermes/kanban.ts | 173 +++++ .../components/hermes/kanban/KanbanColumn.vue | 91 +++ .../hermes/kanban/KanbanCreateForm.vue | 80 ++ .../hermes/kanban/KanbanTaskCard.vue | 127 ++++ .../hermes/kanban/KanbanTaskDrawer.vue | 683 ++++++++++++++++++ .../src/components/layout/AppSidebar.vue | 8 + packages/client/src/i18n/locales/en.ts | 84 +++ packages/client/src/i18n/locales/zh.ts | 84 +++ packages/client/src/router/index.ts | 5 + packages/client/src/stores/hermes/kanban.ts | 110 +++ .../client/src/views/hermes/KanbanView.vue | 265 +++++++ .../server/src/controllers/hermes/kanban.ts | 225 ++++++ packages/server/src/db/hermes/sessions-db.ts | 101 +++ packages/server/src/routes/hermes/kanban.ts | 16 + packages/server/src/routes/index.ts | 2 + .../src/services/hermes/hermes-kanban.ts | 236 ++++++ tests/client/kanban-api.test.ts | 66 ++ tests/client/kanban-create-form.test.ts | 89 +++ tests/client/kanban-store.test.ts | 78 ++ tests/client/kanban-task-card.test.ts | 66 ++ tests/client/kanban-task-drawer.test.ts | 313 ++++++++ tests/client/kanban-view.test.ts | 137 ++++ tests/server/hermes-kanban-service.test.ts | 67 ++ tests/server/kanban-controller.test.ts | 156 ++++ tests/server/kanban-routes.test.ts | 53 ++ 26 files changed, 3467 insertions(+) create mode 100644 packages/client/src/api/hermes/kanban.ts create mode 100644 packages/client/src/components/hermes/kanban/KanbanColumn.vue create mode 100644 packages/client/src/components/hermes/kanban/KanbanCreateForm.vue create mode 100644 packages/client/src/components/hermes/kanban/KanbanTaskCard.vue create mode 100644 packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue create mode 100644 packages/client/src/stores/hermes/kanban.ts create mode 100644 packages/client/src/views/hermes/KanbanView.vue create mode 100644 packages/server/src/controllers/hermes/kanban.ts create mode 100644 packages/server/src/routes/hermes/kanban.ts create mode 100644 packages/server/src/services/hermes/hermes-kanban.ts create mode 100644 tests/client/kanban-api.test.ts create mode 100644 tests/client/kanban-create-form.test.ts create mode 100644 tests/client/kanban-store.test.ts create mode 100644 tests/client/kanban-task-card.test.ts create mode 100644 tests/client/kanban-task-drawer.test.ts create mode 100644 tests/client/kanban-view.test.ts create mode 100644 tests/server/hermes-kanban-service.test.ts create mode 100644 tests/server/kanban-controller.test.ts create mode 100644 tests/server/kanban-routes.test.ts diff --git a/docs/openapi.json b/docs/openapi.json index 84c4c01..05b65fe 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1764,6 +1764,106 @@ } } }, + "/api/hermes/model-context": { + "get": { + "tags": [ + "Models" + ], + "summary": "Get model-context", + "description": "GET /api/hermes/model-context", + "operationId": "getModelContext", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "Models" + ], + "summary": "Update model-context", + "description": "PUT /api/hermes/model-context", + "operationId": "updateModelContext", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/hermes/model-context/{provider}/{model}": { + "get": { + "tags": [ + "Models" + ], + "summary": "Get :model", + "description": "GET /api/hermes/model-context/:provider/:model", + "operationId": "getModelContext", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "Models" + ], + "summary": "Update :model", + "description": "PUT /api/hermes/model-context/:provider/:model", + "operationId": "updateModelContext", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/api/hermes/profiles": { "get": { "tags": [ @@ -2017,6 +2117,32 @@ } } }, + "/api/hermes/sessions/batch-delete": { + "post": { + "tags": [ + "Sessions" + ], + "summary": "Create batch-delete", + "description": "POST /api/hermes/sessions/batch-delete", + "operationId": "batchRemove", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/api/hermes/sessions/context-length": { "get": { "tags": [ @@ -2272,6 +2398,32 @@ } } }, + "/api/hermes/sessions/{id}/export": { + "get": { + "tags": [ + "Sessions" + ], + "summary": "Get export", + "description": "GET /api/hermes/sessions/:id/export", + "operationId": "exportSession", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + } + } + }, "/api/hermes/sessions/{id}/rename": { "post": { "tags": [ diff --git a/packages/client/src/api/hermes/kanban.ts b/packages/client/src/api/hermes/kanban.ts new file mode 100644 index 0000000..a5f3ab3 --- /dev/null +++ b/packages/client/src/api/hermes/kanban.ts @@ -0,0 +1,173 @@ +import { request } from '../client' + +// ─── Types ────────────────────────────────────────────────────── + +export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived' + +export interface KanbanTask { + id: string + title: string + body: string | null + assignee: string | null + status: KanbanTaskStatus + priority: number + created_by: string | null + created_at: number + started_at: number | null + completed_at: number | null + workspace_kind: string + workspace_path: string | null + tenant: string | null + result: string | null + skills: string[] | null +} + +export interface KanbanRun { + id: number + task_id: string + profile: string | null + status: string + outcome: string | null + summary: string | null + error: string | null + metadata: Record | null + worker_pid: number | null + started_at: number + ended_at: number | null +} + +export interface KanbanComment { + id: number + task_id: string + author: string + body: string + created_at: number +} + +export interface KanbanEvent { + id: number + task_id: string + kind: string + payload: Record | null + created_at: number + run_id: number | null +} + +export interface KanbanTaskMessage { + id: number | string + session_id: string + role: string + content: string + tool_call_id: string | null + tool_calls: any[] | null + tool_name: string | null + timestamp: number + token_count: number | null + finish_reason: string | null + reasoning: string | null +} + +export interface KanbanTaskSession { + id: string + title: string | null + source: string + model: string + started_at: number + ended_at: number | null + messages: KanbanTaskMessage[] +} + +export interface KanbanTaskDetail { + task: KanbanTask + latest_summary: string | null + session?: KanbanTaskSession + comments: KanbanComment[] + events: KanbanEvent[] + runs: KanbanRun[] +} + +export interface KanbanStats { + by_status: Record + by_assignee: Record + total: number +} + +export interface KanbanAssignee { + name: string + on_disk: boolean + counts: Record | null +} + +export interface KanbanCreateRequest { + title: string + body?: string + assignee?: string + priority?: number + tenant?: string +} + +// ─── API functions ─────────────────────────────────────────────── + +export async function listTasks(opts?: { + status?: string + assignee?: string + tenant?: string +}): Promise { + const params = new URLSearchParams() + if (opts?.status) params.set('status', opts.status) + if (opts?.assignee) params.set('assignee', opts.assignee) + if (opts?.tenant) params.set('tenant', opts.tenant) + const qs = params.toString() + const res = await request<{ tasks: KanbanTask[] }>(`/api/hermes/kanban${qs ? `?${qs}` : ''}`) + return res.tasks +} + +export async function getTask(id: string): Promise { + return request(`/api/hermes/kanban/${id}`) +} + +export async function createTask(data: KanbanCreateRequest): Promise { + const res = await request<{ task: KanbanTask }>('/api/hermes/kanban', { + method: 'POST', + body: JSON.stringify(data), + }) + return res.task +} + +export async function completeTasks(taskIds: string[], summary?: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>('/api/hermes/kanban/complete', { + method: 'POST', + body: JSON.stringify({ task_ids: taskIds, summary }), + }) +} + +export async function blockTask(taskId: string, reason: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/hermes/kanban/${taskId}/block`, { + method: 'POST', + body: JSON.stringify({ reason }), + }) +} + +export async function unblockTasks(taskIds: string[]): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>('/api/hermes/kanban/unblock', { + method: 'POST', + body: JSON.stringify({ task_ids: taskIds }), + }) +} + +export async function assignTask(taskId: string, profile: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/hermes/kanban/${taskId}/assign`, { + method: 'POST', + body: JSON.stringify({ profile }), + }) +} + +export async function getStats(): Promise { + const res = await request<{ stats: KanbanStats }>('/api/hermes/kanban/stats') + return res.stats +} + +export async function getAssignees(): Promise { + const res = await request<{ assignees: KanbanAssignee[] }>('/api/hermes/kanban/assignees') + return res.assignees +} diff --git a/packages/client/src/components/hermes/kanban/KanbanColumn.vue b/packages/client/src/components/hermes/kanban/KanbanColumn.vue new file mode 100644 index 0000000..bd0b727 --- /dev/null +++ b/packages/client/src/components/hermes/kanban/KanbanColumn.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue b/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue new file mode 100644 index 0000000..c573d6c --- /dev/null +++ b/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/client/src/components/hermes/kanban/KanbanTaskCard.vue b/packages/client/src/components/hermes/kanban/KanbanTaskCard.vue new file mode 100644 index 0000000..9e0e0a1 --- /dev/null +++ b/packages/client/src/components/hermes/kanban/KanbanTaskCard.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue b/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue new file mode 100644 index 0000000..9c93559 --- /dev/null +++ b/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue @@ -0,0 +1,683 @@ + + + + + diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index 679030e..b5e6797 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -135,6 +135,14 @@ function openChangelog() { {{ t("sidebar.jobs") }} + ', + }), + useMessage: () => mockMessage, +})) + +import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue' + +describe('KanbanCreateForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('validates required title before submit', async () => { + const wrapper = mount(KanbanCreateForm) + + await wrapper.findAll('.n-button-stub')[1].trigger('click') + + expect(mockMessage.warning).toHaveBeenCalledWith('kanban.form.titleRequired') + expect(mockCreateTask).not.toHaveBeenCalled() + }) + + it('submits trimmed values and emits created/close', async () => { + mockCreateTask.mockResolvedValue({ id: 'task-1' }) + const wrapper = mount(KanbanCreateForm) + + const inputs = wrapper.findAll('.n-input-stub') + await inputs[0].setValue(' Ship kanban ') + await inputs[1].setValue(' write tests ') + const selects = wrapper.findAll('.n-select-stub') + await selects[0].setValue('alice') + await selects[1].setValue('3') + await wrapper.findAll('.n-button-stub')[1].trigger('click') + await flushPromises() + + expect(mockCreateTask).toHaveBeenCalledWith({ + title: 'Ship kanban', + body: 'write tests', + assignee: 'alice', + priority: 3, + }) + expect(mockMessage.success).toHaveBeenCalledWith('kanban.message.taskCreated') + expect(wrapper.emitted('created')).toBeTruthy() + expect(wrapper.emitted('close')).toBeTruthy() + }) +}) diff --git a/tests/client/kanban-store.test.ts b/tests/client/kanban-store.test.ts new file mode 100644 index 0000000..ce54584 --- /dev/null +++ b/tests/client/kanban-store.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const mockKanbanApi = vi.hoisted(() => ({ + listTasks: vi.fn(), + getStats: vi.fn(), + getAssignees: vi.fn(), + createTask: vi.fn(), + completeTasks: vi.fn(), + blockTask: vi.fn(), + unblockTasks: vi.fn(), + assignTask: vi.fn(), +})) + +vi.mock('@/api/hermes/kanban', () => mockKanbanApi) + +import { useKanbanStore } from '@/stores/hermes/kanban' + +describe('Kanban store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('fetchTasks uses active filters and updates loading', async () => { + mockKanbanApi.listTasks.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve([{ id: 'task-1', status: 'todo' }]), 0)) + ) + + const store = useKanbanStore() + store.setFilter('status', 'blocked') + store.setFilter('assignee', 'alice') + const promise = store.fetchTasks() + + expect(store.loading).toBe(true) + await promise + + expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ status: 'blocked', assignee: 'alice' }) + expect(store.tasks).toEqual([{ id: 'task-1', status: 'todo' }]) + expect(store.loading).toBe(false) + }) + + it('create and status actions update local task state and refresh stats', async () => { + mockKanbanApi.createTask.mockResolvedValue({ id: 'task-2', status: 'todo', assignee: null }) + mockKanbanApi.completeTasks.mockResolvedValue({ ok: true }) + mockKanbanApi.blockTask.mockResolvedValue({ ok: true }) + mockKanbanApi.unblockTasks.mockResolvedValue({ ok: true }) + mockKanbanApi.assignTask.mockResolvedValue({ ok: true }) + mockKanbanApi.getStats.mockResolvedValue({ total: 2, by_status: { done: 1 }, by_assignee: {} }) + + const store = useKanbanStore() + store.tasks = [{ id: 'task-1', status: 'running', assignee: null }] as any + + await store.createTask({ title: 'Ship' }) + await store.completeTasks(['task-1'], 'done') + await store.blockTask('task-2', 'waiting') + await store.unblockTasks(['task-2']) + await store.assignTask('task-2', 'bob') + + expect(store.tasks[0]).toMatchObject({ id: 'task-2', status: 'ready', assignee: 'bob' }) + expect(store.tasks[1]).toMatchObject({ id: 'task-1', status: 'done' }) + expect(mockKanbanApi.getStats).toHaveBeenCalledTimes(4) + }) + + it('refreshAll loads tasks, stats, and assignees together', async () => { + mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1' }]) + mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} }) + mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'alice', on_disk: true, counts: { todo: 1 } }]) + + const store = useKanbanStore() + await store.refreshAll() + + expect(store.tasks).toEqual([{ id: 'task-1' }]) + expect(store.stats).toEqual({ total: 1, by_status: {}, by_assignee: {} }) + expect(store.assignees).toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }]) + }) +}) diff --git a/tests/client/kanban-task-card.test.ts b/tests/client/kanban-task-card.test.ts new file mode 100644 index 0000000..4b671ca --- /dev/null +++ b/tests/client/kanban-task-card.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'kanban.card.timeAgo.justNow') return '刚刚' + if (key === 'kanban.card.timeAgo.minutes') return `${params?.count}分钟前` + if (key === 'kanban.card.timeAgo.hours') return `${params?.count}小时前` + if (key === 'kanban.card.timeAgo.days') return `${params?.count}天前` + if (key === 'kanban.card.priority.high') return '高' + if (key === 'kanban.card.priority.medium') return '中' + if (key === 'kanban.card.priority.low') return '低' + if (key === 'kanban.card.assigneeTooltip') return '负责人' + return key + }, + }), +})) + +vi.mock('naive-ui', () => ({ + NTooltip: defineComponent({ + name: 'NTooltip', + template: '
', + }), +})) + +import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue' + +describe('KanbanTaskCard i18n', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('renders localized priority, tooltip, and relative time', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-08T03:00:00Z')) + + const wrapper = mount(KanbanTaskCard, { + props: { + task: { + id: 'task-1', + title: 'Ship kanban i18n', + body: 'Body preview content', + assignee: 'alice', + status: 'todo', + priority: 3, + created_by: null, + created_at: Math.floor(new Date('2026-05-08T02:58:00Z').getTime() / 1000), + started_at: null, + completed_at: null, + workspace_kind: 'local', + workspace_path: null, + tenant: null, + result: null, + skills: null, + }, + }, + }) + + expect(wrapper.text()).toContain('高') + expect(wrapper.text()).toContain('2分钟前') + expect(wrapper.text()).toContain('负责人') + }) +}) diff --git a/tests/client/kanban-task-drawer.test.ts b/tests/client/kanban-task-drawer.test.ts new file mode 100644 index 0000000..f4a4f5e --- /dev/null +++ b/tests/client/kanban-task-drawer.test.ts @@ -0,0 +1,313 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount, flushPromises } from '@vue/test-utils' + +const mockGetTask = vi.hoisted(() => vi.fn()) +const mockRequest = vi.hoisted(() => vi.fn()) +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 mockRouterPush = vi.hoisted(() => vi.fn()) +const mockUseMessage = vi.hoisted(() => vi.fn(() => ({ + success: vi.fn(), + error: vi.fn(), +}))) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +vi.mock('@/api/client', () => ({ + request: mockRequest, +})) + +vi.mock('@/api/hermes/kanban', () => ({ + getTask: mockGetTask, +})) + +vi.mock('@/stores/hermes/kanban', () => ({ + useKanbanStore: () => ({ + assignees: [{ name: 'alice', counts: { todo: 1 } }, { name: 'bob', counts: { ready: 1 } }], + completeTasks: mockCompleteTasks, + blockTask: mockBlockTask, + unblockTasks: mockUnblockTasks, + assignTask: mockAssignTask, + }), +})) + +vi.mock('@/components/hermes/chat/HistoryMessageList.vue', () => ({ + default: defineComponent({ + name: 'HistoryMessageList', + props: { session: { type: Object, required: false } }, + template: '
{{ session ? session.id : "none" }}
', + }), +})) + +vi.mock('naive-ui', () => ({ + NDrawer: defineComponent({ + name: 'NDrawer', + props: { show: { type: Boolean, required: false } }, + emits: ['update:show'], + template: '
', + }), + NDrawerContent: defineComponent({ + name: 'NDrawerContent', + props: { title: { type: String, required: false }, closable: { type: Boolean, required: false } }, + template: '
', + }), + NButton: defineComponent({ + name: 'NButton', + emits: ['click'], + template: '', + }), + NSelect: defineComponent({ + name: 'NSelect', + props: { value: { required: false }, options: { type: Array, default: () => [] } }, + emits: ['update:value'], + template: '', + }), + NInput: defineComponent({ + name: 'NInput', + props: { value: { required: false }, size: { type: String, required: false }, placeholder: { type: String, required: false } }, + emits: ['update:value'], + template: '', + }), + NSpin: defineComponent({ + name: 'NSpin', + template: '
', + }), + NModal: defineComponent({ + name: 'NModal', + props: { show: { type: Boolean, required: false }, title: { type: String, required: false } }, + emits: ['close'], + template: '
', + }), + useMessage: mockUseMessage, +})) + +import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue' + +describe('KanbanTaskDrawer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequest.mockResolvedValue({ results: [] }) + mockCompleteTasks.mockResolvedValue(undefined) + mockBlockTask.mockResolvedValue(undefined) + mockUnblockTasks.mockResolvedValue(undefined) + mockAssignTask.mockResolvedValue(undefined) + mockGetTask.mockResolvedValue({ + task: { + id: 'task-1', + title: 'Ship kanban', + body: 'Implement feature', + assignee: 'alice', + status: 'done', + priority: 2, + created_at: 100, + started_at: 110, + completed_at: 120, + tenant: null, + result: 'Done summary', + }, + latest_summary: 'Done summary', + comments: [], + events: [], + runs: [{ id: 1, profile: 'alice', status: 'done', started_at: 110, ended_at: 120 }], + session: { + id: 'session-1', + title: 'Hermes session', + source: 'codex', + model: 'gpt-5.5', + started_at: 110, + ended_at: 120, + messages: [ + { id: 'm1', role: 'user', content: 'hello', timestamp: 111 }, + { id: 'm2', role: 'assistant', content: 'world', timestamp: 112 }, + { id: 'm3', role: 'tool', content: 'ignore', timestamp: 113 }, + ], + }, + }) + }) + + it('renders completed-result messages through HistoryMessageList', async () => { + const wrapper = mount(KanbanTaskDrawer, { + props: { taskId: 'task-1' }, + }) + + await flushPromises() + + await wrapper.find('.result-summary').trigger('click') + await flushPromises() + + const modal = wrapper.find('.n-modal-stub') + expect(modal.exists()).toBe(true) + expect(modal.attributes('data-title')).toBe('Ship kanban') + + const history = wrapper.find('.history-message-list-stub') + expect(history.exists()).toBe(true) + expect(history.text()).toBe('session-1') + + const sessionProp = wrapper.getComponent({ name: 'HistoryMessageList' }).props('session') as any + expect(sessionProp.messages).toEqual([ + { id: 'm1', role: 'user', content: 'hello', timestamp: 111 }, + { id: 'm2', role: 'assistant', content: 'world', timestamp: 112 }, + ]) + }) + + it('uses the latest run profile when searching related sessions', async () => { + mockGetTask.mockResolvedValueOnce({ + task: { + id: 'task-2', + title: 'Retry task', + body: null, + assignee: 'bob', + status: 'running', + priority: 2, + created_at: 100, + started_at: 110, + completed_at: null, + tenant: null, + result: null, + }, + latest_summary: null, + comments: [], + events: [], + runs: [ + { id: 1, profile: 'stale', status: 'failed', started_at: 110, ended_at: 120 }, + { id: 2, profile: 'fresh', status: 'running', started_at: 130, ended_at: null }, + ], + }) + mockRequest.mockResolvedValueOnce({ + results: [{ id: 'session-2', title: 'Found session', source: 'codex', model: 'gpt-5.5', started_at: 130 }], + }) + + const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-2' } }) + await flushPromises() + + const sessionsTitle = wrapper.findAll('.section-title').find(node => node.text() === 'kanban.detail.sessions') + await sessionsTitle?.trigger('click') + await flushPromises() + + expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban/search-sessions?task_id=task-2&profile=fresh') + await wrapper.find('.session-item').trigger('click') + expect(mockRouterPush).toHaveBeenCalledWith({ name: 'hermes.chat', query: { session: 'session-2' } }) + }) + + it('executes complete, block, unblock, and assign actions', async () => { + mockGetTask.mockResolvedValueOnce({ + task: { + id: 'task-0', + title: 'Todo task', + body: null, + assignee: null, + status: 'todo', + priority: 1, + created_at: 100, + started_at: null, + completed_at: null, + tenant: null, + result: null, + }, + latest_summary: null, + comments: [], + events: [], + runs: [], + }) + const wrapper = mount(KanbanTaskDrawer, { + props: { taskId: 'task-0' }, + }) + await flushPromises() + + const buttons = wrapper.findAll('.n-button-stub') + await buttons.find(node => node.text() === 'kanban.action.complete')?.trigger('click') + await wrapper.find('.n-input-stub').setValue('done summary') + await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click') + await flushPromises() + expect(mockCompleteTasks).toHaveBeenCalledWith(['task-0'], 'done summary') + + mockGetTask.mockResolvedValueOnce({ + task: { + id: 'task-3', + title: 'Blocked task', + body: null, + assignee: 'alice', + status: 'blocked', + priority: 1, + created_at: 100, + started_at: null, + completed_at: null, + tenant: null, + result: null, + }, + latest_summary: null, + comments: [], + events: [], + runs: [], + }) + await wrapper.setProps({ taskId: 'task-3' }) + await flushPromises() + await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.unblock')?.trigger('click') + expect(mockUnblockTasks).toHaveBeenCalledWith(['task-3']) + + mockGetTask.mockResolvedValueOnce({ + task: { + id: 'task-4', + title: 'Todo task', + body: null, + assignee: null, + status: 'todo', + priority: 1, + created_at: 100, + started_at: null, + completed_at: null, + tenant: null, + result: null, + }, + latest_summary: null, + comments: [], + events: [], + runs: [], + }) + mockGetTask.mockResolvedValueOnce({ + task: { + id: 'task-4', + title: 'Todo task', + body: null, + assignee: 'bob', + status: 'todo', + priority: 1, + created_at: 100, + started_at: null, + completed_at: null, + tenant: null, + result: null, + }, + latest_summary: null, + comments: [], + events: [], + runs: [], + }) + await wrapper.setProps({ taskId: 'task-4' }) + await flushPromises() + await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.block')?.trigger('click') + await wrapper.find('.n-input-stub').setValue('waiting dependency') + await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click') + expect(mockBlockTask).toHaveBeenCalledWith('task-4', 'waiting dependency') + + const select = wrapper.find('.n-select-stub') + await select.setValue('bob') + await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.assign')?.trigger('click') + await flushPromises() + expect(mockAssignTask).toHaveBeenCalledWith('task-4', 'bob') + }) +}) diff --git a/tests/client/kanban-view.test.ts b/tests/client/kanban-view.test.ts new file mode 100644 index 0000000..7c9fc7d --- /dev/null +++ b/tests/client/kanban-view.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount, flushPromises } from '@vue/test-utils' + +const storeState = vi.hoisted(() => ({ + tasks: [] as Array<{ id: string; title: string; status: string; created_at: number }>, + stats: { by_status: { todo: 1, done: 0 }, by_assignee: {}, total: 1 } as Record, + assignees: [] as Array<{ name: string; counts: Record | null }>, + loading: false, + filterStatus: null as string | null, + filterAssignee: null as string | null, +})) + +const mockRefreshAll = vi.hoisted(() => vi.fn()) +const mockFetchTasks = vi.hoisted(() => vi.fn()) +const mockFetchStats = vi.hoisted(() => vi.fn()) +const mockSetFilter = vi.hoisted(() => vi.fn()) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/stores/hermes/kanban', () => ({ + useKanbanStore: () => ({ + ...storeState, + refreshAll: mockRefreshAll, + fetchTasks: mockFetchTasks, + fetchStats: mockFetchStats, + setFilter: mockSetFilter, + }), +})) + +vi.mock('@/components/hermes/kanban/KanbanTaskCard.vue', () => ({ + default: defineComponent({ + name: 'KanbanTaskCard', + props: { task: { type: Object, required: true } }, + template: '
{{ task.title }}
', + }), +})) + +vi.mock('@/components/hermes/kanban/KanbanTaskDrawer.vue', () => ({ + default: defineComponent({ + name: 'KanbanTaskDrawer', + emits: ['updated', 'close'], + template: '', + }), +})) + +vi.mock('@/components/hermes/kanban/KanbanCreateForm.vue', () => ({ + default: defineComponent({ + name: 'KanbanCreateForm', + emits: ['created', 'close'], + template: '', + }), +})) + +vi.mock('naive-ui', () => ({ + NButton: defineComponent({ + name: 'NButton', + emits: ['click'], + template: '', + }), + NSelect: defineComponent({ + name: 'NSelect', + props: { value: null, options: { type: Array, default: () => [] } }, + emits: ['update:value'], + template: '
', + }), + NSpin: defineComponent({ + name: 'NSpin', + template: '
', + }), + NCollapse: defineComponent({ + name: 'NCollapse', + props: { defaultExpandedNames: { type: Array, required: false } }, + template: '
', + }), + NCollapseItem: defineComponent({ + name: 'NCollapseItem', + props: { title: { type: String, required: false }, name: { type: String, required: false } }, + template: '
', + }), +})) + +import KanbanView from '@/views/hermes/KanbanView.vue' + +describe('KanbanView', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + storeState.tasks = [ + { id: 'task-1', title: 'Task one', status: 'todo', created_at: 10 }, + { id: 'task-2', title: 'Task two', status: 'done', created_at: 20 }, + ] + storeState.stats = { + by_status: { triage: 0, todo: 1, ready: 0, running: 0, blocked: 0, done: 1, archived: 0 }, + by_assignee: {}, + total: 2, + } + storeState.assignees = [] + storeState.loading = false + storeState.filterStatus = null + storeState.filterAssignee = null + mockRefreshAll.mockResolvedValue(undefined) + mockFetchTasks.mockResolvedValue(undefined) + mockFetchStats.mockResolvedValue(undefined) + mockSetFilter.mockImplementation((key: 'status' | 'assignee', value: string | null) => { + if (key === 'status') storeState.filterStatus = value + else storeState.filterAssignee = value + }) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }) + }) + + it('starts with collapsed panels and refreshes stats alongside tasks', async () => { + const wrapper = mount(KanbanView) + await flushPromises() + + expect(mockRefreshAll).toHaveBeenCalledOnce() + expect(wrapper.find('.n-collapse-stub').attributes('data-default-expanded')).toBe('null') + + await wrapper.find('.drawer-updated').trigger('click') + expect(mockFetchTasks).toHaveBeenCalledTimes(1) + expect(mockFetchStats).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(15000) + await flushPromises() + + expect(mockFetchTasks).toHaveBeenCalledTimes(2) + expect(mockFetchStats).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/server/hermes-kanban-service.test.ts b/tests/server/hermes-kanban-service.test.ts new file mode 100644 index 0000000..023ccd3 --- /dev/null +++ b/tests/server/hermes-kanban-service.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockExecFileAsync = vi.hoisted(() => vi.fn()) +const mockLoggerError = vi.hoisted(() => vi.fn()) + +vi.mock('util', () => ({ + promisify: () => mockExecFileAsync, +})) + +vi.mock('../../packages/server/src/services/logger', () => ({ + logger: { + error: mockLoggerError, + }, +})) + +import * as service from '../../packages/server/src/services/hermes/hermes-kanban' + +describe('hermes kanban service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('builds list/create/stats CLI calls correctly', async () => { + mockExecFileAsync + .mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'task-2' }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ total: 1, by_status: {}, by_assignee: {} }) }) + + await expect(service.listTasks({ status: 'todo', assignee: 'alice', tenant: 'ops' })).resolves.toEqual([{ id: 'task-1' }]) + await expect(service.createTask('Ship', { body: 'write', assignee: 'alice', priority: 3, tenant: 'ops' })).resolves.toEqual({ id: 'task-2' }) + await expect(service.getStats()).resolves.toEqual({ total: 1, by_status: {}, by_assignee: {} }) + + expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', 'list', '--json', '--status', 'todo', '--assignee', 'alice', '--tenant', 'ops']) + expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', 'create', 'Ship', '--json', '--body', 'write', '--assignee', 'alice', '--priority', '3', '--tenant', 'ops']) + expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', 'stats', '--json']) + }) + + it('builds action CLI calls and maps not-found show to null', async () => { + mockExecFileAsync + .mockRejectedValueOnce({ code: 1 }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ stdout: JSON.stringify([{ name: 'alice' }]) }) + + await expect(service.getTask('missing')).resolves.toBeNull() + await service.completeTasks(['task-1'], 'done') + await service.blockTask('task-1', 'wait') + await service.unblockTasks(['task-1']) + await service.assignTask('task-1', 'alice') + await expect(service.getAssignees()).resolves.toEqual([{ name: 'alice' }]) + + expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', 'complete', 'task-1', '--summary', 'done']) + expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', 'block', 'task-1', 'wait']) + expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', 'unblock', 'task-1']) + expect(mockExecFileAsync.mock.calls[4][1]).toEqual(['kanban', 'assign', 'task-1', 'alice']) + expect(mockExecFileAsync.mock.calls[5][1]).toEqual(['kanban', 'assignees', '--json']) + }) + + it('wraps CLI failures with service-specific errors', async () => { + mockExecFileAsync.mockRejectedValue(new Error('boom')) + + await expect(service.listTasks()).rejects.toThrow('Failed to list kanban tasks: boom') + expect(mockLoggerError).toHaveBeenCalled() + }) +}) diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts new file mode 100644 index 0000000..edb4b0d --- /dev/null +++ b/tests/server/kanban-controller.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockReadFile = vi.hoisted(() => vi.fn()) +const mockListTasks = vi.hoisted(() => vi.fn()) +const mockGetTask = vi.hoisted(() => vi.fn()) +const mockCreateTask = vi.hoisted(() => vi.fn()) +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 mockGetStats = vi.hoisted(() => vi.fn()) +const mockGetAssignees = vi.hoisted(() => vi.fn()) +const mockSearchSessions = vi.hoisted(() => vi.fn()) +const mockGetSessionDetail = vi.hoisted(() => vi.fn()) + +vi.mock('fs/promises', () => ({ + readFile: mockReadFile, +})) + +vi.mock('os', () => ({ + homedir: () => '/Users/tester', +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({ + listTasks: mockListTasks, + getTask: mockGetTask, + createTask: mockCreateTask, + completeTasks: mockCompleteTasks, + blockTask: mockBlockTask, + unblockTasks: mockUnblockTasks, + assignTask: mockAssignTask, + getStats: mockGetStats, + getAssignees: mockGetAssignees, +})) + +vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ + searchSessionSummariesWithProfile: mockSearchSessions, + getSessionDetailFromDbWithProfile: mockGetSessionDetail, +})) + +import * as ctrl from '../../packages/server/src/controllers/hermes/kanban' + +function ctx(overrides: Record = {}) { + return { + query: {}, + params: {}, + request: { body: {} }, + status: 200, + body: null, + ...overrides, + } as any +} + +describe('kanban controller', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lists tasks with filters', async () => { + mockListTasks.mockResolvedValue([{ id: 'task-1' }]) + const c = ctx({ query: { status: 'todo', assignee: 'alice', tenant: 'ops' } }) + await ctrl.list(c) + expect(mockListTasks).toHaveBeenCalledWith({ status: 'todo', assignee: 'alice', tenant: 'ops' }) + expect(c.body).toEqual({ tasks: [{ id: 'task-1' }] }) + }) + + it('enriches completed task details using the latest run profile', async () => { + mockGetTask.mockResolvedValue({ + task: { id: 'task-1', status: 'done' }, + runs: [{ profile: 'stale' }, { profile: 'fresh' }], + comments: [], + events: [], + }) + mockSearchSessions.mockResolvedValue([{ id: 'session-1' }]) + mockGetSessionDetail.mockResolvedValue({ + title: 'Session one', + source: 'codex', + model: 'gpt-5.5', + started_at: 1, + ended_at: 2, + messages: [], + }) + + const c = ctx({ params: { id: 'task-1' } }) + await ctrl.get(c) + + expect(mockSearchSessions).toHaveBeenCalledWith('task-1', 'fresh', undefined, 5) + expect(mockGetSessionDetail).toHaveBeenCalledWith('session-1', 'fresh') + expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' }) + }) + + it('validates create/search/readArtifact requests', async () => { + const createCtx = ctx({ request: { body: {} } }) + await ctrl.create(createCtx) + expect(createCtx.status).toBe(400) + + const searchCtx = ctx({ query: { task_id: 'task-1' } }) + await ctrl.searchSessions(searchCtx) + expect(searchCtx.status).toBe(400) + + const fileCtx = ctx({ query: { path: '/tmp/outside.txt' } }) + await ctrl.readArtifact(fileCtx) + expect(fileCtx.status).toBe(403) + }) + + it('reads workspace artifacts and proxies action routes', async () => { + mockReadFile.mockResolvedValue('artifact-content') + mockCreateTask.mockResolvedValue({ id: 'task-2' }) + mockCompleteTasks.mockResolvedValue(undefined) + mockBlockTask.mockResolvedValue(undefined) + mockUnblockTasks.mockResolvedValue(undefined) + mockAssignTask.mockResolvedValue(undefined) + mockGetStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} }) + mockGetAssignees.mockResolvedValue([{ name: 'alice' }]) + mockSearchSessions.mockResolvedValue([{ id: 'session-2' }]) + + const fileCtx = ctx({ query: { path: '/Users/tester/.hermes/kanban/workspaces/task/out.txt' } }) + await ctrl.readArtifact(fileCtx) + expect(fileCtx.body).toEqual({ + content: 'artifact-content', + path: '/Users/tester/.hermes/kanban/workspaces/task/out.txt', + }) + + const createCtx = ctx({ request: { body: { title: 'Ship', body: 'x' } } }) + await ctrl.create(createCtx) + expect(createCtx.body).toEqual({ task: { id: 'task-2' } }) + + const completeCtx = ctx({ request: { body: { task_ids: ['task-1'], summary: 'done' } } }) + await ctrl.complete(completeCtx) + expect(mockCompleteTasks).toHaveBeenCalledWith(['task-1'], 'done') + + const blockCtx = ctx({ params: { id: 'task-1' }, request: { body: { reason: 'wait' } } }) + await ctrl.block(blockCtx) + expect(mockBlockTask).toHaveBeenCalledWith('task-1', 'wait') + + const unblockCtx = ctx({ request: { body: { task_ids: ['task-1'] } } }) + await ctrl.unblock(unblockCtx) + expect(mockUnblockTasks).toHaveBeenCalledWith(['task-1']) + + const assignCtx = ctx({ params: { id: 'task-1' }, request: { body: { profile: 'alice' } } }) + await ctrl.assign(assignCtx) + expect(mockAssignTask).toHaveBeenCalledWith('task-1', 'alice') + + const statsCtx = ctx() + await ctrl.stats(statsCtx) + expect(statsCtx.body).toEqual({ stats: { total: 1, by_status: {}, by_assignee: {} } }) + + const assigneesCtx = ctx() + await ctrl.assignees(assigneesCtx) + expect(assigneesCtx.body).toEqual({ assignees: [{ name: 'alice' }] }) + + const searchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice', q: 'custom' } }) + await ctrl.searchSessions(searchCtx) + expect(mockSearchSessions).toHaveBeenCalledWith('custom', 'alice', undefined, 10) + }) +}) diff --git a/tests/server/kanban-routes.test.ts b/tests/server/kanban-routes.test.ts new file mode 100644 index 0000000..23c2ded --- /dev/null +++ b/tests/server/kanban-routes.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = { + stats: vi.fn(async (ctx: any) => { ctx.body = { stats: {} } }), + assignees: vi.fn(async (ctx: any) => { ctx.body = { assignees: [] } }), + readArtifact: vi.fn(async (ctx: any) => { ctx.body = { content: 'x' } }), + searchSessions: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }), + list: vi.fn(async (ctx: any) => { ctx.body = { tasks: [] } }), + get: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }), + create: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }), + complete: 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 } }), + assign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }), +} + +vi.mock('../../packages/server/src/controllers/hermes/kanban', () => handlers) + +describe('kanban routes', () => { + beforeEach(() => { + vi.resetModules() + Object.values(handlers).forEach(fn => fn.mockClear()) + }) + + it('registers all kanban routes', async () => { + const { kanbanRoutes } = await import('../../packages/server/src/routes/hermes/kanban') + const paths = kanbanRoutes.stack.map((entry: any) => entry.path) + + expect(paths).toEqual(expect.arrayContaining([ + '/api/hermes/kanban/stats', + '/api/hermes/kanban/assignees', + '/api/hermes/kanban/artifact', + '/api/hermes/kanban/search-sessions', + '/api/hermes/kanban', + '/api/hermes/kanban/:id', + '/api/hermes/kanban/complete', + '/api/hermes/kanban/unblock', + '/api/hermes/kanban/:id/block', + '/api/hermes/kanban/:id/assign', + ])) + }) + + it('delegates search-sessions to the controller', async () => { + const { kanbanRoutes } = await import('../../packages/server/src/routes/hermes/kanban') + const layer = kanbanRoutes.stack.find((entry: any) => entry.path === '/api/hermes/kanban/search-sessions') + const ctx: any = { query: { task_id: 'task-1', profile: 'alice' }, body: null, params: {} } + + await layer.stack[0](ctx) + + expect(handlers.searchSessions).toHaveBeenCalledWith(ctx) + expect(ctx.body).toEqual({ results: [] }) + }) +})