kin: KIN-067 При попытке сохранить настройки и синхронизироваться с обсидианом через настройки ошибка 'Sync error: Error: 400 Bad Request'. Разобраться с проблемой. Синхронизация работает в обе стороны.

This commit is contained in:
Gros Frumos 2026-03-16 08:38:49 +02:00
parent 81f974e6d3
commit 993362341b
5 changed files with 106 additions and 18 deletions

View file

@ -24,6 +24,15 @@ PERMISSION_PATTERNS = [
]
def _next_task_id(
conn: sqlite3.Connection,
project_id: str,
category: str | None = None,
) -> str:
"""Thin wrapper around models.next_task_id for testability."""
return models.next_task_id(conn, project_id, category=category)
def _is_permission_blocked(item: dict) -> bool:
"""Check if a follow-up item describes a permission/write failure."""
text = f"{item.get('title', '')} {item.get('brief', '')}".lower()
@ -139,7 +148,7 @@ def generate_followups(
"options": ["rerun", "manual_task", "skip"],
})
else:
new_id = models.next_task_id(conn, project_id)
new_id = _next_task_id(conn, project_id, category=task.get("category"))
brief_dict = {"source": f"followup:{task_id}"}
if item.get("type"):
brief_dict["route_type"] = item["type"]
@ -152,6 +161,7 @@ def generate_followups(
priority=item.get("priority", 5),
parent_task_id=task_id,
brief=brief_dict,
category=task.get("category"),
)
created.append(t)
@ -191,7 +201,7 @@ def resolve_pending_action(
return None
if choice == "manual_task":
new_id = models.next_task_id(conn, project_id)
new_id = _next_task_id(conn, project_id, category=task.get("category"))
brief_dict = {"source": f"followup:{task_id}", "task_type": "manual_escalation"}
if item.get("type"):
brief_dict["route_type"] = item["type"]
@ -203,6 +213,7 @@ def resolve_pending_action(
priority=item.get("priority", 5),
parent_task_id=task_id,
brief=brief_dict,
category=task.get("category"),
)
if choice == "rerun":

View file

@ -1071,3 +1071,65 @@ def test_task_full_project_deploy_command_none_when_not_set(client):
data = r.json()
assert "project_deploy_command" in data
assert data["project_deploy_command"] is None
# ---------------------------------------------------------------------------
# KIN-067 — PATCH obsidian_vault_path + sync/obsidian не возвращает 400
# ---------------------------------------------------------------------------
def test_patch_project_obsidian_vault_path_persisted_via_sql(client):
"""PATCH с obsidian_vault_path сохраняется в БД — прямой SQL."""
r = client.patch("/api/projects/p1", json={"obsidian_vault_path": "/tmp/vault"})
assert r.status_code == 200
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT obsidian_vault_path FROM projects WHERE id = 'p1'").fetchone()
conn.close()
assert row is not None
assert row[0] == "/tmp/vault"
def test_patch_project_obsidian_vault_path_returned_in_response(client):
"""PATCH возвращает обновлённый obsidian_vault_path в ответе."""
r = client.patch("/api/projects/p1", json={"obsidian_vault_path": "/my/vault"})
assert r.status_code == 200
assert r.json()["obsidian_vault_path"] == "/my/vault"
def test_sync_obsidian_without_vault_path_returns_400(client):
"""POST sync/obsidian без сохранённого vault_path → 400 Bad Request."""
r = client.post("/api/projects/p1/sync/obsidian")
assert r.status_code == 400
def test_sync_obsidian_after_patch_vault_path_not_400(client, tmp_path):
"""Сценарий бага KIN-067: сначала PATCH vault_path, затем sync → не 400.
Раньше runSync() вызывал sync/obsidian без предварительного сохранения пути,
что приводило к 400. После фикса PATCH вызывается первым.
"""
vault = tmp_path / "vault"
vault.mkdir()
# Шаг 1: сохранить vault_path через PATCH (как теперь делает runSync)
r = client.patch("/api/projects/p1", json={"obsidian_vault_path": str(vault)})
assert r.status_code == 200
# Шаг 2: запустить синхронизацию — не должно вернуть 400
r = client.post("/api/projects/p1/sync/obsidian")
assert r.status_code != 400, f"Ожидался не 400, получен {r.status_code}: {r.text}"
assert r.status_code == 200
def test_sync_obsidian_after_patch_returns_sync_result_fields(client, tmp_path):
"""После PATCH vault_path синхронизация возвращает поля exported_decisions и tasks_updated."""
vault = tmp_path / "vault"
vault.mkdir()
client.patch("/api/projects/p1", json={"obsidian_vault_path": str(vault)})
r = client.post("/api/projects/p1/sync/obsidian")
assert r.status_code == 200
data = r.json()
assert "exported_decisions" in data
assert "tasks_updated" in data

View file

@ -9,6 +9,11 @@ const colors: Record<string, string> = {
gray: 'bg-gray-800/50 text-gray-400 border-gray-700',
purple: 'bg-purple-900/50 text-purple-400 border-purple-800',
orange: 'bg-orange-900/50 text-orange-400 border-orange-800',
indigo: 'bg-indigo-900/50 text-indigo-400 border-indigo-800',
cyan: 'bg-cyan-900/50 text-cyan-400 border-cyan-800',
pink: 'bg-pink-900/50 text-pink-400 border-pink-800',
rose: 'bg-rose-900/50 text-rose-400 border-rose-800',
teal: 'bg-teal-900/50 text-teal-400 border-teal-800',
}
</script>

View file

@ -26,6 +26,7 @@ function initStatusFilter(): string[] {
}
const selectedStatuses = ref<string[]>(initStatusFilter())
const selectedCategory = ref('')
function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s)
@ -119,9 +120,9 @@ async function applyAudit() {
// Add task modal
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
const CATEGORY_COLORS: Record<string, string> = {
SEC: 'red', UI: 'blue', API: 'green', INFRA: 'orange', BIZ: 'purple',
DB: 'yellow', ARCH: 'gray', TEST: 'purple', PERF: 'orange', DOCS: 'gray',
FIX: 'red', OBS: 'blue',
SEC: 'red', UI: 'purple', API: 'blue', INFRA: 'orange', BIZ: 'green',
DB: 'yellow', ARCH: 'indigo', TEST: 'cyan', PERF: 'pink', DOCS: 'gray',
FIX: 'rose', OBS: 'teal',
}
const showAddTask = ref(false)
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '' })
@ -150,10 +151,17 @@ watch(selectedStatuses, (val) => {
onMounted(async () => { await load(); loadMode(); loadAutocommit() })
const taskCategories = computed(() => {
if (!project.value) return []
const cats = new Set(project.value.tasks.map(t => t.category).filter(Boolean) as string[])
return Array.from(cats).sort()
})
const filteredTasks = computed(() => {
if (!project.value) return []
let tasks = project.value.tasks
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value)
return tasks
})
@ -306,7 +314,8 @@ async function addDecision() {
<!-- Tasks Tab -->
<div v-if="activeTab === 'tasks'">
<div class="flex items-center justify-between mb-3">
<div class="flex flex-col gap-2 mb-3">
<div class="flex items-center justify-between">
<div class="flex gap-1 flex-wrap items-center">
<button v-for="s in ALL_TASK_STATUSES" :key="s"
:data-status="s"

View file

@ -56,6 +56,7 @@ async function runSync(projectId: string) {
syncResults.value[projectId] = null
saveStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { obsidian_vault_path: vaultPaths.value[projectId] })
syncResults.value[projectId] = await api.syncObsidian(projectId)
} catch (e) {
saveStatus.value[projectId] = `Sync error: ${e}`