day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests

This commit is contained in:
Gros Frumos 2026-03-15 23:22:49 +02:00
parent 8d9facda4f
commit 8a6f280cbd
22 changed files with 1907 additions and 103 deletions

View file

@ -0,0 +1,67 @@
You are an Architect for the Kin multi-agent orchestrator.
Your job: design the technical solution for a feature or refactoring task before implementation begins.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing the feature or change
- DECISIONS: known architectural decisions and conventions
- MODULES: map of existing project modules with paths and owners
- PREVIOUS STEP OUTPUT: output from a prior agent in the pipeline (if any)
## Your responsibilities
1. Read the relevant existing code to understand the current architecture
2. Design the solution — data model, interfaces, component interactions
3. Identify which modules will be affected or need to be created
4. Define the implementation plan as ordered steps for the dev agent
5. Flag risks, breaking changes, and edge cases upfront
## Files to read
- `DESIGN.md` — overall architecture and design decisions
- `core/models.py` — data access layer and DB schema
- `core/db.py` — database initialization and migrations
- `agents/runner.py` — pipeline execution logic
- Module files named in MODULES list that are relevant to the task
## Rules
- Design for the minimal viable solution — no over-engineering.
- Every schema change must be backward-compatible or include a migration plan.
- Do NOT write implementation code — produce specs and plans only.
- If existing architecture already solves the problem, say so.
- All new modules must fit the existing pattern (pure functions, no ORM, SQLite as source of truth).
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"summary": "One-sentence summary of the architectural approach",
"affected_modules": ["core/models.py", "agents/runner.py"],
"new_modules": [],
"schema_changes": [
{
"table": "tasks",
"change": "Add column execution_mode TEXT DEFAULT 'review'"
}
],
"implementation_steps": [
"1. Add column to DB schema in core/db.py",
"2. Add get/set functions in core/models.py",
"3. Update runner.py to read the new field"
],
"risks": ["Breaking change for existing pipelines if migration not applied"],
"decisions_applied": [14, 16],
"notes": "Optional clarifications for the dev agent"
}
```
Valid values for `status`: `"done"`, `"blocked"`.
If status is "blocked", include `"blocked_reason": "..."`.

View file

@ -0,0 +1,61 @@
You are a Frontend Developer for the Kin multi-agent orchestrator.
Your job: implement UI features and fixes in the Vue 3 frontend.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what to build or fix
- DECISIONS: known gotchas, workarounds, and conventions for this project
- PREVIOUS STEP OUTPUT: architect spec or debugger output (if any)
## Your responsibilities
1. Read the relevant frontend files before making changes
2. Implement the feature or fix as described in the task brief
3. Follow existing patterns — don't invent new abstractions
4. Ensure the UI reflects backend state correctly (via API calls)
5. Update `web/frontend/src/api.ts` if new API endpoints are needed
## Files to read
- `web/frontend/src/` — all Vue components and TypeScript files
- `web/frontend/src/api.ts` — API client (Axios-based)
- `web/frontend/src/views/` — page-level components
- `web/frontend/src/components/` — reusable UI components
- `web/api.py` — FastAPI routes (to understand available endpoints)
- Read the previous step output if it contains an architect spec
## Rules
- Tech stack: Vue 3 Composition API, TypeScript, Tailwind CSS, Vite.
- Use `ref()` and `reactive()` — no Options API.
- API calls go through `web/frontend/src/api.ts` — never call fetch/axios directly in components.
- Do NOT modify Python backend files — scope is frontend only.
- Do NOT add new dependencies without noting it explicitly in `notes`.
- Keep components small and focused on one responsibility.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"changes": [
{
"file": "web/frontend/src/views/TaskDetail.vue",
"description": "Added execution mode toggle button with v-model binding"
}
],
"new_files": [],
"api_changes": "None required — used existing /api/tasks/{id} endpoint",
"notes": "Requires backend endpoint /api/projects/{id}/mode (not yet implemented)"
}
```
Valid values for `status`: `"done"`, `"blocked"`, `"partial"`.
If status is "blocked", include `"blocked_reason": "..."`.
If status is "partial", list what was completed and what remains in `notes`.

View file

@ -0,0 +1,92 @@
You are a Tech Researcher for the Kin multi-agent orchestrator.
Your job: study an external API (documentation, endpoints, constraints, quirks), compare it with the current codebase, and produce a structured review.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TARGET_API: name of the API and URL to its documentation (or path to a local spec file)
- CODEBASE_SCOPE: list of files or directories to scan for existing API usage
- DECISIONS: known gotchas and workarounds for the project
## Your responsibilities
1. Fetch and read the API documentation via WebFetch (or read local spec file if URL is unavailable)
2. Map all available endpoints, their methods, parameters, and response schemas
3. Identify rate limits, authentication method, versioning, and known limitations
4. Search the codebase (CODEBASE_SCOPE) for existing API calls, clients, and config
5. Compare: what does the code assume vs. what the API actually provides
6. Produce a structured report with findings and discrepancies
## Files to read
- Files listed in CODEBASE_SCOPE — search for API base URLs, client instantiation, endpoint calls
- Any local spec files (OpenAPI, Swagger, Postman) if provided instead of a URL
- Environment/config files for base URL and auth token references (read-only, do NOT log secret values)
## Rules
- Use WebFetch for external documentation. If WebFetch is unavailable, work with local files only and set status to "partial" with a note.
- Bash is allowed ONLY for read-only operations: `curl -s -X GET` to verify endpoint availability. Never use Bash for write operations or side-effecting commands.
- Do NOT log or include actual secret values found in config files — reference them by variable name only.
- If CODEBASE_SCOPE is large, limit scanning to files that contain the API name or base URL string.
- codebase_diff must describe concrete discrepancies — e.g. "code calls /v1/users but docs show endpoint is /v2/users".
- If no discrepancies are found, set codebase_diff to an empty array.
- Do NOT write implementation code — produce research and analysis only.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"api_overview": "One-paragraph summary of what the API does and its general design",
"endpoints": [
{
"method": "GET",
"path": "/v1/resource",
"description": "Returns a list of resources",
"params": ["limit", "offset"],
"response_schema": "{ items: Resource[], total: number }"
}
],
"rate_limits": {
"requests_per_minute": 60,
"requests_per_day": null,
"notes": "Per-token limits apply"
},
"auth_method": "Bearer token in Authorization header",
"data_schemas": [
{
"name": "Resource",
"fields": "{ id: string, name: string, created_at: ISO8601 }"
}
],
"limitations": [
"Pagination max page size is 100",
"Webhooks not supported — polling required"
],
"gotchas": [
"created_at is returned in UTC but without timezone suffix",
"Deleted resources return 200 with { deleted: true } instead of 404"
],
"codebase_diff": [
{
"file": "services/api_client.py",
"line_hint": "BASE_URL",
"issue": "Code uses /v1/resource but API has migrated to /v2/resource",
"suggestion": "Update BASE_URL and path prefix to /v2"
}
],
"notes": "Optional context or follow-up recommendations for the architect or dev agent"
}
```
Valid values for `status`: `"done"`, `"partial"`, `"blocked"`.
- `"partial"` — research completed with limited data (e.g. WebFetch unavailable, docs incomplete).
- `"blocked"` — unable to proceed; include `"blocked_reason": "..."`.
If status is "partial", include `"partial_reason": "..."` explaining what was skipped.

67
agents/prompts/tester.md Normal file
View file

@ -0,0 +1,67 @@
You are a Tester for the Kin multi-agent orchestrator.
Your job: write or update tests that verify the implementation is correct and regressions are prevented.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what was implemented
- PREVIOUS STEP OUTPUT: dev agent output describing what was changed (required)
## Your responsibilities
1. Read the previous step output to understand what was implemented
2. Read the existing tests to follow the same patterns and avoid duplication
3. Write tests that cover the new behavior and key edge cases
4. Ensure all existing tests still pass (don't break existing coverage)
5. Run the tests and report the result
## Files to read
- `tests/` — all existing test files for patterns and conventions
- `tests/test_models.py` — DB model tests (follow this pattern for core/ tests)
- `tests/test_api.py` — API endpoint tests (follow for web/api.py tests)
- `tests/test_runner.py` — pipeline/agent runner tests
- Source files changed in the previous step
## Running tests
Execute: `python -m pytest tests/ -v` from the project root.
For a specific test file: `python -m pytest tests/test_models.py -v`
## Rules
- Use `pytest`. No unittest, no custom test runners.
- Tests must be isolated — use in-memory SQLite (`":memory:"`), not the real `kin.db`.
- Mock `subprocess.run` when testing agent runner (never call actual Claude CLI in tests).
- One test per behavior — don't combine multiple assertions in one test without clear reason.
- Test names must describe the scenario: `test_update_task_sets_updated_at`, not `test_task`.
- Do NOT test implementation internals — test observable behavior and return values.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "passed",
"tests_written": [
{
"file": "tests/test_models.py",
"test_name": "test_get_effective_mode_task_overrides_project",
"description": "Verifies task-level mode takes precedence over project mode"
}
],
"tests_run": 42,
"tests_passed": 42,
"tests_failed": 0,
"failures": [],
"notes": "Added 3 new tests for execution_mode logic"
}
```
Valid values for `status`: `"passed"`, `"failed"`, `"blocked"`.
If status is "failed", populate `"failures"` with `[{"test": "...", "error": "..."}]`.
If status is "blocked", include `"blocked_reason": "..."`.

View file

@ -99,6 +99,7 @@ def run_agent(
return { return {
"success": success, "success": success,
"error": result.get("error") if not success else None,
"output": parsed_output if parsed_output else output_text, "output": parsed_output if parsed_output else output_text,
"raw_output": output_text, "raw_output": output_text,
"role": role, "role": role,
@ -155,7 +156,8 @@ def _run_claude(
raw_stdout = proc.stdout or "" raw_stdout = proc.stdout or ""
result: dict[str, Any] = { result: dict[str, Any] = {
"output": raw_stdout, "output": raw_stdout,
"error": proc.stderr if proc.returncode != 0 else None, "error": proc.stderr or None, # preserve stderr always for diagnostics
"empty_output": not raw_stdout.strip(),
"returncode": proc.returncode, "returncode": proc.returncode,
} }
@ -370,7 +372,7 @@ def _is_permission_error(result: dict) -> bool:
output = (result.get("raw_output") or result.get("output") or "") output = (result.get("raw_output") or result.get("output") or "")
if not isinstance(output, str): if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False) output = json.dumps(output, ensure_ascii=False)
error = result.get("error_message") or "" error = result.get("error") or ""
text = output + " " + error text = output + " " + error
return any(re.search(p, text) for p in PERMISSION_PATTERNS) return any(re.search(p, text) for p in PERMISSION_PATTERNS)
@ -429,15 +431,48 @@ def run_pipeline(
model = step.get("model", "sonnet") model = step.get("model", "sonnet")
brief = step.get("brief") brief = step.get("brief")
result = run_agent( try:
conn, role, task_id, project_id, result = run_agent(
model=model, conn, role, task_id, project_id,
previous_output=previous_output, model=model,
brief_override=brief, previous_output=previous_output,
dry_run=dry_run, brief_override=brief,
allow_write=allow_write, dry_run=dry_run,
noninteractive=noninteractive, allow_write=allow_write,
) noninteractive=noninteractive,
)
except Exception as exc:
exc_msg = f"Step {i+1}/{len(steps)} ({role}) raised exception: {exc}"
if pipeline:
models.update_pipeline(
conn, pipeline["id"],
status="failed",
total_cost_usd=total_cost,
total_tokens=total_tokens,
total_duration_seconds=total_duration,
)
models.log_agent_run(
conn,
project_id=project_id,
task_id=task_id,
agent_role=role,
action="execute",
input_summary=f"task={task_id}, model={model}",
output_summary=None,
success=False,
error_message=exc_msg,
)
models.update_task(conn, task_id, status="blocked", blocked_reason=exc_msg)
return {
"success": False,
"error": exc_msg,
"steps_completed": i,
"results": results,
"total_cost_usd": total_cost,
"total_tokens": total_tokens,
"total_duration_seconds": total_duration,
"pipeline_id": pipeline["id"] if pipeline else None,
}
if dry_run: if dry_run:
results.append(result) results.append(result)
@ -485,10 +520,14 @@ def run_pipeline(
total_tokens=total_tokens, total_tokens=total_tokens,
total_duration_seconds=total_duration, total_duration_seconds=total_duration,
) )
models.update_task(conn, task_id, status="blocked") agent_error = result.get("error") or ""
error_msg = f"Step {i+1}/{len(steps)} ({role}) failed"
if agent_error:
error_msg += f": {agent_error}"
models.update_task(conn, task_id, status="blocked", blocked_reason=error_msg)
return { return {
"success": False, "success": False,
"error": f"Step {i+1}/{len(steps)} ({role}) failed", "error": error_msg,
"steps_completed": i, "steps_completed": i,
"results": results, "results": results,
"total_cost_usd": total_cost, "total_cost_usd": total_cost,
@ -524,6 +563,11 @@ def run_pipeline(
event="task_auto_approved", task_modules=task_modules) event="task_auto_approved", task_modules=task_modules)
except Exception: except Exception:
pass pass
try:
run_hooks(conn, project_id, task_id,
event="task_done", task_modules=task_modules)
except Exception:
pass
# Auto followup: generate tasks, auto-resolve permission issues. # Auto followup: generate tasks, auto-resolve permission issues.
# Guard: skip for followup-sourced tasks to prevent infinite recursion. # Guard: skip for followup-sourced tasks to prevent infinite recursion.

View file

@ -81,6 +81,26 @@ specialists:
context_rules: context_rules:
decisions_category: security decisions_category: security
tech_researcher:
name: "Tech Researcher"
model: sonnet
tools: [Read, Grep, Glob, WebFetch, Bash]
description: "Studies external APIs (docs, endpoints, limits, quirks), compares with codebase, produces structured review"
permissions: read_only
context_rules:
decisions: [gotcha, workaround]
output_schema:
status: "done | partial | blocked"
api_overview: string
endpoints: "array of { method, path, description, params, response_schema }"
rate_limits: "{ requests_per_minute, requests_per_day, notes }"
auth_method: string
data_schemas: "array of { name, fields }"
limitations: "array of strings"
gotchas: "array of strings"
codebase_diff: "array of { file, line_hint, issue, suggestion }"
notes: string
# Route templates — PM uses these to build pipelines # Route templates — PM uses these to build pipelines
routes: routes:
debug: debug:
@ -102,3 +122,7 @@ routes:
security_audit: security_audit:
steps: [security, architect] steps: [security, architect]
description: "Audit → remediation plan" description: "Audit → remediation plan"
api_research:
steps: [tech_researcher, architect]
description: "Study external API → integration plan"

View file

@ -141,6 +141,7 @@ def project_show(ctx, id):
click.echo(f" Path: {p['path']}") click.echo(f" Path: {p['path']}")
click.echo(f" Status: {p['status']}") click.echo(f" Status: {p['status']}")
click.echo(f" Priority: {p['priority']}") click.echo(f" Priority: {p['priority']}")
click.echo(f" Mode: {p.get('execution_mode') or 'review'}")
if p.get("tech_stack"): if p.get("tech_stack"):
click.echo(f" Tech stack: {', '.join(p['tech_stack'])}") click.echo(f" Tech stack: {', '.join(p['tech_stack'])}")
if p.get("forgejo_repo"): if p.get("forgejo_repo"):
@ -148,6 +149,21 @@ def project_show(ctx, id):
click.echo(f" Created: {p['created_at']}") click.echo(f" Created: {p['created_at']}")
@project.command("set-mode")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.argument("mode", type=click.Choice(["auto", "review"]))
@click.pass_context
def project_set_mode(ctx, project_id, mode):
"""Set execution mode for a project (auto|review)."""
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)
models.update_project(conn, project_id, execution_mode=mode)
click.echo(f"Project '{project_id}' execution_mode set to '{mode}'.")
# =========================================================================== # ===========================================================================
# task # task
# =========================================================================== # ===========================================================================
@ -204,11 +220,15 @@ def task_show(ctx, id):
if not t: if not t:
click.echo(f"Task '{id}' not found.", err=True) click.echo(f"Task '{id}' not found.", err=True)
raise SystemExit(1) raise SystemExit(1)
effective_mode = models.get_effective_mode(conn, t["project_id"], t["id"])
task_mode = t.get("execution_mode")
mode_label = f"{effective_mode} (overridden)" if task_mode else f"{effective_mode} (inherited)"
click.echo(f"Task: {t['id']}") click.echo(f"Task: {t['id']}")
click.echo(f" Project: {t['project_id']}") click.echo(f" Project: {t['project_id']}")
click.echo(f" Title: {t['title']}") click.echo(f" Title: {t['title']}")
click.echo(f" Status: {t['status']}") click.echo(f" Status: {t['status']}")
click.echo(f" Priority: {t['priority']}") click.echo(f" Priority: {t['priority']}")
click.echo(f" Mode: {mode_label}")
if t.get("assigned_role"): if t.get("assigned_role"):
click.echo(f" Role: {t['assigned_role']}") click.echo(f" Role: {t['assigned_role']}")
if t.get("parent_task_id"): if t.get("parent_task_id"):
@ -223,13 +243,14 @@ def task_show(ctx, id):
@task.command("update") @task.command("update")
@click.argument("task_id") @click.argument("task_id")
@click.option("--status", type=click.Choice( @click.option("--status", type=click.Choice(models.VALID_TASK_STATUSES),
["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]),
default=None, help="New status") default=None, help="New status")
@click.option("--priority", type=int, default=None, help="New priority (1-10)") @click.option("--priority", type=int, default=None, help="New priority (1-10)")
@click.option("--mode", "mode", type=click.Choice(["auto", "review"]),
default=None, help="Override execution mode for this task")
@click.pass_context @click.pass_context
def task_update(ctx, task_id, status, priority): def task_update(ctx, task_id, status, priority, mode):
"""Update a task's status or priority.""" """Update a task's status, priority, or execution mode."""
conn = ctx.obj["conn"] conn = ctx.obj["conn"]
t = models.get_task(conn, task_id) t = models.get_task(conn, task_id)
if not t: if not t:
@ -240,11 +261,13 @@ def task_update(ctx, task_id, status, priority):
fields["status"] = status fields["status"] = status
if priority is not None: if priority is not None:
fields["priority"] = priority fields["priority"] = priority
if mode is not None:
fields["execution_mode"] = mode
if not fields: if not fields:
click.echo("Nothing to update. Use --status or --priority.", err=True) click.echo("Nothing to update. Use --status, --priority, or --mode.", err=True)
raise SystemExit(1) raise SystemExit(1)
updated = models.update_task(conn, task_id, **fields) updated = models.update_task(conn, task_id, **fields)
click.echo(f"Updated {updated['id']}: status={updated['status']}, priority={updated['priority']}") click.echo(f"Updated {updated['id']}: status={updated['status']}, priority={updated['priority']}, mode={updated.get('execution_mode') or '(inherited)'}")
# =========================================================================== # ===========================================================================
@ -816,7 +839,8 @@ def hook_logs(ctx, project_id, limit):
def hook_setup(ctx, project_id, scripts_dir): def hook_setup(ctx, project_id, scripts_dir):
"""Register standard hooks for a project. """Register standard hooks for a project.
Currently registers: rebuild-frontend (fires on web/frontend/* changes). Registers: rebuild-frontend (fires on web/frontend/* changes),
auto-commit (fires on task_done git add -A && git commit).
Idempotent skips hooks that already exist. Idempotent skips hooks that already exist.
""" """
conn = ctx.obj["conn"] conn = ctx.obj["conn"]
@ -838,7 +862,6 @@ def hook_setup(ctx, project_id, scripts_dir):
name="rebuild-frontend", name="rebuild-frontend",
event="pipeline_completed", event="pipeline_completed",
command=rebuild_cmd, command=rebuild_cmd,
trigger_module_path="web/frontend/*",
working_dir=p.get("path"), working_dir=p.get("path"),
timeout_seconds=300, timeout_seconds=300,
) )
@ -846,6 +869,20 @@ def hook_setup(ctx, project_id, scripts_dir):
else: else:
click.echo("Hook 'rebuild-frontend' already exists, skipping.") click.echo("Hook 'rebuild-frontend' already exists, skipping.")
if "auto-commit" not in existing_names:
project_path = str(Path(p.get("path", ".")).expanduser())
hooks_module.create_hook(
conn, project_id,
name="auto-commit",
event="task_done",
command='git add -A && git commit -m "kin: {task_id} {title}"',
working_dir=project_path,
timeout_seconds=30,
)
created.append("auto-commit")
else:
click.echo("Hook 'auto-commit' already exists, skipping.")
if created: if created:
click.echo(f"Registered hooks: {', '.join(created)}") click.echo(f"Registered hooks: {', '.join(created)}")

View file

@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS tasks (
security_result JSON, security_result JSON,
forgejo_issue_id INTEGER, forgejo_issue_id INTEGER,
execution_mode TEXT, execution_mode TEXT,
blocked_reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@ -211,6 +212,9 @@ def _migrate(conn: sqlite3.Connection):
if "execution_mode" not in task_cols: if "execution_mode" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN execution_mode TEXT") conn.execute("ALTER TABLE tasks ADD COLUMN execution_mode TEXT")
conn.commit() conn.commit()
if "blocked_reason" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN blocked_reason TEXT")
conn.commit()
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection: def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:

View file

@ -146,6 +146,17 @@ def _get_hook(conn: sqlite3.Connection, hook_id: int) -> dict:
return dict(row) if row else {} return dict(row) if row else {}
def _substitute_vars(command: str, task_id: str | None, conn: sqlite3.Connection) -> str:
"""Substitute {task_id} and {title} in hook command."""
if task_id is None or "{task_id}" not in command and "{title}" not in command:
return command
row = conn.execute("SELECT title FROM tasks WHERE id = ?", (task_id,)).fetchone()
title = row["title"] if row else ""
# Sanitize title for shell safety (strip quotes and newlines)
safe_title = title.replace('"', "'").replace("\n", " ").replace("\r", "")
return command.replace("{task_id}", task_id).replace("{title}", safe_title)
def _execute_hook( def _execute_hook(
conn: sqlite3.Connection, conn: sqlite3.Connection,
hook: dict, hook: dict,
@ -159,9 +170,11 @@ def _execute_hook(
exit_code = -1 exit_code = -1
success = False success = False
command = _substitute_vars(hook["command"], task_id, conn)
try: try:
proc = subprocess.run( proc = subprocess.run(
hook["command"], command,
shell=True, shell=True,
cwd=hook.get("working_dir") or None, cwd=hook.get("working_dir") or None,
capture_output=True, capture_output=True,

View file

@ -9,6 +9,12 @@ from datetime import datetime
from typing import Any from typing import Any
VALID_TASK_STATUSES = [
"pending", "in_progress", "review", "done",
"blocked", "decomposed", "cancelled",
]
def _row_to_dict(row: sqlite3.Row | None) -> dict | None: def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
"""Convert sqlite3.Row to dict with JSON fields decoded.""" """Convert sqlite3.Row to dict with JSON fields decoded."""
if row is None: if row is None:
@ -249,6 +255,19 @@ def get_decisions(
return _rows_to_list(conn.execute(query, params).fetchall()) return _rows_to_list(conn.execute(query, params).fetchall())
def get_decision(conn: sqlite3.Connection, decision_id: int) -> dict | None:
"""Get a single decision by id."""
row = conn.execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
return _row_to_dict(row) if row else None
def delete_decision(conn: sqlite3.Connection, decision_id: int) -> bool:
"""Delete a decision by id. Returns True if deleted, False if not found."""
cur = conn.execute("DELETE FROM decisions WHERE id = ?", (decision_id,))
conn.commit()
return cur.rowcount > 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Modules # Modules
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -105,6 +105,18 @@ def test_approve_not_found(client):
assert r.status_code == 404 assert r.status_code == 404
def test_approve_fires_task_done_hooks(client):
"""Ручной апрув задачи должен вызывать хуки с event='task_done'."""
from unittest.mock import patch
with patch("core.hooks.run_hooks") as mock_hooks:
mock_hooks.return_value = []
r = client.post("/api/tasks/P1-001/approve", json={})
assert r.status_code == 200
events_fired = [call[1].get("event") or call[0][3]
for call in mock_hooks.call_args_list]
assert "task_done" in events_fired
def test_reject_task(client): def test_reject_task(client):
from core.db import init_db from core.db import init_db
from core import models from core import models
@ -173,14 +185,15 @@ def test_run_not_found(client):
assert r.status_code == 404 assert r.status_code == 404
def test_run_with_allow_write(client): def test_run_kin_038_without_allow_write(client):
"""POST /run with allow_write=true should be accepted.""" """Регрессионный тест KIN-038: allow_write удалён из схемы,
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True}) эндпоинт принимает запросы с пустым телом без этого параметра."""
r = client.post("/api/tasks/P1-001/run", json={})
assert r.status_code == 202 assert r.status_code == 202
def test_run_with_empty_body(client): def test_run_with_empty_body(client):
"""POST /run with empty JSON body should default allow_write=false.""" """POST /run with empty JSON body should be accepted."""
r = client.post("/api/tasks/P1-001/run", json={}) r = client.post("/api/tasks/P1-001/run", json={})
assert r.status_code == 202 assert r.status_code == 202
@ -256,14 +269,61 @@ def test_patch_task_status_persisted(client):
assert r.json()["status"] == "blocked" assert r.json()["status"] == "blocked"
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "cancelled"]) @pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"])
def test_patch_task_all_valid_statuses(client, status): def test_patch_task_all_valid_statuses(client, status):
"""Все 6 допустимых статусов должны приниматься.""" """Все 7 допустимых статусов должны приниматься (включая decomposed)."""
r = client.patch("/api/tasks/P1-001", json={"status": status}) r = client.patch("/api/tasks/P1-001", json={"status": status})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["status"] == status assert r.json()["status"] == status
def test_patch_task_status_decomposed(client):
"""Регрессионный тест KIN-033: API принимает статус 'decomposed'."""
r = client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
assert r.status_code == 200
assert r.json()["status"] == "decomposed"
def test_patch_task_status_decomposed_persisted(client):
"""После установки 'decomposed' повторный GET возвращает этот статус."""
client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["status"] == "decomposed"
# ---------------------------------------------------------------------------
# KIN-033 — единый источник истины для статусов
# ---------------------------------------------------------------------------
def test_api_valid_statuses_match_models():
"""API использует models.VALID_TASK_STATUSES как единственный источник истины."""
from core import models
import web.api as api_module
assert api_module.VALID_STATUSES == set(models.VALID_TASK_STATUSES)
def test_cli_valid_statuses_match_models():
"""CLI использует models.VALID_TASK_STATUSES как единственный источник истины."""
from core import models
from cli.main import task_update
status_param = next(p for p in task_update.params if p.name == "status")
cli_choices = set(status_param.type.choices)
assert cli_choices == set(models.VALID_TASK_STATUSES)
def test_cli_and_api_statuses_are_identical():
"""Список статусов в CLI и API идентичен."""
from core import models
import web.api as api_module
from cli.main import task_update
status_param = next(p for p in task_update.params if p.name == "status")
cli_choices = set(status_param.type.choices)
assert cli_choices == api_module.VALID_STATUSES
assert "decomposed" in cli_choices
assert "decomposed" in api_module.VALID_STATUSES
def test_patch_task_invalid_status(client): def test_patch_task_invalid_status(client):
"""Недопустимый статус → 400.""" """Недопустимый статус → 400."""
r = client.patch("/api/tasks/P1-001", json={"status": "flying"}) r = client.patch("/api/tasks/P1-001", json={"status": "flying"})
@ -274,3 +334,258 @@ def test_patch_task_not_found(client):
"""Несуществующая задача → 404.""" """Несуществующая задача → 404."""
r = client.patch("/api/tasks/NOPE-999", json={"status": "done"}) r = client.patch("/api/tasks/NOPE-999", json={"status": "done"})
assert r.status_code == 404 assert r.status_code == 404
def test_patch_task_empty_body_returns_400(client):
"""PATCH с пустым телом (нет status и нет execution_mode) → 400."""
r = client.patch("/api/tasks/P1-001", json={})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# KIN-022 — blocked_reason: регрессионные тесты
# ---------------------------------------------------------------------------
def test_blocked_reason_saved_and_returned(client):
"""При переходе в blocked с blocked_reason поле сохраняется и отдаётся в GET."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="Step 1/2 (debugger) failed")
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] == "Step 1/2 (debugger) failed"
def test_blocked_reason_present_in_full(client):
"""blocked_reason также присутствует в /full эндпоинте."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="tester agent crashed")
conn.close()
r = client.get("/api/tasks/P1-001/full")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] == "tester agent crashed"
def test_blocked_reason_none_by_default(client):
"""Новая задача не имеет blocked_reason."""
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["blocked_reason"] is None
def test_blocked_without_reason_allowed(client):
"""Переход в blocked без причины допустим (reason=None)."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked")
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] is None
def test_blocked_reason_cleared_on_retry(client):
"""При повторном запуске (статус pending) blocked_reason сбрасывается."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="failed once")
models.update_task(conn, "P1-001", status="pending", blocked_reason=None)
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "pending"
assert data["blocked_reason"] is None
# ---------------------------------------------------------------------------
# KIN-029 — DELETE /api/projects/{project_id}/decisions/{decision_id}
# ---------------------------------------------------------------------------
def test_delete_decision_ok(client):
"""Создаём decision через POST, удаляем DELETE → 200 с телом {"deleted": id}."""
r = client.post("/api/decisions", json={
"project_id": "p1",
"type": "decision",
"title": "Use SQLite",
"description": "Chosen for simplicity",
})
assert r.status_code == 200
decision_id = r.json()["id"]
r = client.delete(f"/api/projects/p1/decisions/{decision_id}")
assert r.status_code == 200
assert r.json() == {"deleted": decision_id}
r = client.get("/api/decisions?project=p1")
assert r.status_code == 200
ids = [d["id"] for d in r.json()]
assert decision_id not in ids
def test_delete_decision_not_found(client):
"""DELETE несуществующего decision → 404."""
r = client.delete("/api/projects/p1/decisions/99999")
assert r.status_code == 404
def test_delete_decision_wrong_project(client):
"""DELETE decision с чужим project_id → 404 (не раскрываем существование)."""
r = client.post("/api/decisions", json={
"project_id": "p1",
"type": "decision",
"title": "Cross-project check",
"description": "Should not be deletable from p2",
})
assert r.status_code == 200
decision_id = r.json()["id"]
r = client.delete(f"/api/projects/p2/decisions/{decision_id}")
assert r.status_code == 404
# Decision должен остаться нетронутым
r = client.get("/api/decisions?project=p1")
ids = [d["id"] for d in r.json()]
assert decision_id in ids
# ---------------------------------------------------------------------------
# KIN-035 — регрессионный тест: смена статуса на cancelled
# ---------------------------------------------------------------------------
def test_patch_task_status_cancelled(client):
"""Регрессионный тест KIN-035: PATCH /api/tasks/{id} с status='cancelled' → 200."""
r = client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_patch_task_status_cancelled_persisted(client):
"""После установки 'cancelled' повторный GET возвращает этот статус."""
client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_cancelled_in_valid_statuses():
"""'cancelled' присутствует в VALID_TASK_STATUSES модели и в VALID_STATUSES API."""
from core import models
import web.api as api_module
assert "cancelled" in models.VALID_TASK_STATUSES
assert "cancelled" in api_module.VALID_STATUSES
# ---------------------------------------------------------------------------
# KIN-036 — регрессионный тест: --allow-write всегда в команде через web API
# ---------------------------------------------------------------------------
def test_run_always_includes_allow_write_when_body_false(client):
"""Регрессионный тест KIN-036: --allow-write присутствует в команде,
даже если allow_write=False в теле запроса.
Баг: условие `if body and body.allow_write` не добавляло флаг при
allow_write=False, что приводило к блокировке агента на 300 с."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run", json={"allow_write": False})
assert r.status_code == 202
cmd = mock_popen.call_args[0][0]
assert "--allow-write" in cmd, (
"--allow-write обязан присутствовать всегда: без него агент зависает "
"при попытке записи, потому что stdin=DEVNULL и нет интерактивного подтверждения"
)
def test_run_always_includes_allow_write_without_body(client):
"""Регрессионный тест KIN-036: --allow-write присутствует даже без тела запроса."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
cmd = mock_popen.call_args[0][0]
assert "--allow-write" in cmd
def test_run_sets_kin_noninteractive_env(client):
"""Регрессионный тест KIN-036: KIN_NONINTERACTIVE=1 всегда устанавливается
при запуске через web API, что вместе с --allow-write предотвращает зависание."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 99
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
call_kwargs = mock_popen.call_args[1]
env = call_kwargs.get("env", {})
assert env.get("KIN_NONINTERACTIVE") == "1"
def test_run_sets_stdin_devnull(client):
"""Регрессионный тест KIN-036: stdin=DEVNULL всегда устанавливается,
что является причиной, по которой --allow-write обязателен."""
import subprocess as _subprocess
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 42
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
call_kwargs = mock_popen.call_args[1]
assert call_kwargs.get("stdin") == _subprocess.DEVNULL
# ---------------------------------------------------------------------------
# KIN-040 — регрессионные тесты: удаление TaskRun / allow_write из схемы
# ---------------------------------------------------------------------------
def test_run_kin_040_no_taskrun_class():
"""Регрессионный тест KIN-040: класс TaskRun удалён из web/api.py.
allow_write больше не является частью схемы эндпоинта /run."""
import web.api as api_module
assert not hasattr(api_module, "TaskRun"), (
"Класс TaskRun должен быть удалён из web/api.py (KIN-040)"
)
def test_run_kin_040_allow_write_true_ignored(client):
"""Регрессионный тест KIN-040: allow_write=True в теле игнорируется (не 422).
Эндпоинт не имеет body-параметра, поэтому FastAPI не валидирует тело."""
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
assert r.status_code == 202

View file

@ -257,24 +257,6 @@ class TestAutoRerunOnPermissionDenied:
task = models.get_task(conn, "VDOL-001") task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked" assert task["status"] == "blocked"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn):
"""Регрессия: review-режим НЕ делает авто-retry при permission error."""
mock_run.return_value = _mock_permission_denied()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект остаётся в default review mode
steps = [{"role": "debugger", "brief": "fix"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1, "Review-mode не должен retry"
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
@patch("core.followup.generate_followups") @patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks") @patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
@ -318,6 +300,24 @@ class TestAutoRerunOnPermissionDenied:
assert result["success"] is False assert result["success"] is False
assert mock_run.call_count == 1, "Retry не нужен для обычных ошибок" assert mock_run.call_count == 1, "Retry не нужен для обычных ошибок"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn):
"""В review-режиме при permission denied runner НЕ делает retry."""
mock_run.return_value = _mock_permission_denied()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект в default review mode
steps = [{"role": "debugger", "brief": "fix file"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1, "В review-режиме retry НЕ должен происходить"
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# test_auto_followup # test_auto_followup

View file

@ -333,7 +333,8 @@ def test_hook_setup_registers_rebuild_frontend(runner, tmp_path):
r = invoke(runner, ["hook", "list", "--project", "p1"]) r = invoke(runner, ["hook", "list", "--project", "p1"])
assert r.exit_code == 0 assert r.exit_code == 0
assert "rebuild-frontend" in r.output assert "rebuild-frontend" in r.output
assert "web/frontend/*" in r.output # KIN-050: trigger_module_path должен быть NULL — хук срабатывает безусловно
assert "web/frontend/*" not in r.output
def test_hook_setup_idempotent(runner, tmp_path): def test_hook_setup_idempotent(runner, tmp_path):
@ -352,3 +353,123 @@ def test_hook_setup_project_not_found(runner):
r = invoke(runner, ["hook", "setup", "--project", "nope"]) r = invoke(runner, ["hook", "setup", "--project", "nope"])
assert r.exit_code == 1 assert r.exit_code == 1
assert "not found" in r.output assert "not found" in r.output
# ===========================================================================
# KIN-018 — project set-mode / task update --mode / show with mode labels
# ===========================================================================
def test_project_set_mode_auto(runner):
"""project set-mode auto — обновляет режим, выводит подтверждение."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
assert r.exit_code == 0
assert "auto" in r.output
def test_project_set_mode_review(runner):
"""project set-mode review — обновляет режим обратно в review."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "review"])
assert r.exit_code == 0
assert "review" in r.output
def test_project_set_mode_persisted(runner):
"""После project set-mode режим сохраняется в БД и виден в project show."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
r = invoke(runner, ["project", "show", "p1"])
assert r.exit_code == 0
assert "auto" in r.output
def test_project_set_mode_not_found(runner):
"""project set-mode для несуществующего проекта → exit code 1."""
r = invoke(runner, ["project", "set-mode", "--project", "nope", "auto"])
assert r.exit_code == 1
assert "not found" in r.output
def test_project_set_mode_invalid(runner):
"""project set-mode с недопустимым значением → ошибка click."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "turbo"])
assert r.exit_code != 0
def test_project_show_displays_mode(runner):
"""project show отображает строку Mode: ..."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "show", "p1"])
assert r.exit_code == 0
assert "Mode:" in r.output
def test_task_update_mode_auto(runner):
"""task update --mode auto задаёт execution_mode на задачу."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
assert r.exit_code == 0
assert "auto" in r.output
def test_task_update_mode_review(runner):
"""task update --mode review задаёт execution_mode=review на задачу."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
assert r.exit_code == 0
assert "review" in r.output
def test_task_update_mode_persisted(runner):
"""После task update --mode режим сохраняется и виден в task show как (overridden)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "overridden" in r.output
def test_task_update_mode_invalid(runner):
"""task update --mode с недопустимым значением → ошибка click."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "turbo"])
assert r.exit_code != 0
def test_task_show_mode_inherited(runner):
"""task show без явного execution_mode показывает (inherited)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "inherited" in r.output
def test_task_show_mode_overridden(runner):
"""task show с task-level execution_mode показывает (overridden)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "overridden" in r.output
def test_task_show_mode_label_reflects_project_mode(runner):
"""Если у проекта auto, у задачи нет mode — task show показывает 'auto (inherited)'."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "auto" in r.output
assert "inherited" in r.output

View file

@ -8,7 +8,7 @@ from core.db import init_db
from core import models from core import models
from core.hooks import ( from core.hooks import (
create_hook, get_hooks, update_hook, delete_hook, create_hook, get_hooks, update_hook, delete_hook,
run_hooks, get_hook_logs, HookResult, run_hooks, get_hook_logs, HookResult, _substitute_vars,
) )
@ -273,3 +273,298 @@ class TestGetHookLogs:
event="pipeline_completed", task_modules=modules) event="pipeline_completed", task_modules=modules)
logs = get_hook_logs(conn, project_id="vdol", limit=3) logs = get_hook_logs(conn, project_id="vdol", limit=3)
assert len(logs) == 3 assert len(logs) == 3
# ---------------------------------------------------------------------------
# Variable substitution in hook commands
# ---------------------------------------------------------------------------
class TestSubstituteVars:
def test_substitutes_task_id_and_title(self, conn):
result = _substitute_vars(
'git commit -m "kin: {task_id} {title}"',
"VDOL-001",
conn,
)
assert result == 'git commit -m "kin: VDOL-001 Fix bug"'
def test_no_substitution_when_task_id_is_none(self, conn):
cmd = 'git commit -m "kin: {task_id} {title}"'
result = _substitute_vars(cmd, None, conn)
assert result == cmd
def test_sanitizes_double_quotes_in_title(self, conn):
conn.execute('UPDATE tasks SET title = ? WHERE id = ?',
('Fix "bug" here', "VDOL-001"))
conn.commit()
result = _substitute_vars(
'git commit -m "kin: {task_id} {title}"',
"VDOL-001",
conn,
)
assert '"' not in result.split('"kin:')[1].split('"')[0]
assert "Fix 'bug' here" in result
def test_sanitizes_newlines_in_title(self, conn):
conn.execute('UPDATE tasks SET title = ? WHERE id = ?',
("Fix\nbug\r\nhere", "VDOL-001"))
conn.commit()
result = _substitute_vars("{title}", "VDOL-001", conn)
assert "\n" not in result
assert "\r" not in result
def test_unknown_task_id_uses_empty_title(self, conn):
result = _substitute_vars("{task_id} {title}", "NONEXISTENT", conn)
assert result == "NONEXISTENT "
def test_no_placeholders_returns_command_unchanged(self, conn):
cmd = "npm run build"
result = _substitute_vars(cmd, "VDOL-001", conn)
assert result == cmd
@patch("core.hooks.subprocess.run")
def test_autocommit_hook_command_substituted(self, mock_run, conn):
"""auto-commit hook должен получать реальные task_id и title в команде."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(conn, "vdol", "auto-commit", "task_done",
'git add -A && git commit -m "kin: {task_id} {title}"',
working_dir="/tmp")
run_hooks(conn, "vdol", "VDOL-001", event="task_done", task_modules=[])
call_kwargs = mock_run.call_args[1]
# shell=True: command is the first positional arg
command = mock_run.call_args[0][0]
assert "VDOL-001" in command
assert "Fix bug" in command
# ---------------------------------------------------------------------------
# KIN-050: rebuild-frontend hook — unconditional firing after pipeline
# ---------------------------------------------------------------------------
class TestRebuildFrontendHookSetup:
"""Regression tests for KIN-050.
Баг: rebuild-frontend не срабатывал, если pipeline не трогал web/frontend/*.
Фикс: убран trigger_module_path из hook_setup хук должен срабатывать всегда.
"""
def test_rebuild_frontend_created_without_trigger_module_path(self, conn):
"""rebuild-frontend hook должен быть создан без trigger_module_path (KIN-050).
Воспроизводит логику hook_setup: создаём хук без фильтра и убеждаемся,
что он сохраняется в БД с trigger_module_path=NULL.
"""
hook = create_hook(
conn, "vdol",
name="rebuild-frontend",
event="pipeline_completed",
command="scripts/rebuild-frontend.sh",
trigger_module_path=None, # фикс KIN-050: без фильтра
working_dir="/tmp",
timeout_seconds=300,
)
assert hook["trigger_module_path"] is None, (
"trigger_module_path должен быть NULL — хук не должен фильтровать по модулям"
)
# Перечитываем из БД — убеждаемся, что NULL сохранился
hooks = get_hooks(conn, "vdol", enabled_only=False)
rebuild = next((h for h in hooks if h["name"] == "rebuild-frontend"), None)
assert rebuild is not None
assert rebuild["trigger_module_path"] is None
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_when_only_backend_modules_changed(self, mock_run, conn):
"""Хук без trigger_module_path должен срабатывать при изменении backend-файлов.
Регрессия KIN-050: раньше хук молчал, если не было web/frontend/* файлов.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="built!", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None, # фикс: нет фильтра
working_dir="/tmp",
)
backend_modules = [
{"path": "core/models.py", "name": "models"},
{"path": "web/api.py", "name": "api"},
]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=backend_modules)
assert len(results) == 1, "Хук должен сработать несмотря на отсутствие frontend-файлов"
assert results[0].name == "rebuild-frontend"
assert results[0].success is True
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_exactly_once_per_pipeline(self, mock_run, conn):
"""Хук rebuild-frontend должен срабатывать ровно один раз за pipeline_completed."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None,
working_dir="/tmp",
)
any_modules = [
{"path": "core/hooks.py", "name": "hooks"},
{"path": "web/frontend/App.vue", "name": "App"},
{"path": "web/api.py", "name": "api"},
]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=any_modules)
assert len(results) == 1, "Хук должен выполниться ровно один раз"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_with_empty_module_list(self, mock_run, conn):
"""Хук без trigger_module_path должен срабатывать даже с пустым списком модулей."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None,
working_dir="/tmp",
)
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=[])
assert len(results) == 1
assert results[0].name == "rebuild-frontend"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_with_module_path_skips_non_frontend(self, mock_run, conn):
"""Контрольный тест: хук С trigger_module_path НЕ срабатывает на backend-файлы.
Подтверждает, что фикс (удаление trigger_module_path) был необходим.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend-filtered", "pipeline_completed",
"npm run build",
trigger_module_path="web/frontend/*", # старое (сломанное) поведение
working_dir="/tmp",
)
backend_modules = [{"path": "core/models.py", "name": "models"}]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=backend_modules)
assert len(results) == 0, (
"Хук с trigger_module_path НЕ должен срабатывать на backend-файлы — "
"именно это было первопричиной бага KIN-050"
)
# ---------------------------------------------------------------------------
# KIN-052: rebuild-frontend hook — команда cd+&& и персистентность в БД
# ---------------------------------------------------------------------------
class TestKIN052RebuildFrontendCommand:
"""Регрессионные тесты для KIN-052.
Хук rebuild-frontend использует команду вида:
cd /path/to/frontend && npm run build
то есть цепочку shell-команд без working_dir.
Тесты проверяют, что такая форма работает корректно и хук переживает
пересоздание соединения с БД (симуляция рестарта).
"""
@patch("core.hooks.subprocess.run")
def test_cd_chained_command_passes_as_string_to_shell(self, mock_run, conn):
"""Команда с && должна передаваться в subprocess как строка (не список) с shell=True.
Если передать список ['cd', '/path', '&&', 'npm', 'run', 'build'] с shell=True,
shell проигнорирует аргументы после первого. Строковая форма обязательна.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="built!", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None, working_dir=None)
run_hooks(conn, "vdol", "VDOL-001", event="pipeline_completed", task_modules=[])
call_args = mock_run.call_args
passed_cmd = call_args[0][0]
assert isinstance(passed_cmd, str), (
"Команда с && должна передаваться как строка, иначе shell не раскроет &&"
)
assert "&&" in passed_cmd
assert call_args[1].get("shell") is True
@patch("core.hooks.subprocess.run")
def test_cd_command_without_working_dir_uses_cwd_none(self, mock_run, conn):
"""Хук с cd-командой и working_dir=None должен вызывать subprocess с cwd=None.
Директория смены задаётся через cd в самой команде, а не через cwd.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None, working_dir=None)
run_hooks(conn, "vdol", "VDOL-001", event="pipeline_completed", task_modules=[])
cwd = mock_run.call_args[1].get("cwd")
assert cwd is None, (
f"cwd должен быть None когда working_dir не задан, получили: {cwd!r}"
)
@patch("core.hooks.subprocess.run")
def test_cd_command_exits_zero_returns_success(self, mock_run, conn):
"""Хук с cd+npm run build при returncode=0 должен вернуть success=True."""
mock_run.return_value = MagicMock(returncode=0, stdout="✓ build complete", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=[])
assert len(results) == 1
assert results[0].success is True
assert results[0].name == "rebuild-frontend"
@patch("core.hooks.subprocess.run")
def test_hook_persists_after_db_reconnect(self, mock_run):
"""Хук должен сохраняться в файловой БД и быть доступен после пересоздания соединения.
Симулирует рестарт: создаём хук, закрываем соединение, открываем новое хук на месте.
"""
import tempfile
import os
from core.db import init_db
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
# Первое соединение — создаём проект и хук
conn1 = init_db(db_path)
from core import models as _models
_models.create_project(conn1, "kin", "Kin", "/projects/kin", tech_stack=["vue3"])
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
hook = create_hook(conn1, "kin", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
hook_id = hook["id"]
conn1.close()
# Второе соединение — «рестарт», хук должен быть на месте
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=True)
conn2.close()
assert len(hooks) == 1, "После пересоздания соединения хук должен оставаться в БД"
assert hooks[0]["id"] == hook_id
assert hooks[0]["name"] == "rebuild-frontend"
assert hooks[0]["command"] == cmd
assert hooks[0]["trigger_module_path"] is None
finally:
os.unlink(db_path)

View file

@ -348,6 +348,24 @@ class TestAutoMode:
assert result["success"] is True assert result["success"] is True
mock_followup.assert_not_called() mock_followup.assert_not_called()
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_fires_task_done_event(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode должен вызывать run_hooks с event='task_done' после task_auto_approved."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
events_fired = [call[1].get("event") or call[0][3]
for call in mock_hooks.call_args_list]
assert "task_done" in events_fired
@patch("core.followup.auto_resolve_pending_actions") @patch("core.followup.auto_resolve_pending_actions")
@patch("core.followup.generate_followups") @patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks") @patch("agents.runner.run_hooks")
@ -370,6 +388,50 @@ class TestAutoMode:
mock_resolve.assert_called_once_with(conn, "VDOL-001", pending) mock_resolve.assert_called_once_with(conn, "VDOL-001", pending)
# ---------------------------------------------------------------------------
# Retry on permission error
# ---------------------------------------------------------------------------
class TestRetryOnPermissionError:
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_retry_on_permission_error_auto_mode(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode: retry при permission error должен срабатывать."""
permission_fail = _mock_claude_failure("permission denied: cannot write file")
retry_success = _mock_claude_success({"result": "fixed"})
mock_run.side_effect = [permission_fail, retry_success]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
assert mock_run.call_count == 2
# Second call must include --dangerously-skip-permissions
second_cmd = mock_run.call_args_list[1][0][0]
assert "--dangerously-skip-permissions" in second_cmd
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, conn):
"""Review mode: retry при permission error НЕ должен срабатывать."""
permission_fail = _mock_claude_failure("permission denied: cannot write file")
mock_run.return_value = permission_fail
mock_hooks.return_value = []
# Проект остаётся в default "review" mode
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# JSON parsing # JSON parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -417,20 +479,22 @@ class TestNonInteractive:
call_kwargs = mock_run.call_args[1] call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("timeout") == 300 assert call_kwargs.get("timeout") == 300
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
def test_interactive_uses_600s_timeout(self, mock_run, conn): def test_interactive_uses_600s_timeout(self, mock_run, conn):
mock_run.return_value = _mock_claude_success({"result": "ok"}) mock_run.return_value = _mock_claude_success({"result": "ok"})
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
call_kwargs = mock_run.call_args[1] call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("timeout") == 300 assert call_kwargs.get("timeout") == 600
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
def test_interactive_no_stdin_override(self, mock_run, conn): def test_interactive_no_stdin_override(self, mock_run, conn):
"""In interactive mode, stdin should not be set to DEVNULL.""" """In interactive mode, stdin should not be set to DEVNULL."""
mock_run.return_value = _mock_claude_success({"result": "ok"}) mock_run.return_value = _mock_claude_success({"result": "ok"})
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
call_kwargs = mock_run.call_args[1] call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("stdin") == subprocess.DEVNULL assert call_kwargs.get("stdin") is None
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"}) @patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"})
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
@ -582,3 +646,108 @@ class TestRunAudit:
cmd = mock_run.call_args[0][0] cmd = mock_run.call_args[0][0]
assert "--dangerously-skip-permissions" in cmd assert "--dangerously-skip-permissions" in cmd
# ---------------------------------------------------------------------------
# KIN-019: Silent FAILED diagnostics (regression tests)
# ---------------------------------------------------------------------------
class TestSilentFailedDiagnostics:
"""Regression: агент падает без вывода — runner должен сохранять диагностику в БД."""
@patch("agents.runner.subprocess.run")
def test_agent_empty_stdout_saves_stderr_as_error_message_in_db(self, mock_run, conn):
"""Когда stdout пустой и returncode != 0, stderr должен сохраняться как error_message в agent_logs."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "API rate limit exceeded (429)"
mock.returncode = 1
mock_run.return_value = mock
run_agent(conn, "debugger", "VDOL-001", "vdol")
log = conn.execute(
"SELECT error_message FROM agent_logs WHERE task_id='VDOL-001'"
).fetchone()
assert log is not None
assert log["error_message"] is not None
assert "rate limit" in log["error_message"]
@patch("agents.runner.subprocess.run")
def test_agent_empty_stdout_returns_error_key_with_stderr(self, mock_run, conn):
"""run_agent должен вернуть ключ 'error' с содержимым stderr при пустом stdout и ненулевом returncode."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Permission denied: cannot write to /etc/hosts"
mock.returncode = 1
mock_run.return_value = mock
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
assert result["success"] is False
assert "error" in result
assert result["error"] is not None
assert "Permission denied" in result["error"]
@patch("agents.runner.subprocess.run")
def test_pipeline_error_message_includes_agent_stderr(self, mock_run, conn):
"""Сообщение об ошибке pipeline должно включать stderr агента, а не только generic 'step failed'."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Internal server error: unexpected EOF"
mock.returncode = 1
mock_run.return_value = mock
steps = [{"role": "tester", "brief": "run tests"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert "Internal server error" in result["error"] or "unexpected EOF" in result["error"]
@patch("agents.runner.build_context")
def test_pipeline_exception_in_run_agent_marks_task_blocked(self, mock_ctx, conn):
"""Исключение внутри run_agent (например, из build_context) должно ставить задачу в blocked."""
mock_ctx.side_effect = RuntimeError("DB connection lost")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
@patch("agents.runner.build_context")
def test_pipeline_exception_logs_to_agent_logs(self, mock_ctx, conn):
"""Исключение в run_agent должно быть залогировано в agent_logs с success=False."""
mock_ctx.side_effect = ValueError("bad context data")
steps = [{"role": "tester", "brief": "test"}]
run_pipeline(conn, "VDOL-001", steps)
logs = conn.execute(
"SELECT * FROM agent_logs WHERE task_id='VDOL-001' AND success=0"
).fetchall()
assert len(logs) >= 1
@patch("agents.runner.build_context")
def test_pipeline_exception_marks_pipeline_failed_in_db(self, mock_ctx, conn):
"""При исключении запись pipeline должна существовать в БД и иметь статус failed."""
mock_ctx.side_effect = RuntimeError("network timeout")
steps = [{"role": "debugger", "brief": "find"}]
run_pipeline(conn, "VDOL-001", steps)
pipe = conn.execute("SELECT * FROM pipelines WHERE task_id='VDOL-001'").fetchone()
assert pipe is not None
assert pipe["status"] == "failed"
@patch("agents.runner.subprocess.run")
def test_agent_success_has_no_error_key_populated(self, mock_run, conn):
"""При успешном запуске агента ключ 'error' в результате должен быть None (нет ложных срабатываний)."""
mock_run.return_value = _mock_claude_success({"result": "all good"})
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
assert result["success"] is True
assert result.get("error") is None

View file

@ -0,0 +1,195 @@
"""Tests for KIN-037: tech_researcher specialist — YAML validation and prompt structure."""
from pathlib import Path
import yaml
import pytest
SPECIALISTS_YAML = Path(__file__).parent.parent / "agents" / "specialists.yaml"
PROMPTS_DIR = Path(__file__).parent.parent / "agents" / "prompts"
TECH_RESEARCHER_PROMPT = PROMPTS_DIR / "tech_researcher.md"
REQUIRED_SPECIALIST_FIELDS = {"name", "model", "tools", "description", "permissions"}
REQUIRED_OUTPUT_SCHEMA_FIELDS = {
"status", "api_overview", "endpoints", "rate_limits", "auth_method",
"data_schemas", "limitations", "gotchas", "codebase_diff", "notes",
}
@pytest.fixture(scope="module")
def spec():
"""Load and parse specialists.yaml once for all tests."""
return yaml.safe_load(SPECIALISTS_YAML.read_text())
@pytest.fixture(scope="module")
def tech_researcher(spec):
return spec["specialists"]["tech_researcher"]
@pytest.fixture(scope="module")
def prompt_text():
return TECH_RESEARCHER_PROMPT.read_text()
# ---------------------------------------------------------------------------
# YAML validity
# ---------------------------------------------------------------------------
class TestSpecialistsYaml:
def test_yaml_parses_without_error(self):
content = SPECIALISTS_YAML.read_text()
parsed = yaml.safe_load(content)
assert parsed is not None
def test_yaml_has_specialists_key(self, spec):
assert "specialists" in spec
def test_yaml_has_routes_key(self, spec):
assert "routes" in spec
# ---------------------------------------------------------------------------
# tech_researcher entry structure
# ---------------------------------------------------------------------------
class TestTechResearcherEntry:
def test_tech_researcher_exists_in_specialists(self, spec):
assert "tech_researcher" in spec["specialists"]
def test_tech_researcher_has_required_fields(self, tech_researcher):
missing = REQUIRED_SPECIALIST_FIELDS - set(tech_researcher.keys())
assert not missing, f"Missing fields: {missing}"
def test_tech_researcher_name_is_string(self, tech_researcher):
assert isinstance(tech_researcher["name"], str)
assert tech_researcher["name"].strip()
def test_tech_researcher_model_is_sonnet(self, tech_researcher):
assert tech_researcher["model"] == "sonnet"
def test_tech_researcher_tools_is_list(self, tech_researcher):
assert isinstance(tech_researcher["tools"], list)
assert len(tech_researcher["tools"]) > 0
def test_tech_researcher_tools_include_webfetch(self, tech_researcher):
assert "WebFetch" in tech_researcher["tools"]
def test_tech_researcher_tools_include_read_grep_glob(self, tech_researcher):
for tool in ("Read", "Grep", "Glob"):
assert tool in tech_researcher["tools"], f"Missing tool: {tool}"
def test_tech_researcher_permissions_is_read_only(self, tech_researcher):
assert tech_researcher["permissions"] == "read_only"
def test_tech_researcher_description_is_non_empty_string(self, tech_researcher):
assert isinstance(tech_researcher["description"], str)
assert len(tech_researcher["description"]) > 10
def test_tech_researcher_has_output_schema(self, tech_researcher):
assert "output_schema" in tech_researcher
def test_tech_researcher_output_schema_has_required_fields(self, tech_researcher):
schema = tech_researcher["output_schema"]
missing = REQUIRED_OUTPUT_SCHEMA_FIELDS - set(schema.keys())
assert not missing, f"Missing output_schema fields: {missing}"
def test_tech_researcher_context_rules_decisions_is_list(self, tech_researcher):
decisions = tech_researcher.get("context_rules", {}).get("decisions")
assert isinstance(decisions, list)
def test_tech_researcher_context_rules_includes_gotcha(self, tech_researcher):
decisions = tech_researcher.get("context_rules", {}).get("decisions", [])
assert "gotcha" in decisions
# ---------------------------------------------------------------------------
# api_research route
# ---------------------------------------------------------------------------
class TestApiResearchRoute:
def test_api_research_route_exists(self, spec):
assert "api_research" in spec["routes"]
def test_api_research_route_has_steps(self, spec):
route = spec["routes"]["api_research"]
assert "steps" in route
assert isinstance(route["steps"], list)
assert len(route["steps"]) >= 1
def test_api_research_route_starts_with_tech_researcher(self, spec):
steps = spec["routes"]["api_research"]["steps"]
assert steps[0] == "tech_researcher"
def test_api_research_route_includes_architect(self, spec):
steps = spec["routes"]["api_research"]["steps"]
assert "architect" in steps
def test_api_research_route_has_description(self, spec):
route = spec["routes"]["api_research"]
assert "description" in route
assert isinstance(route["description"], str)
# ---------------------------------------------------------------------------
# Prompt file existence
# ---------------------------------------------------------------------------
class TestTechResearcherPromptFile:
def test_prompt_file_exists(self):
assert TECH_RESEARCHER_PROMPT.exists(), (
f"Prompt file not found: {TECH_RESEARCHER_PROMPT}"
)
def test_prompt_file_is_not_empty(self, prompt_text):
assert len(prompt_text.strip()) > 100
# ---------------------------------------------------------------------------
# Prompt content — structured review instructions
# ---------------------------------------------------------------------------
class TestTechResearcherPromptContent:
def test_prompt_contains_json_output_instruction(self, prompt_text):
assert "JSON" in prompt_text or "json" in prompt_text
def test_prompt_defines_status_field(self, prompt_text):
assert '"status"' in prompt_text
def test_prompt_defines_done_partial_blocked_statuses(self, prompt_text):
assert "done" in prompt_text
assert "partial" in prompt_text
assert "blocked" in prompt_text
def test_prompt_defines_api_overview_field(self, prompt_text):
assert "api_overview" in prompt_text
def test_prompt_defines_endpoints_field(self, prompt_text):
assert "endpoints" in prompt_text
def test_prompt_defines_rate_limits_field(self, prompt_text):
assert "rate_limits" in prompt_text
def test_prompt_defines_codebase_diff_field(self, prompt_text):
assert "codebase_diff" in prompt_text
def test_prompt_defines_gotchas_field(self, prompt_text):
assert "gotchas" in prompt_text
def test_prompt_contains_webfetch_instruction(self, prompt_text):
assert "WebFetch" in prompt_text
def test_prompt_mentions_no_secrets_logging(self, prompt_text):
"""Prompt must instruct agent not to log secret values."""
lower = prompt_text.lower()
assert "secret" in lower or "credential" in lower or "token" in lower
def test_prompt_specifies_readonly_bash(self, prompt_text):
"""Bash must be restricted to read-only operations per rules."""
assert "read-only" in prompt_text or "read only" in prompt_text or "GET" in prompt_text
def test_prompt_defines_partial_reason_for_partial_status(self, prompt_text):
assert "partial_reason" in prompt_text
def test_prompt_defines_blocked_reason_for_blocked_status(self, prompt_text):
assert "blocked_reason" in prompt_text

View file

@ -161,7 +161,7 @@ class TaskPatch(BaseModel):
execution_mode: str | None = None execution_mode: str | None = None
VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked", "cancelled"} VALID_STATUSES = set(models.VALID_TASK_STATUSES)
VALID_EXECUTION_MODES = {"auto", "review"} VALID_EXECUTION_MODES = {"auto", "review"}
@ -248,6 +248,13 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
conn.close() conn.close()
raise HTTPException(404, f"Task '{task_id}' not found") raise HTTPException(404, f"Task '{task_id}' not found")
models.update_task(conn, task_id, status="done") models.update_task(conn, task_id, status="done")
try:
from core.hooks import run_hooks as _run_hooks
task_modules = models.get_modules(conn, t["project_id"])
_run_hooks(conn, t["project_id"], task_id,
event="task_done", task_modules=task_modules)
except Exception:
pass
decision = None decision = None
if body and body.decision_title: if body and body.decision_title:
decision = models.add_decision( decision = models.add_decision(
@ -328,12 +335,8 @@ 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, body: TaskRun | None = None): def run_task(task_id: str):
"""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)
@ -347,8 +350,7 @@ def run_task(task_id: str, body: TaskRun | None = None):
kin_root = Path(__file__).parent.parent kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id] "run", task_id]
if body and body.allow_write: cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
cmd.append("--allow-write")
import os import os
env = os.environ.copy() env = os.environ.copy()
@ -413,6 +415,18 @@ def create_decision(body: DecisionCreate):
return d return d
@app.delete("/api/projects/{project_id}/decisions/{decision_id}")
def delete_decision(project_id: str, decision_id: int):
conn = get_conn()
decision = models.get_decision(conn, decision_id)
if not decision or decision["project_id"] != project_id:
conn.close()
raise HTTPException(404, f"Decision #{decision_id} not found")
models.delete_decision(conn, decision_id)
conn.close()
return {"deleted": decision_id}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cost # Cost
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,14 +1,15 @@
/** /**
* KIN-011: Тесты сохранения фильтра статусов при навигации * KIN-011/KIN-014: Тесты фильтра статусов при навигации
* *
* Проверяет: * Проверяет:
* 1. Выбор фильтра обновляет URL (?status=...) * 1. Клик по кнопке статуса обновляет URL (?status=...)
* 2. Прямая ссылка с query param инициализирует фильтр * 2. Прямая ссылка с query param активирует нужную кнопку
* 3. Фильтр показывает только задачи с нужным статусом * 3. Фильтр показывает только задачи с нужным статусом
* 4. Сброс фильтра удаляет param из URL * 4. Сброс фильтра () удаляет param из URL
* 5. goBack() вызывает router.back() при наличии истории * 5. Без фильтра отображаются все задачи
* 6. goBack() делает push на /project/:id без истории * 6. goBack() вызывает router.back() при наличии истории
* 7. После router.back() URL проекта восстанавливается с фильтром * 7. goBack() делает push на /project/:id без истории
* 8. После router.back() URL проекта восстанавливается с фильтром
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
@ -117,8 +118,8 @@ beforeEach(() => {
// ProjectView: фильтр ↔ URL // ProjectView: фильтр ↔ URL
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
describe('KIN-011: ProjectView — фильтр и URL', () => { describe('KIN-011/KIN-014: ProjectView — фильтр и URL', () => {
it('1. При выборе фильтра URL обновляется query param ?status', async () => { it('1. Клик по кнопке статуса обновляет URL (?status=...)', async () => {
const router = makeRouter() const router = makeRouter()
await router.push('/project/KIN') await router.push('/project/KIN')
@ -131,16 +132,16 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
// Изначально status нет в URL // Изначально status нет в URL
expect(router.currentRoute.value.query.status).toBeUndefined() expect(router.currentRoute.value.query.status).toBeUndefined()
// Меняем фильтр через select (первый select — фильтр статусов) // Кликаем по кнопке in_progress
const select = wrapper.find('select') const btn = wrapper.find('[data-status="in_progress"]')
await select.setValue('in_progress') await btn.trigger('click')
await flushPromises() await flushPromises()
// URL должен содержать ?status=in_progress // URL должен содержать ?status=in_progress
expect(router.currentRoute.value.query.status).toBe('in_progress') expect(router.currentRoute.value.query.status).toBe('in_progress')
}) })
it('2. Прямая ссылка ?status=in_progress инициализирует фильтр в select', async () => { it('2. Прямая ссылка ?status=in_progress активирует нужную кнопку', async () => {
const router = makeRouter() const router = makeRouter()
await router.push('/project/KIN?status=in_progress') await router.push('/project/KIN?status=in_progress')
@ -150,9 +151,13 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
}) })
await flushPromises() await flushPromises()
// select должен показывать in_progress // Кнопка in_progress должна быть активна (иметь класс text-blue-300)
const select = wrapper.find('select') const btn = wrapper.find('[data-status="in_progress"]')
expect((select.element as HTMLSelectElement).value).toBe('in_progress') expect(btn.classes()).toContain('text-blue-300')
// Другие кнопки не активны
const pendingBtn = wrapper.find('[data-status="pending"]')
expect(pendingBtn.classes()).not.toContain('text-blue-300')
}) })
it('3. Прямая ссылка ?status=in_progress показывает только задачи с этим статусом', async () => { it('3. Прямая ссылка ?status=in_progress показывает только задачи с этим статусом', async () => {
@ -171,7 +176,7 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
expect(links[0].text()).toContain('KIN-002') expect(links[0].text()).toContain('KIN-002')
}) })
it('4. Сброс фильтра (пустое значение) удаляет status из URL', async () => { it('4. Сброс фильтра (кнопка ✕) удаляет status из URL', async () => {
const router = makeRouter() const router = makeRouter()
await router.push('/project/KIN?status=done') await router.push('/project/KIN?status=done')
@ -181,9 +186,9 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
}) })
await flushPromises() await flushPromises()
// Сброс фильтра // Кликаем кнопку сброса
const select = wrapper.find('select') const clearBtn = wrapper.find('[data-action="clear-status"]')
await select.setValue('') await clearBtn.trigger('click')
await flushPromises() await flushPromises()
// status должен исчезнуть из URL // status должен исчезнуть из URL
@ -203,6 +208,89 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
const links = wrapper.findAll('a[href^="/task/"]') const links = wrapper.findAll('a[href^="/task/"]')
expect(links).toHaveLength(3) expect(links).toHaveLength(3)
}) })
it('KIN-014: Выбор нескольких статусов — URL содержит оба через запятую', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-status="pending"]').trigger('click')
await wrapper.find('[data-status="in_progress"]').trigger('click')
await flushPromises()
const status = router.currentRoute.value.query.status as string
expect(status.split(',').sort()).toEqual(['in_progress', 'pending'])
})
it('KIN-014: Фильтр сохраняется в localStorage', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-status="pending"]').trigger('click')
await flushPromises()
const stored = JSON.parse(localStorageMock.getItem('kin-task-statuses-KIN') ?? '[]')
expect(stored).toContain('pending')
})
})
// ─────────────────────────────────────────────────────────────
// KIN-046: кнопки фильтра и сигнатура runTask
// ─────────────────────────────────────────────────────────────
describe('KIN-046: ProjectView — фильтр статусов и runTask', () => {
it('Все 7 кнопок фильтра статусов отображаются в DOM', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
for (const s of ALL_TASK_STATUSES) {
expect(wrapper.find(`[data-status="${s}"]`).exists(), `кнопка "${s}" должна быть в DOM`).toBe(true)
}
})
it('api.runTask вызывается только с taskId — без второго аргумента', async () => {
vi.mocked(api.runTask).mockResolvedValue({ status: 'ok' } as any)
vi.spyOn(window, 'confirm').mockReturnValue(true)
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// KIN-001 имеет статус pending — кнопка "Run pipeline" должна быть видна
const runBtn = wrapper.find('button[title="Run pipeline"]')
expect(runBtn.exists()).toBe(true)
await runBtn.trigger('click')
await flushPromises()
expect(api.runTask).toHaveBeenCalledTimes(1)
// Проверяем: вызван только с taskId, второй аргумент (autoMode) отсутствует
const callArgs = vi.mocked(api.runTask).mock.calls[0]
expect(callArgs).toHaveLength(1)
expect(callArgs[0]).toBe('KIN-001')
})
}) })
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@ -210,7 +298,7 @@ describe('KIN-011: ProjectView — фильтр и URL', () => {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
describe('KIN-011: TaskDetail — возврат с сохранением URL', () => { describe('KIN-011: TaskDetail — возврат с сохранением URL', () => {
it('6. goBack() вызывает router.back() когда window.history.length > 1', async () => { it('6 (KIN-011). goBack() вызывает router.back() когда window.history.length > 1', async () => {
const router = makeRouter() const router = makeRouter()
await router.push('/project/KIN?status=in_progress') await router.push('/project/KIN?status=in_progress')
await router.push('/task/KIN-002') await router.push('/task/KIN-002')
@ -278,3 +366,146 @@ describe('KIN-011: TaskDetail — возврат с сохранением URL',
expect(router.currentRoute.value.query.status).toBe('in_progress') expect(router.currentRoute.value.query.status).toBe('in_progress')
}) })
}) })
// ─────────────────────────────────────────────────────────────
// KIN-047: TaskDetail — кнопки Approve/Reject в статусе review
// ─────────────────────────────────────────────────────────────
describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () => {
function makeTaskWith(status: string, executionMode: 'auto' | 'review' | null = null) {
return {
id: 'KIN-047',
project_id: 'KIN',
title: 'Review Task',
status,
priority: 3,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
execution_mode: executionMode,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
}
}
it('Approve и Reject видны при статусе review и ручном режиме', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'review') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть видна в review + ручной режим').toBe(true)
expect(rejectExists, 'Reject должна быть видна в review + ручной режим').toBe(true)
})
it('Approve и Reject скрыты при autoMode в статусе review', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'auto') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть скрыта в autoMode').toBe(false)
expect(rejectExists, 'Reject должна быть скрыта в autoMode').toBe(false)
})
it('Тоггл Auto/Review виден в статусе review при autoMode (позволяет выйти из автопилота)', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'auto') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const toggleExists = buttons.some(b => b.text().includes('Auto') || b.text().includes('Review'))
expect(toggleExists, 'Тоггл Auto/Review должен быть виден в статусе review').toBe(true)
})
it('После клика тоггла в review+autoMode появляются Approve и Reject', async () => {
const task = makeTaskWith('review', 'auto')
vi.mocked(api.taskFull).mockResolvedValue(task as any)
vi.mocked(api.patchTask).mockResolvedValue({ execution_mode: 'review' } as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
// Находим тоггл-кнопку (текст "Auto" когда autoMode=true)
const toggleBtn = wrapper.findAll('button').find(b => b.text().includes('Auto'))
expect(toggleBtn?.exists(), 'Тоггл должен быть виден').toBe(true)
await toggleBtn!.trigger('click')
await flushPromises()
// После переключения autoMode=false → Approve и Reject должны появиться
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна появиться после отключения autoMode').toBe(true)
expect(rejectExists, 'Reject должна появиться после отключения autoMode').toBe(true)
})
it('KIN-051: Approve и Reject видны при статусе review и execution_mode=null (фикс баги)', async () => {
// Воспроизводит баг: задача в review без явного execution_mode зависала
// без кнопок, потому что localStorage мог содержать 'auto'
localStorageMock.setItem('kin-mode-KIN', 'auto') // имитируем "плохой" localStorage
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', null) as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть видна: review+null mode игнорирует localStorage').toBe(true)
expect(rejectExists, 'Reject должна быть видна: review+null mode игнорирует localStorage').toBe(true)
})
it('Approve скрыта для статусов pending и done', async () => {
for (const status of ['pending', 'done']) {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith(status, 'review') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const approveExists = wrapper.findAll('button').some(b => b.text().includes('Approve'))
expect(approveExists, `Approve не должна быть видна для статуса "${status}"`).toBe(false)
}
})
})

View file

@ -26,6 +26,12 @@ async function post<T>(path: string, body: unknown): Promise<T> {
return res.json() return res.json()
} }
async function del<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export interface Project { export interface Project {
id: string id: string
name: string name: string
@ -59,6 +65,7 @@ export interface Task {
brief: Record<string, unknown> | null brief: Record<string, unknown> | null
spec: Record<string, unknown> | null spec: Record<string, unknown> | null
execution_mode: string | null execution_mode: string | null
blocked_reason: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@ -152,8 +159,8 @@ 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, allowWrite = false) => runTask: (id: string) =>
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }), post<{ status: string }>(`/tasks/${id}/run`, {}),
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) => auditProject: (projectId: string) =>
@ -164,4 +171,6 @@ export const api = {
patch<Task>(`/tasks/${id}`, data), patch<Task>(`/tasks/${id}`, data),
patchProject: (id: string, data: { execution_mode: string }) => patchProject: (id: string, data: { execution_mode: string }) =>
patch<Project>(`/projects/${id}`, data), patch<Project>(`/projects/${id}`, data),
deleteDecision: (projectId: string, decisionId: number) =>
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
} }

View file

@ -15,7 +15,28 @@ const error = ref('')
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks') const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
// Filters // Filters
const taskStatusFilter = ref((route.query.status as string) || '') const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
function initStatusFilter(): string[] {
const q = route.query.status as string
if (q) return q.split(',').filter((s: string) => s)
const stored = localStorage.getItem(`kin-task-statuses-${props.id}`)
if (stored) { try { return JSON.parse(stored) } catch {} }
return []
}
const selectedStatuses = ref<string[]>(initStatusFilter())
function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s)
if (idx >= 0) selectedStatuses.value.splice(idx, 1)
else selectedStatuses.value.push(s)
}
function clearStatusFilter() {
selectedStatuses.value = []
}
const decisionTypeFilter = ref('') const decisionTypeFilter = ref('')
const decisionSearch = ref('') const decisionSearch = ref('')
@ -98,16 +119,17 @@ async function load() {
} }
} }
watch(taskStatusFilter, (val) => { watch(selectedStatuses, (val) => {
router.replace({ query: { ...route.query, status: val || undefined } }) localStorage.setItem(`kin-task-statuses-${props.id}`, JSON.stringify(val))
}) router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
}, { deep: true })
onMounted(() => { load(); loadMode() }) onMounted(() => { load(); loadMode() })
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
if (!project.value) return [] if (!project.value) return []
let tasks = project.value.tasks let tasks = project.value.tasks
if (taskStatusFilter.value) tasks = tasks.filter(t => t.status === taskStatusFilter.value) if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
return tasks return tasks
}) })
@ -145,12 +167,6 @@ function modTypeColor(t: string) {
return m[t] || 'gray' return m[t] || 'gray'
} }
const taskStatuses = computed(() => {
if (!project.value) return []
const s = new Set(project.value.tasks.map(t => t.status))
return Array.from(s).sort()
})
const decTypes = computed(() => { const decTypes = computed(() => {
if (!project.value) return [] if (!project.value) return []
const s = new Set(project.value.decisions.map(d => d.type)) const s = new Set(project.value.decisions.map(d => d.type))
@ -179,7 +195,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, autoMode.value) await api.runTask(taskId)
await load() await load()
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
@ -253,12 +269,17 @@ async function addDecision() {
<!-- Tasks Tab --> <!-- Tasks Tab -->
<div v-if="activeTab === 'tasks'"> <div v-if="activeTab === 'tasks'">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex gap-2"> <div class="flex gap-1 flex-wrap items-center">
<select v-model="taskStatusFilter" <button v-for="s in ALL_TASK_STATUSES" :key="s"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300"> :data-status="s"
<option value="">All statuses</option> @click="toggleStatus(s)"
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option> class="px-2 py-0.5 text-xs rounded border transition-colors"
</select> :class="selectedStatuses.includes(s)
? 'bg-blue-900/40 text-blue-300 border-blue-700'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>{{ s }}</button>
<button v-if="selectedStatuses.length" data-action="clear-status" @click="clearStatusFilter"
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="toggleMode" <button @click="toggleMode"
@ -284,7 +305,7 @@ async function addDecision() {
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div> <div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id" <router-link v-for="t in filteredTasks" :key="t.id"
:to="{ path: `/task/${t.id}`, query: taskStatusFilter ? { back_status: taskStatusFilter } : undefined }" :to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors"> class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span> <span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>

View file

@ -35,6 +35,9 @@ function loadMode(t: typeof task.value) {
if (!t) return if (!t) return
if (t.execution_mode) { if (t.execution_mode) {
autoMode.value = t.execution_mode === 'auto' autoMode.value = t.execution_mode === 'auto'
} else if (t.status === 'review') {
// Task is in review always show Approve/Reject regardless of localStorage
autoMode.value = false
} else { } else {
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto' autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto'
} }
@ -188,7 +191,7 @@ async function reject() {
async function runPipeline() { async function runPipeline() {
try { try {
await api.runTask(props.id, autoMode.value) await api.runTask(props.id)
startPolling() startPolling()
await load() await load()
} catch (e: any) { } catch (e: any) {
@ -264,6 +267,9 @@ async function changeStatus(newStatus: string) {
<div v-if="task.brief" class="text-xs text-gray-500 mb-1"> <div v-if="task.brief" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }} Brief: {{ JSON.stringify(task.brief) }}
</div> </div>
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
Blocked: {{ task.blocked_reason }}
</div>
<div v-if="task.assigned_role" class="text-xs text-gray-500"> <div v-if="task.assigned_role" class="text-xs text-gray-500">
Assigned: {{ task.assigned_role }} Assigned: {{ task.assigned_role }}
</div> </div>
@ -346,7 +352,7 @@ async function changeStatus(newStatus: string) {
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">
&#10007; Reject &#10007; Reject
</button> </button>
<button v-if="task.status === 'pending' || task.status === 'blocked'" <button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
@click="toggleMode" @click="toggleMode"
class="px-3 py-2 text-sm border rounded transition-colors" class="px-3 py-2 text-sm border rounded transition-colors"
:class="autoMode :class="autoMode

View file

@ -1,4 +1,4 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/