diff --git a/agents/prompts/backlog_audit.md b/agents/prompts/backlog_audit.md deleted file mode 100644 index cb6f277..0000000 --- a/agents/prompts/backlog_audit.md +++ /dev/null @@ -1,44 +0,0 @@ -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 6ae013a..d5c6c1a 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -4,7 +4,6 @@ Each agent = separate process with isolated context. """ import json -import os import sqlite3 import subprocess import time @@ -13,7 +12,6 @@ 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( @@ -26,7 +24,6 @@ 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. @@ -67,7 +64,7 @@ def run_agent( # Run claude subprocess start = time.monotonic() result = _run_claude(prompt, model=model, working_dir=working_dir, - allow_write=allow_write, noninteractive=noninteractive) + allow_write=allow_write) duration = int(time.monotonic() - start) # Parse output — ensure output_text is always a string for DB storage @@ -112,7 +109,6 @@ 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 = [ @@ -124,17 +120,13 @@ 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=timeout, + timeout=600, # 10 min max cwd=working_dir, - stdin=subprocess.DEVNULL if is_noninteractive else None, ) except FileNotFoundError: return { @@ -145,7 +137,7 @@ def _run_claude( except subprocess.TimeoutExpired: return { "output": "", - "error": f"Agent timed out after {timeout}s", + "error": "Agent timed out after 600s", "returncode": 124, } @@ -211,153 +203,6 @@ 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 # --------------------------------------------------------------------------- @@ -368,7 +213,6 @@ 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. @@ -416,7 +260,6 @@ def run_pipeline( brief_override=brief, dry_run=dry_run, allow_write=allow_write, - noninteractive=noninteractive, ) results.append(result) @@ -466,14 +309,6 @@ 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 f11f82d..8ed3281 100644 --- a/cli/main.py +++ b/cli/main.py @@ -4,7 +4,6 @@ Uses core.models for all data access, never raw SQL. """ import json -import os import sys from pathlib import Path @@ -15,7 +14,6 @@ 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, @@ -221,32 +219,6 @@ 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 # =========================================================================== @@ -509,9 +481,8 @@ 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, allow_write): +def run_task(ctx, task_id, dry_run): """Run a task through the agent pipeline. PM decomposes the task into specialist steps, then the pipeline executes. @@ -526,7 +497,6 @@ def run_task(ctx, task_id, dry_run, allow_write): 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 @@ -534,7 +504,6 @@ def run_task(ctx, task_id, dry_run, allow_write): 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: @@ -568,17 +537,13 @@ def run_task(ctx, task_id, dry_run, allow_write): for i, step in enumerate(pipeline_steps, 1): click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}") - if is_noninteractive: - click.echo("\n[non-interactive] Auto-executing pipeline...") - elif not click.confirm("\nExecute pipeline?"): + if 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, - allow_write=allow_write, - noninteractive=is_noninteractive) + result = run_pipeline(conn, task_id, pipeline_steps) if result["success"]: click.echo(f"\nPipeline completed: {result['steps_completed']} steps") @@ -591,71 +556,6 @@ def run_task(ctx, task_id, dry_run, allow_write): 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 # =========================================================================== @@ -721,135 +621,6 @@ 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 f3f26bc..284c66c 100644 --- a/core/db.py +++ b/core/db.py @@ -103,35 +103,6 @@ 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 deleted file mode 100644 index 1b9775b..0000000 --- a/core/hooks.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -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 deleted file mode 100755 index 19b9ea6..0000000 --- a/scripts/rebuild-frontend.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/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 d8939d1..8d7ea42 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -173,24 +173,6 @@ 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 @@ -201,76 +183,3 @@ 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 f056f6d..b19551b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -205,150 +205,3 @@ 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 deleted file mode 100644 index 2778ee0..0000000 --- a/tests/test_hooks.py +++ /dev/null @@ -1,275 +0,0 @@ -"""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 deleted file mode 100644 index 569de53..0000000 --- a/tests/test_no_connection_artifacts.py +++ /dev/null @@ -1,112 +0,0 @@ -"""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 e05da75..f1dd4cd 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,12 +1,11 @@ """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, run_audit, _try_parse_json +from agents.runner import run_agent, run_pipeline, _try_parse_json @pytest.fixture @@ -249,45 +248,6 @@ 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 @@ -314,190 +274,3 @@ 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 52ebbe2..6536a77 100644 --- a/web/api.py +++ b/web/api.py @@ -12,8 +12,7 @@ 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, FileResponse -from fastapi.staticfiles import StaticFiles +from fastapi.responses import JSONResponse from pydantic import BaseModel from core.db import init_db @@ -29,7 +28,7 @@ app = FastAPI(title="Kin API", version="0.1.0") app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_methods=["*"], allow_headers=["*"], ) @@ -137,28 +136,6 @@ 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).""" @@ -298,12 +275,8 @@ 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, body: TaskRun | None = None): +def run_task(task_id: str): """Launch pipeline for a task in background. Returns 202.""" conn = get_conn() t = models.get_task(conn, task_id) @@ -315,22 +288,12 @@ def run_task(task_id: str, body: TaskRun | None = None): 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( - cmd, + [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), + "run", task_id], 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}") @@ -407,47 +370,6 @@ 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 # --------------------------------------------------------------------------- @@ -492,20 +414,3 @@ 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 4b44050..89afee3 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -1,4 +1,4 @@ -const BASE = '/api' +const BASE = 'http://localhost:8420/api' async function get(path: string): Promise { const res = await fetch(`${BASE}${path}`) @@ -6,16 +6,6 @@ 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', @@ -118,21 +108,6 @@ 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}`), @@ -150,14 +125,8 @@ 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, allowWrite = false) => - post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }), + runTask: (id: string) => + post<{ status: string }>(`/tasks/${id}/run`, {}), 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 5ceb196..06e608f 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -1,6 +1,6 @@