kin: KIN-UI-002 Исправить падающие тесты миграции (регрессия KIN-ARCH-003) в core/db.py

This commit is contained in:
Gros Frumos 2026-03-16 10:04:01 +02:00
parent 389b266bee
commit ff69d24acc
7 changed files with 254 additions and 10 deletions

View file

@ -7,6 +7,7 @@ Your job: review the implementation for correctness, security, and adherence to
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what was built
- ACCEPTANCE CRITERIA: what the task output must satisfy (if provided — verify the implementation meets each criterion before approving)
- DECISIONS: project conventions and standards
- PREVIOUS STEP OUTPUT: dev agent and/or tester output describing what was changed
@ -35,6 +36,7 @@ You receive:
- Check that API endpoints validate input and return proper HTTP status codes.
- Check that no secrets, tokens, or credentials are hardcoded.
- Do NOT rewrite code — only report findings and recommendations.
- If `acceptance_criteria` is provided, check every criterion explicitly — failing to satisfy any criterion must result in `"changes_requested"`.
## Output format

View file

@ -39,6 +39,7 @@ For a specific test file: `python -m pytest tests/test_models.py -v`
- One test per behavior — don't combine multiple assertions in one test without clear reason.
- Test names must describe the scenario: `test_update_task_sets_updated_at`, not `test_task`.
- Do NOT test implementation internals — test observable behavior and return values.
- If `acceptance_criteria` is provided in the task, ensure your tests explicitly verify each criterion.
## Output format

View file

