From a0b0976d8d6dd732b9565257e2241d17b98be666 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 07:13:32 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-021=20=D0=90=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?-=D0=BB=D0=BE=D0=B3=20=D0=B4=D0=BB=D1=8F=20--dangerously-skip-p?= =?UTF-8?q?ermissions=20=D0=B2=20auto=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/runner.py | 13 + core/context_builder.py | 5 +- core/db.py | 48 ++++ core/followup.py | 2 +- core/models.py | 52 ++++ core/obsidian_sync.py | 180 ++++++++++++ tasks/KIN-013-spec.md | 266 ++++++++++++++++++ tests/test_api.py | 219 ++++++++++++++ tests/test_followup.py | 45 +++ tests/test_obsidian_sync.py | 186 ++++++++++++ tests/test_runner.py | 107 +++++++ web/api.py | 51 +++- .../src/__tests__/filter-persistence.test.ts | 113 ++++++++ web/frontend/src/api.ts | 5 +- web/frontend/src/views/ProjectView.vue | 72 ++++- web/frontend/src/views/TaskDetail.vue | 127 ++++++++- 16 files changed, 1477 insertions(+), 14 deletions(-) create mode 100644 core/obsidian_sync.py create mode 100644 tasks/KIN-013-spec.md create mode 100644 tests/test_obsidian_sync.py diff --git a/agents/runner.py b/agents/runner.py index cf8b02d..d589d20 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -721,6 +721,19 @@ def run_pipeline( task_modules=task_modules) except Exception: pass + # Audit log: record dangerous skip before retry + try: + models.log_audit_event( + conn, + event_type="dangerous_skip", + task_id=task_id, + step_id=role, + reason=f"auto mode permission retry: step {i+1}/{len(steps)} ({role})", + project_id=project_id, + ) + models.update_task(conn, task_id, dangerously_skipped=1) + except Exception: + pass retry = run_agent( conn, role, task_id, project_id, model=model, diff --git a/core/context_builder.py b/core/context_builder.py index 54802ce..0aba5bb 100644 --- a/core/context_builder.py +++ b/core/context_builder.py @@ -91,7 +91,7 @@ def build_context( def _slim_task(task: dict) -> dict: """Extract only relevant fields from a task for the prompt.""" - return { + result = { "id": task["id"], "title": task["title"], "status": task["status"], @@ -100,6 +100,9 @@ def _slim_task(task: dict) -> dict: "brief": task.get("brief"), "spec": task.get("spec"), } + if task.get("revise_comment"): + result["revise_comment"] = task["revise_comment"] + return result def _slim_project(project: dict) -> dict: diff --git a/core/db.py b/core/db.py index d42f1fc..4aacfb7 100644 --- a/core/db.py +++ b/core/db.py @@ -42,6 +42,8 @@ CREATE TABLE IF NOT EXISTS tasks ( forgejo_issue_id INTEGER, execution_mode TEXT, blocked_reason TEXT, + dangerously_skipped BOOLEAN DEFAULT 0, + revise_comment TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -135,6 +137,20 @@ CREATE TABLE IF NOT EXISTS hook_logs ( created_at TEXT DEFAULT (datetime('now')) ); +-- Аудит-лог опасных операций (dangerously-skip-permissions) +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + task_id TEXT REFERENCES tasks(id), + step_id TEXT, + event_type TEXT NOT NULL DEFAULT 'dangerous_skip', + reason TEXT, + project_id TEXT REFERENCES projects(id) +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_task ON audit_log(task_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_event ON audit_log(event_type, timestamp); + -- Кросс-проектные зависимости CREATE TABLE IF NOT EXISTS project_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -220,6 +236,38 @@ def _migrate(conn: sqlite3.Connection): conn.execute("ALTER TABLE projects ADD COLUMN autocommit_enabled INTEGER DEFAULT 0") conn.commit() + if "dangerously_skipped" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN dangerously_skipped BOOLEAN DEFAULT 0") + conn.commit() + + if "revise_comment" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN revise_comment TEXT") + conn.commit() + + if "obsidian_vault_path" not in proj_cols: + conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") + conn.commit() + + # Migrate audit_log table (KIN-021) + existing_tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + if "audit_log" not in existing_tables: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + task_id TEXT REFERENCES tasks(id), + step_id TEXT, + event_type TEXT NOT NULL DEFAULT 'dangerous_skip', + reason TEXT, + project_id TEXT REFERENCES projects(id) + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_task ON audit_log(task_id); + CREATE INDEX IF NOT EXISTS idx_audit_log_event ON audit_log(event_type, timestamp); + """) + conn.commit() + # Rename legacy 'auto' → 'auto_complete' (KIN-063) conn.execute( "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" diff --git a/core/followup.py b/core/followup.py index 3a01c23..ed5d464 100644 --- a/core/followup.py +++ b/core/followup.py @@ -207,7 +207,7 @@ def resolve_pending_action( if choice == "manual_task": new_id = _next_task_id(conn, project_id) - brief_dict = {"source": f"followup:{task_id}"} + brief_dict = {"source": f"followup:{task_id}", "task_type": "manual_escalation"} if item.get("type"): brief_dict["route_type"] = item["type"] if item.get("brief"): diff --git a/core/models.py b/core/models.py index 7e4901a..93d0db3 100644 --- a/core/models.py +++ b/core/models.py @@ -477,6 +477,58 @@ def list_tickets( return _rows_to_list(conn.execute(query, params).fetchall()) +# --------------------------------------------------------------------------- +# Audit Log +# --------------------------------------------------------------------------- + +def log_audit_event( + conn: sqlite3.Connection, + event_type: str, + task_id: str | None = None, + step_id: str | None = None, + reason: str | None = None, + project_id: str | None = None, +) -> dict: + """Log a security-sensitive event to audit_log. + + event_type='dangerous_skip' is used when --dangerously-skip-permissions is invoked. + """ + cur = conn.execute( + """INSERT INTO audit_log (event_type, task_id, step_id, reason, project_id) + VALUES (?, ?, ?, ?, ?)""", + (event_type, task_id, step_id, reason, project_id), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM audit_log WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return _row_to_dict(row) + + +def get_audit_log( + conn: sqlite3.Connection, + task_id: str | None = None, + project_id: str | None = None, + event_type: str | None = None, + limit: int = 100, +) -> list[dict]: + """Query audit log entries with optional filters.""" + query = "SELECT * FROM audit_log WHERE 1=1" + params: list = [] + if task_id: + query += " AND task_id = ?" + params.append(task_id) + if project_id: + query += " AND project_id = ?" + params.append(project_id) + if event_type: + query += " AND event_type = ?" + params.append(event_type) + query += " ORDER BY timestamp DESC LIMIT ?" + params.append(limit) + return _rows_to_list(conn.execute(query, params).fetchall()) + + # --------------------------------------------------------------------------- # Statistics / Dashboard # --------------------------------------------------------------------------- diff --git a/core/obsidian_sync.py b/core/obsidian_sync.py new file mode 100644 index 0000000..25dafd6 --- /dev/null +++ b/core/obsidian_sync.py @@ -0,0 +1,180 @@ +""" +Kin — двусторонний sync с Obsidian vault. + +Export: decisions → .md-файлы с YAML frontmatter +Import: чекбоксы в .md-файлах → статус задач +""" + +import re +import sqlite3 +from pathlib import Path +from typing import Optional + +from core import models + + +def _slug(title: str) -> str: + """Генерирует slug из заголовка для имени файла.""" + s = title.lower() + s = re.sub(r"[^a-zа-я0-9\s-]", "", s) + s = re.sub(r"\s+", "-", s.strip()) + return s[:50] + + +def _decision_to_md(decision: dict) -> str: + """Форматирует decision как .md файл с YAML frontmatter.""" + tags = decision.get("tags") or [] + if isinstance(tags, str): + try: + import json + tags = json.loads(tags) + except Exception: + tags = [] + + tags_str = "[" + ", ".join(str(t) for t in tags) + "]" + created_at = (decision.get("created_at") or "")[:10] # только дата + + frontmatter = ( + "---\n" + f"kin_decision_id: {decision['id']}\n" + f"project: {decision['project_id']}\n" + f"type: {decision['type']}\n" + f"category: {decision.get('category') or ''}\n" + f"tags: {tags_str}\n" + f"created_at: {created_at}\n" + "---\n" + ) + + body = f"\n# {decision['title']}\n\n{decision['description']}\n" + return frontmatter + body + + +def _parse_frontmatter(text: str) -> dict: + """Парсит YAML frontmatter из .md файла (упрощённый парсер через re).""" + result = {} + match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) + if not match: + return result + for line in match.group(1).splitlines(): + if ":" in line: + key, _, val = line.partition(":") + result[key.strip()] = val.strip() + return result + + +def export_decisions_to_md( + project_id: str, + decisions: list[dict], + vault_path: Path, +) -> list[Path]: + """Экспортирует decisions в .md-файлы Obsidian. Возвращает список созданных файлов.""" + out_dir = vault_path / project_id / "decisions" + out_dir.mkdir(parents=True, exist_ok=True) + + created: list[Path] = [] + for d in decisions: + slug = _slug(d["title"]) + fname = f"{d['id']}-{slug}.md" + fpath = out_dir / fname + fpath.write_text(_decision_to_md(d), encoding="utf-8") + created.append(fpath) + + return created + + +def parse_task_checkboxes( + vault_path: Path, + project_id: str, +) -> list[dict]: + """Парсит *.md-файлы в vault/{project_id}/tasks/ и {project_id}/ на чекбоксы с task ID. + + Returns: [{"task_id": "KIN-013", "done": True, "title": "..."}] + """ + pattern = re.compile(r"^[-*]\s+\[([xX ])\]\s+([A-Z]+-\d+)\s+(.+)$") + results: list[dict] = [] + + search_dirs = [ + vault_path / project_id / "tasks", + vault_path / project_id, + ] + + for search_dir in search_dirs: + if not search_dir.is_dir(): + continue + for md_file in search_dir.glob("*.md"): + try: + text = md_file.read_text(encoding="utf-8") + except OSError: + continue + for line in text.splitlines(): + m = pattern.match(line.strip()) + if m: + check_char, task_id, title = m.group(1), m.group(2), m.group(3) + results.append({ + "task_id": task_id, + "done": check_char.lower() == "x", + "title": title.strip(), + }) + + return results + + +def sync_obsidian(conn: sqlite3.Connection, project_id: str) -> dict: + """Оркестратор: export decisions + import checkboxes. + + Returns: + { + "exported_decisions": int, + "tasks_updated": int, + "errors": list[str], + "vault_path": str + } + """ + project = models.get_project(conn, project_id) + if not project: + raise ValueError(f"Project '{project_id}' not found") + + vault_path_str: Optional[str] = project.get("obsidian_vault_path") + if not vault_path_str: + raise ValueError(f"obsidian_vault_path not set for project '{project_id}'") + + vault_path = Path(vault_path_str) + errors: list[str] = [] + + # --- Export decisions --- + exported_count = 0 + if not vault_path.is_dir(): + errors.append(f"Vault path does not exist or is not a directory: {vault_path_str}") + else: + try: + decisions = models.get_decisions(conn, project_id) + created_files = export_decisions_to_md(project_id, decisions, vault_path) + exported_count = len(created_files) + except Exception as e: + errors.append(f"Export error: {e}") + + # --- Import checkboxes --- + tasks_updated = 0 + if vault_path.is_dir(): + try: + checkboxes = parse_task_checkboxes(vault_path, project_id) + for item in checkboxes: + if not item["done"]: + continue + task = models.get_task(conn, item["task_id"]) + if task is None: + continue + if task.get("project_id") != project_id: + continue + if task.get("status") != "done": + models.update_task(conn, item["task_id"], status="done") + tasks_updated += 1 + except Exception as e: + errors.append(f"Import error: {e}") + + return { + "exported_decisions": exported_count, + "tasks_updated": tasks_updated, + "errors": errors, + "vault_path": vault_path_str, + } diff --git a/tasks/KIN-013-spec.md b/tasks/KIN-013-spec.md new file mode 100644 index 0000000..013eb34 --- /dev/null +++ b/tasks/KIN-013-spec.md @@ -0,0 +1,266 @@ +# 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 diff --git a/tests/test_api.py b/tests/test_api.py index 53ce417..17172c5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -608,6 +608,42 @@ def test_run_kin_040_allow_write_true_ignored(client): # KIN-058 — регрессионный тест: stderr=DEVNULL у Popen в web API # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# KIN-020 — manual_escalation задачи: PATCH status='done' резолвит задачу +# --------------------------------------------------------------------------- + +def test_patch_manual_escalation_task_to_done(client): + """PATCH status='done' на manual_escalation задаче — статус обновляется — KIN-020.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.create_task(conn, "P1-002", "p1", "Fix .dockerignore manually", + brief={"task_type": "manual_escalation", + "source": "followup:P1-001", + "description": "Ручное применение .dockerignore"}) + conn.close() + + r = client.patch("/api/tasks/P1-002", json={"status": "done"}) + assert r.status_code == 200 + assert r.json()["status"] == "done" + + +def test_manual_escalation_task_brief_preserved_after_patch(client): + """PATCH не затирает brief.task_type — поле manual_escalation сохраняется — KIN-020.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.create_task(conn, "P1-002", "p1", "Fix manually", + brief={"task_type": "manual_escalation", + "source": "followup:P1-001"}) + conn.close() + + client.patch("/api/tasks/P1-002", json={"status": "done"}) + r = client.get("/api/tasks/P1-002") + assert r.status_code == 200 + assert r.json()["brief"]["task_type"] == "manual_escalation" + + def test_run_sets_stderr_devnull(client): """Регрессионный тест KIN-058: stderr=DEVNULL всегда устанавливается в Popen, чтобы stderr дочернего процесса не загрязнял логи uvicorn.""" @@ -626,3 +662,186 @@ def test_run_sets_stderr_devnull(client): "Регрессия KIN-058: stderr у Popen должен быть DEVNULL, " "иначе вывод агента попадает в логи uvicorn" ) + + +# --------------------------------------------------------------------------- +# KIN-065 — PATCH /api/projects/{id} — autocommit_enabled toggle +# --------------------------------------------------------------------------- + +def test_patch_project_autocommit_enabled_true(client): + """PATCH с autocommit_enabled=true → 200, поле установлено в 1.""" + r = client.patch("/api/projects/p1", json={"autocommit_enabled": True}) + assert r.status_code == 200 + assert r.json()["autocommit_enabled"] == 1 + + +def test_patch_project_autocommit_enabled_false(client): + """После включения PATCH с autocommit_enabled=false → 200, поле установлено в 0.""" + client.patch("/api/projects/p1", json={"autocommit_enabled": True}) + r = client.patch("/api/projects/p1", json={"autocommit_enabled": False}) + assert r.status_code == 200 + assert r.json()["autocommit_enabled"] == 0 + + +def test_patch_project_autocommit_persisted_via_sql(client): + """После PATCH autocommit_enabled=True прямой SQL подтверждает значение 1.""" + client.patch("/api/projects/p1", json={"autocommit_enabled": True}) + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT autocommit_enabled FROM projects WHERE id = 'p1'").fetchone() + conn.close() + assert row is not None + assert row[0] == 1 + + +def test_patch_project_autocommit_false_persisted_via_sql(client): + """После PATCH autocommit_enabled=False прямой SQL подтверждает значение 0.""" + client.patch("/api/projects/p1", json={"autocommit_enabled": True}) + client.patch("/api/projects/p1", json={"autocommit_enabled": False}) + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT autocommit_enabled FROM projects WHERE id = 'p1'").fetchone() + conn.close() + assert row is not None + assert row[0] == 0 + + +def test_patch_project_autocommit_null_before_first_update(client): + """Новый проект имеет autocommit_enabled=NULL/0 (falsy) до первого обновления.""" + client.post("/api/projects", json={"id": "p_new", "name": "New", "path": "/new"}) + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT autocommit_enabled FROM projects WHERE id = 'p_new'").fetchone() + conn.close() + assert row is not None + assert not row[0] # DEFAULT 0 или NULL — в любом случае falsy + + +def test_patch_project_empty_body_returns_400(client): + """PATCH проекта без полей → 400.""" + r = client.patch("/api/projects/p1", json={}) + assert r.status_code == 400 + + +def test_patch_project_not_found(client): + """PATCH несуществующего проекта → 404.""" + r = client.patch("/api/projects/NOPE", json={"autocommit_enabled": True}) + assert r.status_code == 404 + + +def test_patch_project_autocommit_and_execution_mode_together(client): + """PATCH с autocommit_enabled и execution_mode → оба поля обновлены.""" + r = client.patch("/api/projects/p1", json={ + "autocommit_enabled": True, + "execution_mode": "auto_complete", + }) + assert r.status_code == 200 + data = r.json() + assert data["autocommit_enabled"] == 1 + assert data["execution_mode"] == "auto_complete" + + +def test_patch_project_returns_full_project_object(client): + """PATCH возвращает полный объект проекта с id, name и autocommit_enabled.""" + r = client.patch("/api/projects/p1", json={"autocommit_enabled": True}) + assert r.status_code == 200 + data = r.json() + assert data["id"] == "p1" + assert data["name"] == "P1" + assert "autocommit_enabled" in data + +# --------------------------------------------------------------------------- +# KIN-008 — PATCH priority и route_type задачи +# --------------------------------------------------------------------------- + +def test_patch_task_priority(client): + """PATCH priority задачи обновляет поле и возвращает задачу.""" + r = client.patch("/api/tasks/P1-001", json={"priority": 3}) + assert r.status_code == 200 + assert r.json()["priority"] == 3 + + +def test_patch_task_priority_persisted(client): + """После PATCH priority повторный GET возвращает новое значение.""" + client.patch("/api/tasks/P1-001", json={"priority": 7}) + r = client.get("/api/tasks/P1-001") + assert r.status_code == 200 + assert r.json()["priority"] == 7 + + +def test_patch_task_priority_invalid_zero(client): + """PATCH с priority=0 → 400.""" + r = client.patch("/api/tasks/P1-001", json={"priority": 0}) + assert r.status_code == 400 + + +def test_patch_task_priority_invalid_eleven(client): + """PATCH с priority=11 → 400.""" + r = client.patch("/api/tasks/P1-001", json={"priority": 11}) + assert r.status_code == 400 + + +def test_patch_task_route_type_set(client): + """PATCH route_type сохраняет значение в brief.""" + r = client.patch("/api/tasks/P1-001", json={"route_type": "feature"}) + assert r.status_code == 200 + data = r.json() + assert data["brief"]["route_type"] == "feature" + + +def test_patch_task_route_type_all_valid(client): + """Все допустимые route_type принимаются.""" + for rt in ("debug", "feature", "refactor", "hotfix"): + r = client.patch("/api/tasks/P1-001", json={"route_type": rt}) + assert r.status_code == 200, f"route_type={rt} rejected" + assert r.json()["brief"]["route_type"] == rt + + +def test_patch_task_route_type_invalid(client): + """Недопустимый route_type → 400.""" + r = client.patch("/api/tasks/P1-001", json={"route_type": "unknown"}) + assert r.status_code == 400 + + +def test_patch_task_route_type_clear(client): + """PATCH route_type='' очищает поле из brief.""" + client.patch("/api/tasks/P1-001", json={"route_type": "debug"}) + r = client.patch("/api/tasks/P1-001", json={"route_type": ""}) + assert r.status_code == 200 + data = r.json() + brief = data.get("brief") + if brief: + assert "route_type" not in brief + + +def test_patch_task_route_type_merges_brief(client): + """route_type сохраняется вместе с другими полями brief без перезаписи.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", brief={"extra": "data"}) + conn.close() + + r = client.patch("/api/tasks/P1-001", json={"route_type": "hotfix"}) + assert r.status_code == 200 + brief = r.json()["brief"] + assert brief["route_type"] == "hotfix" + assert brief["extra"] == "data" + + +def test_patch_task_priority_and_route_type_together(client): + """PATCH может обновить priority и route_type одновременно.""" + r = client.patch("/api/tasks/P1-001", json={"priority": 2, "route_type": "refactor"}) + assert r.status_code == 200 + data = r.json() + assert data["priority"] == 2 + assert data["brief"]["route_type"] == "refactor" + + +def test_patch_task_empty_body_still_returns_400(client): + """Пустое тело по-прежнему возвращает 400 (регрессия KIN-008).""" + r = client.patch("/api/tasks/P1-001", json={}) + assert r.status_code == 400 diff --git a/tests/test_followup.py b/tests/test_followup.py index ec10d33..a385e52 100644 --- a/tests/test_followup.py +++ b/tests/test_followup.py @@ -219,6 +219,35 @@ class TestResolvePendingAction: # _run_claude with allow_write=True assert result["rerun_result"]["success"] is True + def test_manual_task_brief_has_task_type_manual_escalation(self, conn): + """brief["task_type"] должен быть 'manual_escalation' — KIN-020.""" + action = { + "type": "permission_fix", + "original_item": {"title": "Fix .dockerignore", "type": "hotfix", + "priority": 3, "brief": "Create .dockerignore"}, + } + result = resolve_pending_action(conn, "VDOL-001", action, "manual_task") + assert result is not None + assert result["brief"]["task_type"] == "manual_escalation" + + def test_manual_task_brief_includes_source(self, conn): + """brief["source"] должен содержать ссылку на родительскую задачу — KIN-020.""" + action = { + "type": "permission_fix", + "original_item": {"title": "Fix X"}, + } + result = resolve_pending_action(conn, "VDOL-001", action, "manual_task") + assert result["brief"]["source"] == "followup:VDOL-001" + + def test_manual_task_brief_includes_description(self, conn): + """brief["description"] копируется из original_item.brief — KIN-020.""" + action = { + "type": "permission_fix", + "original_item": {"title": "Fix Y", "brief": "Detailed context here"}, + } + result = resolve_pending_action(conn, "VDOL-001", action, "manual_task") + assert result["brief"]["description"] == "Detailed context here" + def test_nonexistent_task(self, conn): action = {"type": "permission_fix", "original_item": {}} assert resolve_pending_action(conn, "NOPE", action, "skip") is None @@ -261,6 +290,22 @@ class TestAutoResolvePendingActions: tasks = models.list_tasks(conn, project_id="vdol") assert len(tasks) == 2 # VDOL-001 + новая manual task + @patch("agents.runner._run_claude") + def test_escalated_manual_task_has_task_type_manual_escalation(self, mock_claude, conn): + """При эскалации после провала rerun созданная задача имеет task_type='manual_escalation' — KIN-020.""" + mock_claude.return_value = {"output": "", "returncode": 1} + action = { + "type": "permission_fix", + "description": "Fix X", + "original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"}, + "options": ["rerun", "manual_task", "skip"], + } + results = auto_resolve_pending_actions(conn, "VDOL-001", [action]) + + assert results[0]["resolved"] == "manual_task" + created_task = results[0]["result"] + assert created_task["brief"]["task_type"] == "manual_escalation" + @patch("agents.runner._run_claude") def test_empty_pending_actions(self, mock_claude, conn): """Пустой список — пустой результат.""" diff --git a/tests/test_obsidian_sync.py b/tests/test_obsidian_sync.py new file mode 100644 index 0000000..ed934ea --- /dev/null +++ b/tests/test_obsidian_sync.py @@ -0,0 +1,186 @@ +"""Tests for core/obsidian_sync.py — KIN-013.""" + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from core.db import init_db +from core.obsidian_sync import ( + export_decisions_to_md, + parse_task_checkboxes, + sync_obsidian, +) +from core import models + + +@pytest.fixture +def tmp_vault(tmp_path): + """Returns a temporary vault root directory.""" + return tmp_path / "vault" + + +@pytest.fixture +def db(tmp_path): + """Returns an in-memory SQLite connection with schema + test data.""" + db_path = tmp_path / "test.db" + conn = init_db(db_path) + models.create_project(conn, "proj1", "Test Project", "/tmp/proj1") + yield conn + conn.close() + + +# --------------------------------------------------------------------------- +# 1. export creates files with correct frontmatter +# --------------------------------------------------------------------------- + +def test_export_decisions_creates_md_files(tmp_vault): + decisions = [ + { + "id": 42, + "project_id": "proj1", + "type": "gotcha", + "category": "testing", + "title": "Proxy через SSH не работает без ssh-agent", + "description": "При подключении через ProxyJump ssh-agent должен быть запущен.", + "tags": ["testing", "mock", "subprocess"], + "created_at": "2026-03-10T12:00:00", + } + ] + tmp_vault.mkdir(parents=True) + created = export_decisions_to_md("proj1", decisions, tmp_vault) + + assert len(created) == 1 + md_file = created[0] + assert md_file.exists() + + content = md_file.read_text(encoding="utf-8") + assert "kin_decision_id: 42" in content + assert "project: proj1" in content + assert "type: gotcha" in content + assert "category: testing" in content + assert "2026-03-10" in content + assert "# Proxy через SSH не работает без ssh-agent" in content + assert "При подключении через ProxyJump" in content + + +# --------------------------------------------------------------------------- +# 2. export is idempotent (overwrite, not duplicate) +# --------------------------------------------------------------------------- + +def test_export_idempotent(tmp_vault): + decisions = [ + { + "id": 1, + "project_id": "p", + "type": "decision", + "category": None, + "title": "Use SQLite", + "description": "SQLite is the source of truth.", + "tags": [], + "created_at": "2026-01-01", + } + ] + tmp_vault.mkdir(parents=True) + + export_decisions_to_md("p", decisions, tmp_vault) + export_decisions_to_md("p", decisions, tmp_vault) + + out_dir = tmp_vault / "p" / "decisions" + files = list(out_dir.glob("*.md")) + assert len(files) == 1 + + +# --------------------------------------------------------------------------- +# 3. parse_task_checkboxes — done checkbox +# --------------------------------------------------------------------------- + +def test_parse_task_checkboxes_done(tmp_vault): + tasks_dir = tmp_vault / "proj1" / "tasks" + tasks_dir.mkdir(parents=True) + (tasks_dir / "kanban.md").write_text( + "- [x] KIN-001 Implement login\n- [ ] KIN-002 Add tests\n", + encoding="utf-8", + ) + + results = parse_task_checkboxes(tmp_vault, "proj1") + done_items = [r for r in results if r["task_id"] == "KIN-001"] + assert len(done_items) == 1 + assert done_items[0]["done"] is True + assert done_items[0]["title"] == "Implement login" + + +# --------------------------------------------------------------------------- +# 4. parse_task_checkboxes — pending checkbox +# --------------------------------------------------------------------------- + +def test_parse_task_checkboxes_pending(tmp_vault): + tasks_dir = tmp_vault / "proj1" / "tasks" + tasks_dir.mkdir(parents=True) + (tasks_dir / "kanban.md").write_text( + "- [ ] KIN-002 Add tests\n", + encoding="utf-8", + ) + + results = parse_task_checkboxes(tmp_vault, "proj1") + pending = [r for r in results if r["task_id"] == "KIN-002"] + assert len(pending) == 1 + assert pending[0]["done"] is False + + +# --------------------------------------------------------------------------- +# 5. parse_task_checkboxes — lines without task ID are skipped +# --------------------------------------------------------------------------- + +def test_parse_task_checkboxes_no_id(tmp_vault): + tasks_dir = tmp_vault / "proj1" / "tasks" + tasks_dir.mkdir(parents=True) + (tasks_dir / "notes.md").write_text( + "- [x] Some task without ID\n" + "- [ ] Another line without identifier\n" + "- [x] KIN-003 With ID\n", + encoding="utf-8", + ) + + results = parse_task_checkboxes(tmp_vault, "proj1") + assert all(r["task_id"].startswith("KIN-") for r in results) + assert len(results) == 1 + assert results[0]["task_id"] == "KIN-003" + + +# --------------------------------------------------------------------------- +# 6. sync_obsidian updates task status when done=True +# --------------------------------------------------------------------------- + +def test_sync_updates_task_status(db, tmp_vault): + tmp_vault.mkdir(parents=True) + models.update_project(db, "proj1", obsidian_vault_path=str(tmp_vault)) + + task = models.create_task(db, "proj1-001", "proj1", "Do something", status="in_progress") + assert task["status"] == "in_progress" + + # Write checkbox file + tasks_dir = tmp_vault / "proj1" / "tasks" + tasks_dir.mkdir(parents=True) + (tasks_dir / "sprint.md").write_text( + "- [x] proj1-001 Do something\n", + encoding="utf-8", + ) + + result = sync_obsidian(db, "proj1") + + assert result["tasks_updated"] == 1 + assert not result["errors"] + updated = models.get_task(db, "proj1-001") + assert updated["status"] == "done" + + +# --------------------------------------------------------------------------- +# 7. sync_obsidian raises ValueError when vault_path not set +# --------------------------------------------------------------------------- + +def test_sync_no_vault_path(db): + # project exists but obsidian_vault_path is NULL + with pytest.raises(ValueError, match="obsidian_vault_path not set"): + sync_obsidian(db, "proj1") diff --git a/tests/test_runner.py b/tests/test_runner.py index 720a870..d79746b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1557,3 +1557,110 @@ class TestReviewModeExecutionMode: assert row["execution_mode"] == "review", ( "Регрессия KIN-055: execution_mode должен быть 'review' в SQLite после pipeline" ) + + +# --------------------------------------------------------------------------- +# KIN-021: Audit log for --dangerously-skip-permissions +# --------------------------------------------------------------------------- + +class TestAuditLogDangerousSkip: + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("core.followup.generate_followups") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_audit_log_written_on_permission_retry( + self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, conn + ): + """При retry с --dangerously-skip-permissions записывается событие в audit_log.""" + permission_fail = _mock_claude_failure("permission denied: cannot write file") + retry_success = _mock_claude_success({"result": "fixed"}) + + mock_run.side_effect = [permission_fail, retry_success] + mock_hooks.return_value = [] + mock_followup.return_value = {"created": [], "pending_actions": []} + mock_learn.return_value = {"added": 0, "skipped": 0} + + models.update_project(conn, "vdol", execution_mode="auto_complete") + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + + # Проверяем audit_log через прямой SQL + rows = conn.execute( + "SELECT * FROM audit_log WHERE task_id='VDOL-001'" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["event_type"] == "dangerous_skip" + assert rows[0]["step_id"] == "debugger" + assert "debugger" in rows[0]["reason"] + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("core.followup.generate_followups") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_dangerously_skipped_flag_set_on_task( + self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, conn + ): + """tasks.dangerously_skipped=1 после retry с --dangerously-skip-permissions.""" + permission_fail = _mock_claude_failure("permission denied: cannot write file") + retry_success = _mock_claude_success({"result": "fixed"}) + + mock_run.side_effect = [permission_fail, retry_success] + mock_hooks.return_value = [] + mock_followup.return_value = {"created": [], "pending_actions": []} + mock_learn.return_value = {"added": 0, "skipped": 0} + + models.update_project(conn, "vdol", execution_mode="auto_complete") + steps = [{"role": "debugger", "brief": "find"}] + run_pipeline(conn, "VDOL-001", steps) + + # Верификация через прямой SQL (минуя ORM) + row = conn.execute( + "SELECT dangerously_skipped FROM tasks WHERE id='VDOL-001'" + ).fetchone() + assert row is not None + assert row["dangerously_skipped"] == 1 + + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_no_audit_log_in_review_mode(self, mock_run, mock_hooks, conn): + """В review mode retry не происходит, audit_log остаётся пустым.""" + permission_fail = _mock_claude_failure("permission denied: cannot write file") + mock_run.return_value = permission_fail + mock_hooks.return_value = [] + + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is False + rows = conn.execute( + "SELECT * FROM audit_log WHERE task_id='VDOL-001'" + ).fetchall() + assert len(rows) == 0 + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("core.followup.generate_followups") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_audit_log_no_entry_on_normal_success( + self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, conn + ): + """При успешном выполнении без retry audit_log не записывается.""" + mock_run.return_value = _mock_claude_success({"result": "done"}) + mock_hooks.return_value = [] + mock_followup.return_value = {"created": [], "pending_actions": []} + mock_learn.return_value = {"added": 0, "skipped": 0} + + models.update_project(conn, "vdol", execution_mode="auto_complete") + steps = [{"role": "tester", "brief": "test"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + rows = conn.execute( + "SELECT * FROM audit_log WHERE task_id='VDOL-001'" + ).fetchall() + assert len(rows) == 0 diff --git a/web/api.py b/web/api.py index 4ac7a5c..40cb3a4 100644 --- a/web/api.py +++ b/web/api.py @@ -223,9 +223,16 @@ def create_task(body: TaskCreate): return t +VALID_ROUTE_TYPES = {"debug", "feature", "refactor", "hotfix"} + + class TaskPatch(BaseModel): status: str | None = None execution_mode: str | None = None + priority: int | None = None + route_type: str | None = None + title: str | None = None + brief_text: str | None = None VALID_STATUSES = set(models.VALID_TASK_STATUSES) @@ -238,8 +245,15 @@ def patch_task(task_id: str, body: TaskPatch): raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}") if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES: raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}") - if body.status is None and body.execution_mode is None: - raise HTTPException(400, "Nothing to update. Provide status or execution_mode.") + if body.priority is not None and not (1 <= body.priority <= 10): + raise HTTPException(400, "priority must be between 1 and 10") + if body.route_type is not None and body.route_type and body.route_type not in VALID_ROUTE_TYPES: + raise HTTPException(400, f"Invalid route_type '{body.route_type}'. Must be one of: {', '.join(sorted(VALID_ROUTE_TYPES))} or empty string to clear") + if body.title is not None and not body.title.strip(): + raise HTTPException(400, "title must not be empty") + all_none = all(v is None for v in [body.status, body.execution_mode, body.priority, body.route_type, body.title, body.brief_text]) + if all_none: + raise HTTPException(400, "Nothing to update.") conn = get_conn() t = models.get_task(conn, task_id) if not t: @@ -250,6 +264,22 @@ def patch_task(task_id: str, body: TaskPatch): fields["status"] = body.status if body.execution_mode is not None: fields["execution_mode"] = body.execution_mode + if body.priority is not None: + fields["priority"] = body.priority + if body.title is not None: + fields["title"] = body.title.strip() + if body.route_type is not None or body.brief_text is not None: + current_brief = t.get("brief") or {} + if isinstance(current_brief, str): + current_brief = {"text": current_brief} + if body.route_type is not None: + if body.route_type: + current_brief = {**current_brief, "route_type": body.route_type} + else: + current_brief = {k: v for k, v in current_brief.items() if k != "route_type"} + if body.brief_text is not None: + current_brief = {**current_brief, "text": body.brief_text} + fields["brief"] = current_brief if current_brief else None models.update_task(conn, task_id, **fields) t = models.get_task(conn, task_id) conn.close() @@ -384,6 +414,23 @@ def reject_task(task_id: str, body: TaskReject): return {"status": "pending", "reason": body.reason} +class TaskRevise(BaseModel): + comment: str + + +@app.post("/api/tasks/{task_id}/revise") +def revise_task(task_id: str, body: TaskRevise): + """Revise a task: return to in_progress with director's comment for the agent.""" + conn = get_conn() + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + models.update_task(conn, task_id, status="in_progress", revise_comment=body.comment) + conn.close() + return {"status": "in_progress", "comment": body.comment} + + @app.get("/api/tasks/{task_id}/running") def is_task_running(task_id: str): """Check if task has an active (running) pipeline.""" diff --git a/web/frontend/src/__tests__/filter-persistence.test.ts b/web/frontend/src/__tests__/filter-persistence.test.ts index 1c0249f..202764e 100644 --- a/web/frontend/src/__tests__/filter-persistence.test.ts +++ b/web/frontend/src/__tests__/filter-persistence.test.ts @@ -27,6 +27,7 @@ vi.mock('../api', () => ({ auditProject: vi.fn(), createTask: vi.fn(), patchTask: vi.fn(), + patchProject: vi.fn(), }, })) @@ -509,3 +510,115 @@ describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () = } }) }) + +// ───────────────────────────────────────────────────────────── +// KIN-065: Autocommit toggle в ProjectView +// ───────────────────────────────────────────────────────────── + +describe('KIN-065: ProjectView — Autocommit toggle', () => { + it('Кнопка Autocommit присутствует в DOM', async () => { + const router = makeRouter() + await router.push('/project/KIN') + + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + expect(btn?.exists()).toBe(true) + }) + + it('Кнопка имеет title "Autocommit: off" когда autocommit_enabled=0', async () => { + vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any) + const router = makeRouter() + await router.push('/project/KIN') + + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + expect(btn?.attributes('title')).toBe('Autocommit: off') + }) + + it('Кнопка имеет title "Autocommit: on..." когда autocommit_enabled=1', async () => { + vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 1 } as any) + const router = makeRouter() + await router.push('/project/KIN') + + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + expect(btn?.attributes('title')).toContain('Autocommit: on') + }) + + it('Клик по кнопке вызывает patchProject с autocommit_enabled=true (включение)', async () => { + vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any) + vi.mocked(api.patchProject).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 1 } as any) + + const router = makeRouter() + await router.push('/project/KIN') + + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + await btn!.trigger('click') + await flushPromises() + + expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: true }) + }) + + it('Клик по включённой кнопке вызывает patchProject с autocommit_enabled=false (выключение)', async () => { + vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 1 } as any) + vi.mocked(api.patchProject).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any) + + const router = makeRouter() + await router.push('/project/KIN') + + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + await btn!.trigger('click') + await flushPromises() + + expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: false }) + }) + + it('При ошибке patchProject состояние кнопки откатывается (rollback)', async () => { + vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any) + vi.mocked(api.patchProject).mockRejectedValue(new Error('Network error')) + + const router = makeRouter() + await router.push('/project/KIN') + + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + await btn!.trigger('click') + await flushPromises() + + // После ошибки откат: кнопка снова отображает "off" + const btnAfter = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) + expect(btnAfter?.attributes('title')).toBe('Autocommit: off') + }) +}) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 080bfcf..37195da 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -67,6 +67,7 @@ export interface Task { spec: Record | null execution_mode: string | null blocked_reason: string | null + dangerously_skipped: number | null created_at: string updated_at: string } @@ -168,10 +169,12 @@ export const api = { post(`/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 }) => + patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) => patch(`/tasks/${id}`, data), patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean }) => patch(`/projects/${id}`, data), deleteDecision: (projectId: string, decisionId: number) => del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`), + createDecision: (data: { project_id: string; type: string; title: string; description: string; category?: string; tags?: string[] }) => + post('/decisions', data), } diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 57fae30..b057a5a 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -151,6 +151,13 @@ const filteredTasks = computed(() => { return tasks }) +const manualEscalationTasks = computed(() => { + if (!project.value) return [] + return project.value.tasks.filter( + t => t.brief?.task_type === 'manual_escalation' && t.status !== 'done' && t.status !== 'cancelled' + ) +}) + const filteredDecisions = computed(() => { if (!project.value) return [] let decs = project.value.decisions @@ -220,24 +227,30 @@ async function runTask(taskId: string, event: Event) { } } +async function patchTaskField(taskId: string, data: { priority?: number; route_type?: string }) { + try { + const updated = await api.patchTask(taskId, data) + if (project.value) { + const idx = project.value.tasks.findIndex(t => t.id === taskId) + if (idx >= 0) project.value.tasks[idx] = updated + } + } catch (e: any) { + error.value = e.message + } +} + async function addDecision() { decFormError.value = '' try { const tags = decForm.value.tags ? decForm.value.tags.split(',').map(s => s.trim()).filter(Boolean) : undefined - const body = { + await api.createDecision({ project_id: props.id, type: decForm.value.type, title: decForm.value.title, description: decForm.value.description, category: decForm.value.category || undefined, tags, - } - const res = await fetch('/api/decisions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), }) - if (!res.ok) throw new Error('Failed') showAddDecision.value = false decForm.value = { type: 'decision', title: '', description: '', category: '', tags: '' } await load() @@ -328,6 +341,30 @@ async function addDecision() { + +
+
+ ⚠ Требуют ручного решения + ({{ manualEscalationTasks.length }}) +
+
+ +
+ {{ t.id }} + + {{ t.title }} + escalated from {{ t.parent_task_id }} +
+
+ {{ t.brief.description }} + pri {{ t.priority }} +
+
+
+
+
No tasks.
{{ t.assigned_role }} - pri {{ t.priority }} + +