diff --git a/core/db.py b/core/db.py index 618523a..d35c90a 100644 --- a/core/db.py +++ b/core/db.py @@ -222,6 +222,9 @@ CREATE TABLE IF NOT EXISTS project_links ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX IF NOT EXISTS idx_project_links_to ON project_links(to_project); +CREATE INDEX IF NOT EXISTS idx_project_links_from ON project_links(from_project); + -- Тикеты от пользователей CREATE TABLE IF NOT EXISTS support_tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -716,6 +719,23 @@ def _migrate(conn: sqlite3.Connection): ) conn.commit() + # Create indexes for project_links (KIN-INFRA-008). + # Guard: indexes must be created AFTER the table exists. + if "project_links" in existing_tables: + existing_indexes = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='index'" + ).fetchall()} + if "idx_project_links_to" not in existing_indexes: + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_links_to ON project_links(to_project)" + ) + conn.commit() + if "idx_project_links_from" not in existing_indexes: + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_links_from ON project_links(from_project)" + ) + conn.commit() + def _seed_default_hooks(conn: sqlite3.Connection): """Seed default hooks for the kin project (idempotent). diff --git a/tests/test_deploy.py b/tests/test_deploy.py index a198bc9..38dae85 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -624,3 +624,103 @@ class TestBuildDeployStepsPythonRestartCmd: assert steps[2] == "pm2 restart all" assert steps[3] == "pm2 restart myservice" assert len(steps) == 4 + + +# --------------------------------------------------------------------------- +# 11. Migration: project_links indexes (KIN-INFRA-008) +# Convention #433: set-assert all columns/indexes after migration +# Convention #384: three test cases for conditional guard in _migrate() +# --------------------------------------------------------------------------- + +def _schema_with_project_links_no_indexes(): + """Minimal schema: project_links table exists but its indexes are absent.""" + import sqlite3 as _sqlite3 + conn = _sqlite3.connect(":memory:") + conn.row_factory = _sqlite3.Row + conn.executescript(""" + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT, + status TEXT DEFAULT 'active', + language TEXT DEFAULT 'ru', + execution_mode TEXT NOT NULL DEFAULT 'review', + project_type TEXT DEFAULT 'development' + ); + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT DEFAULT 'pending' + ); + CREATE TABLE project_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_project TEXT NOT NULL REFERENCES projects(id), + to_project TEXT NOT NULL REFERENCES projects(id), + type TEXT NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + return conn + + +def _get_indexes(conn) -> set: + return {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='index'" + ).fetchall()} + + +class TestProjectLinksIndexMigration: + """KIN-INFRA-008: индексы idx_project_links_to / idx_project_links_from.""" + + # --- fresh schema --- + + def test_fresh_schema_has_idx_project_links_to(self): + conn = init_db(db_path=":memory:") + assert "idx_project_links_to" in _get_indexes(conn) + conn.close() + + def test_fresh_schema_has_idx_project_links_from(self): + conn = init_db(db_path=":memory:") + assert "idx_project_links_from" in _get_indexes(conn) + conn.close() + + # Convention #433: assert all columns of project_links after fresh init + def test_fresh_schema_project_links_columns(self): + conn = init_db(db_path=":memory:") + cols = {r["name"] for r in conn.execute("PRAGMA table_info(project_links)").fetchall()} + assert cols == {"id", "from_project", "to_project", "type", "description", "created_at"} + conn.close() + + # --- Convention #384: три кейса для guard в _migrate() --- + + # Кейс 1: без таблицы — guard не падает, индексы не создаются + def test_migrate_without_project_links_table_no_error(self): + conn = _old_schema_no_deploy() # project_links отсутствует + _migrate(conn) # не должно упасть + indexes = _get_indexes(conn) + assert "idx_project_links_to" not in indexes + assert "idx_project_links_from" not in indexes + conn.close() + + # Кейс 2: таблица есть, индексов нет → _migrate() создаёт оба + def test_migrate_creates_both_indexes_when_table_exists(self): + conn = _schema_with_project_links_no_indexes() + _migrate(conn) + indexes = _get_indexes(conn) + assert "idx_project_links_to" in indexes + assert "idx_project_links_from" in indexes + conn.close() + + # Кейс 3: таблица есть, индексы уже есть → _migrate() идемпотентен + def test_migrate_is_idempotent_when_indexes_already_exist(self): + conn = init_db(db_path=":memory:") + before = _get_indexes(conn) + _migrate(conn) + after = _get_indexes(conn) + assert "idx_project_links_to" in after + assert "idx_project_links_from" in after + assert before == after + conn.close() diff --git a/web/frontend/src/__tests__/live-console.test.ts b/web/frontend/src/__tests__/live-console.test.ts index c05a9b4..2d44d04 100644 --- a/web/frontend/src/__tests__/live-console.test.ts +++ b/web/frontend/src/__tests__/live-console.test.ts @@ -329,6 +329,72 @@ describe('отображение логов', () => { expect(wrapper.text()).toContain('Network fail') wrapper.unmount() }) + + // KIN-OBS-026: catch (e: unknown) — type narrowing + it('не-Error строка отображается через String(e) (catch unknown narrowing)', async () => { + vi.mocked(api.getPipelineLogs).mockRejectedValueOnce('строковая ошибка') + + const wrapper = mountConsole('done') + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Ошибка') + expect(wrapper.text()).toContain('строковая ошибка') + wrapper.unmount() + }) + + it('не-Error число отображается через String(e) (catch unknown narrowing)', async () => { + vi.mocked(api.getPipelineLogs).mockRejectedValueOnce(500) + + const wrapper = mountConsole('done') + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Ошибка') + expect(wrapper.text()).toContain('500') + wrapper.unmount() + }) +}) + +// ───────────────────────────────────────────────────────────── +// KIN-OBS-026: scrollTimer cleanup +// ───────────────────────────────────────────────────────────── +describe('scrollTimer cleanup (KIN-OBS-026)', () => { + it('onUnmounted очищает scrollTimer если он был активен', async () => { + // fetchLogs с логами создаёт scrollTimer = setTimeout(scrollToBottom, 0) + vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1)] as any) + + const wrapper = mountConsole('done') + await wrapper.find('button').trigger('click') + await flushPromises() + + // С fake timers setTimeout(scrollToBottom, 0) ещё не выполнен — таймер висит + expect(vi.getTimerCount()).toBeGreaterThan(0) + + wrapper.unmount() + // stopPolling() очищает scrollTimer через clearTimeout + expect(vi.getTimerCount()).toBe(0) + }) + + it('закрытие панели очищает scrollTimer вместе с setInterval', async () => { + vi.mocked(api.getPipelineLogs) + .mockResolvedValueOnce([makeLog(1)] as any) + .mockResolvedValue([]) + + const wrapper = mountConsole('running') + await wrapper.find('button').trigger('click') + await flushPromises() + + // Есть как минимум setInterval + scrollTimer + expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2) + + // Закрываем панель → stopPolling() вызывает clearInterval + clearTimeout + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(vi.getTimerCount()).toBe(0) + wrapper.unmount() + }) }) // ───────────────────────────────────────────────────────────── diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue index d6f23aa..7359c76 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -37,6 +37,11 @@ const rejectReason = ref('') const showRevise = ref(false) const reviseComment = ref('') +const parsedSelectedOutput = computed(() => { + if (!selectedStep.value) return null + return parseAgentOutput(selectedStep.value.output_summary) +}) + // Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project) const autoMode = ref(false) @@ -135,6 +140,28 @@ function formatOutput(text: string | null): string { } } +interface ParsedAgentOutput { + verdict: string | null + details: string | null + raw: string +} + +function parseAgentOutput(text: string | null): ParsedAgentOutput { + if (!text) return { verdict: null, details: null, raw: '' } + const verdictMatch = text.match(/##\s*Verdict\s*\n([\s\S]*?)(?=##\s*Details|$)/m) + const detailsJsonMatch = text.match(/##\s*Details[\s\S]*?```json\n([\s\S]*?)```/) + const verdict = verdictMatch ? verdictMatch[1].trim() : null + let details: string | null = null + if (detailsJsonMatch) { + try { + details = JSON.stringify(JSON.parse(detailsJsonMatch[1].trim()), null, 2) + } catch { + details = detailsJsonMatch[1].trim() + } + } + return { verdict, details, raw: text } +} + async function approve() { if (!task.value) return approveLoading.value = true