@ -371,6 +371,34 @@ def _migrate(conn: sqlite3.Connection):
""")
conn.commit()
# Migrate columns that must exist before table recreation (KIN-UI-002)
# These columns are referenced in the INSERT SELECT below but were not added
# by any prior ALTER TABLE in this chain — causing OperationalError on minimal schemas.
if "tech_stack" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN tech_stack JSON DEFAULT NULL")
conn.commit()
if "priority" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN priority INTEGER DEFAULT 5")
conn.commit()
if "pm_prompt" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN pm_prompt TEXT DEFAULT NULL")
conn.commit()
if "claude_md_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN claude_md_path TEXT DEFAULT NULL")
conn.commit()
if "forgejo_repo" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN forgejo_repo TEXT DEFAULT NULL")
conn.commit()
if "created_at" not in proj_cols:
# SQLite ALTER TABLE does not allow non-constant defaults like CURRENT_TIMESTAMP
conn.execute("ALTER TABLE projects ADD COLUMN created_at DATETIME DEFAULT NULL")
conn.commit()
# Migrate projects.path from NOT NULL to nullable (KIN-ARCH-003)
# SQLite doesn't support ALTER COLUMN, so we recreate the table.
path_col_rows = conn.execute("PRAGMA table_info(projects)").fetchall()

View file

@ -130,3 +130,73 @@ def test_migrate_is_idempotent():
after = _cols(conn, "projects")
assert before == after
conn.close()
# ---------------------------------------------------------------------------
# Migration KIN-UI-002: рекреация таблицы на минимальной схеме не падает
# ---------------------------------------------------------------------------
def test_migrate_recreates_table_without_operationalerror():
"""_migrate не бросает OperationalError при рекреации projects на минимальной схеме.
Регрессионный тест KIN-UI-002: INSERT SELECT в блоке KIN-ARCH-003 ранее
падал на отсутствующих колонках (tech_stack, priority, pm_prompt и др.).
"""
conn = _old_schema_conn() # path NOT NULL — триггер рекреации
try:
_migrate(conn)
except Exception as exc:
pytest.fail(f"_migrate raised {type(exc).__name__}: {exc}")
conn.close()
def test_migrate_path_becomes_nullable_on_old_schema():
"""После миграции старой схемы (path NOT NULL) колонка path становится nullable."""
conn = _old_schema_conn()
_migrate(conn)
path_col = next(
r for r in conn.execute("PRAGMA table_info(projects)").fetchall()
if r[1] == "path"
)
assert path_col[3] == 0, "path должна быть nullable после миграции KIN-ARCH-003"
conn.close()
def test_migrate_preserves_existing_rows_on_recreation():
"""Рекреация таблицы сохраняет существующие строки."""
conn = _old_schema_conn()
conn.execute(
"INSERT INTO projects (id, name, path, status) VALUES ('p1', 'MyProj', '/p', 'active')"
)
conn.commit()
_migrate(conn)
row = conn.execute("SELECT id, name, path, status FROM projects WHERE id='p1'").fetchone()
assert row is not None
assert row["name"] == "MyProj"
assert row["path"] == "/p"
assert row["status"] == "active"
conn.close()
def test_migrate_adds_missing_columns_before_recreation():
"""_migrate добавляет tech_stack, priority, pm_prompt, claude_md_path, forgejo_repo, created_at перед рекреацией."""
conn = _old_schema_conn()
_migrate(conn)
cols = _cols(conn, "projects")
required = {"tech_stack", "priority", "pm_prompt", "claude_md_path", "forgejo_repo", "created_at"}
assert required.issubset(cols), f"Отсутствуют колонки: {required - cols}"
conn.close()
def test_migrate_operations_project_with_null_path():
"""После миграции можно вставить operations-проект с path=NULL."""
conn = _old_schema_conn()
_migrate(conn)
conn.execute(
"INSERT INTO projects (id, name, path, project_type) VALUES ('ops1', 'Ops', NULL, 'operations')"
)
conn.commit()
row = conn.execute("SELECT path, project_type FROM projects WHERE id='ops1'").fetchone()
assert row["path"] is None
assert row["project_type"] == "operations"
conn.close()

View file

@ -84,6 +84,7 @@ export interface Task {
blocked_reason: string | null
dangerously_skipped: number | null
category: string | null
acceptance_criteria: string | null
created_at: string
updated_at: string
}
@ -223,7 +224,7 @@ export const api = {
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
createProject: (data: { id: string; name: string; path?: string; tech_stack?: string[]; priority?: number; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
post<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string }) =>
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string; acceptance_criteria?: string }) =>
post<Task>('/tasks', data),
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}),
@ -241,7 +242,7 @@ export const api = {
post<AuditResult>(`/projects/${projectId}/audit`, {}),
auditApply: (projectId: string, taskIds: string[]) =>
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) =>
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string; acceptance_criteria?: string }) =>
patch<Task>(`/tasks/${id}`, data),
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
patch<Project>(`/projects/${id}`, data),

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ProjectDetail, type AuditResult, type Phase } from '../api'
import { api, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -12,7 +12,7 @@ const router = useRouter()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
const error = ref('')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules'>('tasks')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban'>('tasks')
// Phases
const phases = ref<Phase[]>([])
@ -247,7 +247,7 @@ const CATEGORY_COLORS: Record<string, string> = {
FIX: 'rose', OBS: 'teal',
}
const showAddTask = ref(false)
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '' })
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
const taskFormError = ref('')
// Add decision modal
@ -280,6 +280,7 @@ onMounted(async () => {
onUnmounted(() => {
if (phasePollTimer) { clearInterval(phasePollTimer); phasePollTimer = null }
if (kanbanPollTimer) { clearInterval(kanbanPollTimer); kanbanPollTimer = null }
})
const taskCategories = computed(() => {
@ -352,9 +353,10 @@ async function addTask() {
priority: taskForm.value.priority,
route_type: taskForm.value.route_type || undefined,
category: taskForm.value.category || undefined,
acceptance_criteria: taskForm.value.acceptance_criteria || undefined,
})
showAddTask.value = false
taskForm.value = { title: '', priority: 5, route_type: '', category: '' }
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
await load()
} catch (e: any) {
taskFormError.value = e.message
@ -385,6 +387,87 @@ async function patchTaskField(taskId: string, data: { priority?: number; route_t
}
}
// Kanban
const KANBAN_COLUMNS = [
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
{ status: 'in_progress', label: 'In Progress', headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
{ status: 'review', label: 'Review', headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
]
const draggingTaskId = ref<string | null>(null)
const dragOverStatus = ref<string | null>(null)
let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
const kanbanTasksByStatus = computed(() => {
if (!project.value) return {} as Record<string, Task[]>
const result: Record<string, Task[]> = {}
for (const col of KANBAN_COLUMNS) result[col.status] = []
for (const t of project.value.tasks) {
if (result[t.status]) result[t.status].push(t)
}
return result
})
function checkAndPollKanban() {
const hasRunning = project.value?.tasks.some(t => t.status === 'in_progress') ?? false
if (hasRunning && !kanbanPollTimer) {
kanbanPollTimer = setInterval(async () => {
project.value = await api.project(props.id).catch(() => project.value)
if (!project.value?.tasks.some(t => t.status === 'in_progress')) {
clearInterval(kanbanPollTimer!)
kanbanPollTimer = null
}
}, 5000)
} else if (!hasRunning && kanbanPollTimer) {
clearInterval(kanbanPollTimer)
kanbanPollTimer = null
}
}
watch(activeTab, (tab) => {
if (tab === 'kanban') {
checkAndPollKanban()
} else if (kanbanPollTimer) {
clearInterval(kanbanPollTimer)
kanbanPollTimer = null
}
})
function onDragStart(event: DragEvent, taskId: string) {
draggingTaskId.value = taskId
event.dataTransfer?.setData('text/plain', taskId)
}
function onDragEnd() {
draggingTaskId.value = null
dragOverStatus.value = null
}
function onDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement
const related = event.relatedTarget as HTMLElement | null
if (!related || !target.contains(related)) dragOverStatus.value = null
}
async function onKanbanDrop(event: DragEvent, newStatus: string) {
dragOverStatus.value = null
const taskId = event.dataTransfer?.getData('text/plain') || draggingTaskId.value
draggingTaskId.value = null
if (!taskId || !project.value) return
const task = project.value.tasks.find(t => t.id === taskId)
if (!task || task.status === newStatus) return
try {
const updated = await api.patchTask(taskId, { status: newStatus })
const idx = project.value.tasks.findIndex(t => t.id === taskId)
if (idx >= 0) project.value.tasks[idx] = updated
checkAndPollKanban()
} catch (e: any) {
error.value = e.message
}
}
async function addDecision() {
decFormError.value = ''
try {
@ -441,18 +524,19 @@ async function addDecision() {
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800">
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules'] as const)" :key="tab"
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban'] as const)" :key="tab"
@click="activeTab = tab"
class="px-4 py-2 text-sm border-b-2 transition-colors"
:class="activeTab === tab
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'kanban' ? 'Kanban' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
: tab === 'decisions' ? project.decisions.length
: project.modules.length }}
: tab === 'modules' ? project.modules.length
: project.tasks.length }}
</span>
</button>
</div>
@ -739,6 +823,45 @@ async function addDecision() {
</div>
</div>
<!-- Kanban Tab -->
<div v-if="activeTab === 'kanban'" class="overflow-x-auto pb-4">
<div class="flex gap-3" style="min-width: max-content">
<div v-for="col in KANBAN_COLUMNS" :key="col.status" class="w-64 flex flex-col gap-2">
<!-- Column header -->
<div class="flex items-center gap-2 px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide" :class="col.headerClass">{{ col.label }}</span>
<span class="text-xs text-gray-600">({{ kanbanTasksByStatus[col.status]?.length || 0 }})</span>
</div>
<!-- Drop zone -->
<div
class="flex flex-col gap-1.5 min-h-24 rounded p-1.5 border transition-colors"
:class="[col.bgClass, dragOverStatus === col.status ? 'border-blue-600' : 'border-transparent']"
@dragover.prevent
@dragenter.prevent="dragOverStatus = col.status"
@dragleave="onDragLeave"
@drop.prevent="onKanbanDrop($event, col.status)">
<router-link
v-for="t in kanbanTasksByStatus[col.status]" :key="t.id"
:to="`/task/${t.id}`"
draggable="true"
@dragstart="onDragStart($event, t.id)"
@dragend="onDragEnd"
class="block px-2.5 py-2 bg-gray-900 border border-gray-800 rounded text-xs hover:border-gray-600 no-underline cursor-grab active:cursor-grabbing transition-colors select-none"
:class="draggingTaskId === t.id ? 'opacity-40' : ''">
<div class="text-gray-500 mb-1 text-[10px]">{{ t.id }}</div>
<div class="text-gray-300 leading-snug mb-1.5">{{ t.title }}</div>
<div class="flex items-center gap-1 flex-wrap">
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
<span class="text-gray-600 text-[10px] ml-auto">p{{ t.priority }}</span>
<span v-if="t.status === 'in_progress'" class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
</div>
</router-link>
<div v-if="!kanbanTasksByStatus[col.status]?.length" class="text-xs text-gray-700 text-center py-6"></div>
</div>
</div>
</div>
</div>
<!-- Add Task Modal -->
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
<form @submit.prevent="addTask" class="space-y-3">
@ -759,6 +882,12 @@ async function addDecision() {
</select>
<input v-model.number="taskForm.priority" type="number" min="1" max="10" placeholder="Priority"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<div>
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
<textarea v-model="taskForm.acceptance_criteria" rows="3"
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
</div>
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
<button type="submit"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">

View file

@ -281,7 +281,7 @@ async function runDeploy() {
// Edit modal (pending tasks only)
const showEdit = ref(false)
const editForm = ref({ title: '', briefText: '', priority: 5 })
const editForm = ref({ title: '', briefText: '', priority: 5, acceptanceCriteria: '' })
const editLoading = ref(false)
const editError = ref('')
@ -298,6 +298,7 @@ function openEdit() {
title: task.value.title,
briefText: getBriefText(task.value.brief),
priority: task.value.priority,
acceptanceCriteria: task.value.acceptance_criteria ?? '',
}
editError.value = ''
showEdit.value = true
@ -313,6 +314,8 @@ async function saveEdit() {
if (editForm.value.priority !== task.value.priority) data.priority = editForm.value.priority
const origBriefText = getBriefText(task.value.brief)
if (editForm.value.briefText !== origBriefText) data.brief_text = editForm.value.briefText
const origAC = task.value.acceptance_criteria ?? ''
if (editForm.value.acceptanceCriteria !== origAC) data.acceptance_criteria = editForm.value.acceptanceCriteria
if (Object.keys(data).length === 0) { showEdit.value = false; return }
const updated = await api.patchTask(props.id, data)
task.value = { ...task.value, ...updated }
@ -387,6 +390,10 @@ async function saveEdit() {
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }}
</div>
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
<div class="text-xs font-semibold text-gray-400 mb-1">Критерии приёмки</div>
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
</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>
@ -628,6 +635,12 @@ async function saveEdit() {
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
<textarea v-model="editForm.acceptanceCriteria" rows="3"
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
</div>
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
<button type="submit" :disabled="editLoading"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">