day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests
This commit is contained in:
parent
8d9facda4f
commit
8a6f280cbd
22 changed files with 1907 additions and 103 deletions
30
web/api.py
30
web/api.py
|
|
@ -161,7 +161,7 @@ class TaskPatch(BaseModel):
|
|||
execution_mode: str | None = None
|
||||
|
||||
|
||||
VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked", "cancelled"}
|
||||
VALID_STATUSES = set(models.VALID_TASK_STATUSES)
|
||||
VALID_EXECUTION_MODES = {"auto", "review"}
|
||||
|
||||
|
||||
|
|
@ -248,6 +248,13 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
|
|||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
models.update_task(conn, task_id, status="done")
|
||||
try:
|
||||
from core.hooks import run_hooks as _run_hooks
|
||||
task_modules = models.get_modules(conn, t["project_id"])
|
||||
_run_hooks(conn, t["project_id"], task_id,
|
||||
event="task_done", task_modules=task_modules)
|
||||
except Exception:
|
||||
pass
|
||||
decision = None
|
||||
if body and body.decision_title:
|
||||
decision = models.add_decision(
|
||||
|
|
@ -328,12 +335,8 @@ def is_task_running(task_id: str):
|
|||
return {"running": False}
|
||||
|
||||
|
||||
class TaskRun(BaseModel):
|
||||
allow_write: bool = False
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/run")
|
||||
def run_task(task_id: str, body: TaskRun | None = None):
|
||||
def run_task(task_id: str):
|
||||
"""Launch pipeline for a task in background. Returns 202."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
|
|
@ -347,8 +350,7 @@ def run_task(task_id: str, body: TaskRun | None = None):
|
|||
kin_root = Path(__file__).parent.parent
|
||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
||||
"run", task_id]
|
||||
if body and body.allow_write:
|
||||
cmd.append("--allow-write")
|
||||
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
|
||||
|
||||
import os
|
||||
env = os.environ.copy()
|
||||
|
|
@ -413,6 +415,18 @@ def create_decision(body: DecisionCreate):
|
|||
return d
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}/decisions/{decision_id}")
|
||||
def delete_decision(project_id: str, decision_id: int):
|
||||
conn = get_conn()
|
||||
decision = models.get_decision(conn, decision_id)
|
||||
if not decision or decision["project_id"] != project_id:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Decision #{decision_id} not found")
|
||||
models.delete_decision(conn, decision_id)
|
||||
conn.close()
|
||||
return {"deleted": decision_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
✗ 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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue