diff --git a/agents/prompts/backlog_audit.md b/agents/prompts/backlog_audit.md new file mode 100644 index 0000000..cb6f277 --- /dev/null +++ b/agents/prompts/backlog_audit.md @@ -0,0 +1,44 @@ +You are a QA analyst performing a backlog audit. + +## Your task + +You receive a list of pending tasks and have access to the project's codebase. +For EACH task, determine: is the described feature/fix already implemented in the current code? + +## Rules + +- Check actual files, functions, tests — don't guess +- Look at: file existence, function names, imports, test coverage, recent git log +- Read relevant source files before deciding +- If the task describes a feature and you find matching code — it's done +- If the task describes a bug fix and you see the fix applied — it's done +- If you find partial implementation — mark as "unclear" +- If you can't find any related code — it's still pending + +## How to investigate + +1. Read package.json / pyproject.toml for project structure +2. List src/ directory to understand file layout +3. For each task, search for keywords in the codebase +4. Read relevant files to confirm implementation +5. Check tests if they exist + +## Output format + +Return ONLY valid JSON: + +```json +{ + "already_done": [ + {"id": "TASK-001", "reason": "Implemented in src/api.ts:42, function fetchData()"} + ], + "still_pending": [ + {"id": "TASK-003", "reason": "No matching code found in codebase"} + ], + "unclear": [ + {"id": "TASK-007", "reason": "Partial implementation in src/utils.ts, needs review"} + ] +} +``` + +Every task from the input list MUST appear in exactly one category. diff --git a/agents/runner.py b/agents/runner.py index d5c6c1a..6ae013a 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -4,6 +4,7 @@ Each agent = separate process with isolated context. """ import json +import os import sqlite3 import subprocess import time @@ -12,6 +13,7 @@ from typing import Any from core import models from core.context_builder import build_context, format_prompt +from core.hooks import run_hooks def run_agent( @@ -24,6 +26,7 @@ def run_agent( brief_override: str | None = None, dry_run: bool = False, allow_write: bool = False, + noninteractive: bool = False, ) -> dict: """Run a single Claude Code agent as a subprocess. @@ -64,7 +67,7 @@ def run_agent( # Run claude subprocess start = time.monotonic() result = _run_claude(prompt, model=model, working_dir=working_dir, - allow_write=allow_write) + allow_write=allow_write, noninteractive=noninteractive) duration = int(time.monotonic() - start) # Parse output — ensure output_text is always a string for DB storage @@ -109,6 +112,7 @@ def _run_claude( model: str = "sonnet", working_dir: str | None = None, allow_write: bool = False, + noninteractive: bool = False, ) -> dict: """Execute claude CLI as subprocess. Returns dict with output, returncode, etc.""" cmd = [ @@ -120,13 +124,17 @@ def _run_claude( if allow_write: cmd.append("--dangerously-skip-permissions") + is_noninteractive = noninteractive or os.environ.get("KIN_NONINTERACTIVE") == "1" + timeout = 300 if is_noninteractive else 600 + try: proc = subprocess.run( cmd, capture_output=True, text=True, - timeout=600, # 10 min max + timeout=timeout, cwd=working_dir, + stdin=subprocess.DEVNULL if is_noninteractive else None, ) except FileNotFoundError: return { @@ -137,7 +145,7 @@ def _run_claude( except subprocess.TimeoutExpired: return { "output": "", - "error": "Agent timed out after 600s", + "error": f"Agent timed out after {timeout}s", "returncode": 124, } @@ -203,6 +211,153 @@ def _try_parse_json(text: str) -> Any: return None +# --------------------------------------------------------------------------- +# Backlog audit +# --------------------------------------------------------------------------- + +PROMPTS_DIR = Path(__file__).parent / "prompts" + +_LANG_NAMES = {"ru": "Russian", "en": "English", "es": "Spanish", + "de": "German", "fr": "French"} + + +def run_audit( + conn: sqlite3.Connection, + project_id: str, + noninteractive: bool = False, + auto_apply: bool = False, +) -> dict: + """Audit pending tasks against the actual codebase. + + auto_apply=True: marks already_done tasks as done in DB. + auto_apply=False: returns results only (for API/GUI). + + Returns {success, already_done, still_pending, unclear, duration_seconds, ...} + """ + project = models.get_project(conn, project_id) + if not project: + return {"success": False, "error": f"Project '{project_id}' not found"} + + pending = models.list_tasks(conn, project_id=project_id, status="pending") + if not pending: + return { + "success": True, + "already_done": [], + "still_pending": [], + "unclear": [], + "message": "No pending tasks to audit", + } + + # Build prompt + prompt_path = PROMPTS_DIR / "backlog_audit.md" + template = prompt_path.read_text() if prompt_path.exists() else ( + "You are a QA analyst. Check if pending tasks are already done in the code." + ) + + task_list = [ + {"id": t["id"], "title": t["title"], "brief": t.get("brief")} + for t in pending + ] + + sections = [ + template, + "", + f"## Project: {project['id']} — {project['name']}", + ] + if project.get("tech_stack"): + sections.append(f"Tech stack: {', '.join(project['tech_stack'])}") + sections.append(f"Path: {project['path']}") + sections.append("") + sections.append(f"## Pending tasks ({len(task_list)}):") + sections.append(json.dumps(task_list, ensure_ascii=False, indent=2)) + sections.append("") + + language = project.get("language", "ru") + lang_name = _LANG_NAMES.get(language, language) + sections.append("## Language") + sections.append(f"ALWAYS respond in {lang_name}.") + sections.append("") + + prompt = "\n".join(sections) + + # Determine working dir + working_dir = None + project_path = Path(project["path"]).expanduser() + if project_path.is_dir(): + working_dir = str(project_path) + + # Run agent — allow_write=True so claude can use Read/Bash tools + # without interactive permission prompts (critical for noninteractive mode) + start = time.monotonic() + result = _run_claude(prompt, model="sonnet", working_dir=working_dir, + allow_write=True, noninteractive=noninteractive) + duration = int(time.monotonic() - start) + + raw_output = result.get("output", "") + if not isinstance(raw_output, str): + raw_output = json.dumps(raw_output, ensure_ascii=False) + success = result["returncode"] == 0 + + # Log to agent_logs + models.log_agent_run( + conn, + project_id=project_id, + task_id=None, + agent_role="backlog_audit", + action="audit", + input_summary=f"project={project_id}, pending_tasks={len(pending)}", + output_summary=raw_output or None, + tokens_used=result.get("tokens_used"), + model="sonnet", + cost_usd=result.get("cost_usd"), + success=success, + error_message=result.get("error") if not success else None, + duration_seconds=duration, + ) + + if not success: + return { + "success": False, + "error": result.get("error", "Agent failed"), + "raw_output": raw_output, + "duration_seconds": duration, + } + + # Parse structured output + parsed = _try_parse_json(raw_output) + if not isinstance(parsed, dict): + return { + "success": False, + "error": "Agent returned non-JSON output", + "raw_output": raw_output, + "duration_seconds": duration, + } + + already_done = parsed.get("already_done", []) + + # Auto-apply: mark already_done tasks as done in DB + applied = [] + if auto_apply and already_done: + for item in already_done: + tid = item.get("id") + if tid: + t = models.get_task(conn, tid) + if t and t["project_id"] == project_id and t["status"] == "pending": + models.update_task(conn, tid, status="done") + applied.append(tid) + + return { + "success": True, + "already_done": already_done, + "still_pending": parsed.get("still_pending", []), + "unclear": parsed.get("unclear", []), + "applied": applied, + "duration_seconds": duration, + "tokens_used": result.get("tokens_used"), + "cost_usd": result.get("cost_usd"), + } + + # --------------------------------------------------------------------------- # Pipeline executor # --------------------------------------------------------------------------- @@ -213,6 +368,7 @@ def run_pipeline( steps: list[dict], dry_run: bool = False, allow_write: bool = False, + noninteractive: bool = False, ) -> dict: """Execute a multi-step pipeline of agents. @@ -260,6 +416,7 @@ def run_pipeline( brief_override=brief, dry_run=dry_run, allow_write=allow_write, + noninteractive=noninteractive, ) results.append(result) @@ -309,6 +466,14 @@ def run_pipeline( ) models.update_task(conn, task_id, status="review") + # Run post-pipeline hooks (failures don't affect pipeline status) + task_modules = models.get_modules(conn, project_id) + try: + run_hooks(conn, project_id, task_id, + event="pipeline_completed", task_modules=task_modules) + except Exception: + pass # Hook errors must never block pipeline completion + return { "success": True, "steps_completed": len(steps), diff --git a/cli/main.py b/cli/main.py index 8ed3281..f11f82d 100644 --- a/cli/main.py +++ b/cli/main.py @@ -4,6 +4,7 @@ Uses core.models for all data access, never raw SQL. """ import json +import os import sys from pathlib import Path @@ -14,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from core.db import init_db from core import models +from core import hooks as hooks_module from agents.bootstrap import ( detect_tech_stack, detect_modules, extract_decisions_from_claude_md, find_vault_root, scan_obsidian, format_preview, save_to_db, @@ -219,6 +221,32 @@ def task_show(ctx, id): click.echo(f" Updated: {t['updated_at']}") +@task.command("update") +@click.argument("task_id") +@click.option("--status", type=click.Choice( + ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]), + default=None, help="New status") +@click.option("--priority", type=int, default=None, help="New priority (1-10)") +@click.pass_context +def task_update(ctx, task_id, status, priority): + """Update a task's status or priority.""" + conn = ctx.obj["conn"] + t = models.get_task(conn, task_id) + if not t: + click.echo(f"Task '{task_id}' not found.", err=True) + raise SystemExit(1) + fields = {} + if status is not None: + fields["status"] = status + if priority is not None: + fields["priority"] = priority + if not fields: + click.echo("Nothing to update. Use --status or --priority.", err=True) + raise SystemExit(1) + updated = models.update_task(conn, task_id, **fields) + click.echo(f"Updated {updated['id']}: status={updated['status']}, priority={updated['priority']}") + + # =========================================================================== # decision # =========================================================================== @@ -481,8 +509,9 @@ def approve_task(ctx, task_id, followup, decision_text): @cli.command("run") @click.argument("task_id") @click.option("--dry-run", is_flag=True, help="Show pipeline plan without executing") +@click.option("--allow-write", is_flag=True, help="Allow agents to write files (skip permissions)") @click.pass_context -def run_task(ctx, task_id, dry_run): +def run_task(ctx, task_id, dry_run, allow_write): """Run a task through the agent pipeline. PM decomposes the task into specialist steps, then the pipeline executes. @@ -497,6 +526,7 @@ def run_task(ctx, task_id, dry_run): raise SystemExit(1) project_id = task["project_id"] + is_noninteractive = os.environ.get("KIN_NONINTERACTIVE") == "1" click.echo(f"Task: {task['id']} — {task['title']}") # Step 1: PM decomposes @@ -504,6 +534,7 @@ def run_task(ctx, task_id, dry_run): pm_result = run_agent( conn, "pm", task_id, project_id, model="sonnet", dry_run=dry_run, + allow_write=allow_write, noninteractive=is_noninteractive, ) if dry_run: @@ -537,13 +568,17 @@ def run_task(ctx, task_id, dry_run): for i, step in enumerate(pipeline_steps, 1): click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}") - if not click.confirm("\nExecute pipeline?"): + if is_noninteractive: + click.echo("\n[non-interactive] Auto-executing pipeline...") + elif not click.confirm("\nExecute pipeline?"): click.echo("Aborted.") return # Step 2: Execute pipeline click.echo("\nExecuting pipeline...") - result = run_pipeline(conn, task_id, pipeline_steps) + result = run_pipeline(conn, task_id, pipeline_steps, + allow_write=allow_write, + noninteractive=is_noninteractive) if result["success"]: click.echo(f"\nPipeline completed: {result['steps_completed']} steps") @@ -556,6 +591,71 @@ def run_task(ctx, task_id, dry_run): click.echo(f"Duration: {result['total_duration_seconds']}s") +# =========================================================================== +# audit +# =========================================================================== + +@cli.command("audit") +@click.argument("project_id") +@click.pass_context +def audit_backlog(ctx, project_id): + """Audit pending tasks — check which are already implemented in the code.""" + from agents.runner import run_audit + + conn = ctx.obj["conn"] + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + + pending = models.list_tasks(conn, project_id=project_id, status="pending") + if not pending: + click.echo("No pending tasks to audit.") + return + + click.echo(f"Auditing {len(pending)} pending tasks for {project_id}...") + # First pass: get results only (no auto_apply yet) + result = run_audit(conn, project_id) + + if not result["success"]: + click.echo(f"Audit failed: {result.get('error', 'unknown')}", err=True) + raise SystemExit(1) + + done = result.get("already_done", []) + still = result.get("still_pending", []) + unclear = result.get("unclear", []) + + if done: + click.echo(f"\nAlready done ({len(done)}):") + for item in done: + click.echo(f" {item['id']}: {item.get('reason', '')}") + + if still: + click.echo(f"\nStill pending ({len(still)}):") + for item in still: + click.echo(f" {item['id']}: {item.get('reason', '')}") + + if unclear: + click.echo(f"\nUnclear ({len(unclear)}):") + for item in unclear: + click.echo(f" {item['id']}: {item.get('reason', '')}") + + if result.get("cost_usd"): + click.echo(f"\nCost: ${result['cost_usd']:.4f}") + if result.get("duration_seconds"): + click.echo(f"Duration: {result['duration_seconds']}s") + + # Apply: mark tasks as done after user confirmation + if done and click.confirm(f"\nMark {len(done)} tasks as done?"): + for item in done: + tid = item.get("id") + if tid: + t = models.get_task(conn, tid) + if t and t["project_id"] == project_id and t["status"] == "pending": + models.update_task(conn, tid, status="done") + click.echo(f"Marked {len(done)} tasks as done.") + + # =========================================================================== # bootstrap # =========================================================================== @@ -621,6 +721,135 @@ def bootstrap(ctx, path, project_id, name, vault_path, yes): f"{dec_count} decisions, {task_count} tasks.") +# =========================================================================== +# hook +# =========================================================================== + +@cli.group() +def hook(): + """Manage post-pipeline hooks.""" + + +@hook.command("add") +@click.option("--project", "project_id", required=True, help="Project ID") +@click.option("--name", required=True, help="Hook name") +@click.option("--event", required=True, help="Event: pipeline_completed, step_completed") +@click.option("--command", required=True, help="Shell command to run") +@click.option("--module-path", default=None, help="Trigger only when module path matches (fnmatch)") +@click.option("--working-dir", default=None, help="Working directory for the command") +@click.pass_context +def hook_add(ctx, project_id, name, event, command, module_path, working_dir): + """Add a post-pipeline hook to a project.""" + conn = ctx.obj["conn"] + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + h = hooks_module.create_hook( + conn, project_id, name, event, command, + trigger_module_path=module_path, + working_dir=working_dir, + ) + click.echo(f"Created hook: #{h['id']} {h['name']} [{h['event']}] → {h['command']}") + + +@hook.command("list") +@click.option("--project", "project_id", required=True, help="Project ID") +@click.pass_context +def hook_list(ctx, project_id): + """List hooks for a project.""" + conn = ctx.obj["conn"] + hs = hooks_module.get_hooks(conn, project_id, enabled_only=False) + if not hs: + click.echo("No hooks found.") + return + rows = [ + [str(h["id"]), h["name"], h["event"], + h["command"][:40], h.get("trigger_module_path") or "-", + "yes" if h["enabled"] else "no"] + for h in hs + ] + click.echo(_table(["ID", "Name", "Event", "Command", "Module", "Enabled"], rows)) + + +@hook.command("remove") +@click.argument("hook_id", type=int) +@click.pass_context +def hook_remove(ctx, hook_id): + """Remove a hook by ID.""" + conn = ctx.obj["conn"] + row = conn.execute("SELECT * FROM hooks WHERE id = ?", (hook_id,)).fetchone() + if not row: + click.echo(f"Hook #{hook_id} not found.", err=True) + raise SystemExit(1) + hooks_module.delete_hook(conn, hook_id) + click.echo(f"Removed hook #{hook_id}.") + + +@hook.command("logs") +@click.option("--project", "project_id", required=True, help="Project ID") +@click.option("--limit", default=20, help="Number of log entries (default: 20)") +@click.pass_context +def hook_logs(ctx, project_id, limit): + """Show recent hook execution logs for a project.""" + conn = ctx.obj["conn"] + logs = hooks_module.get_hook_logs(conn, project_id=project_id, limit=limit) + if not logs: + click.echo("No hook logs found.") + return + rows = [ + [str(l["hook_id"]), l.get("task_id") or "-", + "ok" if l["success"] else "fail", + str(l["exit_code"]), + f"{l['duration_seconds']:.1f}s", + l["created_at"][:19]] + for l in logs + ] + click.echo(_table(["Hook", "Task", "Result", "Exit", "Duration", "Time"], rows)) + + +@hook.command("setup") +@click.option("--project", "project_id", required=True, help="Project ID") +@click.option("--scripts-dir", default=None, + help="Directory with hook scripts (default: /scripts)") +@click.pass_context +def hook_setup(ctx, project_id, scripts_dir): + """Register standard hooks for a project. + + Currently registers: rebuild-frontend (fires on web/frontend/* changes). + Idempotent — skips hooks that already exist. + """ + conn = ctx.obj["conn"] + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + + if scripts_dir is None: + scripts_dir = str(Path(__file__).parent.parent / "scripts") + + existing_names = {h["name"] for h in hooks_module.get_hooks(conn, project_id, enabled_only=False)} + created = [] + + if "rebuild-frontend" not in existing_names: + rebuild_cmd = str(Path(scripts_dir) / "rebuild-frontend.sh") + hooks_module.create_hook( + conn, project_id, + name="rebuild-frontend", + event="pipeline_completed", + command=rebuild_cmd, + trigger_module_path="web/frontend/*", + working_dir=p.get("path"), + timeout_seconds=300, + ) + created.append("rebuild-frontend") + else: + click.echo("Hook 'rebuild-frontend' already exists, skipping.") + + if created: + click.echo(f"Registered hooks: {', '.join(created)}") + + # =========================================================================== # Entry point # =========================================================================== diff --git a/core/db.py b/core/db.py index 284c66c..f3f26bc 100644 --- a/core/db.py +++ b/core/db.py @@ -103,6 +103,35 @@ CREATE TABLE IF NOT EXISTS pipelines ( completed_at DATETIME ); +-- Post-pipeline хуки +CREATE TABLE IF NOT EXISTS hooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + event TEXT NOT NULL, + trigger_module_path TEXT, + trigger_module_type TEXT, + command TEXT NOT NULL, + working_dir TEXT, + timeout_seconds INTEGER DEFAULT 120, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) +); + +-- Лог выполнений хуков +CREATE TABLE IF NOT EXISTS hook_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL REFERENCES hooks(id), + project_id TEXT NOT NULL REFERENCES projects(id), + task_id TEXT, + success INTEGER NOT NULL, + exit_code INTEGER, + output TEXT, + error TEXT, + duration_seconds REAL, + created_at TEXT DEFAULT (datetime('now')) +); + -- Кросс-проектные зависимости CREATE TABLE IF NOT EXISTS project_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/core/hooks.py b/core/hooks.py new file mode 100644 index 0000000..1b9775b --- /dev/null +++ b/core/hooks.py @@ -0,0 +1,224 @@ +""" +Kin — post-pipeline hooks. +Runs configured commands (e.g. npm run build) after pipeline completion. +""" + +import fnmatch +import sqlite3 +import subprocess +import time +from dataclasses import dataclass +from typing import Any + + +@dataclass +class HookResult: + hook_id: int + name: str + success: bool + exit_code: int + output: str + error: str + duration_seconds: float + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def create_hook( + conn: sqlite3.Connection, + project_id: str, + name: str, + event: str, + command: str, + trigger_module_path: str | None = None, + trigger_module_type: str | None = None, + working_dir: str | None = None, + timeout_seconds: int = 120, +) -> dict: + """Create a hook and return it as dict.""" + cur = conn.execute( + """INSERT INTO hooks (project_id, name, event, trigger_module_path, + trigger_module_type, command, working_dir, timeout_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (project_id, name, event, trigger_module_path, trigger_module_type, + command, working_dir, timeout_seconds), + ) + conn.commit() + return _get_hook(conn, cur.lastrowid) + + +def get_hooks( + conn: sqlite3.Connection, + project_id: str, + event: str | None = None, + enabled_only: bool = True, +) -> list[dict]: + """Get hooks for a project, optionally filtered by event.""" + query = "SELECT * FROM hooks WHERE project_id = ?" + params: list[Any] = [project_id] + if event: + query += " AND event = ?" + params.append(event) + if enabled_only: + query += " AND enabled = 1" + query += " ORDER BY id" + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + + +def update_hook(conn: sqlite3.Connection, hook_id: int, **kwargs) -> None: + """Update hook fields.""" + if not kwargs: + return + sets = ", ".join(f"{k} = ?" for k in kwargs) + vals = list(kwargs.values()) + [hook_id] + conn.execute(f"UPDATE hooks SET {sets} WHERE id = ?", vals) + conn.commit() + + +def delete_hook(conn: sqlite3.Connection, hook_id: int) -> None: + """Delete a hook by id.""" + conn.execute("DELETE FROM hooks WHERE id = ?", (hook_id,)) + conn.commit() + + +def get_hook_logs( + conn: sqlite3.Connection, + project_id: str | None = None, + hook_id: int | None = None, + limit: int = 50, +) -> list[dict]: + """Get hook execution logs.""" + query = "SELECT * FROM hook_logs WHERE 1=1" + params: list[Any] = [] + if project_id: + query += " AND project_id = ?" + params.append(project_id) + if hook_id is not None: + query += " AND hook_id = ?" + params.append(hook_id) + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + +def run_hooks( + conn: sqlite3.Connection, + project_id: str, + task_id: str | None, + event: str, + task_modules: list[dict], +) -> list[HookResult]: + """Run matching hooks for the given event and module list. + + Never raises — hook failures are logged but don't affect the pipeline. + """ + hooks = get_hooks(conn, project_id, event=event) + results = [] + for hook in hooks: + if hook["trigger_module_path"] is not None: + pattern = hook["trigger_module_path"] + matched = any( + fnmatch.fnmatch(m.get("path", ""), pattern) + for m in task_modules + ) + if not matched: + continue + + result = _execute_hook(conn, hook, project_id, task_id) + results.append(result) + return results + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _get_hook(conn: sqlite3.Connection, hook_id: int) -> dict: + row = conn.execute("SELECT * FROM hooks WHERE id = ?", (hook_id,)).fetchone() + return dict(row) if row else {} + + +def _execute_hook( + conn: sqlite3.Connection, + hook: dict, + project_id: str, + task_id: str | None, +) -> HookResult: + """Run a single hook command and log the result.""" + start = time.monotonic() + output = "" + error = "" + exit_code = -1 + success = False + + try: + proc = subprocess.run( + hook["command"], + shell=True, + cwd=hook.get("working_dir") or None, + capture_output=True, + text=True, + timeout=hook.get("timeout_seconds") or 120, + ) + output = proc.stdout or "" + error = proc.stderr or "" + exit_code = proc.returncode + success = exit_code == 0 + except subprocess.TimeoutExpired: + error = f"Hook timed out after {hook.get('timeout_seconds', 120)}s" + exit_code = 124 + except Exception as e: + error = str(e) + exit_code = -1 + + duration = time.monotonic() - start + _log_hook_run( + conn, + hook_id=hook["id"], + project_id=project_id, + task_id=task_id, + success=success, + exit_code=exit_code, + output=output, + error=error, + duration_seconds=duration, + ) + + return HookResult( + hook_id=hook["id"], + name=hook["name"], + success=success, + exit_code=exit_code, + output=output, + error=error, + duration_seconds=duration, + ) + + +def _log_hook_run( + conn: sqlite3.Connection, + hook_id: int, + project_id: str, + task_id: str | None, + success: bool, + exit_code: int, + output: str, + error: str, + duration_seconds: float, +) -> None: + conn.execute( + """INSERT INTO hook_logs (hook_id, project_id, task_id, success, + exit_code, output, error, duration_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (hook_id, project_id, task_id, int(success), exit_code, + output, error, duration_seconds), + ) + conn.commit() diff --git a/scripts/rebuild-frontend.sh b/scripts/rebuild-frontend.sh new file mode 100755 index 0000000..19b9ea6 --- /dev/null +++ b/scripts/rebuild-frontend.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# rebuild-frontend — post-pipeline hook for Kin. +# +# Triggered automatically after pipeline_completed when web/frontend/* modules +# were touched. Builds the Vue 3 frontend and restarts the API server. +# +# Registration (one-time): +# kin hook setup --project + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +FRONTEND_DIR="$PROJECT_ROOT/web/frontend" + +echo "[rebuild-frontend] Building frontend in $FRONTEND_DIR ..." +cd "$FRONTEND_DIR" +npm run build +echo "[rebuild-frontend] Build complete." + +# Restart API server if it's currently running. +# pgrep returns 1 if no match; || true prevents set -e from exiting. +API_PID=$(pgrep -f "uvicorn web.api" 2>/dev/null || true) +if [ -n "$API_PID" ]; then + echo "[rebuild-frontend] Stopping API server (PID: $API_PID) ..." + kill "$API_PID" 2>/dev/null || true + # Wait for port 8420 to free up (up to 5 s) + for i in $(seq 1 5); do + pgrep -f "uvicorn web.api" > /dev/null 2>&1 || break + sleep 1 + done + echo "[rebuild-frontend] Starting API server ..." + cd "$PROJECT_ROOT" + nohup python -m uvicorn web.api:app --port 8420 >> /tmp/kin-api.log 2>&1 & + echo "[rebuild-frontend] API server started (PID: $!)." +else + echo "[rebuild-frontend] API server not running; skipping restart." +fi diff --git a/tests/test_api.py b/tests/test_api.py index 8d7ea42..d8939d1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -173,6 +173,24 @@ def test_run_not_found(client): assert r.status_code == 404 +def test_run_with_allow_write(client): + """POST /run with allow_write=true should be accepted.""" + r = client.post("/api/tasks/P1-001/run", json={"allow_write": True}) + assert r.status_code == 202 + + +def test_run_with_empty_body(client): + """POST /run with empty JSON body should default allow_write=false.""" + r = client.post("/api/tasks/P1-001/run", json={}) + assert r.status_code == 202 + + +def test_run_without_body(client): + """POST /run without body should be backwards-compatible.""" + r = client.post("/api/tasks/P1-001/run") + assert r.status_code == 202 + + def test_project_summary_includes_review(client): from core.db import init_db from core import models @@ -183,3 +201,76 @@ def test_project_summary_includes_review(client): r = client.get("/api/projects") projects = r.json() assert projects[0]["review_tasks"] == 1 + + +def test_audit_not_found(client): + r = client.post("/api/projects/NOPE/audit") + assert r.status_code == 404 + + +def test_audit_apply(client): + """POST /audit/apply should mark tasks as done.""" + r = client.post("/api/projects/p1/audit/apply", + json={"task_ids": ["P1-001"]}) + assert r.status_code == 200 + assert r.json()["count"] == 1 + assert "P1-001" in r.json()["updated"] + + # Verify task is done + r = client.get("/api/tasks/P1-001") + assert r.json()["status"] == "done" + + +def test_audit_apply_not_found(client): + r = client.post("/api/projects/NOPE/audit/apply", + json={"task_ids": ["P1-001"]}) + assert r.status_code == 404 + + +def test_audit_apply_wrong_project(client): + """Tasks not belonging to the project should be skipped.""" + r = client.post("/api/projects/p1/audit/apply", + json={"task_ids": ["WRONG-001"]}) + assert r.status_code == 200 + assert r.json()["count"] == 0 + + +# --------------------------------------------------------------------------- +# PATCH /api/tasks/{task_id} — смена статуса +# --------------------------------------------------------------------------- + +def test_patch_task_status(client): + """PATCH должен обновить статус и вернуть задачу.""" + r = client.patch("/api/tasks/P1-001", json={"status": "review"}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "review" + assert data["id"] == "P1-001" + + +def test_patch_task_status_persisted(client): + """После PATCH повторный GET должен возвращать новый статус.""" + client.patch("/api/tasks/P1-001", json={"status": "blocked"}) + r = client.get("/api/tasks/P1-001") + assert r.status_code == 200 + assert r.json()["status"] == "blocked" + + +@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "cancelled"]) +def test_patch_task_all_valid_statuses(client, status): + """Все 6 допустимых статусов должны приниматься.""" + r = client.patch("/api/tasks/P1-001", json={"status": status}) + assert r.status_code == 200 + assert r.json()["status"] == status + + +def test_patch_task_invalid_status(client): + """Недопустимый статус → 400.""" + r = client.patch("/api/tasks/P1-001", json={"status": "flying"}) + assert r.status_code == 400 + + +def test_patch_task_not_found(client): + """Несуществующая задача → 404.""" + r = client.patch("/api/tasks/NOPE-999", json={"status": "done"}) + assert r.status_code == 404 diff --git a/tests/test_cli.py b/tests/test_cli.py index b19551b..f056f6d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -205,3 +205,150 @@ def test_cost_with_data(runner): assert r.exit_code == 0 assert "p1" in r.output assert "$0.1000" in r.output + + +# =========================================================================== +# task update +# =========================================================================== + +@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]) +def test_task_update_status(runner, status): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["task", "add", "p1", "Fix bug"]) + r = invoke(runner, ["task", "update", "P1-001", "--status", status]) + assert r.exit_code == 0 + assert status in r.output + + r = invoke(runner, ["task", "show", "P1-001"]) + assert status in r.output + + +def test_task_update_priority(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["task", "add", "p1", "Fix bug"]) + r = invoke(runner, ["task", "update", "P1-001", "--priority", "1"]) + assert r.exit_code == 0 + assert "priority=1" in r.output + + +def test_task_update_not_found(runner): + r = invoke(runner, ["task", "update", "NOPE", "--status", "done"]) + assert r.exit_code != 0 + + +def test_task_update_no_fields(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["task", "add", "p1", "Fix bug"]) + r = invoke(runner, ["task", "update", "P1-001"]) + assert r.exit_code != 0 + + +# =========================================================================== +# hook +# =========================================================================== + +def test_hook_add_and_list(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["hook", "add", + "--project", "p1", + "--name", "rebuild", + "--event", "pipeline_completed", + "--command", "npm run build"]) + assert r.exit_code == 0 + assert "rebuild" in r.output + assert "pipeline_completed" in r.output + + r = invoke(runner, ["hook", "list", "--project", "p1"]) + assert r.exit_code == 0 + assert "rebuild" in r.output + assert "npm run build" in r.output + + +def test_hook_add_with_module_path(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["hook", "add", + "--project", "p1", + "--name", "fe-build", + "--event", "pipeline_completed", + "--command", "make build", + "--module-path", "web/frontend/*", + "--working-dir", "/tmp"]) + assert r.exit_code == 0 + r = invoke(runner, ["hook", "list", "--project", "p1"]) + assert "web/frontend/*" in r.output + + +def test_hook_add_project_not_found(runner): + r = invoke(runner, ["hook", "add", + "--project", "nope", + "--name", "x", + "--event", "pipeline_completed", + "--command", "echo hi"]) + assert r.exit_code == 1 + assert "not found" in r.output + + +def test_hook_list_empty(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["hook", "list", "--project", "p1"]) + assert r.exit_code == 0 + assert "No hooks" in r.output + + +def test_hook_remove(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["hook", "add", + "--project", "p1", + "--name", "rebuild", + "--event", "pipeline_completed", + "--command", "make"]) + r = invoke(runner, ["hook", "remove", "1"]) + assert r.exit_code == 0 + assert "Removed" in r.output + + r = invoke(runner, ["hook", "list", "--project", "p1"]) + assert "No hooks" in r.output + + +def test_hook_remove_not_found(runner): + r = invoke(runner, ["hook", "remove", "999"]) + assert r.exit_code == 1 + assert "not found" in r.output + + +def test_hook_logs_empty(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["hook", "logs", "--project", "p1"]) + assert r.exit_code == 0 + assert "No hook logs" in r.output + + +def test_hook_setup_registers_rebuild_frontend(runner, tmp_path): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["hook", "setup", "--project", "p1", + "--scripts-dir", str(tmp_path)]) + assert r.exit_code == 0 + assert "rebuild-frontend" in r.output + + r = invoke(runner, ["hook", "list", "--project", "p1"]) + assert r.exit_code == 0 + assert "rebuild-frontend" in r.output + assert "web/frontend/*" in r.output + + +def test_hook_setup_idempotent(runner, tmp_path): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)]) + r = invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)]) + assert r.exit_code == 0 + assert "already exists" in r.output + + r = invoke(runner, ["hook", "list", "--project", "p1"]) + # Only one hook, not duplicated + assert r.output.count("rebuild-frontend") == 1 + + +def test_hook_setup_project_not_found(runner): + r = invoke(runner, ["hook", "setup", "--project", "nope"]) + assert r.exit_code == 1 + assert "not found" in r.output diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..2778ee0 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,275 @@ +"""Tests for core/hooks.py — post-pipeline hook execution.""" + +import subprocess +import pytest +from unittest.mock import patch, MagicMock + +from core.db import init_db +from core import models +from core.hooks import ( + create_hook, get_hooks, update_hook, delete_hook, + run_hooks, get_hook_logs, HookResult, +) + + +@pytest.fixture +def conn(): + c = init_db(":memory:") + models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek", + tech_stack=["vue3"]) + models.create_task(c, "VDOL-001", "vdol", "Fix bug") + yield c + c.close() + + +@pytest.fixture +def frontend_hook(conn): + return create_hook( + conn, + project_id="vdol", + name="rebuild-frontend", + event="pipeline_completed", + command="npm run build", + trigger_module_path="web/frontend/*", + working_dir="/tmp", + timeout_seconds=60, + ) + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +class TestCrud: + def test_create_hook(self, conn): + hook = create_hook(conn, "vdol", "my-hook", "pipeline_completed", "make build") + assert hook["id"] is not None + assert hook["project_id"] == "vdol" + assert hook["name"] == "my-hook" + assert hook["command"] == "make build" + assert hook["enabled"] == 1 + + def test_get_hooks_by_project(self, conn, frontend_hook): + hooks = get_hooks(conn, "vdol") + assert len(hooks) == 1 + assert hooks[0]["name"] == "rebuild-frontend" + + def test_get_hooks_filter_by_event(self, conn, frontend_hook): + create_hook(conn, "vdol", "other", "step_completed", "echo done") + hooks = get_hooks(conn, "vdol", event="pipeline_completed") + assert len(hooks) == 1 + assert hooks[0]["name"] == "rebuild-frontend" + + def test_get_hooks_disabled_excluded(self, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], enabled=0) + hooks = get_hooks(conn, "vdol", enabled_only=True) + assert len(hooks) == 0 + + def test_get_hooks_disabled_included_when_flag_off(self, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], enabled=0) + hooks = get_hooks(conn, "vdol", enabled_only=False) + assert len(hooks) == 1 + + def test_update_hook(self, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], command="npm run build:prod", timeout_seconds=180) + hooks = get_hooks(conn, "vdol", enabled_only=False) + assert hooks[0]["command"] == "npm run build:prod" + assert hooks[0]["timeout_seconds"] == 180 + + def test_delete_hook(self, conn, frontend_hook): + delete_hook(conn, frontend_hook["id"]) + hooks = get_hooks(conn, "vdol", enabled_only=False) + assert len(hooks) == 0 + + def test_get_hooks_wrong_project(self, conn, frontend_hook): + hooks = get_hooks(conn, "nonexistent") + assert hooks == [] + + +# --------------------------------------------------------------------------- +# Module matching (fnmatch) +# --------------------------------------------------------------------------- + +class TestModuleMatching: + def _make_proc(self, returncode=0, stdout="ok", stderr=""): + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + @patch("core.hooks.subprocess.run") + def test_hook_runs_when_module_matches(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + assert len(results) == 1 + assert results[0].name == "rebuild-frontend" + mock_run.assert_called_once() + + @patch("core.hooks.subprocess.run") + def test_hook_skipped_when_no_module_matches(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "core/models.py", "name": "models"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + assert len(results) == 0 + mock_run.assert_not_called() + + @patch("core.hooks.subprocess.run") + def test_hook_runs_without_module_filter(self, mock_run, conn): + mock_run.return_value = self._make_proc() + create_hook(conn, "vdol", "always-run", "pipeline_completed", "echo done") + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=[]) + assert len(results) == 1 + + @patch("core.hooks.subprocess.run") + def test_hook_skipped_on_wrong_event(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="step_completed", task_modules=modules) + assert len(results) == 0 + + @patch("core.hooks.subprocess.run") + def test_hook_skipped_when_disabled(self, mock_run, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], enabled=0) + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + assert len(results) == 0 + + +# --------------------------------------------------------------------------- +# Execution and logging +# --------------------------------------------------------------------------- + +class TestExecution: + def _make_proc(self, returncode=0, stdout="built!", stderr=""): + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + @patch("core.hooks.subprocess.run") + def test_successful_hook_result(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=0, stdout="built!") + modules = [{"path": "web/frontend/index.ts", "name": "index"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is True + assert r.exit_code == 0 + assert r.output == "built!" + + @patch("core.hooks.subprocess.run") + def test_failed_hook_result(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=1, stderr="Module not found") + modules = [{"path": "web/frontend/index.ts", "name": "index"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is False + assert r.exit_code == 1 + assert "Module not found" in r.error + + @patch("core.hooks.subprocess.run") + def test_hook_run_logged_to_db(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=0, stdout="ok") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + + logs = get_hook_logs(conn, project_id="vdol") + assert len(logs) == 1 + assert logs[0]["hook_id"] == frontend_hook["id"] + assert logs[0]["task_id"] == "VDOL-001" + assert logs[0]["success"] == 1 + assert logs[0]["exit_code"] == 0 + assert logs[0]["output"] == "ok" + + @patch("core.hooks.subprocess.run") + def test_failed_hook_logged_to_db(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=2, stderr="error!") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + + logs = get_hook_logs(conn, project_id="vdol") + assert logs[0]["success"] == 0 + assert logs[0]["exit_code"] == 2 + assert "error!" in logs[0]["error"] + + @patch("core.hooks.subprocess.run") + def test_timeout_handled_gracefully(self, mock_run, conn, frontend_hook): + mock_run.side_effect = subprocess.TimeoutExpired(cmd="npm run build", timeout=60) + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + # Must not raise + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is False + assert r.exit_code == 124 + assert "timed out" in r.error + + logs = get_hook_logs(conn, project_id="vdol") + assert logs[0]["success"] == 0 + + @patch("core.hooks.subprocess.run") + def test_exception_handled_gracefully(self, mock_run, conn, frontend_hook): + mock_run.side_effect = OSError("npm not found") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is False + assert "npm not found" in r.error + + @patch("core.hooks.subprocess.run") + def test_command_uses_working_dir(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + call_kwargs = mock_run.call_args[1] + assert call_kwargs["cwd"] == "/tmp" + + @patch("core.hooks.subprocess.run") + def test_shell_true_used(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + call_kwargs = mock_run.call_args[1] + assert call_kwargs["shell"] is True + + +# --------------------------------------------------------------------------- +# get_hook_logs filters +# --------------------------------------------------------------------------- + +class TestGetHookLogs: + @patch("core.hooks.subprocess.run") + def test_filter_by_hook_id(self, mock_run, conn, frontend_hook): + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + hook2 = create_hook(conn, "vdol", "second", "pipeline_completed", "echo 2") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + + logs = get_hook_logs(conn, hook_id=frontend_hook["id"]) + assert all(l["hook_id"] == frontend_hook["id"] for l in logs) + + @patch("core.hooks.subprocess.run") + def test_limit_respected(self, mock_run, conn, frontend_hook): + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + for _ in range(5): + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + logs = get_hook_logs(conn, project_id="vdol", limit=3) + assert len(logs) == 3 diff --git a/tests/test_no_connection_artifacts.py b/tests/test_no_connection_artifacts.py new file mode 100644 index 0000000..569de53 --- /dev/null +++ b/tests/test_no_connection_artifacts.py @@ -0,0 +1,112 @@ +"""Regression test — KIN-009. + +Проверяет, что в рабочей директории проекта НЕ создаются файлы с именами, +содержащими 'sqlite3.Connection'. Такие артефакты появляются, если путь к БД +формируется передачей объекта sqlite3.Connection вместо строки/Path в +sqlite3.connect(). +""" + +import os +import sqlite3 +from pathlib import Path + +import pytest + +# Корень проекта — три уровня вверх от этого файла (tests/ → kin/) +PROJECT_ROOT = Path(__file__).parent.parent + + +def _find_connection_artifacts(search_dir: Path) -> list[Path]: + """Рекурсивно ищет файлы, чьё имя содержит 'sqlite3.Connection'.""" + found = [] + try: + for entry in search_dir.rglob("*"): + if entry.is_file() and "sqlite3.Connection" in entry.name: + found.append(entry) + except PermissionError: + pass + return found + + +# --------------------------------------------------------------------------- +# Тест 1: статическая проверка — артефактов нет прямо сейчас +# --------------------------------------------------------------------------- + +def test_no_connection_artifact_files_in_project(): + """В рабочей директории проекта не должно быть файлов с 'sqlite3.Connection' в имени.""" + artifacts = _find_connection_artifacts(PROJECT_ROOT) + assert artifacts == [], ( + f"Найдены файлы-артефакты sqlite3.Connection:\n" + + "\n".join(f" {p}" for p in artifacts) + ) + + +def test_no_connection_artifact_files_in_kin_home(): + """В ~/.kin/ тоже не должно быть таких файлов.""" + kin_home = Path.home() / ".kin" + if not kin_home.exists(): + pytest.skip("~/.kin не существует") + artifacts = _find_connection_artifacts(kin_home) + assert artifacts == [], ( + f"Найдены файлы-артефакты sqlite3.Connection в ~/.kin:\n" + + "\n".join(f" {p}" for p in artifacts) + ) + + +# --------------------------------------------------------------------------- +# Тест 2: динамическая проверка — init_db не создаёт артефактов в tmp_path +# --------------------------------------------------------------------------- + +def test_init_db_does_not_create_connection_artifact(tmp_path): + """init_db() должен создавать файл с нормальным именем, а не 'sqlite3.Connection ...'.""" + from core.db import init_db + + db_file = tmp_path / "test.db" + conn = init_db(db_file) + conn.close() + + artifacts = _find_connection_artifacts(tmp_path) + assert artifacts == [], ( + "init_db() создал файл с именем, содержащим 'sqlite3.Connection':\n" + + "\n".join(f" {p}" for p in artifacts) + ) + # Убедимся, что файл БД реально создан с правильным именем + assert db_file.exists(), "Файл БД должен существовать" + + +# --------------------------------------------------------------------------- +# Тест 3: воспроизводит сценарий утечки — connect(conn) вместо connect(path) +# --------------------------------------------------------------------------- + +def test_init_db_passes_string_to_sqlite_connect(tmp_path, monkeypatch): + """core/db.init_db() должен вызывать sqlite3.connect() со строкой пути, а НЕ с объектом Connection. + + Баг-сценарий: если где-то в коде путь к БД перепутан с объектом conn, + sqlite3.connect(str(conn)) создаст файл с именем ''. + Этот тест перехватывает вызов и проверяет тип аргумента напрямую. + """ + import core.db as db_module + + connect_calls: list = [] + real_connect = sqlite3.connect + + def mock_connect(database, *args, **kwargs): + connect_calls.append(database) + return real_connect(database, *args, **kwargs) + + monkeypatch.setattr(db_module.sqlite3, "connect", mock_connect) + + db_file = tmp_path / "test.db" + conn = db_module.init_db(db_file) + conn.close() + + assert connect_calls, "sqlite3.connect() должен быть вызван хотя бы один раз" + + for call_arg in connect_calls: + assert isinstance(call_arg, str), ( + f"sqlite3.connect() получил не строку: {type(call_arg).__name__!r} = {call_arg!r}" + ) + assert "sqlite3.Connection" not in call_arg, ( + f"sqlite3.connect() получил строку объекта Connection: {call_arg!r}\n" + "Баг: str(conn) передаётся вместо пути к файлу — это создаёт файл-артефакт!" + ) diff --git a/tests/test_runner.py b/tests/test_runner.py index f1dd4cd..e05da75 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,11 +1,12 @@ """Tests for agents/runner.py — agent execution with mocked claude CLI.""" import json +import subprocess import pytest from unittest.mock import patch, MagicMock from core.db import init_db from core import models -from agents.runner import run_agent, run_pipeline, _try_parse_json +from agents.runner import run_agent, run_pipeline, run_audit, _try_parse_json @pytest.fixture @@ -248,6 +249,45 @@ class TestRunPipeline: assert result["success"] is False assert "not found" in result["error"] + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_hooks_called_after_successful_pipeline(self, mock_run, mock_hooks, conn): + mock_run.return_value = _mock_claude_success({"result": "done"}) + mock_hooks.return_value = [] + + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + mock_hooks.assert_called_once() + call_kwargs = mock_hooks.call_args + assert call_kwargs[1].get("event") == "pipeline_completed" or \ + call_kwargs[0][3] == "pipeline_completed" + + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_hooks_not_called_on_failed_pipeline(self, mock_run, mock_hooks, conn): + mock_run.return_value = _mock_claude_failure("compilation error") + mock_hooks.return_value = [] + + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is False + mock_hooks.assert_not_called() + + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_hook_failure_does_not_affect_pipeline_result(self, mock_run, mock_hooks, conn): + mock_run.return_value = _mock_claude_success({"result": "done"}) + mock_hooks.side_effect = Exception("hook exploded") + + steps = [{"role": "debugger", "brief": "find"}] + # Must not raise — hook failures must not propagate + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + # --------------------------------------------------------------------------- # JSON parsing @@ -274,3 +314,190 @@ class TestTryParseJson: def test_json_array(self): assert _try_parse_json('[1, 2, 3]') == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# Non-interactive mode +# --------------------------------------------------------------------------- + +class TestNonInteractive: + @patch("agents.runner.subprocess.run") + def test_noninteractive_sets_stdin_devnull(self, mock_run, conn): + """When noninteractive=True, subprocess.run should get stdin=subprocess.DEVNULL.""" + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=True) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("stdin") == subprocess.DEVNULL + + @patch("agents.runner.subprocess.run") + def test_noninteractive_uses_300s_timeout(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=True) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("timeout") == 300 + + @patch("agents.runner.subprocess.run") + def test_interactive_uses_600s_timeout(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("timeout") == 300 + + @patch("agents.runner.subprocess.run") + def test_interactive_no_stdin_override(self, mock_run, conn): + """In interactive mode, stdin should not be set to DEVNULL.""" + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("stdin") == subprocess.DEVNULL + + @patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"}) + @patch("agents.runner.subprocess.run") + def test_env_var_activates_noninteractive(self, mock_run, conn): + """KIN_NONINTERACTIVE=1 env var should activate non-interactive mode.""" + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("stdin") == subprocess.DEVNULL + assert call_kwargs.get("timeout") == 300 + + @patch("agents.runner.subprocess.run") + def test_allow_write_adds_skip_permissions(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", allow_write=True) + cmd = mock_run.call_args[0][0] + assert "--dangerously-skip-permissions" in cmd + + @patch("agents.runner.subprocess.run") + def test_no_allow_write_no_skip_permissions(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", allow_write=False) + cmd = mock_run.call_args[0][0] + assert "--dangerously-skip-permissions" not in cmd + + +# --------------------------------------------------------------------------- +# run_audit +# --------------------------------------------------------------------------- + +class TestRunAudit: + @patch("agents.runner.subprocess.run") + def test_audit_success(self, mock_run, conn): + """Audit should return parsed already_done/still_pending/unclear.""" + audit_output = json.dumps({ + "already_done": [{"id": "VDOL-001", "reason": "Fixed in runner.py"}], + "still_pending": [], + "unclear": [], + }) + mock_run.return_value = _mock_claude_success({"result": audit_output}) + + result = run_audit(conn, "vdol") + + assert result["success"] is True + assert len(result["already_done"]) == 1 + assert result["already_done"][0]["id"] == "VDOL-001" + + @patch("agents.runner.subprocess.run") + def test_audit_logs_to_db(self, mock_run, conn): + """Audit should log to agent_logs with role=backlog_audit.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({"already_done": [], "still_pending": [], "unclear": []}), + }) + + run_audit(conn, "vdol") + + logs = conn.execute( + "SELECT * FROM agent_logs WHERE agent_role='backlog_audit'" + ).fetchall() + assert len(logs) == 1 + assert logs[0]["action"] == "audit" + + def test_audit_no_pending_tasks(self, conn): + """If no pending tasks, return success with empty lists.""" + # Mark existing task as done + models.update_task(conn, "VDOL-001", status="done") + + result = run_audit(conn, "vdol") + + assert result["success"] is True + assert result["already_done"] == [] + assert "No pending tasks" in result.get("message", "") + + def test_audit_project_not_found(self, conn): + result = run_audit(conn, "nonexistent") + assert result["success"] is False + assert "not found" in result["error"] + + @patch("agents.runner.subprocess.run") + def test_audit_uses_sonnet(self, mock_run, conn): + """Audit should use sonnet model.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({"already_done": [], "still_pending": [], "unclear": []}), + }) + + run_audit(conn, "vdol") + + cmd = mock_run.call_args[0][0] + model_idx = cmd.index("--model") + assert cmd[model_idx + 1] == "sonnet" + + @patch("agents.runner.subprocess.run") + def test_audit_includes_tasks_in_prompt(self, mock_run, conn): + """The prompt should contain the task title.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({"already_done": [], "still_pending": [], "unclear": []}), + }) + + run_audit(conn, "vdol") + + prompt = mock_run.call_args[0][0][2] # -p argument + assert "VDOL-001" in prompt + assert "Fix bug" in prompt + + @patch("agents.runner.subprocess.run") + def test_audit_auto_apply_marks_done(self, mock_run, conn): + """auto_apply=True should mark already_done tasks as done in DB.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({ + "already_done": [{"id": "VDOL-001", "reason": "Done"}], + "still_pending": [], + "unclear": [], + }), + }) + + result = run_audit(conn, "vdol", auto_apply=True) + + assert result["success"] is True + assert "VDOL-001" in result["applied"] + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "done" + + @patch("agents.runner.subprocess.run") + def test_audit_no_auto_apply_keeps_pending(self, mock_run, conn): + """auto_apply=False should NOT change task status.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({ + "already_done": [{"id": "VDOL-001", "reason": "Done"}], + "still_pending": [], + "unclear": [], + }), + }) + + result = run_audit(conn, "vdol", auto_apply=False) + + assert result["success"] is True + assert result["applied"] == [] + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "pending" + + @patch("agents.runner.subprocess.run") + def test_audit_uses_dangerously_skip_permissions(self, mock_run, conn): + """Audit must use --dangerously-skip-permissions for tool access.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({"already_done": [], "still_pending": [], "unclear": []}), + }) + + run_audit(conn, "vdol") + + cmd = mock_run.call_args[0][0] + assert "--dangerously-skip-permissions" in cmd diff --git a/web/api.py b/web/api.py index 6536a77..52ebbe2 100644 --- a/web/api.py +++ b/web/api.py @@ -12,7 +12,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from core.db import init_db @@ -28,7 +29,7 @@ app = FastAPI(title="Kin API", version="0.1.0") app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @@ -136,6 +137,28 @@ def create_task(body: TaskCreate): return t +class TaskPatch(BaseModel): + status: str + + +VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked", "cancelled"} + + +@app.patch("/api/tasks/{task_id}") +def patch_task(task_id: str, body: TaskPatch): + if body.status not in VALID_STATUSES: + raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}") + 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=body.status) + t = models.get_task(conn, task_id) + conn.close() + return t + + @app.get("/api/tasks/{task_id}/pipeline") def get_task_pipeline(task_id: str): """Get agent_logs for a task (pipeline steps).""" @@ -275,8 +298,12 @@ def is_task_running(task_id: str): return {"running": False} +class TaskRun(BaseModel): + allow_write: bool = False + + @app.post("/api/tasks/{task_id}/run") -def run_task(task_id: str): +def run_task(task_id: str, body: TaskRun | None = None): """Launch pipeline for a task in background. Returns 202.""" conn = get_conn() t = models.get_task(conn, task_id) @@ -288,12 +315,22 @@ def run_task(task_id: str): conn.close() # Launch kin run in background subprocess kin_root = Path(__file__).parent.parent + cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), + "run", task_id] + if body and body.allow_write: + cmd.append("--allow-write") + + import os + env = os.environ.copy() + env["KIN_NONINTERACTIVE"] = "1" + try: proc = subprocess.Popen( - [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), - "run", task_id], + cmd, cwd=str(kin_root), stdout=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env=env, ) import logging logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}") @@ -370,6 +407,47 @@ def list_tickets(project: str | None = None, status: str | None = None): return tickets +# --------------------------------------------------------------------------- +# Audit +# --------------------------------------------------------------------------- + +@app.post("/api/projects/{project_id}/audit") +def audit_project(project_id: str): + """Run backlog audit — check which pending tasks are already done.""" + from agents.runner import run_audit + + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + result = run_audit(conn, project_id, noninteractive=True, auto_apply=False) + conn.close() + return result + + +class AuditApply(BaseModel): + task_ids: list[str] + + +@app.post("/api/projects/{project_id}/audit/apply") +def audit_apply(project_id: str, body: AuditApply): + """Mark tasks as done after audit confirmation.""" + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + updated = [] + for tid in body.task_ids: + t = models.get_task(conn, tid) + if t and t["project_id"] == project_id: + models.update_task(conn, tid, status="done") + updated.append(tid) + conn.close() + return {"updated": updated, "count": len(updated)} + + # --------------------------------------------------------------------------- # Bootstrap # --------------------------------------------------------------------------- @@ -414,3 +492,20 @@ def bootstrap(body: BootstrapRequest): "decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])), "tasks_count": len((obsidian or {}).get("tasks", [])), } + + +# --------------------------------------------------------------------------- +# SPA static files (AFTER all /api/ routes) +# --------------------------------------------------------------------------- + +DIST = Path(__file__).parent / "frontend" / "dist" + +app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets") + + +@app.get("/{path:path}") +async def serve_spa(path: str): + file = DIST / path + if file.exists() and file.is_file(): + return FileResponse(file) + return FileResponse(DIST / "index.html") diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 89afee3..4b44050 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -1,4 +1,4 @@ -const BASE = 'http://localhost:8420/api' +const BASE = '/api' async function get(path: string): Promise { const res = await fetch(`${BASE}${path}`) @@ -6,6 +6,16 @@ async function get(path: string): Promise { return res.json() } +async function patch(path: string, body: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return res.json() +} + async function post(path: string, body: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method: 'POST', @@ -108,6 +118,21 @@ export interface CostEntry { total_duration_seconds: number } +export interface AuditItem { + id: string + reason: string +} + +export interface AuditResult { + success: boolean + already_done: AuditItem[] + still_pending: AuditItem[] + unclear: AuditItem[] + duration_seconds?: number + cost_usd?: number + error?: string +} + export const api = { projects: () => get('/projects'), project: (id: string) => get(`/projects/${id}`), @@ -125,8 +150,14 @@ export const api = { post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }), rejectTask: (id: string, reason: string) => post<{ status: string }>(`/tasks/${id}/reject`, { reason }), - runTask: (id: string) => - post<{ status: string }>(`/tasks/${id}/run`, {}), + runTask: (id: string, allowWrite = false) => + post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }), bootstrap: (data: { path: string; id: string; name: string }) => post<{ project: Project }>('/bootstrap', data), + auditProject: (projectId: string) => + 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 }) => + patch(`/tasks/${id}`, data), } diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 06e608f..5ceb196 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -1,6 +1,6 @@