kin: KIN-021 Аудит-лог для --dangerously-skip-permissions в auto mode
This commit is contained in:
parent
67071c757d
commit
a0b0976d8d
16 changed files with 1477 additions and 14 deletions
|
|
@ -721,6 +721,19 @@ def run_pipeline(
|
||||||
task_modules=task_modules)
|
task_modules=task_modules)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(
|
retry = run_agent(
|
||||||
conn, role, task_id, project_id,
|
conn, role, task_id, project_id,
|
||||||
model=model,
|
model=model,
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ def build_context(
|
||||||
|
|
||||||
def _slim_task(task: dict) -> dict:
|
def _slim_task(task: dict) -> dict:
|
||||||
"""Extract only relevant fields from a task for the prompt."""
|
"""Extract only relevant fields from a task for the prompt."""
|
||||||
return {
|
result = {
|
||||||
"id": task["id"],
|
"id": task["id"],
|
||||||
"title": task["title"],
|
"title": task["title"],
|
||||||
"status": task["status"],
|
"status": task["status"],
|
||||||
|
|
@ -100,6 +100,9 @@ def _slim_task(task: dict) -> dict:
|
||||||
"brief": task.get("brief"),
|
"brief": task.get("brief"),
|
||||||
"spec": task.get("spec"),
|
"spec": task.get("spec"),
|
||||||
}
|
}
|
||||||
|
if task.get("revise_comment"):
|
||||||
|
result["revise_comment"] = task["revise_comment"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _slim_project(project: dict) -> dict:
|
def _slim_project(project: dict) -> dict:
|
||||||
|
|
|
||||||
48
core/db.py
48
core/db.py
|
|
@ -42,6 +42,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||||
forgejo_issue_id INTEGER,
|
forgejo_issue_id INTEGER,
|
||||||
execution_mode TEXT,
|
execution_mode TEXT,
|
||||||
blocked_reason TEXT,
|
blocked_reason TEXT,
|
||||||
|
dangerously_skipped BOOLEAN DEFAULT 0,
|
||||||
|
revise_comment TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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'))
|
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 (
|
CREATE TABLE IF NOT EXISTS project_links (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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.execute("ALTER TABLE projects ADD COLUMN autocommit_enabled INTEGER DEFAULT 0")
|
||||||
conn.commit()
|
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)
|
# Rename legacy 'auto' → 'auto_complete' (KIN-063)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'"
|
"UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'"
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ def resolve_pending_action(
|
||||||
|
|
||||||
if choice == "manual_task":
|
if choice == "manual_task":
|
||||||
new_id = _next_task_id(conn, project_id)
|
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"):
|
if item.get("type"):
|
||||||
brief_dict["route_type"] = item["type"]
|
brief_dict["route_type"] = item["type"]
|
||||||
if item.get("brief"):
|
if item.get("brief"):
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,58 @@ def list_tickets(
|
||||||
return _rows_to_list(conn.execute(query, params).fetchall())
|
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
|
# Statistics / Dashboard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
180
core/obsidian_sync.py
Normal file
180
core/obsidian_sync.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
266
tasks/KIN-013-spec.md
Normal file
266
tasks/KIN-013-spec.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -608,6 +608,42 @@ def test_run_kin_040_allow_write_true_ignored(client):
|
||||||
# KIN-058 — регрессионный тест: stderr=DEVNULL у Popen в web API
|
# 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):
|
def test_run_sets_stderr_devnull(client):
|
||||||
"""Регрессионный тест KIN-058: stderr=DEVNULL всегда устанавливается в Popen,
|
"""Регрессионный тест KIN-058: stderr=DEVNULL всегда устанавливается в Popen,
|
||||||
чтобы stderr дочернего процесса не загрязнял логи uvicorn."""
|
чтобы stderr дочернего процесса не загрязнял логи uvicorn."""
|
||||||
|
|
@ -626,3 +662,186 @@ def test_run_sets_stderr_devnull(client):
|
||||||
"Регрессия KIN-058: stderr у Popen должен быть DEVNULL, "
|
"Регрессия KIN-058: stderr у Popen должен быть DEVNULL, "
|
||||||
"иначе вывод агента попадает в логи uvicorn"
|
"иначе вывод агента попадает в логи 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
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,35 @@ class TestResolvePendingAction:
|
||||||
# _run_claude with allow_write=True
|
# _run_claude with allow_write=True
|
||||||
assert result["rerun_result"]["success"] is 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):
|
def test_nonexistent_task(self, conn):
|
||||||
action = {"type": "permission_fix", "original_item": {}}
|
action = {"type": "permission_fix", "original_item": {}}
|
||||||
assert resolve_pending_action(conn, "NOPE", action, "skip") is None
|
assert resolve_pending_action(conn, "NOPE", action, "skip") is None
|
||||||
|
|
@ -261,6 +290,22 @@ class TestAutoResolvePendingActions:
|
||||||
tasks = models.list_tasks(conn, project_id="vdol")
|
tasks = models.list_tasks(conn, project_id="vdol")
|
||||||
assert len(tasks) == 2 # VDOL-001 + новая manual task
|
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")
|
@patch("agents.runner._run_claude")
|
||||||
def test_empty_pending_actions(self, mock_claude, conn):
|
def test_empty_pending_actions(self, mock_claude, conn):
|
||||||
"""Пустой список — пустой результат."""
|
"""Пустой список — пустой результат."""
|
||||||
|
|
|
||||||
186
tests/test_obsidian_sync.py
Normal file
186
tests/test_obsidian_sync.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -1557,3 +1557,110 @@ class TestReviewModeExecutionMode:
|
||||||
assert row["execution_mode"] == "review", (
|
assert row["execution_mode"] == "review", (
|
||||||
"Регрессия KIN-055: execution_mode должен быть 'review' в SQLite после pipeline"
|
"Регрессия 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
|
||||||
|
|
|
||||||
51
web/api.py
51
web/api.py
|
|
@ -223,9 +223,16 @@ def create_task(body: TaskCreate):
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
VALID_ROUTE_TYPES = {"debug", "feature", "refactor", "hotfix"}
|
||||||
|
|
||||||
|
|
||||||
class TaskPatch(BaseModel):
|
class TaskPatch(BaseModel):
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
execution_mode: 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)
|
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)}")
|
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:
|
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)}")
|
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:
|
if body.priority is not None and not (1 <= body.priority <= 10):
|
||||||
raise HTTPException(400, "Nothing to update. Provide status or execution_mode.")
|
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()
|
conn = get_conn()
|
||||||
t = models.get_task(conn, task_id)
|
t = models.get_task(conn, task_id)
|
||||||
if not t:
|
if not t:
|
||||||
|
|
@ -250,6 +264,22 @@ def patch_task(task_id: str, body: TaskPatch):
|
||||||
fields["status"] = body.status
|
fields["status"] = body.status
|
||||||
if body.execution_mode is not None:
|
if body.execution_mode is not None:
|
||||||
fields["execution_mode"] = body.execution_mode
|
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)
|
models.update_task(conn, task_id, **fields)
|
||||||
t = models.get_task(conn, task_id)
|
t = models.get_task(conn, task_id)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -384,6 +414,23 @@ def reject_task(task_id: str, body: TaskReject):
|
||||||
return {"status": "pending", "reason": body.reason}
|
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")
|
@app.get("/api/tasks/{task_id}/running")
|
||||||
def is_task_running(task_id: str):
|
def is_task_running(task_id: str):
|
||||||
"""Check if task has an active (running) pipeline."""
|
"""Check if task has an active (running) pipeline."""
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ vi.mock('../api', () => ({
|
||||||
auditProject: vi.fn(),
|
auditProject: vi.fn(),
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
patchTask: 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export interface Task {
|
||||||
spec: Record<string, unknown> | null
|
spec: Record<string, unknown> | null
|
||||||
execution_mode: string | null
|
execution_mode: string | null
|
||||||
blocked_reason: string | null
|
blocked_reason: string | null
|
||||||
|
dangerously_skipped: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -168,10 +169,12 @@ export const api = {
|
||||||
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
||||||
auditApply: (projectId: string, taskIds: string[]) =>
|
auditApply: (projectId: string, taskIds: string[]) =>
|
||||||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
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<Task>(`/tasks/${id}`, data),
|
patch<Task>(`/tasks/${id}`, data),
|
||||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean }) =>
|
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean }) =>
|
||||||
patch<Project>(`/projects/${id}`, data),
|
patch<Project>(`/projects/${id}`, data),
|
||||||
deleteDecision: (projectId: string, decisionId: number) =>
|
deleteDecision: (projectId: string, decisionId: number) =>
|
||||||
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
|
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
|
||||||
|
createDecision: (data: { project_id: string; type: string; title: string; description: string; category?: string; tags?: string[] }) =>
|
||||||
|
post<Decision>('/decisions', data),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,13 @@ const filteredTasks = computed(() => {
|
||||||
return tasks
|
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(() => {
|
const filteredDecisions = computed(() => {
|
||||||
if (!project.value) return []
|
if (!project.value) return []
|
||||||
let decs = project.value.decisions
|
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() {
|
async function addDecision() {
|
||||||
decFormError.value = ''
|
decFormError.value = ''
|
||||||
try {
|
try {
|
||||||
const tags = decForm.value.tags ? decForm.value.tags.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
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,
|
project_id: props.id,
|
||||||
type: decForm.value.type,
|
type: decForm.value.type,
|
||||||
title: decForm.value.title,
|
title: decForm.value.title,
|
||||||
description: decForm.value.description,
|
description: decForm.value.description,
|
||||||
category: decForm.value.category || undefined,
|
category: decForm.value.category || undefined,
|
||||||
tags,
|
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
|
showAddDecision.value = false
|
||||||
decForm.value = { type: 'decision', title: '', description: '', category: '', tags: '' }
|
decForm.value = { type: 'decision', title: '', description: '', category: '', tags: '' }
|
||||||
await load()
|
await load()
|
||||||
|
|
@ -328,6 +341,30 @@ async function addDecision() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Manual escalation tasks -->
|
||||||
|
<div v-if="manualEscalationTasks.length" class="mb-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">⚠ Требуют ручного решения</span>
|
||||||
|
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<router-link v-for="t in manualEscalationTasks" :key="t.id"
|
||||||
|
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
||||||
|
class="flex items-center justify-between px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded text-sm hover:border-orange-600 no-underline block transition-colors">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
||||||
|
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
||||||
|
<span class="text-orange-300 truncate">{{ t.title }}</span>
|
||||||
|
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">escalated from {{ t.parent_task_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||||
|
<span v-if="t.brief?.description" class="text-orange-600 truncate max-w-[200px]">{{ t.brief.description }}</span>
|
||||||
|
<span>pri {{ t.priority }}</span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="space-y-1">
|
||||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||||
|
|
@ -344,7 +381,26 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||||
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
||||||
<span>pri {{ t.priority }}</span>
|
<select
|
||||||
|
@click.stop
|
||||||
|
@change.stop="patchTaskField(t.id, { route_type: ($event.target as HTMLSelectElement).value })"
|
||||||
|
:value="(t.brief as Record<string, string> | null)?.route_type || ''"
|
||||||
|
class="bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-[10px] text-gray-500 cursor-pointer hover:border-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
title="Task type">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="debug">debug</option>
|
||||||
|
<option value="feature">feature</option>
|
||||||
|
<option value="refactor">refactor</option>
|
||||||
|
<option value="hotfix">hotfix</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
@click.stop
|
||||||
|
@change.stop="patchTaskField(t.id, { priority: Number(($event.target as HTMLSelectElement).value) })"
|
||||||
|
:value="t.priority"
|
||||||
|
class="bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-[10px] text-gray-500 cursor-pointer hover:border-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
title="Priority">
|
||||||
|
<option v-for="n in 10" :key="n" :value="n">p{{ n }}</option>
|
||||||
|
</select>
|
||||||
<button v-if="t.status === 'pending'"
|
<button v-if="t.status === 'pending'"
|
||||||
@click="runTask(t.id, $event)"
|
@click="runTask(t.id, $event)"
|
||||||
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
|
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,23 @@ async function runPipeline() {
|
||||||
|
|
||||||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||||
|
const isManualEscalation = computed(() => task.value?.brief?.task_type === 'manual_escalation')
|
||||||
|
|
||||||
|
const resolvingManually = ref(false)
|
||||||
|
|
||||||
|
async function resolveManually() {
|
||||||
|
if (!task.value) return
|
||||||
|
if (!confirm('Пометить задачу как решённую вручную?')) return
|
||||||
|
resolvingManually.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.patchTask(props.id, { status: 'done' })
|
||||||
|
task.value = { ...task.value, ...updated }
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
resolvingManually.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
|
|
@ -228,6 +245,51 @@ async function changeStatus(newStatus: string) {
|
||||||
statusChanging.value = false
|
statusChanging.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit modal (pending tasks only)
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editForm = ref({ title: '', briefText: '', priority: 5 })
|
||||||
|
const editLoading = ref(false)
|
||||||
|
const editError = ref('')
|
||||||
|
|
||||||
|
function getBriefText(brief: Record<string, unknown> | null): string {
|
||||||
|
if (!brief) return ''
|
||||||
|
if (typeof brief === 'string') return brief as string
|
||||||
|
if ('text' in brief) return String(brief.text)
|
||||||
|
return JSON.stringify(brief)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!task.value) return
|
||||||
|
editForm.value = {
|
||||||
|
title: task.value.title,
|
||||||
|
briefText: getBriefText(task.value.brief),
|
||||||
|
priority: task.value.priority,
|
||||||
|
}
|
||||||
|
editError.value = ''
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!task.value) return
|
||||||
|
editLoading.value = true
|
||||||
|
editError.value = ''
|
||||||
|
try {
|
||||||
|
const data: Parameters<typeof api.patchTask>[1] = {}
|
||||||
|
if (editForm.value.title !== task.value.title) data.title = editForm.value.title
|
||||||
|
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
|
||||||
|
if (Object.keys(data).length === 0) { showEdit.value = false; return }
|
||||||
|
const updated = await api.patchTask(props.id, data)
|
||||||
|
task.value = { ...task.value, ...updated }
|
||||||
|
showEdit.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
editError.value = e.message
|
||||||
|
} finally {
|
||||||
|
editLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -264,7 +326,32 @@ async function changeStatus(newStatus: string) {
|
||||||
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
||||||
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
|
<!-- Manual escalation context banner -->
|
||||||
|
<div v-if="isManualEscalation" class="mb-3 px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-xs font-semibold text-orange-400">⚠ Требует ручного решения</span>
|
||||||
|
<span v-if="task.parent_task_id" class="text-xs text-gray-600">
|
||||||
|
— эскалация из
|
||||||
|
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
|
||||||
|
{{ task.parent_task_id }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-orange-300">{{ task.title }}</p>
|
||||||
|
<p v-if="task.brief?.description" class="text-xs text-gray-400 mt-1">{{ task.brief.description }}</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dangerous skip warning banner -->
|
||||||
|
<div v-if="task.dangerously_skipped" class="mb-3 px-3 py-2 border border-red-700 bg-red-950/40 rounded flex items-start gap-2">
|
||||||
|
<span class="text-red-400 text-base shrink-0">⚠</span>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
|
||||||
|
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
|
||||||
Brief: {{ JSON.stringify(task.brief) }}
|
Brief: {{ JSON.stringify(task.brief) }}
|
||||||
</div>
|
</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">
|
<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">
|
||||||
|
|
@ -361,6 +448,11 @@ async function changeStatus(newStatus: string) {
|
||||||
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
||||||
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
|
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="task.status === 'pending'"
|
||||||
|
@click="openEdit"
|
||||||
|
class="px-3 py-2 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
|
||||||
|
✎ Edit
|
||||||
|
</button>
|
||||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||||
@click="runPipeline"
|
@click="runPipeline"
|
||||||
:disabled="polling"
|
:disabled="polling"
|
||||||
|
|
@ -368,6 +460,13 @@ async function changeStatus(newStatus: string) {
|
||||||
<span v-if="polling" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
<span v-if="polling" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
{{ polling ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
{{ polling ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
||||||
|
@click="resolveManually"
|
||||||
|
:disabled="resolvingManually"
|
||||||
|
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 disabled:opacity-50">
|
||||||
|
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
|
{{ resolvingManually ? 'Сохраняем...' : '✓ Решить вручную' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Approve Modal -->
|
<!-- Approve Modal -->
|
||||||
|
|
@ -438,5 +537,31 @@ async function changeStatus(newStatus: string) {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Edit Modal (pending tasks only) -->
|
||||||
|
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
|
||||||
|
<form @submit.prevent="saveEdit" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Title</label>
|
||||||
|
<input v-model="editForm.title" required
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Brief</label>
|
||||||
|
<textarea v-model="editForm.briefText" rows="4" placeholder="Task description..."
|
||||||
|
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>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Priority (1–10)</label>
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
{{ editLoading ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue