kin/tasks/KIN-013-spec.md

10 KiB
Raw Permalink Blame History

KIN-013 — Settings + Obsidian Sync: Техническая спецификация

Контекст

Фича добавляет:

  1. Страницу Settings в GUI для управления конфигурацией проектов
  2. Двусторонний Obsidian sync: decisions → .md-файлы, чекбоксы Obsidian → статус задач

Sync вызывается явно по кнопке (не демон), через API-эндпоинт.


1. Схема данных

Изменение таблицы projects

Добавить колонку:

ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT;

Миграция: в core/db.py_migrate(), по паттерну существующих миграций:

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)

---
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 из заголовка

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

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 модуля

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. Возвращает статистику."""

Вспомогательные (приватные)

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:

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 — новый

@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 — добавить методы

// Обновить настройки проекта
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 Titledone: 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