Compare commits
No commits in common. "62f0ccc29209ae59424234fa33ddb7449214cb16" and "dc4a0cd69f481fcc2d2c60ef23570f379e5b95df" have entirely different histories.
62f0ccc292
...
dc4a0cd69f
4 changed files with 0 additions and 213 deletions
20
core/db.py
20
core/db.py
|
|
@ -222,9 +222,6 @@ CREATE TABLE IF NOT EXISTS project_links (
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -719,23 +716,6 @@ def _migrate(conn: sqlite3.Connection):
|
||||||
)
|
)
|
||||||
conn.commit()
|
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):
|
def _seed_default_hooks(conn: sqlite3.Connection):
|
||||||
"""Seed default hooks for the kin project (idempotent).
|
"""Seed default hooks for the kin project (idempotent).
|
||||||
|
|
|
||||||
|
|
@ -624,103 +624,3 @@ class TestBuildDeployStepsPythonRestartCmd:
|
||||||
assert steps[2] == "pm2 restart all"
|
assert steps[2] == "pm2 restart all"
|
||||||
assert steps[3] == "pm2 restart myservice"
|
assert steps[3] == "pm2 restart myservice"
|
||||||
assert len(steps) == 4
|
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()
|
|
||||||
|
|
|
||||||
|
|
@ -329,72 +329,6 @@ describe('отображение логов', () => {
|
||||||
expect(wrapper.text()).toContain('Network fail')
|
expect(wrapper.text()).toContain('Network fail')
|
||||||
wrapper.unmount()
|
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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,6 @@ const rejectReason = ref('')
|
||||||
const showRevise = ref(false)
|
const showRevise = ref(false)
|
||||||
const reviseComment = ref('')
|
const reviseComment = ref('')
|
||||||
|
|
||||||
const parsedSelectedOutput = computed<ParsedAgentOutput | null>(() => {
|
|
||||||
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)
|
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
|
||||||
const autoMode = ref(false)
|
const autoMode = ref(false)
|
||||||
|
|
||||||
|
|
@ -140,28 +135,6 @@ 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() {
|
async function approve() {
|
||||||
if (!task.value) return
|
if (!task.value) return
|
||||||
approveLoading.value = true
|
approveLoading.value = true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue