day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests

This commit is contained in:
Gros Frumos 2026-03-15 23:22:49 +02:00
parent 8d9facda4f
commit 8a6f280cbd
22 changed files with 1907 additions and 103 deletions

View file

@ -1,14 +1,15 @@
/**
* KIN-011: Тесты сохранения фильтра статусов при навигации
* KIN-011/KIN-014: Тесты фильтра статусов при навигации
*
* Проверяет:
* 1. Выбор фильтра обновляет URL (?status=...)
* 2. Прямая ссылка с query param инициализирует фильтр
* 1. Клик по кнопке статуса обновляет URL (?status=...)
* 2. Прямая ссылка с query param активирует нужную кнопку
* 3. Фильтр показывает только задачи с нужным статусом
* 4. Сброс фильтра удаляет param из URL
* 5. goBack() вызывает router.back() при наличии истории
* 6. goBack() делает push на /project/:id без истории
* 7. После router.back() URL проекта восстанавливается с фильтром
* 4. Сброс фильтра () удаляет param из URL
* 5. Без фильтра отображаются все задачи
* 6. goBack() вызывает router.back() при наличии истории
* 7. goBack() делает push на /project/:id без истории
* 8. После router.back() URL проекта восстанавливается с фильтром
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@ -117,8 +118,8 @@ beforeEach(() => {
// ProjectView: фильтр ↔ URL
// ─────────────────────────────────────────────────────────────
describe('KIN-011: ProjectView — фильтр и URL', () => {
it('1. При выборе фильтра URL обновляется query param ?status', async () => {
describe('KIN-011/KIN-014: ProjectView — фильтр и URL', () => {
it('1. Клик по кнопке статуса обновляет URL (?status=...)', async () => {
const router = makeRouter()
await router.push('/project/KIN')
@ -131,16 +132,16 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
// Изначально status нет в URL
expect(router.currentRoute.value.query.status).toBeUndefined()
// Меняем фильтр через select (первый select — фильтр статусов)
const select = wrapper.find('select')
await select.setValue('in_progress')
// Кликаем по кнопке in_progress
const btn = wrapper.find('[data-status="in_progress"]')
await btn.trigger('click')
await flushPromises()
// URL должен содержать ?status=in_progress
expect(router.currentRoute.value.query.status).toBe('in_progress')
})
it('2. Прямая ссылка ?status=in_progress инициализирует фильтр в select', async () => {
it('2. Прямая ссылка ?status=in_progress активирует нужную кнопку', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
@ -150,9 +151,13 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
})
await flushPromises()
// select должен показывать in_progress
const select = wrapper.find('select')
expect((select.element as HTMLSelectElement).value).toBe('in_progress')
// Кнопка in_progress должна быть активна (иметь класс text-blue-300)
const btn = wrapper.find('[data-status="in_progress"]')
expect(btn.classes()).toContain('text-blue-300')
// Другие кнопки не активны
const pendingBtn = wrapper.find('[data-status="pending"]')
expect(pendingBtn.classes()).not.toContain('text-blue-300')
})
it('3. Прямая ссылка ?status=in_progress показывает только задачи с этим статусом', async () => {
@ -171,7 +176,7 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
expect(links[0].text()).toContain('KIN-002')
})
it('4. Сброс фильтра (пустое значение) удаляет status из URL', async () => {
it('4. Сброс фильтра (кнопка ✕) удаляет status из URL', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
@ -181,9 +186,9 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
})
await flushPromises()
// Сброс фильтра
const select = wrapper.find('select')
await select.setValue('')
// Кликаем кнопку сброса
const clearBtn = wrapper.find('[data-action="clear-status"]')
await clearBtn.trigger('click')
await flushPromises()
// status должен исчезнуть из URL
@ -203,6 +208,89 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
const links = wrapper.findAll('a[href^="/task/"]')
expect(links).toHaveLength(3)
})
it('KIN-014: Выбор нескольких статусов — URL содержит оба через запятую', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-status="pending"]').trigger('click')
await wrapper.find('[data-status="in_progress"]').trigger('click')
await flushPromises()
const status = router.currentRoute.value.query.status as string
expect(status.split(',').sort()).toEqual(['in_progress', 'pending'])
})
it('KIN-014: Фильтр сохраняется в localStorage', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-status="pending"]').trigger('click')
await flushPromises()
const stored = JSON.parse(localStorageMock.getItem('kin-task-statuses-KIN') ?? '[]')
expect(stored).toContain('pending')
})
})
// ─────────────────────────────────────────────────────────────
// KIN-046: кнопки фильтра и сигнатура runTask
// ─────────────────────────────────────────────────────────────
describe('KIN-046: ProjectView — фильтр статусов и runTask', () => {
it('Все 7 кнопок фильтра статусов отображаются в DOM', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
for (const s of ALL_TASK_STATUSES) {
expect(wrapper.find(`[data-status="${s}"]`).exists(), `кнопка "${s}" должна быть в DOM`).toBe(true)
}
})
it('api.runTask вызывается только с taskId — без второго аргумента', async () => {
vi.mocked(api.runTask).mockResolvedValue({ status: 'ok' } as any)
vi.spyOn(window, 'confirm').mockReturnValue(true)
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// KIN-001 имеет статус pending — кнопка "Run pipeline" должна быть видна
const runBtn = wrapper.find('button[title="Run pipeline"]')
expect(runBtn.exists()).toBe(true)
await runBtn.trigger('click')
await flushPromises()
expect(api.runTask).toHaveBeenCalledTimes(1)
// Проверяем: вызван только с taskId, второй аргумент (autoMode) отсутствует
const callArgs = vi.mocked(api.runTask).mock.calls[0]
expect(callArgs).toHaveLength(1)
expect(callArgs[0]).toBe('KIN-001')
})
})
// ─────────────────────────────────────────────────────────────
@ -210,7 +298,7 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
// ─────────────────────────────────────────────────────────────
describe('KIN-011: TaskDetail — возврат с сохранением URL', () => {
it('6. goBack() вызывает router.back() когда window.history.length > 1', async () => {
it('6 (KIN-011). goBack() вызывает router.back() когда window.history.length > 1', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
await router.push('/task/KIN-002')
@ -278,3 +366,146 @@ describe('KIN-011: TaskDetail — возврат с сохранением URL',
expect(router.currentRoute.value.query.status).toBe('in_progress')
})
})
// ─────────────────────────────────────────────────────────────
// KIN-047: TaskDetail — кнопки Approve/Reject в статусе review
// ─────────────────────────────────────────────────────────────
describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () => {
function makeTaskWith(status: string, executionMode: 'auto' | 'review' | null = null) {
return {
id: 'KIN-047',
project_id: 'KIN',
title: 'Review Task',
status,
priority: 3,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
execution_mode: executionMode,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
}
}
it('Approve и Reject видны при статусе review и ручном режиме', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'review') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть видна в review + ручной режим').toBe(true)
expect(rejectExists, 'Reject должна быть видна в review + ручной режим').toBe(true)
})
it('Approve и Reject скрыты при autoMode в статусе review', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'auto') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть скрыта в autoMode').toBe(false)
expect(rejectExists, 'Reject должна быть скрыта в autoMode').toBe(false)
})
it('Тоггл Auto/Review виден в статусе review при autoMode (позволяет выйти из автопилота)', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'auto') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const toggleExists = buttons.some(b => b.text().includes('Auto') || b.text().includes('Review'))
expect(toggleExists, 'Тоггл Auto/Review должен быть виден в статусе review').toBe(true)
})
it('После клика тоггла в review+autoMode появляются Approve и Reject', async () => {
const task = makeTaskWith('review', 'auto')
vi.mocked(api.taskFull).mockResolvedValue(task as any)
vi.mocked(api.patchTask).mockResolvedValue({ execution_mode: 'review' } as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
// Находим тоггл-кнопку (текст "Auto" когда autoMode=true)
const toggleBtn = wrapper.findAll('button').find(b => b.text().includes('Auto'))
expect(toggleBtn?.exists(), 'Тоггл должен быть виден').toBe(true)
await toggleBtn!.trigger('click')
await flushPromises()
// После переключения autoMode=false → Approve и Reject должны появиться
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна появиться после отключения autoMode').toBe(true)
expect(rejectExists, 'Reject должна появиться после отключения autoMode').toBe(true)
})
it('KIN-051: Approve и Reject видны при статусе review и execution_mode=null (фикс баги)', async () => {
// Воспроизводит баг: задача в review без явного execution_mode зависала
// без кнопок, потому что localStorage мог содержать 'auto'
localStorageMock.setItem('kin-mode-KIN', 'auto') // имитируем "плохой" localStorage
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', null) as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть видна: review+null mode игнорирует localStorage').toBe(true)
expect(rejectExists, 'Reject должна быть видна: review+null mode игнорирует localStorage').toBe(true)
})
it('Approve скрыта для статусов pending и done', async () => {
for (const status of ['pending', 'done']) {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith(status, 'review') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const approveExists = wrapper.findAll('button').some(b => b.text().includes('Approve'))
expect(approveExists, `Approve не должна быть видна для статуса "${status}"`).toBe(false)
}
})
})

View file

@ -26,6 +26,12 @@ async function post<T>(path: string, body: unknown): Promise<T> {
return res.json()
}
async function del<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export interface Project {
id: string
name: string
@ -59,6 +65,7 @@ export interface Task {
brief: Record<string, unknown> | null
spec: Record<string, unknown> | null
execution_mode: string | null
blocked_reason: string | null
created_at: string
updated_at: string
}
@ -152,8 +159,8 @@ export const api = {
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
rejectTask: (id: string, reason: string) =>
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
runTask: (id: string, allowWrite = false) =>
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }),
runTask: (id: string) =>
post<{ status: string }>(`/tasks/${id}/run`, {}),
bootstrap: (data: { path: string; id: string; name: string }) =>
post<{ project: Project }>('/bootstrap', data),
auditProject: (projectId: string) =>
@ -164,4 +171,6 @@ export const api = {
patch<Task>(`/tasks/${id}`, data),
patchProject: (id: string, data: { execution_mode: string }) =>
patch<Project>(`/projects/${id}`, data),
deleteDecision: (projectId: string, decisionId: number) =>
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
}

View file

@ -15,7 +15,28 @@ const error = ref('')
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
// Filters
const taskStatusFilter = ref((route.query.status as string) || '')
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
function initStatusFilter(): string[] {
const q = route.query.status as string
if (q) return q.split(',').filter((s: string) => s)
const stored = localStorage.getItem(`kin-task-statuses-${props.id}`)
if (stored) { try { return JSON.parse(stored) } catch {} }
return []
}
const selectedStatuses = ref<string[]>(initStatusFilter())
function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s)
if (idx >= 0) selectedStatuses.value.splice(idx, 1)
else selectedStatuses.value.push(s)
}
function clearStatusFilter() {
selectedStatuses.value = []
}
const decisionTypeFilter = ref('')
const decisionSearch = ref('')
@ -98,16 +119,17 @@ async function load() {
}
}
watch(taskStatusFilter, (val) => {
router.replace({ query: { ...route.query, status: val || undefined } })
})
watch(selectedStatuses, (val) => {
localStorage.setItem(`kin-task-statuses-${props.id}`, JSON.stringify(val))
router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
}, { deep: true })
onMounted(() => { load(); loadMode() })
const filteredTasks = computed(() => {
if (!project.value) return []
let tasks = project.value.tasks
if (taskStatusFilter.value) tasks = tasks.filter(t => t.status === taskStatusFilter.value)
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
return tasks
})
@ -145,12 +167,6 @@ function modTypeColor(t: string) {
return m[t] || 'gray'
}
const taskStatuses = computed(() => {
if (!project.value) return []
const s = new Set(project.value.tasks.map(t => t.status))
return Array.from(s).sort()
})
const decTypes = computed(() => {
if (!project.value) return []
const s = new Set(project.value.decisions.map(d => d.type))
@ -179,7 +195,7 @@ async function runTask(taskId: string, event: Event) {
event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return
try {
await api.runTask(taskId, autoMode.value)
await api.runTask(taskId)
await load()
} catch (e: any) {
error.value = e.message
@ -253,12 +269,17 @@ async function addDecision() {
<!-- Tasks Tab -->
<div v-if="activeTab === 'tasks'">
<div class="flex items-center justify-between mb-3">
<div class="flex gap-2">
<select v-model="taskStatusFilter"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
<option value="">All statuses</option>
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
</select>
<div class="flex gap-1 flex-wrap items-center">
<button v-for="s in ALL_TASK_STATUSES" :key="s"
:data-status="s"
@click="toggleStatus(s)"
class="px-2 py-0.5 text-xs rounded border transition-colors"
:class="selectedStatuses.includes(s)
? 'bg-blue-900/40 text-blue-300 border-blue-700'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>{{ s }}</button>
<button v-if="selectedStatuses.length" data-action="clear-status" @click="clearStatusFilter"
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div>
<div class="flex gap-2">
<button @click="toggleMode"
@ -284,7 +305,7 @@ async function addDecision() {
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id"
:to="{ path: `/task/${t.id}`, query: taskStatusFilter ? { back_status: taskStatusFilter } : undefined }"
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>

View file

@ -35,6 +35,9 @@ function loadMode(t: typeof task.value) {
if (!t) return
if (t.execution_mode) {
autoMode.value = t.execution_mode === 'auto'
} else if (t.status === 'review') {
// Task is in review always show Approve/Reject regardless of localStorage
autoMode.value = false
} else {
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto'
}
@ -188,7 +191,7 @@ async function reject() {
async function runPipeline() {
try {
await api.runTask(props.id, autoMode.value)
await api.runTask(props.id)
startPolling()
await load()
} catch (e: any) {
@ -264,6 +267,9 @@ async function changeStatus(newStatus: string) {
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }}
</div>
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
Blocked: {{ task.blocked_reason }}
</div>
<div v-if="task.assigned_role" class="text-xs text-gray-500">
Assigned: {{ task.assigned_role }}
</div>
@ -346,7 +352,7 @@ async function changeStatus(newStatus: string) {
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
&#10007; Reject
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
@click="toggleMode"
class="px-3 py-2 text-sm border rounded transition-colors"
:class="autoMode