kin/tasks/KIN-013-spec.md

267 lines
10 KiB
Markdown
Raw Normal View History

# KIN-013 — Settings + Obsidian Sync: Техническая спецификация
## Контекст
Фича добавляет:
1. Страницу Settings в GUI для управления конфигурацией проектов
2. Двусторонний Obsidian sync: decisions → .md-файлы, чекбоксы Obsidian → статус задач
Sync вызывается явно по кнопке (не демон), через API-эндпоинт.
---
## 1. Схема данных
### Изменение таблицы `projects`
Добавить колонку:
```sql
ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT;
```
**Миграция**: в `core/db.py``_migrate()`, по паттерну существующих миграций:
```python
if "obsidian_vault_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
conn.commit()
```
**Семантика**: путь к корневой папке Obsidian vault для данного проекта.
Пример: `/Users/grosfrumos/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault`
---
## 2. Формат .md-файлов для decisions
### Расположение
```
{vault_path}/{project_id}/decisions/{id}-{slug}.md
```
Пример: `.../kin/decisions/42-proxy-jump-ssh-gotcha.md`
### Формат файла (YAML frontmatter + Markdown body)
```markdown
---
kin_decision_id: 42
project: kin
type: gotcha
category: testing
tags: [testing, mock, subprocess]
created_at: 2026-03-10
---
# Proxy через SSH не работает без ssh-agent
Описание: полный текст description из БД.
```
**Обоснование frontmatter**:
- Позволяет идентифицировать файл при импорте (поле `kin_decision_id`)
- Позволяет Obsidian показывать метаданные в Properties panel
- Поддерживает round-trip sync без парсинга имени файла
### Slug из заголовка
```python
import re
def _slug(title: str) -> str:
s = title.lower()
s = re.sub(r"[^a-zа-я0-9\s-]", "", s)
s = re.sub(r"\s+", "-", s.strip())
return s[:50]
```
---
## 3. Механизм двустороннего sync
### 3.1 Decisions → Obsidian (export)
- Создать/перезаписать `.md`-файл для каждого decision проекта
- Директория создаётся автоматически (`mkdir -p`)
- Если файл для `kin_decision_id` уже существует — перезаписать (идемпотентно)
- Решения, удалённые из БД → файлы НЕ удаляются (безопасно)
### 3.2 Obsidian чекбоксы → Tasks (import)
**Источник**: файлы `*.md` в `{vault_path}/{project_id}/tasks/`
Дополнительно: файлы `{vault_path}/{project_id}/*.md`
**Формат строки задачи**:
```
- [x] KIN-013 Title of the task
- [ ] KIN-014 Another task
```
**Алгоритм**:
1. Найти строки по паттерну: `^[-*]\s+\[([xX ])\]\s+([A-Z]+-\d+)\s+(.+)$`
2. Извлечь: `done` (bool), `task_id` (str), `title` (str)
3. Найти задачу в БД по `task_id`
4. Если `done=True` и `task.status != 'done'``update_task(conn, task_id, status='done')`
5. Если `done=False` → не трогать (не откатываем)
6. Если задача не найдена → пропустить (не создавать)
**Обоснование**: строгий маппинг только по task_id исключает случайное создание мусора.
### 3.3 Функция `sync_obsidian`
```python
def sync_obsidian(conn, project_id: str) -> dict:
"""
Returns:
{
"exported_decisions": int,
"tasks_updated": int,
"errors": list[str],
"vault_path": str
}
"""
```
---
## 4. Модуль `core/obsidian_sync.py`
### Публичный API модуля
```python
def export_decisions_to_md(
project_id: str,
decisions: list[dict],
vault_path: Path,
) -> list[Path]:
"""Экспортирует decisions в .md файлы Obsidian. Возвращает список созданных файлов."""
def parse_task_checkboxes(
vault_path: Path,
project_id: str,
) -> list[dict]:
"""Парсит *.md файлы в vault/{project_id}/tasks/ на чекбоксы с task ID.
Returns: [{"task_id": "KIN-013", "done": True, "title": "..."}]
"""
def sync_obsidian(conn, project_id: str) -> dict:
"""Оркестратор: export + import. Возвращает статистику."""
```
### Вспомогательные (приватные)
```python
def _slug(title: str) -> str # slug для имени файла
def _decision_to_md(decision: dict) -> str # форматирует .md с frontmatter
def _parse_frontmatter(text: str) -> dict # для будущего round-trip
```
### Зависимости
- Только стандартная библиотека Python: `pathlib`, `re`, `yaml` (через `import yaml`) или ручной YAML-парсер
- Важно: PyYAML может не быть установлен → использовать простой ручной вывод YAML-фронтматтера, парсинг через `re`
- Импортирует из `core.models`: `get_project`, `get_decisions`, `get_task`, `update_task`
- Импортирует из `core.db`: `get_connection`НЕ нужен, conn передаётся снаружи
---
## 5. API-эндпоинты
### 5.1 PATCH /api/projects/{project_id} — расширить
Добавить в `ProjectPatch`:
```python
class ProjectPatch(BaseModel):
execution_mode: str | None = None
autocommit_enabled: bool | None = None
obsidian_vault_path: str | None = None # новое поле
```
Обновить обработчик: если `obsidian_vault_path` provided → `update_project(conn, id, obsidian_vault_path=...)`
Убрать проверку "Nothing to update" → включить `obsidian_vault_path` в условие.
### 5.2 POST /api/projects/{project_id}/sync/obsidian — новый
```python
@app.post("/api/projects/{project_id}/sync/obsidian")
def sync_obsidian_endpoint(project_id: str):
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, ...)
if not p.get("obsidian_vault_path"):
conn.close()
raise HTTPException(400, "obsidian_vault_path not set for this project")
from core.obsidian_sync import sync_obsidian
result = sync_obsidian(conn, project_id)
conn.close()
return result
```
---
## 6. Frontend: Settings страница
### Маршрут
- Path: `/settings`
- Component: `web/frontend/src/views/SettingsView.vue`
- Регистрация в `main.ts`
### Навигация в `App.vue`
Добавить ссылку `Settings` в хедер рядом с `Kin`.
### SettingsView.vue — структура
```
Settings
├── Список проектов (v-for)
│ ├── Название + id
│ ├── Input: obsidian_vault_path (text input)
│ ├── [Save] кнопка → PATCH /api/projects/{id}
│ └── [Sync Obsidian] кнопка → POST /api/projects/{id}/sync/obsidian
│ └── Показывает результат: "Exported: 5 decisions, Updated: 2 tasks"
```
### api.ts — добавить методы
```typescript
// Обновить настройки проекта
patchProject(id: string, data: { obsidian_vault_path?: string, execution_mode?: string, autocommit_enabled?: boolean })
// Запустить Obsidian sync
syncObsidian(projectId: string): Promise<{ exported_decisions: number, tasks_updated: number, errors: string[] }>
```
---
## 7. Тесты
### `tests/test_obsidian_sync.py`
Обязательные кейсы:
1. `test_export_decisions_creates_md_files` — export создаёт файлы с правильным frontmatter
2. `test_export_idempotent` — повторный export перезаписывает, не дублирует
3. `test_parse_task_checkboxes_done``- [x] KIN-001 Title``{"task_id": "KIN-001", "done": True}`
4. `test_parse_task_checkboxes_pending``- [ ] KIN-002 Title``done: False`
5. `test_parse_task_checkboxes_no_id` — строки без task ID пропускаются
6. `test_sync_updates_task_status` — sync обновляет статус задачи если `done=True`
7. `test_sync_no_vault_path` — sync без vault_path выбрасывает ValueError
---
## 8. Риски и ограничения
1. **PyYAML не в зависимостях** → использовать ручную генерацию YAML-строки для frontmatter, парсинг `re`
2. **Vault path может быть недоступен** → sync возвращает error в `errors[]`, не падает
3. **Конфликт при rename decision** → файл со старым slug остаётся, создаётся новый. Приемлемо для v1
4. **Большой vault** → scan только в `{vault_path}/{project_id}/tasks/`, не весь vault
5. **Одновременный sync** → нет блокировки (SQLite WAL + file system). В v1 достаточно
---
## 9. Порядок реализации (для dev-агента)
1. `core/db.py` — добавить `obsidian_vault_path` в `_migrate()`
2. `core/obsidian_sync.py` — реализовать `export_decisions_to_md`, `parse_task_checkboxes`, `sync_obsidian`
3. `tests/test_obsidian_sync.py` — написать тесты (7 кейсов выше)
4. `web/api.py` — расширить `ProjectPatch`, добавить `/sync/obsidian` эндпоинт
5. `web/frontend/src/api.ts` — добавить `patchProject` обновление и `syncObsidian`
6. `web/frontend/src/views/SettingsView.vue` — создать компонент
7. `web/frontend/src/main.ts` — зарегистрировать `/settings` маршрут
8. `web/frontend/src/App.vue` — добавить ссылку Settings в nav