add hermes kanban board (#534)
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, number>
|
||||
by_assignee: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface KanbanAssignee {
|
||||
name: string
|
||||
on_disk: boolean
|
||||
counts: Record<string, number> | 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<KanbanTask[]> {
|
||||
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<KanbanTaskDetail> {
|
||||
return request<KanbanTaskDetail>(`/api/hermes/kanban/${id}`)
|
||||
}
|
||||
|
||||
export async function createTask(data: KanbanCreateRequest): Promise<KanbanTask> {
|
||||
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<KanbanStats> {
|
||||
const res = await request<{ stats: KanbanStats }>('/api/hermes/kanban/stats')
|
||||
return res.stats
|
||||
}
|
||||
|
||||
export async function getAssignees(): Promise<KanbanAssignee[]> {
|
||||
const res = await request<{ assignees: KanbanAssignee[] }>('/api/hermes/kanban/assignees')
|
||||
return res.assignees
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import KanbanTaskCard from './KanbanTaskCard.vue'
|
||||
import type { KanbanTask, KanbanTaskStatus } from '@/api/hermes/kanban'
|
||||
|
||||
const props = defineProps<{
|
||||
status: KanbanTaskStatus
|
||||
tasks: KanbanTask[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
taskClick: [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const statusLabel = computed(() => t(`kanban.columns.${props.status}`, props.status))
|
||||
const statusCount = computed(() => props.tasks.length)
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'todo': return '○'
|
||||
case 'ready': return '◎'
|
||||
case 'running': return '●'
|
||||
case 'blocked': return '⊘'
|
||||
case 'done': return '✓'
|
||||
default: return '○'
|
||||
}
|
||||
})
|
||||
|
||||
const headerTitle = computed(() => `${statusIcon.value} ${statusLabel.value} (${statusCount.value})`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-column">
|
||||
<NCollapse :default-expanded-names="[status]" display-directive="show">
|
||||
<NCollapseItem :title="headerTitle" :name="status">
|
||||
<KanbanTaskCard
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="emit('taskClick', task.id)"
|
||||
/>
|
||||
<div v-if="tasks.length === 0" class="column-empty">
|
||||
{{ t('kanban.noTasks') }}
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.kanban-column {
|
||||
flex: 1 1 calc(20% - 12px);
|
||||
min-width: 200px;
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.02);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid $border-light;
|
||||
|
||||
:deep(.n-collapse) {
|
||||
--n-title-font-size: 13px;
|
||||
--n-title-font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.n-collapse-item__header-main) {
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
:deep(.n-collapse-item__content-wrapper) {
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
:deep(.n-collapse-item) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.column-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const kanbanStore = useKanbanStore()
|
||||
|
||||
const title = ref('')
|
||||
const body = ref('')
|
||||
const assignee = ref<string | null>(null)
|
||||
const priority = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const priorityOptions = computed(() => [
|
||||
{ label: t('kanban.card.priority.low'), value: 1 },
|
||||
{ label: t('kanban.card.priority.medium'), value: 2 },
|
||||
{ label: t('kanban.card.priority.high'), value: 3 },
|
||||
])
|
||||
|
||||
const assigneeOptions = computed(() => {
|
||||
return kanbanStore.assignees.map(a => {
|
||||
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
|
||||
return { label: `${a.name} (${total})`, value: a.name }
|
||||
})
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.value.trim()) {
|
||||
message.warning(t('kanban.form.titleRequired'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await kanbanStore.createTask({
|
||||
title: title.value.trim(),
|
||||
body: body.value.trim() || undefined,
|
||||
assignee: assignee.value || undefined,
|
||||
priority: priority.value ?? undefined,
|
||||
})
|
||||
message.success(t('kanban.message.taskCreated'))
|
||||
emit('created')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="true" preset="dialog" :title="t('kanban.createTask')" style="width: 480px;" @close="emit('close')">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('kanban.form.title')">
|
||||
<NInput v-model:value="title" :placeholder="t('kanban.form.titlePlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('kanban.form.body')">
|
||||
<NInput v-model:value="body" type="textarea" :rows="3" :placeholder="t('kanban.form.bodyPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('kanban.form.assignee')">
|
||||
<NSelect v-model:value="assignee" :options="assigneeOptions" :placeholder="t('kanban.form.selectAssignee')" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('kanban.form.priority')">
|
||||
<NSelect v-model:value="priority" :options="priorityOptions" :placeholder="t('kanban.form.selectPriority')" clearable />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #action>
|
||||
<NButton @click="emit('close')">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="handleSubmit">{{ t('common.create') }}</NButton>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NTooltip } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { KanbanTask } from '@/api/hermes/kanban'
|
||||
|
||||
const props = defineProps<{
|
||||
task: KanbanTask
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const timeAgo = computed(() => {
|
||||
const diff = Date.now() / 1000 - props.task.created_at
|
||||
if (diff < 60) return t('kanban.card.timeAgo.justNow')
|
||||
if (diff < 3600) return t('kanban.card.timeAgo.minutes', { count: Math.floor(diff / 60) })
|
||||
if (diff < 86400) return t('kanban.card.timeAgo.hours', { count: Math.floor(diff / 3600) })
|
||||
return t('kanban.card.timeAgo.days', { count: Math.floor(diff / 86400) })
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
if (props.task.priority >= 3) return 'high'
|
||||
if (props.task.priority === 2) return 'medium'
|
||||
return 'low'
|
||||
})
|
||||
|
||||
const priorityText = computed(() => {
|
||||
return t(`kanban.card.priority.${priorityLabel.value}`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-task-card" @click="emit('click', task.id)">
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div class="card-meta">
|
||||
<NTooltip v-if="task.assignee" trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="meta-tag assignee-tag">{{ task.assignee }}</span>
|
||||
</template>
|
||||
{{ t('kanban.card.assigneeTooltip') }}
|
||||
</NTooltip>
|
||||
<span v-if="task.priority >= 2" class="meta-tag priority-tag" :class="priorityLabel">{{ priorityText }}</span>
|
||||
<span class="meta-time">{{ timeAgo }}</span>
|
||||
</div>
|
||||
<div v-if="task.body" class="card-body-preview">{{ task.body.slice(0, 80) }}{{ task.body.length > 80 ? '...' : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.kanban-task-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignee-tag {
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
.priority-tag {
|
||||
&.high {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
background: rgba(var(--warning-rgb), 0.12);
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.low {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-body-preview {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,683 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NDrawer, NDrawerContent, NButton, NSelect, NInput, NSpin, NModal, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { request } from '@/api/client'
|
||||
import { getTask } from '@/api/hermes/kanban'
|
||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
||||
import type { Session, Message } from '@/stores/hermes/chat'
|
||||
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const kanbanStore = useKanbanStore()
|
||||
|
||||
const detail = ref<KanbanTaskDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const assignProfile = ref<string | null>(null)
|
||||
const blockReason = ref('')
|
||||
const showBlockInput = ref(false)
|
||||
const completeSummary = ref('')
|
||||
const showCompleteInput = ref(false)
|
||||
const showMessagesModal = ref(false)
|
||||
|
||||
const completionSummary = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
return detail.value.task.result || detail.value.latest_summary || ''
|
||||
})
|
||||
|
||||
const localizedTaskStatus = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
|
||||
})
|
||||
|
||||
const sessionResults = ref<any[]>([])
|
||||
const sessionLoading = ref(false)
|
||||
const showSessions = ref(false)
|
||||
|
||||
const latestRunProfile = computed(() => {
|
||||
if (!detail.value) return null
|
||||
return [...detail.value.runs].reverse().find(run => run.profile)?.profile || null
|
||||
})
|
||||
|
||||
async function searchTaskSessions() {
|
||||
if (!detail.value) return
|
||||
const profile = latestRunProfile.value
|
||||
if (!profile) return
|
||||
showSessions.value = !showSessions.value
|
||||
if (!showSessions.value) return
|
||||
sessionLoading.value = true
|
||||
try {
|
||||
const res = await request<{ results: any[] }>(
|
||||
`/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}`
|
||||
)
|
||||
sessionResults.value = res.results
|
||||
} catch {
|
||||
sessionResults.value = []
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openResultDetail() {
|
||||
if (detail.value?.session) {
|
||||
showMessagesModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const historySession = computed<Session | null>(() => {
|
||||
const s = detail.value?.session
|
||||
if (!s) return null
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title || '',
|
||||
source: s.source,
|
||||
messages: s.messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({
|
||||
id: String(m.id),
|
||||
role: m.role as Message['role'],
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})),
|
||||
createdAt: s.started_at,
|
||||
updatedAt: s.ended_at || s.started_at,
|
||||
model: s.model,
|
||||
messageCount: s.messages.length,
|
||||
endedAt: s.ended_at,
|
||||
}
|
||||
})
|
||||
|
||||
const assigneeOptions = computed(() => {
|
||||
return kanbanStore.assignees.map(a => {
|
||||
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
|
||||
return { label: `${a.name} (${total})`, value: a.name }
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.taskId, async (id) => {
|
||||
if (!id) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await getTask(id)
|
||||
} catch (err: any) {
|
||||
message.error(t('kanban.message.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatTime(ts: number | null) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (!props.taskId) return
|
||||
if (!showCompleteInput.value) {
|
||||
showCompleteInput.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanStore.completeTasks([props.taskId], completeSummary.value.trim() || undefined)
|
||||
message.success(t('kanban.message.taskCompleted'))
|
||||
showCompleteInput.value = false
|
||||
completeSummary.value = ''
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlock() {
|
||||
if (!props.taskId || !blockReason.value.trim()) return
|
||||
try {
|
||||
await kanbanStore.blockTask(props.taskId, blockReason.value.trim())
|
||||
message.success(t('kanban.message.taskBlocked'))
|
||||
showBlockInput.value = false
|
||||
blockReason.value = ''
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnblock() {
|
||||
if (!props.taskId) return
|
||||
try {
|
||||
await kanbanStore.unblockTasks([props.taskId])
|
||||
message.success(t('kanban.message.taskUnblocked'))
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign() {
|
||||
if (!props.taskId || !assignProfile.value) return
|
||||
try {
|
||||
await kanbanStore.assignTask(props.taskId, assignProfile.value)
|
||||
message.success(t('kanban.message.taskAssigned'))
|
||||
assignProfile.value = null
|
||||
if (detail.value) {
|
||||
detail.value = await getTask(props.taskId)
|
||||
}
|
||||
emit('updated')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer :show="!!taskId" :width="420" placement="right" @update:show="(v: boolean) => { if (!v) emit('close') }">
|
||||
<NDrawerContent :title="detail?.task.title || ''" closable>
|
||||
<NSpin :show="loading">
|
||||
<template v-if="detail">
|
||||
<!-- Metadata -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.status') }}</span>
|
||||
<span class="detail-value status-badge" :class="detail.task.status">{{ localizedTaskStatus }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.assignee') }}</span>
|
||||
<span class="detail-value">{{ detail.task.assignee || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.priority') }}</span>
|
||||
<span class="detail-value">{{ detail.task.priority }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.tenant') }}</span>
|
||||
<span class="detail-value">{{ detail.task.tenant || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.createdAt') }}</span>
|
||||
<span class="detail-value">{{ formatTime(detail.task.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="detail.task.started_at" class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.startedAt') }}</span>
|
||||
<span class="detail-value">{{ formatTime(detail.task.started_at) }}</span>
|
||||
</div>
|
||||
<div v-if="detail.task.completed_at" class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.completedAt') }}</span>
|
||||
<span class="detail-value">{{ formatTime(detail.task.completed_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-if="detail.task.body" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.form.body') }}</div>
|
||||
<div class="detail-body">{{ detail.task.body }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Result / Summary -->
|
||||
<div v-if="completionSummary" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.result') }}</div>
|
||||
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions (only for non-completed tasks) -->
|
||||
<div v-if="detail.task.status !== 'done'" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.action.title') }}</div>
|
||||
<div class="action-group">
|
||||
<template v-if="!showCompleteInput">
|
||||
<NButton size="small" @click="showCompleteInput = true">
|
||||
{{ t('kanban.action.complete') }}
|
||||
</NButton>
|
||||
</template>
|
||||
<div v-else class="complete-input">
|
||||
<NInput v-model:value="completeSummary" size="small" :placeholder="t('kanban.action.completeSummary')" />
|
||||
<NButton size="small" type="primary" @click="handleComplete">{{ t('common.ok') }}</NButton>
|
||||
<NButton size="small" @click="showCompleteInput = false; completeSummary = ''">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
<template v-if="detail.task.status === 'blocked'">
|
||||
<NButton size="small" @click="handleUnblock">{{ t('kanban.action.unblock') }}</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NButton v-if="!showBlockInput" size="small" @click="showBlockInput = true">{{ t('kanban.action.block') }}</NButton>
|
||||
<div v-else class="block-input">
|
||||
<NInput v-model:value="blockReason" size="small" :placeholder="t('kanban.action.blockReason')" />
|
||||
<NButton size="small" type="primary" @click="handleBlock">{{ t('common.ok') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="detail.task.status !== 'running'" class="assign-group">
|
||||
<NSelect v-model:value="assignProfile" :options="assigneeOptions" size="small" :placeholder="t('kanban.action.assignTo')" style="flex: 1;" />
|
||||
<NButton size="small" :disabled="!assignProfile" @click="handleAssign">{{ t('kanban.action.assign') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Sessions -->
|
||||
<div v-if="detail.runs.length > 0" class="detail-section">
|
||||
<div class="section-title" style="cursor: pointer;" @click="searchTaskSessions">
|
||||
{{ t('kanban.detail.sessions') }}
|
||||
<NSpin v-if="sessionLoading" :size="12" style="margin-left: 6px;" />
|
||||
</div>
|
||||
<div v-if="showSessions && sessionResults.length > 0" class="session-list">
|
||||
<div v-for="session in sessionResults" :key="session.id" class="session-item" @click="router.push({ name: 'hermes.chat', query: { session: session.id } })">
|
||||
<div class="session-title">{{ session.title || session.id }}</div>
|
||||
<div class="session-meta">
|
||||
<span>{{ session.source }}</span>
|
||||
<span>{{ session.model }}</span>
|
||||
<span>{{ formatTime(session.started_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions && !sessionLoading && sessionResults.length === 0" class="column-empty">{{ t('kanban.detail.noSessions') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Runs -->
|
||||
<div v-if="detail.runs.length > 0" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.runs') }}</div>
|
||||
<div v-for="run in detail.runs" :key="run.id" class="run-item">
|
||||
<div class="run-header">
|
||||
<span class="run-status" :class="run.status">{{ run.status }}</span>
|
||||
<span class="run-profile">{{ run.profile || '—' }}</span>
|
||||
<span class="run-time">{{ formatTime(run.started_at) }}</span>
|
||||
</div>
|
||||
<div v-if="run.summary" class="run-summary">{{ run.summary }}</div>
|
||||
<div v-if="run.error" class="run-error">{{ run.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div v-if="detail.comments.length > 0" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.comments') }}</div>
|
||||
<div v-for="comment in detail.comments" :key="comment.id" class="comment-item">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{ comment.author }}</span>
|
||||
<span class="comment-time">{{ formatTime(comment.created_at) }}</span>
|
||||
</div>
|
||||
<div class="comment-body">{{ comment.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div v-if="detail.events.length > 0" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.events') }}</div>
|
||||
<div v-for="event in detail.events.slice(-10)" :key="event.id" class="event-item">
|
||||
<span class="event-kind">{{ event.kind }}</span>
|
||||
<span class="event-time">{{ formatTime(event.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
|
||||
<!-- Session messages modal (click result summary) -->
|
||||
<NModal v-if="historySession" :show="showMessagesModal" preset="card" :title="detail?.task.title || ''" :style="{ width: '900px', maxWidth: 'calc(100vw - 48px)' }" @close="showMessagesModal = false">
|
||||
<div class="messages-modal-body">
|
||||
<HistoryMessageList :session="historySession" />
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
padding: 8px 10px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
border: 1px solid $border-light;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.02);
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin: 8px 0 4px;
|
||||
|
||||
&:first-child { margin-top: 0; }
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
padding: 2px 0;
|
||||
|
||||
code {
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-kv-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meta-kv-key {
|
||||
color: $text-muted;
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-kv-val {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.artifact-link {
|
||||
cursor: pointer;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover { color: $accent-primary; }
|
||||
}
|
||||
|
||||
.artifact-modal-body,
|
||||
.messages-modal-body {
|
||||
max-height: 65vh;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
|
||||
:deep(.message-list) {
|
||||
max-height: 65vh;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid $border-light;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.running {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.done {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.blocked {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
background: rgba(var(--warning-rgb), 0.12);
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.triage, &.archived {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.block-input,
|
||||
.complete-input {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.assign-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-item,
|
||||
.comment-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid $border-light;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.run-header,
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.run-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.running {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.done, &.completed {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.crashed, &.failed {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.run-profile,
|
||||
.comment-author {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.run-time,
|
||||
.comment-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.run-summary,
|
||||
.run-error,
|
||||
.comment-body {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.run-error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.event-kind {
|
||||
font-size: 11px;
|
||||
font-family: $font-code;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-light;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-msg {
|
||||
padding: 10px 12px;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-light;
|
||||
|
||||
&.user {
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.session-msg-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.session-msg-content {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 8px;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -135,6 +135,14 @@ function openChangelog() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.jobs") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.kanban' }" @click="handleNav('hermes.kanban')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="5" height="18" rx="1" />
|
||||
<rect x="10" y="3" width="5" height="12" rx="1" />
|
||||
<rect x="17" y="3" width="5" height="18" rx="1" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.kanban") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.channels' }" @click="handleNav('hermes.channels')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
|
||||
@@ -73,6 +73,7 @@ export default {
|
||||
apiRelay: 'API Relay',
|
||||
history: 'History',
|
||||
jobs: 'Jobs',
|
||||
kanban: 'Kanban',
|
||||
models: 'Models',
|
||||
profiles: 'Profiles',
|
||||
skills: 'Skills',
|
||||
@@ -216,6 +217,89 @@ export default {
|
||||
speechNotSupported: 'Voice playback not supported in this browser',
|
||||
},
|
||||
|
||||
// Kanban
|
||||
kanban: {
|
||||
title: 'Kanban Board',
|
||||
createTask: 'New Task',
|
||||
noTasks: 'No tasks',
|
||||
allStatuses: 'All Statuses',
|
||||
allAssignees: 'All Assignees',
|
||||
columns: {
|
||||
triage: 'Triage',
|
||||
todo: 'To Do',
|
||||
ready: 'Ready',
|
||||
running: 'Running',
|
||||
blocked: 'Blocked',
|
||||
done: 'Done',
|
||||
archived: 'Archived',
|
||||
},
|
||||
form: {
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'Task title',
|
||||
titleRequired: 'Title is required',
|
||||
body: 'Description',
|
||||
bodyPlaceholder: 'Task description (optional)',
|
||||
assignee: 'Assignee',
|
||||
selectAssignee: 'Select assignee...',
|
||||
priority: 'Priority',
|
||||
selectPriority: 'Select priority...',
|
||||
},
|
||||
card: {
|
||||
assigneeTooltip: 'Assignee',
|
||||
priority: {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
},
|
||||
timeAgo: {
|
||||
justNow: 'just now',
|
||||
minutes: '{count}m ago',
|
||||
hours: '{count}h ago',
|
||||
days: '{count}d ago',
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
status: 'Status',
|
||||
assignee: 'Assignee',
|
||||
priority: 'Priority',
|
||||
tenant: 'Tenant',
|
||||
createdAt: 'Created',
|
||||
startedAt: 'Started',
|
||||
completedAt: 'Completed',
|
||||
comments: 'Comments',
|
||||
events: 'Events',
|
||||
runs: 'Runs',
|
||||
result: 'Result',
|
||||
sessions: 'Related Sessions',
|
||||
sessionMessages: 'Session Messages',
|
||||
noSessions: 'No related sessions found.',
|
||||
artifacts: 'Artifacts',
|
||||
sources: 'Sources',
|
||||
highlights: 'Highlights',
|
||||
},
|
||||
action: {
|
||||
title: 'Actions',
|
||||
complete: 'Complete',
|
||||
completeSummary: 'Completion summary (optional)',
|
||||
block: 'Block',
|
||||
blockReason: 'Reason for blocking',
|
||||
unblock: 'Unblock',
|
||||
assign: 'Assign',
|
||||
assignTo: 'Assign to...',
|
||||
},
|
||||
message: {
|
||||
taskCreated: 'Task created',
|
||||
taskCompleted: 'Task completed',
|
||||
taskBlocked: 'Task blocked',
|
||||
taskUnblocked: 'Task unblocked',
|
||||
taskAssigned: 'Task assigned',
|
||||
loadFailed: 'Failed to load task',
|
||||
},
|
||||
stats: {
|
||||
total: 'Total',
|
||||
},
|
||||
},
|
||||
|
||||
// Jobs
|
||||
jobs: {
|
||||
title: 'Scheduled Jobs',
|
||||
|
||||
@@ -73,6 +73,7 @@ export default {
|
||||
apiRelay: '中转站',
|
||||
history: '历史',
|
||||
jobs: '任务',
|
||||
kanban: '看板',
|
||||
models: '模型',
|
||||
profiles: '用户',
|
||||
skills: '技能',
|
||||
@@ -216,6 +217,89 @@ export default {
|
||||
speechNotSupported: '此浏览器不支持语音播放',
|
||||
},
|
||||
|
||||
// 看板
|
||||
kanban: {
|
||||
title: '看板',
|
||||
createTask: '新建任务',
|
||||
noTasks: '暂无任务',
|
||||
allStatuses: '全部状态',
|
||||
allAssignees: '全部负责人',
|
||||
columns: {
|
||||
triage: '待分拣',
|
||||
todo: '待办',
|
||||
ready: '就绪',
|
||||
running: '进行中',
|
||||
blocked: '阻塞',
|
||||
done: '已完成',
|
||||
archived: '已归档',
|
||||
},
|
||||
form: {
|
||||
title: '标题',
|
||||
titlePlaceholder: '任务标题',
|
||||
titleRequired: '标题不能为空',
|
||||
body: '描述',
|
||||
bodyPlaceholder: '任务描述(可选)',
|
||||
assignee: '负责人',
|
||||
selectAssignee: '选择负责人...',
|
||||
priority: '优先级',
|
||||
selectPriority: '选择优先级...',
|
||||
},
|
||||
card: {
|
||||
assigneeTooltip: '负责人',
|
||||
priority: {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
},
|
||||
timeAgo: {
|
||||
justNow: '刚刚',
|
||||
minutes: '{count}分钟前',
|
||||
hours: '{count}小时前',
|
||||
days: '{count}天前',
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
status: '状态',
|
||||
assignee: '负责人',
|
||||
priority: '优先级',
|
||||
tenant: '租户',
|
||||
createdAt: '创建时间',
|
||||
startedAt: '开始时间',
|
||||
completedAt: '完成时间',
|
||||
comments: '评论',
|
||||
events: '事件',
|
||||
runs: '运行记录',
|
||||
result: '完成结果',
|
||||
sessions: '关联会话',
|
||||
sessionMessages: '会话记录',
|
||||
noSessions: '未找到关联会话。',
|
||||
artifacts: '产出文件',
|
||||
sources: '数据来源',
|
||||
highlights: '关键信息',
|
||||
},
|
||||
action: {
|
||||
title: '操作',
|
||||
complete: '完成',
|
||||
completeSummary: '完成摘要(可选)',
|
||||
block: '阻塞',
|
||||
blockReason: '阻塞原因',
|
||||
unblock: '解除阻塞',
|
||||
assign: '分配',
|
||||
assignTo: '分配给...',
|
||||
},
|
||||
message: {
|
||||
taskCreated: '任务已创建',
|
||||
taskCompleted: '任务已完成',
|
||||
taskBlocked: '任务已阻塞',
|
||||
taskUnblocked: '任务已解除阻塞',
|
||||
taskAssigned: '任务已分配',
|
||||
loadFailed: '加载任务失败',
|
||||
},
|
||||
stats: {
|
||||
total: '总计',
|
||||
},
|
||||
},
|
||||
|
||||
// 定时任务
|
||||
jobs: {
|
||||
title: '定时任务',
|
||||
|
||||
@@ -25,6 +25,11 @@ const router = createRouter({
|
||||
name: 'hermes.jobs',
|
||||
component: () => import('@/views/hermes/JobsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/kanban',
|
||||
name: 'hermes.kanban',
|
||||
component: () => import('@/views/hermes/KanbanView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/models',
|
||||
name: 'hermes.models',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as kanbanApi from '@/api/hermes/kanban'
|
||||
import type { KanbanTask, KanbanStats, KanbanAssignee } from '@/api/hermes/kanban'
|
||||
|
||||
export const useKanbanStore = defineStore('kanban', () => {
|
||||
const tasks = ref<KanbanTask[]>([])
|
||||
const stats = ref<KanbanStats | null>(null)
|
||||
const assignees = ref<KanbanAssignee[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterAssignee = ref<string | null>(null)
|
||||
|
||||
async function fetchTasks() {
|
||||
loading.value = true
|
||||
try {
|
||||
tasks.value = await kanbanApi.listTasks({
|
||||
status: filterStatus.value || undefined,
|
||||
assignee: filterAssignee.value || undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch kanban tasks:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
stats.value = await kanbanApi.getStats()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch kanban stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAssignees() {
|
||||
try {
|
||||
assignees.value = await kanbanApi.getAssignees()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch kanban assignees:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(data: { title: string; body?: string; assignee?: string; priority?: number; tenant?: string }) {
|
||||
const task = await kanbanApi.createTask(data)
|
||||
tasks.value.unshift(task)
|
||||
await fetchStats()
|
||||
return task
|
||||
}
|
||||
|
||||
async function completeTasks(taskIds: string[], summary?: string) {
|
||||
await kanbanApi.completeTasks(taskIds, summary)
|
||||
for (const id of taskIds) {
|
||||
const task = tasks.value.find(t => t.id === id)
|
||||
if (task) task.status = 'done'
|
||||
}
|
||||
await fetchStats()
|
||||
}
|
||||
|
||||
async function blockTask(taskId: string, reason: string) {
|
||||
await kanbanApi.blockTask(taskId, reason)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (task) task.status = 'blocked'
|
||||
await fetchStats()
|
||||
}
|
||||
|
||||
async function unblockTasks(taskIds: string[]) {
|
||||
await kanbanApi.unblockTasks(taskIds)
|
||||
for (const id of taskIds) {
|
||||
const task = tasks.value.find(t => t.id === id)
|
||||
if (task) task.status = 'ready'
|
||||
}
|
||||
await fetchStats()
|
||||
}
|
||||
|
||||
async function assignTask(taskId: string, profile: string) {
|
||||
await kanbanApi.assignTask(taskId, profile)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (task) task.assignee = profile
|
||||
}
|
||||
|
||||
function setFilter(key: 'status' | 'assignee', value: string | null) {
|
||||
if (key === 'status') filterStatus.value = value
|
||||
else filterAssignee.value = value
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([fetchTasks(), fetchStats(), fetchAssignees()])
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
stats,
|
||||
assignees,
|
||||
loading,
|
||||
filterStatus,
|
||||
filterAssignee,
|
||||
fetchTasks,
|
||||
fetchStats,
|
||||
fetchAssignees,
|
||||
createTask,
|
||||
completeTasks,
|
||||
blockTask,
|
||||
unblockTasks,
|
||||
assignTask,
|
||||
setFilter,
|
||||
refreshAll,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { NButton, NSelect, NSpin, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
|
||||
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
|
||||
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
|
||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||
import type { KanbanTaskStatus } from '@/api/hermes/kanban'
|
||||
|
||||
const { t } = useI18n()
|
||||
const kanbanStore = useKanbanStore()
|
||||
|
||||
const showCreateForm = ref(false)
|
||||
const selectedTaskId = ref<string | null>(null)
|
||||
const refreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const boardStatuses: KanbanTaskStatus[] = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
|
||||
|
||||
const tasksByStatus = computed(() => {
|
||||
const grouped: Record<string, typeof kanbanStore.tasks> = {}
|
||||
for (const status of boardStatuses) {
|
||||
grouped[status] = kanbanStore.tasks
|
||||
.filter(t => t.status === status)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ label: t('kanban.allStatuses'), value: '' },
|
||||
...boardStatuses.map(s => ({ label: t(`kanban.columns.${s}`, s), value: s })),
|
||||
])
|
||||
|
||||
const assigneeFilterOptions = computed(() => [
|
||||
{ label: t('kanban.allAssignees'), value: '' },
|
||||
...kanbanStore.assignees.map(a => {
|
||||
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
|
||||
return { label: `${a.name} (${total})`, value: a.name }
|
||||
}),
|
||||
])
|
||||
|
||||
const filterStatusValue = computed({
|
||||
get: () => kanbanStore.filterStatus || '',
|
||||
set: (v: string) => kanbanStore.setFilter('status', v || null),
|
||||
})
|
||||
|
||||
const filterAssigneeValue = computed({
|
||||
get: () => kanbanStore.filterAssignee || '',
|
||||
set: (v: string) => kanbanStore.setFilter('assignee', v || null),
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await kanbanStore.refreshAll()
|
||||
refreshTimer.value = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats()])
|
||||
}
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer.value) clearInterval(refreshTimer.value)
|
||||
})
|
||||
|
||||
function handleTaskClick(taskId: string) {
|
||||
selectedTaskId.value = taskId
|
||||
}
|
||||
|
||||
function handleDrawerClose() {
|
||||
selectedTaskId.value = null
|
||||
}
|
||||
|
||||
async function handleDrawerUpdated() {
|
||||
await Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats()])
|
||||
}
|
||||
|
||||
async function handleApplyFilter() {
|
||||
await kanbanStore.fetchTasks()
|
||||
}
|
||||
|
||||
async function handleTaskCreated() {
|
||||
await Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats()])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('kanban.title') }}</h2>
|
||||
<div class="header-actions">
|
||||
<NSelect
|
||||
v-model:value="filterStatusValue"
|
||||
:options="statusFilterOptions"
|
||||
size="small"
|
||||
style="width: 150px;"
|
||||
@update:value="handleApplyFilter"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterAssigneeValue"
|
||||
:options="assigneeFilterOptions"
|
||||
size="small"
|
||||
style="width: 170px;"
|
||||
@update:value="handleApplyFilter"
|
||||
/>
|
||||
<NButton type="primary" size="small" @click="showCreateForm = true">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
{{ t('kanban.createTask') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div v-if="kanbanStore.stats" class="stats-bar">
|
||||
<div v-for="status in boardStatuses" :key="status" class="stat-chip" :class="status">
|
||||
<span class="stat-count">{{ kanbanStore.stats.by_status[status] || 0 }}</span>
|
||||
<span class="stat-label">{{ t(`kanban.columns.${status}`, status) }}</span>
|
||||
</div>
|
||||
<div class="stat-chip total">
|
||||
<span class="stat-count">{{ kanbanStore.stats.total }}</span>
|
||||
<span class="stat-label">{{ t('kanban.stats.total') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Board -->
|
||||
<NSpin :show="kanbanStore.loading && kanbanStore.tasks.length === 0">
|
||||
<div class="kanban-board">
|
||||
<NCollapse>
|
||||
<NCollapseItem
|
||||
v-for="status in boardStatuses"
|
||||
:key="status"
|
||||
:title="`${t(`kanban.columns.${status}`, status)} (${tasksByStatus[status].length})`"
|
||||
:name="status"
|
||||
>
|
||||
<div class="task-list">
|
||||
<KanbanTaskCard
|
||||
v-for="task in tasksByStatus[status]"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="handleTaskClick(task.id)"
|
||||
/>
|
||||
<div v-if="tasksByStatus[status].length === 0" class="column-empty">
|
||||
{{ t('kanban.noTasks') }}
|
||||
</div>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
<!-- Task detail drawer -->
|
||||
<KanbanTaskDrawer
|
||||
:task-id="selectedTaskId"
|
||||
@close="handleDrawerClose"
|
||||
@updated="handleDrawerUpdated"
|
||||
/>
|
||||
|
||||
<!-- Create form -->
|
||||
<KanbanCreateForm
|
||||
v-if="showCreateForm"
|
||||
@close="showCreateForm = false"
|
||||
@created="handleTaskCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.kanban-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 21px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
border: 1px solid $border-light;
|
||||
|
||||
&.triage, &.todo, &.ready { border-left: 3px solid $text-muted; }
|
||||
&.running { border-left: 3px solid $accent-primary; }
|
||||
&.blocked { border-left: 3px solid $error; }
|
||||
&.done { border-left: 3px solid $success; }
|
||||
&.archived { border-left: 3px solid $border-color; }
|
||||
&.total { border-left: 3px solid $text-primary; }
|
||||
}
|
||||
|
||||
.stat-count {
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
padding: 14px 20px 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.column-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Context } from 'koa'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { resolve, normalize } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||||
import { searchSessionSummariesWithProfile, getSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||||
|
||||
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
|
||||
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
|
||||
}
|
||||
|
||||
export async function list(ctx: Context) {
|
||||
const { status, assignee, tenant } = ctx.query as Record<string, string | undefined>
|
||||
try {
|
||||
const tasks = await kanbanCli.listTasks({ status, assignee, tenant })
|
||||
ctx.body = { tasks }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(ctx: Context) {
|
||||
try {
|
||||
const detail = await kanbanCli.getTask(ctx.params.id)
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Task not found' }
|
||||
return
|
||||
}
|
||||
|
||||
// For completed tasks, find related session from the worker's profile DB
|
||||
if (detail.task.status === 'done' && detail.runs.length > 0) {
|
||||
const profile = getLatestRunProfile(detail)
|
||||
if (profile) {
|
||||
try {
|
||||
const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5)
|
||||
if (results.length > 0) {
|
||||
const sessionId = results[0].id
|
||||
const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile)
|
||||
if (sessionDetail) {
|
||||
;(detail as any).session = {
|
||||
id: sessionId,
|
||||
title: sessionDetail.title,
|
||||
source: sessionDetail.source,
|
||||
model: sessionDetail.model,
|
||||
started_at: sessionDetail.started_at,
|
||||
ended_at: sessionDetail.ended_at,
|
||||
messages: sessionDetail.messages,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session lookup is best-effort, don't fail the whole request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = detail
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(ctx: Context) {
|
||||
const { title, body, assignee, priority, tenant } = ctx.request.body as {
|
||||
title?: string
|
||||
body?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
tenant?: string
|
||||
}
|
||||
if (!title) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const task = await kanbanCli.createTask(title, { body, assignee, priority, tenant })
|
||||
ctx.body = { task }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function complete(ctx: Context) {
|
||||
const { task_ids, summary } = ctx.request.body as {
|
||||
task_ids?: string[]
|
||||
summary?: string
|
||||
}
|
||||
if (!task_ids?.length) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'task_ids is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.completeTasks(task_ids, summary)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function block(ctx: Context) {
|
||||
const { reason } = ctx.request.body as { reason?: string }
|
||||
if (!reason) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'reason is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.blockTask(ctx.params.id, reason)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function unblock(ctx: Context) {
|
||||
const { task_ids } = ctx.request.body as { task_ids?: string[] }
|
||||
if (!task_ids?.length) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'task_ids is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.unblockTasks(task_ids)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function assign(ctx: Context) {
|
||||
const { profile } = ctx.request.body as { profile?: string }
|
||||
if (!profile) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'profile is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.assignTask(ctx.params.id, profile)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function stats(ctx: Context) {
|
||||
try {
|
||||
const stats = await kanbanCli.getStats()
|
||||
ctx.body = { stats }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignees(ctx: Context) {
|
||||
try {
|
||||
const assignees = await kanbanCli.getAssignees()
|
||||
ctx.body = { assignees }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function readArtifact(ctx: Context) {
|
||||
const filePath = ctx.query.path as string | undefined
|
||||
if (!filePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'path is required' }
|
||||
return
|
||||
}
|
||||
|
||||
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
|
||||
const resolved = resolve(normalize(filePath))
|
||||
|
||||
if (!resolved.startsWith(kanbanDir)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Path must be within kanban workspaces' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await readFile(resolved, 'utf-8')
|
||||
ctx.body = { content: data, path: filePath }
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'File not found' }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSessions(ctx: Context) {
|
||||
const { task_id, profile, q } = ctx.query as {
|
||||
task_id?: string
|
||||
profile?: string
|
||||
q?: string
|
||||
}
|
||||
if (!task_id || !profile) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'task_id and profile are required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const searchQuery = q || task_id
|
||||
const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10)
|
||||
ctx.body = { results }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
@@ -859,6 +859,107 @@ export async function listSessionSummaries(source?: string, limit = 2000, profil
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSessionSummariesWithProfile(
|
||||
query: string,
|
||||
profile: string,
|
||||
source?: string,
|
||||
limit = 20,
|
||||
): Promise<HermesSessionSearchRow[]> {
|
||||
if (!SQLITE_AVAILABLE) {
|
||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||
}
|
||||
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
const normalized = sanitizeFtsQuery(trimmed)
|
||||
const prefixQuery = toPrefixQuery(normalized)
|
||||
const titlePattern = buildLikePattern(normalizeTitleLikeQuery(trimmed).toLowerCase())
|
||||
const useLiteralContentSearch = containsCjk(trimmed) || shouldUseLiteralContentSearch(trimmed)
|
||||
const candidateLimit = searchCandidateLimit(limit)
|
||||
|
||||
try {
|
||||
const sourceClause = source ? 'AND s.source = ?' : ''
|
||||
const sourceParams = source ? [source] : []
|
||||
const titleSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT
|
||||
base.*,
|
||||
NULL AS matched_message_id,
|
||||
CASE
|
||||
WHEN base.title IS NOT NULL AND base.title != '' THEN base.title
|
||||
ELSE base.preview
|
||||
END AS snippet,
|
||||
0 AS rank
|
||||
FROM base
|
||||
WHERE LOWER(COALESCE(base.title, '')) LIKE ? ESCAPE '\\'
|
||||
ORDER BY base.last_active DESC
|
||||
LIMIT ?
|
||||
`
|
||||
const titleRows = db.prepare(titleSql).all(...sourceParams, titlePattern, candidateLimit) as Record<string, unknown>[]
|
||||
|
||||
const contentSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT
|
||||
base.*,
|
||||
m.id AS matched_message_id,
|
||||
snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet,
|
||||
bm25(messages_fts) AS rank
|
||||
FROM messages_fts
|
||||
JOIN messages m ON m.id = messages_fts.rowid
|
||||
JOIN base ON base.id = m.session_id
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank, base.last_active DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
const contentRows = useLiteralContentSearch
|
||||
? runLiteralContentSearch(db, source, trimmed, candidateLimit)
|
||||
: prefixQuery
|
||||
? (db.prepare(contentSql).all(...sourceParams, prefixQuery, candidateLimit) as Record<string, unknown>[])
|
||||
: []
|
||||
|
||||
const idx = loadAllSessions(db)
|
||||
const merged = new Map<string, HermesSessionSearchRow>()
|
||||
for (const row of titleRows) {
|
||||
const mapped = projectSearchRow(row, idx, source)
|
||||
if (mapped) merged.set(mapped.id, mapped)
|
||||
}
|
||||
for (const row of contentRows) {
|
||||
const mapped = projectSearchRow(row, idx, source)
|
||||
if (mapped && !merged.has(mapped.id)) merged.set(mapped.id, mapped)
|
||||
}
|
||||
|
||||
const items = [...merged.values()]
|
||||
items.sort((a, b) => {
|
||||
if (a.rank !== b.rank) return a.rank - b.rank
|
||||
return b.last_active - a.last_active
|
||||
})
|
||||
return items.slice(0, limit)
|
||||
} catch (_err) {
|
||||
return []
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSessionSummaries(
|
||||
query: string,
|
||||
source?: string,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/kanban'
|
||||
|
||||
export const kanbanRoutes = new Router()
|
||||
|
||||
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
|
||||
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
|
||||
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
|
||||
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
|
||||
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
|
||||
kanbanRoutes.get('/api/hermes/kanban/:id', ctrl.get)
|
||||
kanbanRoutes.post('/api/hermes/kanban', ctrl.create)
|
||||
kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete)
|
||||
kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock)
|
||||
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
|
||||
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
|
||||
@@ -25,6 +25,7 @@ import { fileRoutes } from './hermes/files'
|
||||
import { downloadRoutes } from './hermes/download'
|
||||
import { jobRoutes } from './hermes/jobs'
|
||||
import { cronHistoryRoutes } from './hermes/cron-history'
|
||||
import { kanbanRoutes } from './hermes/kanban'
|
||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
|
||||
|
||||
@@ -64,6 +65,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(downloadRoutes.routes()) // Must be before proxy
|
||||
app.use(jobRoutes.routes()) // Must be before proxy
|
||||
app.use(cronHistoryRoutes.routes()) // Must be before proxy
|
||||
app.use(kanbanRoutes.routes()) // Must be before proxy
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// Proxy catch-all middleware (must be last)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { logger } from '../logger'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const execOpts = { windowsHide: true }
|
||||
|
||||
function resolveHermesBin(): string {
|
||||
const envBin = process.env.HERMES_BIN?.trim()
|
||||
if (envBin) return envBin
|
||||
return 'hermes'
|
||||
}
|
||||
|
||||
const HERMES_BIN = resolveHermesBin()
|
||||
|
||||
// ─── 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
|
||||
started_at: number
|
||||
ended_at: number | null
|
||||
outcome: string | null
|
||||
summary: string | null
|
||||
error: string | 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<string, unknown> | null
|
||||
created_at: number
|
||||
run_id: number | null
|
||||
}
|
||||
|
||||
export interface KanbanTaskDetail {
|
||||
task: KanbanTask
|
||||
comments: KanbanComment[]
|
||||
events: KanbanEvent[]
|
||||
runs: KanbanRun[]
|
||||
}
|
||||
|
||||
export interface KanbanStats {
|
||||
by_status: Record<string, number>
|
||||
by_assignee: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface KanbanAssignee {
|
||||
name: string
|
||||
on_disk: boolean
|
||||
counts: Record<string, number> | null
|
||||
}
|
||||
|
||||
// ─── CLI wrappers ───────────────────────────────────────────────
|
||||
|
||||
export async function listTasks(opts?: {
|
||||
status?: string
|
||||
assignee?: string
|
||||
tenant?: string
|
||||
}): Promise<KanbanTask[]> {
|
||||
const args = ['kanban', 'list', '--json']
|
||||
if (opts?.status) args.push('--status', opts.status)
|
||||
if (opts?.assignee) args.push('--assignee', opts.assignee)
|
||||
if (opts?.tenant) args.push('--tenant', opts.tenant)
|
||||
|
||||
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 list failed')
|
||||
throw new Error(`Failed to list kanban tasks: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTask(taskId: string): Promise<KanbanTaskDetail | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'show', taskId, '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
if (err.code === 1 || err.status === 1) return null
|
||||
logger.error(err, 'Hermes CLI: kanban show failed')
|
||||
throw new Error(`Failed to get kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
title: string,
|
||||
opts?: {
|
||||
body?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
tenant?: string
|
||||
},
|
||||
): Promise<KanbanTask> {
|
||||
const args = ['kanban', 'create', title, '--json']
|
||||
if (opts?.body) args.push('--body', opts.body)
|
||||
if (opts?.assignee) args.push('--assignee', opts.assignee)
|
||||
if (opts?.priority !== undefined) args.push('--priority', String(opts.priority))
|
||||
if (opts?.tenant) args.push('--tenant', opts.tenant)
|
||||
|
||||
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 create failed')
|
||||
throw new Error(`Failed to create kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeTasks(taskIds: string[], summary?: string): Promise<void> {
|
||||
const args = ['kanban', 'complete', ...taskIds]
|
||||
if (summary) args.push('--summary', summary)
|
||||
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban complete failed')
|
||||
throw new Error(`Failed to complete kanban tasks: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function blockTask(taskId: string, reason: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'block', taskId, reason], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban block failed')
|
||||
throw new Error(`Failed to block kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function unblockTasks(taskIds: string[]): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'unblock', ...taskIds], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban unblock failed')
|
||||
throw new Error(`Failed to unblock kanban tasks: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignTask(taskId: string, profile: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'assign', taskId, profile], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban assign failed')
|
||||
throw new Error(`Failed to assign kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<KanbanStats> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'stats', '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban stats failed')
|
||||
throw new Error(`Failed to get kanban stats: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignees(): Promise<KanbanAssignee[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'assignees', '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban assignees failed')
|
||||
throw new Error(`Failed to get kanban assignees: ${err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockRequest = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../packages/client/src/api/client', () => ({
|
||||
request: mockRequest,
|
||||
}))
|
||||
|
||||
import {
|
||||
listTasks,
|
||||
createTask,
|
||||
completeTasks,
|
||||
blockTask,
|
||||
unblockTasks,
|
||||
assignTask,
|
||||
getStats,
|
||||
getAssignees,
|
||||
} from '../../packages/client/src/api/hermes/kanban'
|
||||
|
||||
describe('Kanban API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('serializes list filters into query params', async () => {
|
||||
mockRequest.mockResolvedValue({ tasks: [{ id: 'task-1' }] })
|
||||
|
||||
const result = await listTasks({ status: 'blocked', assignee: 'alice', tenant: 'ops' })
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban?status=blocked&assignee=alice&tenant=ops')
|
||||
expect(result).toEqual([{ id: 'task-1' }])
|
||||
})
|
||||
|
||||
it('posts create and action payloads in the expected shape', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ task: { id: 'task-1' } })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
|
||||
expect(await createTask({ title: 'Ship', assignee: 'alice', priority: 3 })).toEqual({ id: 'task-1' })
|
||||
await completeTasks(['task-1'], 'done')
|
||||
await blockTask('task-1', 'waiting')
|
||||
await unblockTasks(['task-1'])
|
||||
await assignTask('task-1', 'bob')
|
||||
|
||||
expect(mockRequest.mock.calls).toEqual([
|
||||
['/api/hermes/kanban', { method: 'POST', body: JSON.stringify({ title: 'Ship', assignee: 'alice', priority: 3 }) }],
|
||||
['/api/hermes/kanban/complete', { method: 'POST', body: JSON.stringify({ task_ids: ['task-1'], summary: 'done' }) }],
|
||||
['/api/hermes/kanban/task-1/block', { method: 'POST', body: JSON.stringify({ reason: 'waiting' }) }],
|
||||
['/api/hermes/kanban/unblock', { method: 'POST', body: JSON.stringify({ task_ids: ['task-1'] }) }],
|
||||
['/api/hermes/kanban/task-1/assign', { method: 'POST', body: JSON.stringify({ profile: 'bob' }) }],
|
||||
])
|
||||
})
|
||||
|
||||
it('unwraps stats and assignee response envelopes', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ stats: { total: 3, by_status: {}, by_assignee: {} } })
|
||||
.mockResolvedValueOnce({ assignees: [{ name: 'alice', on_disk: true, counts: { todo: 1 } }] })
|
||||
|
||||
await expect(getStats()).resolves.toEqual({ total: 3, by_status: {}, by_assignee: {} })
|
||||
await expect(getAssignees()).resolves.toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const mockCreateTask = vi.hoisted(() => vi.fn())
|
||||
const mockMessage = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/kanban', () => ({
|
||||
useKanbanStore: () => ({
|
||||
assignees: [{ name: 'alice', counts: { todo: 1 } }],
|
||||
createTask: mockCreateTask,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: defineComponent({
|
||||
emits: ['close'],
|
||||
template: '<div class="n-modal-stub"><slot /><slot name="action" /></div>',
|
||||
}),
|
||||
NForm: defineComponent({ template: '<form><slot /></form>' }),
|
||||
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
|
||||
NInput: defineComponent({
|
||||
props: { value: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
props: { value: { required: false }, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value === \'\' ? null : (/^\\d+$/.test($event.target.value) ? Number($event.target.value) : $event.target.value))"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 } }])
|
||||
})
|
||||
})
|
||||
@@ -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<string, number>) => {
|
||||
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: '<div class="n-tooltip-stub"><slot name="trigger" /><div class="tooltip-content"><slot /></div></div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
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('负责人')
|
||||
})
|
||||
})
|
||||
@@ -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: '<div class="history-message-list-stub">{{ session ? session.id : "none" }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: defineComponent({
|
||||
name: 'NDrawer',
|
||||
props: { show: { type: Boolean, required: false } },
|
||||
emits: ['update:show'],
|
||||
template: '<div class="n-drawer-stub"><slot /></div>',
|
||||
}),
|
||||
NDrawerContent: defineComponent({
|
||||
name: 'NDrawerContent',
|
||||
props: { title: { type: String, required: false }, closable: { type: Boolean, required: false } },
|
||||
template: '<div class="n-drawer-content-stub"><slot /></div>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
name: 'NSelect',
|
||||
props: { value: { required: false }, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value || null)"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NInput: defineComponent({
|
||||
name: 'NInput',
|
||||
props: { value: { required: false }, size: { type: String, required: false }, placeholder: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NSpin: defineComponent({
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
}),
|
||||
NModal: defineComponent({
|
||||
name: 'NModal',
|
||||
props: { show: { type: Boolean, required: false }, title: { type: String, required: false } },
|
||||
emits: ['close'],
|
||||
template: '<div v-if="show" class="n-modal-stub" :data-title="title"><slot /></div>',
|
||||
}),
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, any>,
|
||||
assignees: [] as Array<{ name: string; counts: Record<string, number> | 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: '<div class="kanban-task-card-stub">{{ task.title }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanTaskDrawer.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanTaskDrawer',
|
||||
emits: ['updated', 'close'],
|
||||
template: '<button class="drawer-updated" @click="$emit(\'updated\')">drawer</button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanCreateForm.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanCreateForm',
|
||||
emits: ['created', 'close'],
|
||||
template: '<button class="form-created" @click="$emit(\'created\')">form</button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
name: 'NSelect',
|
||||
props: { value: null, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<div class="n-select-stub"></div>',
|
||||
}),
|
||||
NSpin: defineComponent({
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
}),
|
||||
NCollapse: defineComponent({
|
||||
name: 'NCollapse',
|
||||
props: { defaultExpandedNames: { type: Array, required: false } },
|
||||
template: '<div class="n-collapse-stub" :data-default-expanded="JSON.stringify(defaultExpandedNames ?? null)"><slot /></div>',
|
||||
}),
|
||||
NCollapseItem: defineComponent({
|
||||
name: 'NCollapseItem',
|
||||
props: { title: { type: String, required: false }, name: { type: String, required: false } },
|
||||
template: '<section class="n-collapse-item-stub"><slot /></section>',
|
||||
}),
|
||||
}))
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, any> = {}) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user