Compare commits
11 commits
db1729730f
...
01b269e2b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b269e2b8 | ||
|
|
6705b302f7 | ||
|
|
8e517d5888 | ||
|
|
d311c2fb66 | ||
|
|
bf38532f59 | ||
|
|
6e872121eb | ||
|
|
9cbb3cec37 | ||
|
|
96509dcafc | ||
|
|
e755a19633 | ||
|
|
03961500e6 | ||
|
|
3ef00bced1 |
15 changed files with 1890 additions and 26 deletions
44
agents/prompts/backlog_audit.md
Normal file
44
agents/prompts/backlog_audit.md
Normal file
|
|
@ -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.
|
||||||
171
agents/runner.py
171
agents/runner.py
|
|
@ -4,6 +4,7 @@ Each agent = separate process with isolated context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
@ -12,6 +13,7 @@ from typing import Any
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.context_builder import build_context, format_prompt
|
from core.context_builder import build_context, format_prompt
|
||||||
|
from core.hooks import run_hooks
|
||||||
|
|
||||||
|
|
||||||
def run_agent(
|
def run_agent(
|
||||||
|
|
@ -24,6 +26,7 @@ def run_agent(
|
||||||
brief_override: str | None = None,
|
brief_override: str | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
|
noninteractive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run a single Claude Code agent as a subprocess.
|
"""Run a single Claude Code agent as a subprocess.
|
||||||
|
|
||||||
|
|
@ -64,7 +67,7 @@ def run_agent(
|
||||||
# Run claude subprocess
|
# Run claude subprocess
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
result = _run_claude(prompt, model=model, working_dir=working_dir,
|
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)
|
duration = int(time.monotonic() - start)
|
||||||
|
|
||||||
# Parse output — ensure output_text is always a string for DB storage
|
# Parse output — ensure output_text is always a string for DB storage
|
||||||
|
|
@ -109,6 +112,7 @@ def _run_claude(
|
||||||
model: str = "sonnet",
|
model: str = "sonnet",
|
||||||
working_dir: str | None = None,
|
working_dir: str | None = None,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
|
noninteractive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Execute claude CLI as subprocess. Returns dict with output, returncode, etc."""
|
"""Execute claude CLI as subprocess. Returns dict with output, returncode, etc."""
|
||||||
cmd = [
|
cmd = [
|
||||||
|
|
@ -120,13 +124,17 @@ def _run_claude(
|
||||||
if allow_write:
|
if allow_write:
|
||||||
cmd.append("--dangerously-skip-permissions")
|
cmd.append("--dangerously-skip-permissions")
|
||||||
|
|
||||||
|
is_noninteractive = noninteractive or os.environ.get("KIN_NONINTERACTIVE") == "1"
|
||||||
|
timeout = 300 if is_noninteractive else 600
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600, # 10 min max
|
timeout=timeout,
|
||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
|
stdin=subprocess.DEVNULL if is_noninteractive else None,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {
|
return {
|
||||||
|
|
@ -137,7 +145,7 @@ def _run_claude(
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {
|
return {
|
||||||
"output": "",
|
"output": "",
|
||||||
"error": "Agent timed out after 600s",
|
"error": f"Agent timed out after {timeout}s",
|
||||||
"returncode": 124,
|
"returncode": 124,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +211,153 @@ def _try_parse_json(text: str) -> Any:
|
||||||
return None
|
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
|
# Pipeline executor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -213,6 +368,7 @@ def run_pipeline(
|
||||||
steps: list[dict],
|
steps: list[dict],
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
|
noninteractive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Execute a multi-step pipeline of agents.
|
"""Execute a multi-step pipeline of agents.
|
||||||
|
|
||||||
|
|
@ -260,6 +416,7 @@ def run_pipeline(
|
||||||
brief_override=brief,
|
brief_override=brief,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
allow_write=allow_write,
|
allow_write=allow_write,
|
||||||
|
noninteractive=noninteractive,
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
|
|
@ -309,6 +466,14 @@ def run_pipeline(
|
||||||
)
|
)
|
||||||
models.update_task(conn, task_id, status="review")
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"steps_completed": len(steps),
|
"steps_completed": len(steps),
|
||||||
|
|
|
||||||
235
cli/main.py
235
cli/main.py
|
|
@ -4,6 +4,7 @@ Uses core.models for all data access, never raw SQL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
|
from core import hooks as hooks_module
|
||||||
from agents.bootstrap import (
|
from agents.bootstrap import (
|
||||||
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||||||
find_vault_root, scan_obsidian, format_preview, save_to_db,
|
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']}")
|
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
|
# decision
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -481,8 +509,9 @@ def approve_task(ctx, task_id, followup, decision_text):
|
||||||
@cli.command("run")
|
@cli.command("run")
|
||||||
@click.argument("task_id")
|
@click.argument("task_id")
|
||||||
@click.option("--dry-run", is_flag=True, help="Show pipeline plan without executing")
|
@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
|
@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.
|
"""Run a task through the agent pipeline.
|
||||||
|
|
||||||
PM decomposes the task into specialist steps, then the pipeline executes.
|
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)
|
raise SystemExit(1)
|
||||||
|
|
||||||
project_id = task["project_id"]
|
project_id = task["project_id"]
|
||||||
|
is_noninteractive = os.environ.get("KIN_NONINTERACTIVE") == "1"
|
||||||
click.echo(f"Task: {task['id']} — {task['title']}")
|
click.echo(f"Task: {task['id']} — {task['title']}")
|
||||||
|
|
||||||
# Step 1: PM decomposes
|
# Step 1: PM decomposes
|
||||||
|
|
@ -504,6 +534,7 @@ def run_task(ctx, task_id, dry_run):
|
||||||
pm_result = run_agent(
|
pm_result = run_agent(
|
||||||
conn, "pm", task_id, project_id,
|
conn, "pm", task_id, project_id,
|
||||||
model="sonnet", dry_run=dry_run,
|
model="sonnet", dry_run=dry_run,
|
||||||
|
allow_write=allow_write, noninteractive=is_noninteractive,
|
||||||
)
|
)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
|
|
@ -537,13 +568,17 @@ def run_task(ctx, task_id, dry_run):
|
||||||
for i, step in enumerate(pipeline_steps, 1):
|
for i, step in enumerate(pipeline_steps, 1):
|
||||||
click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}")
|
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.")
|
click.echo("Aborted.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 2: Execute pipeline
|
# Step 2: Execute pipeline
|
||||||
click.echo("\nExecuting 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"]:
|
if result["success"]:
|
||||||
click.echo(f"\nPipeline completed: {result['steps_completed']} steps")
|
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")
|
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
|
# bootstrap
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -621,6 +721,135 @@ def bootstrap(ctx, path, project_id, name, vault_path, yes):
|
||||||
f"{dec_count} decisions, {task_count} tasks.")
|
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: <kin_root>/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
|
# Entry point
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
|
||||||
29
core/db.py
29
core/db.py
|
|
@ -103,6 +103,35 @@ CREATE TABLE IF NOT EXISTS pipelines (
|
||||||
completed_at DATETIME
|
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 (
|
CREATE TABLE IF NOT EXISTS project_links (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
|
||||||
224
core/hooks.py
Normal file
224
core/hooks.py
Normal file
|
|
@ -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()
|
||||||
38
scripts/rebuild-frontend.sh
Executable file
38
scripts/rebuild-frontend.sh
Executable file
|
|
@ -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 <project_id>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -173,6 +173,24 @@ def test_run_not_found(client):
|
||||||
assert r.status_code == 404
|
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):
|
def test_project_summary_includes_review(client):
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
|
|
@ -183,3 +201,76 @@ def test_project_summary_includes_review(client):
|
||||||
r = client.get("/api/projects")
|
r = client.get("/api/projects")
|
||||||
projects = r.json()
|
projects = r.json()
|
||||||
assert projects[0]["review_tasks"] == 1
|
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
|
||||||
|
|
|
||||||
|
|
@ -205,3 +205,150 @@ def test_cost_with_data(runner):
|
||||||
assert r.exit_code == 0
|
assert r.exit_code == 0
|
||||||
assert "p1" in r.output
|
assert "p1" in r.output
|
||||||
assert "$0.1000" 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
|
||||||
|
|
|
||||||
275
tests/test_hooks.py
Normal file
275
tests/test_hooks.py
Normal file
|
|
@ -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
|
||||||
112
tests/test_no_connection_artifacts.py
Normal file
112
tests/test_no_connection_artifacts.py
Normal file
|
|
@ -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)) создаст файл с именем '<sqlite3.Connection object at 0x...>'.
|
||||||
|
Этот тест перехватывает вызов и проверяет тип аргумента напрямую.
|
||||||
|
"""
|
||||||
|
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) передаётся вместо пути к файлу — это создаёт файл-артефакт!"
|
||||||
|
)
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"""Tests for agents/runner.py — agent execution with mocked claude CLI."""
|
"""Tests for agents/runner.py — agent execution with mocked claude CLI."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
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
|
@pytest.fixture
|
||||||
|
|
@ -248,6 +249,45 @@ class TestRunPipeline:
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "not found" in result["error"]
|
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
|
# JSON parsing
|
||||||
|
|
@ -274,3 +314,190 @@ class TestTryParseJson:
|
||||||
|
|
||||||
def test_json_array(self):
|
def test_json_array(self):
|
||||||
assert _try_parse_json('[1, 2, 3]') == [1, 2, 3]
|
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
|
||||||
|
|
|
||||||
105
web/api.py
105
web/api.py
|
|
@ -12,7 +12,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
|
|
@ -28,7 +29,7 @@ app = FastAPI(title="Kin API", version="0.1.0")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
allow_origins=["*"],
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
@ -136,6 +137,28 @@ def create_task(body: TaskCreate):
|
||||||
return t
|
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")
|
@app.get("/api/tasks/{task_id}/pipeline")
|
||||||
def get_task_pipeline(task_id: str):
|
def get_task_pipeline(task_id: str):
|
||||||
"""Get agent_logs for a task (pipeline steps)."""
|
"""Get agent_logs for a task (pipeline steps)."""
|
||||||
|
|
@ -275,8 +298,12 @@ def is_task_running(task_id: str):
|
||||||
return {"running": False}
|
return {"running": False}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRun(BaseModel):
|
||||||
|
allow_write: bool = False
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tasks/{task_id}/run")
|
@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."""
|
"""Launch pipeline for a task in background. Returns 202."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
t = models.get_task(conn, task_id)
|
t = models.get_task(conn, task_id)
|
||||||
|
|
@ -288,12 +315,22 @@ def run_task(task_id: str):
|
||||||
conn.close()
|
conn.close()
|
||||||
# Launch kin run in background subprocess
|
# Launch kin run in background subprocess
|
||||||
kin_root = Path(__file__).parent.parent
|
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:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
cmd,
|
||||||
"run", task_id],
|
|
||||||
cwd=str(kin_root),
|
cwd=str(kin_root),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")
|
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
|
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
|
# Bootstrap
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -414,3 +492,20 @@ def bootstrap(body: BootstrapRequest):
|
||||||
"decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])),
|
"decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])),
|
||||||
"tasks_count": len((obsidian or {}).get("tasks", [])),
|
"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")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const BASE = 'http://localhost:8420/api'
|
const BASE = '/api'
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
async function get<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`)
|
const res = await fetch(`${BASE}${path}`)
|
||||||
|
|
@ -6,6 +6,16 @@ async function get<T>(path: string): Promise<T> {
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<T>(path: string, body: unknown): Promise<T> {
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -108,6 +118,21 @@ export interface CostEntry {
|
||||||
total_duration_seconds: number
|
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 = {
|
export const api = {
|
||||||
projects: () => get<Project[]>('/projects'),
|
projects: () => get<Project[]>('/projects'),
|
||||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||||
|
|
@ -125,8 +150,14 @@ export const api = {
|
||||||
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
|
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
|
||||||
rejectTask: (id: string, reason: string) =>
|
rejectTask: (id: string, reason: string) =>
|
||||||
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
|
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
|
||||||
runTask: (id: string) =>
|
runTask: (id: string, allowWrite = false) =>
|
||||||
post<{ status: string }>(`/tasks/${id}/run`, {}),
|
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }),
|
||||||
bootstrap: (data: { path: string; id: string; name: string }) =>
|
bootstrap: (data: { path: string; id: string; name: string }) =>
|
||||||
post<{ project: Project }>('/bootstrap', data),
|
post<{ project: Project }>('/bootstrap', data),
|
||||||
|
auditProject: (projectId: string) =>
|
||||||
|
post<AuditResult>(`/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<Task>(`/tasks/${id}`, data),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { api, type ProjectDetail } from '../api'
|
import { api, type ProjectDetail, type AuditResult } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
|
@ -16,6 +16,54 @@ const taskStatusFilter = ref('')
|
||||||
const decisionTypeFilter = ref('')
|
const decisionTypeFilter = ref('')
|
||||||
const decisionSearch = ref('')
|
const decisionSearch = ref('')
|
||||||
|
|
||||||
|
// Auto/Review mode (persisted per project)
|
||||||
|
const autoMode = ref(false)
|
||||||
|
|
||||||
|
function loadMode() {
|
||||||
|
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
autoMode.value = !autoMode.value
|
||||||
|
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
const auditLoading = ref(false)
|
||||||
|
const auditResult = ref<AuditResult | null>(null)
|
||||||
|
const showAuditModal = ref(false)
|
||||||
|
const auditApplying = ref(false)
|
||||||
|
|
||||||
|
async function runAudit() {
|
||||||
|
auditLoading.value = true
|
||||||
|
auditResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await api.auditProject(props.id)
|
||||||
|
auditResult.value = res
|
||||||
|
showAuditModal.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAudit() {
|
||||||
|
if (!auditResult.value?.already_done?.length) return
|
||||||
|
auditApplying.value = true
|
||||||
|
try {
|
||||||
|
const ids = auditResult.value.already_done.map(t => t.id)
|
||||||
|
await api.auditApply(props.id, ids)
|
||||||
|
showAuditModal.value = false
|
||||||
|
auditResult.value = null
|
||||||
|
await load()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
auditApplying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add task modal
|
// Add task modal
|
||||||
const showAddTask = ref(false)
|
const showAddTask = ref(false)
|
||||||
const taskForm = ref({ title: '', priority: 5, route_type: '' })
|
const taskForm = ref({ title: '', priority: 5, route_type: '' })
|
||||||
|
|
@ -37,7 +85,7 @@ async function load() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => { load(); loadMode() })
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
const filteredTasks = computed(() => {
|
||||||
if (!project.value) return []
|
if (!project.value) return []
|
||||||
|
|
@ -60,7 +108,7 @@ const filteredDecisions = computed(() => {
|
||||||
function taskStatusColor(s: string) {
|
function taskStatusColor(s: string) {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
pending: 'gray', in_progress: 'blue', review: 'purple',
|
pending: 'gray', in_progress: 'blue', review: 'purple',
|
||||||
done: 'green', blocked: 'red', decomposed: 'yellow',
|
done: 'green', blocked: 'red', decomposed: 'yellow', cancelled: 'gray',
|
||||||
}
|
}
|
||||||
return m[s] || 'gray'
|
return m[s] || 'gray'
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +162,7 @@ async function runTask(taskId: string, event: Event) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||||
try {
|
try {
|
||||||
await api.runTask(taskId)
|
await api.runTask(taskId, autoMode.value)
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
|
@ -133,7 +181,7 @@ async function addDecision() {
|
||||||
category: decForm.value.category || undefined,
|
category: decForm.value.category || undefined,
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
const res = await fetch('http://localhost:8420/api/decisions', {
|
const res = await fetch('/api/decisions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|
@ -195,10 +243,26 @@ async function addDecision() {
|
||||||
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
|
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddTask = true"
|
<div class="flex gap-2">
|
||||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
<button @click="toggleMode"
|
||||||
+ Task
|
class="px-2 py-1 text-xs border rounded transition-colors"
|
||||||
</button>
|
:class="autoMode
|
||||||
|
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
|
||||||
|
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||||
|
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
||||||
|
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
|
||||||
|
</button>
|
||||||
|
<button @click="runAudit" :disabled="auditLoading"
|
||||||
|
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
|
||||||
|
title="Check which pending tasks are already done">
|
||||||
|
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
|
{{ auditLoading ? 'Auditing...' : 'Audit backlog' }}
|
||||||
|
</button>
|
||||||
|
<button @click="showAddTask = true"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||||
|
+ Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="space-y-1">
|
||||||
|
|
@ -328,5 +392,46 @@ async function addDecision() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Audit Modal -->
|
||||||
|
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
|
||||||
|
<div v-if="!auditResult.success" class="text-red-400 text-sm">
|
||||||
|
Audit failed: {{ auditResult.error }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div v-if="auditResult.already_done?.length">
|
||||||
|
<h3 class="text-sm font-semibold text-green-400 mb-2">Already done ({{ auditResult.already_done.length }})</h3>
|
||||||
|
<div v-for="item in auditResult.already_done" :key="item.id"
|
||||||
|
class="px-3 py-2 border border-green-900/50 rounded text-xs mb-1">
|
||||||
|
<span class="text-green-400 font-medium">{{ item.id }}</span>
|
||||||
|
<span class="text-gray-400 ml-2">{{ item.reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="auditResult.still_pending?.length">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-400 mb-2">Still pending ({{ auditResult.still_pending.length }})</h3>
|
||||||
|
<div v-for="item in auditResult.still_pending" :key="item.id"
|
||||||
|
class="px-3 py-2 border border-gray-800 rounded text-xs mb-1">
|
||||||
|
<span class="text-gray-300 font-medium">{{ item.id }}</span>
|
||||||
|
<span class="text-gray-500 ml-2">{{ item.reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="auditResult.unclear?.length">
|
||||||
|
<h3 class="text-sm font-semibold text-yellow-400 mb-2">Unclear ({{ auditResult.unclear.length }})</h3>
|
||||||
|
<div v-for="item in auditResult.unclear" :key="item.id"
|
||||||
|
class="px-3 py-2 border border-yellow-900/50 rounded text-xs mb-1">
|
||||||
|
<span class="text-yellow-400 font-medium">{{ item.id }}</span>
|
||||||
|
<span class="text-gray-400 ml-2">{{ item.reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="auditResult.cost_usd || auditResult.duration_seconds" class="text-xs text-gray-600">
|
||||||
|
<span v-if="auditResult.duration_seconds">{{ auditResult.duration_seconds }}s</span>
|
||||||
|
<span v-if="auditResult.cost_usd" class="ml-2">${{ auditResult.cost_usd?.toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
<button v-if="auditResult.already_done?.length" @click="applyAudit" :disabled="auditApplying"
|
||||||
|
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
|
||||||
|
{{ auditApplying ? 'Applying...' : `Mark ${auditResult.already_done.length} tasks as done` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,25 @@ const resolvingAction = ref(false)
|
||||||
const showReject = ref(false)
|
const showReject = ref(false)
|
||||||
const rejectReason = ref('')
|
const rejectReason = ref('')
|
||||||
|
|
||||||
|
// Auto/Review mode (persisted per project)
|
||||||
|
const autoMode = ref(false)
|
||||||
|
|
||||||
|
function loadMode(projectId: string) {
|
||||||
|
autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
autoMode.value = !autoMode.value
|
||||||
|
if (task.value) {
|
||||||
|
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const prev = task.value
|
const prev = task.value
|
||||||
task.value = await api.taskFull(props.id)
|
task.value = await api.taskFull(props.id)
|
||||||
|
if (task.value?.project_id) loadMode(task.value.project_id)
|
||||||
// Auto-start polling if task is in_progress
|
// Auto-start polling if task is in_progress
|
||||||
if (task.value.status === 'in_progress' && !polling.value) {
|
if (task.value.status === 'in_progress' && !polling.value) {
|
||||||
startPolling()
|
startPolling()
|
||||||
|
|
@ -61,7 +76,7 @@ onUnmounted(stopPolling)
|
||||||
function statusColor(s: string) {
|
function statusColor(s: string) {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
pending: 'gray', in_progress: 'blue', review: 'yellow',
|
pending: 'gray', in_progress: 'blue', review: 'yellow',
|
||||||
done: 'green', blocked: 'red', decomposed: 'purple',
|
done: 'green', blocked: 'red', decomposed: 'purple', cancelled: 'gray',
|
||||||
}
|
}
|
||||||
return m[s] || 'gray'
|
return m[s] || 'gray'
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +175,7 @@ async function reject() {
|
||||||
|
|
||||||
async function runPipeline() {
|
async function runPipeline() {
|
||||||
try {
|
try {
|
||||||
await api.runTask(props.id)
|
await api.runTask(props.id, autoMode.value)
|
||||||
startPolling()
|
startPolling()
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -170,6 +185,21 @@ async function runPipeline() {
|
||||||
|
|
||||||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||||
|
|
||||||
|
const statusChanging = ref(false)
|
||||||
|
|
||||||
|
async function changeStatus(newStatus: string) {
|
||||||
|
if (!task.value || newStatus === task.value.status) return
|
||||||
|
statusChanging.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.patchTask(props.id, { status: newStatus })
|
||||||
|
task.value = { ...task.value, ...updated }
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
statusChanging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -187,6 +217,19 @@ const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||||
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
||||||
<span class="text-gray-400">{{ task.title }}</span>
|
<span class="text-gray-400">{{ task.title }}</span>
|
||||||
<Badge :text="task.status" :color="statusColor(task.status)" />
|
<Badge :text="task.status" :color="statusColor(task.status)" />
|
||||||
|
<select
|
||||||
|
:value="task.status"
|
||||||
|
@change="changeStatus(($event.target as HTMLSelectElement).value)"
|
||||||
|
:disabled="statusChanging"
|
||||||
|
class="text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-2 py-0.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="in_progress">in_progress</option>
|
||||||
|
<option value="review">review</option>
|
||||||
|
<option value="done">done</option>
|
||||||
|
<option value="blocked">blocked</option>
|
||||||
|
<option value="cancelled">cancelled</option>
|
||||||
|
</select>
|
||||||
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
||||||
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -270,6 +313,15 @@ const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||||
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||||
✗ Reject
|
✗ Reject
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||||
|
@click="toggleMode"
|
||||||
|
class="px-3 py-2 text-sm border rounded transition-colors"
|
||||||
|
:class="autoMode
|
||||||
|
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
|
||||||
|
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||||
|
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
||||||
|
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
|
||||||
|
</button>
|
||||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||||
@click="runPipeline"
|
@click="runPipeline"
|
||||||
:disabled="polling"
|
:disabled="polling"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue