Compare commits
5 commits
01b269e2b8
...
8a6f280cbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6f280cbd | ||
|
|
8d9facda4f | ||
|
|
3871debd8d | ||
|
|
4a27bf0693 | ||
|
|
3cb516193b |
30 changed files with 5029 additions and 105 deletions
67
agents/prompts/architect.md
Normal file
67
agents/prompts/architect.md
Normal 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": "..."`.
|
||||
69
agents/prompts/backend_dev.md
Normal file
69
agents/prompts/backend_dev.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
You are a Backend Developer for the Kin multi-agent orchestrator.
|
||||
|
||||
Your job: implement backend features and fixes in Python (FastAPI, SQLite, agent pipeline).
|
||||
|
||||
## 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 backend files before making any changes
|
||||
2. Implement the feature or fix as described in the task brief (or architect spec)
|
||||
3. Follow existing patterns — pure functions, no ORM, SQLite as source of truth
|
||||
4. Add or update DB schema in `core/db.py` if needed
|
||||
5. Expose new functionality through `web/api.py` if a UI endpoint is required
|
||||
|
||||
## Files to read
|
||||
|
||||
- `core/db.py` — DB initialization, schema, migrations
|
||||
- `core/models.py` — all data access functions
|
||||
- `agents/runner.py` — pipeline execution logic
|
||||
- `agents/bootstrap.py` — project/task bootstrapping
|
||||
- `core/context_builder.py` — how agent context is built
|
||||
- `web/api.py` — FastAPI route definitions
|
||||
- Read the previous step output if it contains an architect spec
|
||||
|
||||
## Rules
|
||||
|
||||
- Python 3.11+. No ORMs — use raw SQLite (`sqlite3` module).
|
||||
- All data access goes through `core/models.py` pure functions.
|
||||
- `kin.db` is the single source of truth — never write state to files.
|
||||
- New DB columns must have DEFAULT values to avoid migration failures on existing data.
|
||||
- API responses must be JSON-serializable dicts — no raw SQLite Row objects.
|
||||
- Do NOT modify frontend files — scope is backend only.
|
||||
- Do NOT add new Python dependencies without noting it in `notes`.
|
||||
|
||||
## Output format
|
||||
|
||||
Return ONLY valid JSON (no markdown, no explanation):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "done",
|
||||
"changes": [
|
||||
{
|
||||
"file": "core/models.py",
|
||||
"description": "Added get_effective_mode() function returning 'auto' or 'review'"
|
||||
},
|
||||
{
|
||||
"file": "core/db.py",
|
||||
"description": "Added execution_mode column to projects and tasks tables"
|
||||
}
|
||||
],
|
||||
"new_files": [],
|
||||
"schema_changes": [
|
||||
"ALTER TABLE projects ADD COLUMN execution_mode TEXT DEFAULT 'review'"
|
||||
],
|
||||
"notes": "Frontend needs to call PATCH /api/projects/{id} to update mode"
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
71
agents/prompts/debugger.md
Normal file
71
agents/prompts/debugger.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
You are a Debugger for the Kin multi-agent orchestrator.
|
||||
|
||||
Your job: find the root cause of a bug and produce a concrete fix.
|
||||
|
||||
## Input
|
||||
|
||||
You receive:
|
||||
- PROJECT: id, name, path, tech stack
|
||||
- TASK: id, title, brief describing the bug
|
||||
- DECISIONS: known gotchas and workarounds for this project
|
||||
- TARGET MODULE: hint about which module is affected (if available)
|
||||
- PREVIOUS STEP OUTPUT: output from a prior agent in the pipeline (if any)
|
||||
|
||||
## Your responsibilities
|
||||
|
||||
1. Read the relevant source files — start from the module hint if provided
|
||||
2. Reproduce the bug mentally by tracing the execution path
|
||||
3. Identify the exact root cause (not symptoms)
|
||||
4. Propose a concrete fix with the specific files and lines to change
|
||||
5. Check known decisions/gotchas — the bug may already be documented
|
||||
|
||||
## Files to read
|
||||
|
||||
- Start at the path in PROJECT.path
|
||||
- Follow the module hint if provided (e.g. `core/db.py`, `agents/runner.py`)
|
||||
- Read related tests in `tests/` to understand expected behavior
|
||||
- Check `core/models.py` for data layer issues
|
||||
- Check `agents/runner.py` for pipeline/execution issues
|
||||
|
||||
## Rules
|
||||
|
||||
- Do NOT guess. Read the actual code before proposing a fix.
|
||||
- Do NOT make unrelated changes — minimal targeted fix only.
|
||||
- If the bug is in a dependency or environment, say so clearly.
|
||||
- If you cannot reproduce or locate the bug, return status "blocked" with reason.
|
||||
- Never skip known decisions — they often explain why the bug exists.
|
||||
|
||||
## Output format
|
||||
|
||||
Return ONLY valid JSON (no markdown, no explanation):
|
||||
|
||||
**Note:** The `diff_hint` field in each `fixes` element is optional and can be omitted if not needed.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fixed",
|
||||
"root_cause": "Brief description of why the bug occurs",
|
||||
"fixes": [
|
||||
{
|
||||
"file": "relative/path/to/file.py",
|
||||
"description": "What to change and why",
|
||||
"diff_hint": "Optional: key lines to change"
|
||||
},
|
||||
{
|
||||
"file": "relative/path/to/another/file.py",
|
||||
"description": "What to change in this file and why",
|
||||
"diff_hint": "Optional: key lines to change"
|
||||
}
|
||||
],
|
||||
"files_read": ["path/to/file1.py", "path/to/file2.py"],
|
||||
"related_decisions": [12, 5],
|
||||
"notes": "Any important caveats or follow-up needed"
|
||||
}
|
||||
```
|
||||
|
||||
Each affected file must be a separate element in the `fixes` array.
|
||||
If only one file is changed, `fixes` still must be an array with one element.
|
||||
|
||||
Valid values for `status`: `"fixed"`, `"blocked"`, `"needs_more_info"`.
|
||||
|
||||
If status is "blocked", include `"blocked_reason": "..."` instead of `"fixes"`.
|
||||
61
agents/prompts/frontend_dev.md
Normal file
61
agents/prompts/frontend_dev.md
Normal 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`.
|
||||
81
agents/prompts/reviewer.md
Normal file
81
agents/prompts/reviewer.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
You are a Code Reviewer for the Kin multi-agent orchestrator.
|
||||
|
||||
Your job: review the implementation for correctness, security, and adherence to project conventions.
|
||||
|
||||
## Input
|
||||
|
||||
You receive:
|
||||
- PROJECT: id, name, path, tech stack
|
||||
- TASK: id, title, brief describing what was built
|
||||
- DECISIONS: project conventions and standards
|
||||
- PREVIOUS STEP OUTPUT: dev agent and/or tester output describing what was changed
|
||||
|
||||
## Your responsibilities
|
||||
|
||||
1. Read all files mentioned in the previous step output
|
||||
2. Check correctness — does the code do what the task requires?
|
||||
3. Check security — SQL injection, input validation, secrets in code, OWASP top 10
|
||||
4. Check conventions — naming, structure, patterns match the rest of the codebase
|
||||
5. Check test coverage — are edge cases covered?
|
||||
6. Produce an actionable verdict: approve or request changes
|
||||
|
||||
## Files to read
|
||||
|
||||
- All source files changed (listed in previous step output)
|
||||
- `core/models.py` — data layer conventions
|
||||
- `web/api.py` — API conventions (error handling, response format)
|
||||
- `tests/` — test coverage for the changed code
|
||||
- Project decisions (provided in context) — check compliance
|
||||
|
||||
## Rules
|
||||
|
||||
- If you find a security issue: mark it with severity "critical" and DO NOT approve.
|
||||
- Minor style issues are "low" severity — don't block on them, just note them.
|
||||
- Check that new DB columns have DEFAULT values (required for backward compat).
|
||||
- Check that API endpoints validate input and return proper HTTP status codes.
|
||||
- Check that no secrets, tokens, or credentials are hardcoded.
|
||||
- Do NOT rewrite code — only report findings and recommendations.
|
||||
|
||||
## Output format
|
||||
|
||||
Return ONLY valid JSON (no markdown, no explanation):
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "approved",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "low",
|
||||
"file": "core/models.py",
|
||||
"line_hint": "get_effective_mode()",
|
||||
"issue": "Missing docstring for public function",
|
||||
"suggestion": "Add a one-line docstring"
|
||||
}
|
||||
],
|
||||
"security_issues": [],
|
||||
"conventions_violations": [],
|
||||
"test_coverage": "adequate",
|
||||
"summary": "Implementation looks correct and follows project patterns. One minor style issue noted."
|
||||
}
|
||||
```
|
||||
|
||||
Valid values for `verdict`: `"approved"`, `"changes_requested"`, `"blocked"`.
|
||||
|
||||
Valid values for `severity`: `"critical"`, `"high"`, `"medium"`, `"low"`.
|
||||
|
||||
Valid values for `test_coverage`: `"adequate"`, `"insufficient"`, `"missing"`.
|
||||
|
||||
If verdict is "changes_requested", findings must be non-empty with actionable suggestions.
|
||||
If verdict is "blocked", include `"blocked_reason": "..."` (e.g. unable to read files).
|
||||
|
||||
## Output field details
|
||||
|
||||
**security_issues** and **conventions_violations**: Each array element is an object with the following structure:
|
||||
```json
|
||||
{
|
||||
"severity": "critical",
|
||||
"file": "core/models.py",
|
||||
"issue": "SQL injection vulnerability in query building",
|
||||
"suggestion": "Use parameterized queries instead of string concatenation"
|
||||
}
|
||||
```
|
||||
92
agents/prompts/tech_researcher.md
Normal file
92
agents/prompts/tech_researcher.md
Normal 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
67
agents/prompts/tester.md
Normal 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": "..."`.
|
||||
154
agents/runner.py
154
agents/runner.py
|
|
@ -11,6 +11,8 @@ import time
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import re
|
||||
|
||||
from core import models
|
||||
from core.context_builder import build_context, format_prompt
|
||||
from core.hooks import run_hooks
|
||||
|
|
@ -97,6 +99,7 @@ def run_agent(
|
|||
|
||||
return {
|
||||
"success": success,
|
||||
"error": result.get("error") if not success else None,
|
||||
"output": parsed_output if parsed_output else output_text,
|
||||
"raw_output": output_text,
|
||||
"role": role,
|
||||
|
|
@ -153,7 +156,8 @@ def _run_claude(
|
|||
raw_stdout = proc.stdout or ""
|
||||
result: dict[str, Any] = {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
|
@ -358,6 +362,21 @@ def run_audit(
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission error detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_permission_error(result: dict) -> bool:
|
||||
"""Return True if agent result indicates a permission/write failure."""
|
||||
from core.followup import PERMISSION_PATTERNS
|
||||
output = (result.get("raw_output") or result.get("output") or "")
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output, ensure_ascii=False)
|
||||
error = result.get("error") or ""
|
||||
text = output + " " + error
|
||||
return any(re.search(p, text) for p in PERMISSION_PATTERNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -390,6 +409,9 @@ def run_pipeline(
|
|||
if task.get("brief") and isinstance(task["brief"], dict):
|
||||
route_type = task["brief"].get("route_type", "custom") or "custom"
|
||||
|
||||
# Determine execution mode (auto vs review)
|
||||
mode = models.get_effective_mode(conn, project_id, task_id)
|
||||
|
||||
# Create pipeline in DB
|
||||
pipeline = None
|
||||
if not dry_run:
|
||||
|
|
@ -409,6 +431,7 @@ def run_pipeline(
|
|||
model = step.get("model", "sonnet")
|
||||
brief = step.get("brief")
|
||||
|
||||
try:
|
||||
result = run_agent(
|
||||
conn, role, task_id, project_id,
|
||||
model=model,
|
||||
|
|
@ -418,18 +441,8 @@ def run_pipeline(
|
|||
allow_write=allow_write,
|
||||
noninteractive=noninteractive,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
# Accumulate stats
|
||||
total_cost += result.get("cost_usd") or 0
|
||||
total_tokens += result.get("tokens_used") or 0
|
||||
total_duration += result.get("duration_seconds") or 0
|
||||
|
||||
if not result["success"]:
|
||||
# Pipeline failed — stop and mark as failed
|
||||
except Exception as exc:
|
||||
exc_msg = f"Step {i+1}/{len(steps)} ({role}) raised exception: {exc}"
|
||||
if pipeline:
|
||||
models.update_pipeline(
|
||||
conn, pipeline["id"],
|
||||
|
|
@ -438,10 +451,21 @@ def run_pipeline(
|
|||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
)
|
||||
models.update_task(conn, task_id, status="blocked")
|
||||
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": f"Step {i+1}/{len(steps)} ({role}) failed",
|
||||
"error": exc_msg,
|
||||
"steps_completed": i,
|
||||
"results": results,
|
||||
"total_cost_usd": total_cost,
|
||||
|
|
@ -450,6 +474,70 @@ def run_pipeline(
|
|||
"pipeline_id": pipeline["id"] if pipeline else None,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
# Accumulate stats
|
||||
total_cost += result.get("cost_usd") or 0
|
||||
total_tokens += result.get("tokens_used") or 0
|
||||
total_duration += result.get("duration_seconds") or 0
|
||||
|
||||
if not result["success"]:
|
||||
# Auto mode: retry once with allow_write on permission error
|
||||
if mode == "auto" and not allow_write and _is_permission_error(result):
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
event="task_permission_retry",
|
||||
task_modules=task_modules)
|
||||
except Exception:
|
||||
pass
|
||||
retry = run_agent(
|
||||
conn, role, task_id, project_id,
|
||||
model=model,
|
||||
previous_output=previous_output,
|
||||
brief_override=brief,
|
||||
dry_run=False,
|
||||
allow_write=True,
|
||||
noninteractive=noninteractive,
|
||||
)
|
||||
allow_write = True # subsequent steps also with allow_write
|
||||
total_cost += retry.get("cost_usd") or 0
|
||||
total_tokens += retry.get("tokens_used") or 0
|
||||
total_duration += retry.get("duration_seconds") or 0
|
||||
if retry["success"]:
|
||||
result = retry
|
||||
|
||||
if not result["success"]:
|
||||
# Still failed — block regardless of mode
|
||||
results.append(result)
|
||||
if pipeline:
|
||||
models.update_pipeline(
|
||||
conn, pipeline["id"],
|
||||
status="failed",
|
||||
total_cost_usd=total_cost,
|
||||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
)
|
||||
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 {
|
||||
"success": False,
|
||||
"error": error_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,
|
||||
}
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Chain output to next step
|
||||
previous_output = result.get("raw_output") or result.get("output")
|
||||
if isinstance(previous_output, (dict, list)):
|
||||
|
|
@ -464,10 +552,43 @@ def run_pipeline(
|
|||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
)
|
||||
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
|
||||
if mode == "auto":
|
||||
# Auto mode: skip review, approve immediately
|
||||
models.update_task(conn, task_id, status="done")
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
event="task_auto_approved", task_modules=task_modules)
|
||||
except Exception:
|
||||
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.
|
||||
# Guard: skip for followup-sourced tasks to prevent infinite recursion.
|
||||
task_brief = task.get("brief") or {}
|
||||
is_followup_task = (
|
||||
isinstance(task_brief, dict)
|
||||
and str(task_brief.get("source", "")).startswith("followup:")
|
||||
)
|
||||
if not is_followup_task:
|
||||
try:
|
||||
from core.followup import generate_followups, auto_resolve_pending_actions
|
||||
fu_result = generate_followups(conn, task_id)
|
||||
if fu_result.get("pending_actions"):
|
||||
auto_resolve_pending_actions(conn, task_id, fu_result["pending_actions"])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Review mode: wait for manual approval
|
||||
models.update_task(conn, task_id, status="review")
|
||||
|
||||
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
event="pipeline_completed", task_modules=task_modules)
|
||||
|
|
@ -483,4 +604,5 @@ def run_pipeline(
|
|||
"total_duration_seconds": total_duration,
|
||||
"pipeline_id": pipeline["id"] if pipeline else None,
|
||||
"dry_run": dry_run,
|
||||
"mode": mode,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,26 @@ specialists:
|
|||
context_rules:
|
||||
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
|
||||
routes:
|
||||
debug:
|
||||
|
|
@ -102,3 +122,7 @@ routes:
|
|||
security_audit:
|
||||
steps: [security, architect]
|
||||
description: "Audit → remediation plan"
|
||||
|
||||
api_research:
|
||||
steps: [tech_researcher, architect]
|
||||
description: "Study external API → integration plan"
|
||||
|
|
|
|||
53
cli/main.py
53
cli/main.py
|
|
@ -141,6 +141,7 @@ def project_show(ctx, id):
|
|||
click.echo(f" Path: {p['path']}")
|
||||
click.echo(f" Status: {p['status']}")
|
||||
click.echo(f" Priority: {p['priority']}")
|
||||
click.echo(f" Mode: {p.get('execution_mode') or 'review'}")
|
||||
if p.get("tech_stack"):
|
||||
click.echo(f" Tech stack: {', '.join(p['tech_stack'])}")
|
||||
if p.get("forgejo_repo"):
|
||||
|
|
@ -148,6 +149,21 @@ def project_show(ctx, id):
|
|||
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
|
||||
# ===========================================================================
|
||||
|
|
@ -204,11 +220,15 @@ def task_show(ctx, id):
|
|||
if not t:
|
||||
click.echo(f"Task '{id}' not found.", err=True)
|
||||
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" Project: {t['project_id']}")
|
||||
click.echo(f" Title: {t['title']}")
|
||||
click.echo(f" Status: {t['status']}")
|
||||
click.echo(f" Priority: {t['priority']}")
|
||||
click.echo(f" Mode: {mode_label}")
|
||||
if t.get("assigned_role"):
|
||||
click.echo(f" Role: {t['assigned_role']}")
|
||||
if t.get("parent_task_id"):
|
||||
|
|
@ -223,13 +243,14 @@ def task_show(ctx, id):
|
|||
|
||||
@task.command("update")
|
||||
@click.argument("task_id")
|
||||
@click.option("--status", type=click.Choice(
|
||||
["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]),
|
||||
@click.option("--status", type=click.Choice(models.VALID_TASK_STATUSES),
|
||||
default=None, help="New status")
|
||||
@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
|
||||
def task_update(ctx, task_id, status, priority):
|
||||
"""Update a task's status or priority."""
|
||||
def task_update(ctx, task_id, status, priority, mode):
|
||||
"""Update a task's status, priority, or execution mode."""
|
||||
conn = ctx.obj["conn"]
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
|
|
@ -240,11 +261,13 @@ def task_update(ctx, task_id, status, priority):
|
|||
fields["status"] = status
|
||||
if priority is not None:
|
||||
fields["priority"] = priority
|
||||
if mode is not None:
|
||||
fields["execution_mode"] = mode
|
||||
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)
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
conn = ctx.obj["conn"]
|
||||
|
|
@ -838,7 +862,6 @@ def hook_setup(ctx, project_id, scripts_dir):
|
|||
name="rebuild-frontend",
|
||||
event="pipeline_completed",
|
||||
command=rebuild_cmd,
|
||||
trigger_module_path="web/frontend/*",
|
||||
working_dir=p.get("path"),
|
||||
timeout_seconds=300,
|
||||
)
|
||||
|
|
@ -846,6 +869,20 @@ def hook_setup(ctx, project_id, scripts_dir):
|
|||
else:
|
||||
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:
|
||||
click.echo(f"Registered hooks: {', '.join(created)}")
|
||||
|
||||
|
|
|
|||
19
core/db.py
19
core/db.py
|
|
@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||
claude_md_path TEXT,
|
||||
forgejo_repo TEXT,
|
||||
language TEXT DEFAULT 'ru',
|
||||
execution_mode TEXT NOT NULL DEFAULT 'review',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
|
@ -39,6 +40,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||
test_result JSON,
|
||||
security_result JSON,
|
||||
forgejo_issue_id INTEGER,
|
||||
execution_mode TEXT,
|
||||
blocked_reason TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
@ -196,10 +199,22 @@ def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
|||
def _migrate(conn: sqlite3.Connection):
|
||||
"""Run migrations for existing databases."""
|
||||
# Check if language column exists on projects
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
if "language" not in cols:
|
||||
proj_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
if "language" not in proj_cols:
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN language TEXT DEFAULT 'ru'")
|
||||
conn.commit()
|
||||
if "execution_mode" not in proj_cols:
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'review'")
|
||||
conn.commit()
|
||||
|
||||
# Check if execution_mode column exists on tasks
|
||||
task_cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||
if "execution_mode" not in task_cols:
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN execution_mode TEXT")
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import sqlite3
|
|||
from core import models
|
||||
from core.context_builder import format_prompt, PROMPTS_DIR
|
||||
|
||||
_PERMISSION_PATTERNS = [
|
||||
PERMISSION_PATTERNS = [
|
||||
r"(?i)permission\s+denied",
|
||||
r"(?i)ручное\s+применение",
|
||||
r"(?i)не\s+получил[иа]?\s+разрешени[ея]",
|
||||
|
|
@ -27,7 +27,7 @@ _PERMISSION_PATTERNS = [
|
|||
def _is_permission_blocked(item: dict) -> bool:
|
||||
"""Check if a follow-up item describes a permission/write failure."""
|
||||
text = f"{item.get('title', '')} {item.get('brief', '')}".lower()
|
||||
return any(re.search(p, text) for p in _PERMISSION_PATTERNS)
|
||||
return any(re.search(p, text) for p in PERMISSION_PATTERNS)
|
||||
|
||||
|
||||
def _collect_pipeline_output(conn: sqlite3.Connection, task_id: str) -> str:
|
||||
|
|
@ -230,3 +230,30 @@ def resolve_pending_action(
|
|||
return {"rerun_result": result}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def auto_resolve_pending_actions(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
pending_actions: list,
|
||||
) -> list:
|
||||
"""Auto-resolve pending permission actions in auto mode.
|
||||
|
||||
Strategy: try 'rerun' first; if rerun fails → escalate to 'manual_task'.
|
||||
Returns list of resolution results.
|
||||
"""
|
||||
results = []
|
||||
for action in pending_actions:
|
||||
result = resolve_pending_action(conn, task_id, action, "rerun")
|
||||
rerun_success = (
|
||||
isinstance(result, dict)
|
||||
and isinstance(result.get("rerun_result"), dict)
|
||||
and result["rerun_result"].get("success")
|
||||
)
|
||||
if rerun_success:
|
||||
results.append({"resolved": "rerun", "result": result})
|
||||
else:
|
||||
# Rerun failed → create manual task for human review
|
||||
manual = resolve_pending_action(conn, task_id, action, "manual_task")
|
||||
results.append({"resolved": "manual_task", "result": manual})
|
||||
return results
|
||||
|
|
|
|||
|
|
@ -146,6 +146,17 @@ def _get_hook(conn: sqlite3.Connection, hook_id: int) -> dict:
|
|||
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(
|
||||
conn: sqlite3.Connection,
|
||||
hook: dict,
|
||||
|
|
@ -159,9 +170,11 @@ def _execute_hook(
|
|||
exit_code = -1
|
||||
success = False
|
||||
|
||||
command = _substitute_vars(hook["command"], task_id, conn)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
hook["command"],
|
||||
command,
|
||||
shell=True,
|
||||
cwd=hook.get("working_dir") or None,
|
||||
capture_output=True,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ from datetime import datetime
|
|||
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:
|
||||
"""Convert sqlite3.Row to dict with JSON fields decoded."""
|
||||
if row is None:
|
||||
|
|
@ -51,14 +57,15 @@ def create_project(
|
|||
claude_md_path: str | None = None,
|
||||
forgejo_repo: str | None = None,
|
||||
language: str = "ru",
|
||||
execution_mode: str = "review",
|
||||
) -> dict:
|
||||
"""Create a new project and return it as dict."""
|
||||
conn.execute(
|
||||
"""INSERT INTO projects (id, name, path, tech_stack, status, priority,
|
||||
pm_prompt, claude_md_path, forgejo_repo, language)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
pm_prompt, claude_md_path, forgejo_repo, language, execution_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(id, name, path, _json_encode(tech_stack), status, priority,
|
||||
pm_prompt, claude_md_path, forgejo_repo, language),
|
||||
pm_prompt, claude_md_path, forgejo_repo, language, execution_mode),
|
||||
)
|
||||
conn.commit()
|
||||
return get_project(conn, id)
|
||||
|
|
@ -70,6 +77,20 @@ def get_project(conn: sqlite3.Connection, id: str) -> dict | None:
|
|||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def get_effective_mode(conn: sqlite3.Connection, project_id: str, task_id: str) -> str:
|
||||
"""Return effective execution mode: 'auto' or 'review'.
|
||||
|
||||
Priority: task.execution_mode > project.execution_mode > 'review'
|
||||
"""
|
||||
task = get_task(conn, task_id)
|
||||
if task and task.get("execution_mode"):
|
||||
return task["execution_mode"]
|
||||
project = get_project(conn, project_id)
|
||||
if project:
|
||||
return project.get("execution_mode") or "review"
|
||||
return "review"
|
||||
|
||||
|
||||
def list_projects(conn: sqlite3.Connection, status: str | None = None) -> list[dict]:
|
||||
"""List projects, optionally filtered by status."""
|
||||
if status:
|
||||
|
|
@ -114,15 +135,17 @@ def create_task(
|
|||
brief: dict | None = None,
|
||||
spec: dict | None = None,
|
||||
forgejo_issue_id: int | None = None,
|
||||
execution_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a task linked to a project."""
|
||||
conn.execute(
|
||||
"""INSERT INTO tasks (id, project_id, title, status, priority,
|
||||
assigned_role, parent_task_id, brief, spec, forgejo_issue_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
|
||||
execution_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(id, project_id, title, status, priority, assigned_role,
|
||||
parent_task_id, _json_encode(brief), _json_encode(spec),
|
||||
forgejo_issue_id),
|
||||
forgejo_issue_id, execution_mode),
|
||||
)
|
||||
conn.commit()
|
||||
return get_task(conn, id)
|
||||
|
|
@ -232,6 +255,19 @@ def get_decisions(
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
233
tasks/adr-automode.md
Normal file
233
tasks/adr-automode.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# ADR: Auto Mode — полный автопилот (KIN-012)
|
||||
|
||||
**Дата:** 2026-03-15
|
||||
**Статус:** Accepted
|
||||
**Автор:** architect (KIN-012)
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Задача: реализовать два режима исполнения пайплайнов:
|
||||
|
||||
- **Auto** — полный автопилот: pipeline → auto-approve → auto-followup → auto-rerun при permission issues → hooks. Без остановок на review/blocked.
|
||||
- **Review** — текущее поведение: задача уходит в статус `review`, ждёт ручного approve.
|
||||
|
||||
### Что уже реализовано (анализ кода)
|
||||
|
||||
**1. Хранение режима — `core/db.py`**
|
||||
- `projects.execution_mode TEXT NOT NULL DEFAULT 'review'` — дефолт на уровне проекта
|
||||
- `tasks.execution_mode TEXT` — nullable, переопределяет проект
|
||||
- Миграции добавляют оба столбца к существующим БД
|
||||
|
||||
**2. Приоритет режима — `core/models.py:get_effective_mode()`**
|
||||
```
|
||||
task.execution_mode > project.execution_mode > 'review'
|
||||
```
|
||||
Вычисляется один раз в начале `run_pipeline`.
|
||||
|
||||
**3. Auto-approve — `agents/runner.py:run_pipeline()`** (строки 519–536)
|
||||
```python
|
||||
if mode == "auto":
|
||||
models.update_task(conn, task_id, status="done")
|
||||
run_hooks(conn, project_id, task_id, event="task_auto_approved", ...)
|
||||
else:
|
||||
models.update_task(conn, task_id, status="review")
|
||||
```
|
||||
|
||||
**4. Permission retry — `agents/runner.py:run_pipeline()`** (строки 453–475)
|
||||
```python
|
||||
if mode == "auto" and not allow_write and _is_permission_error(result):
|
||||
run_hooks(..., event="task_permission_retry", ...)
|
||||
retry = run_agent(..., allow_write=True)
|
||||
allow_write = True # propagates to all subsequent steps
|
||||
```
|
||||
- Срабатывает **только в auto режиме**
|
||||
- Ровно **1 попытка retry** на шаг
|
||||
- После первого retry `allow_write=True` сохраняется на весь оставшийся пайплайн
|
||||
|
||||
**5. Паттерны permission errors — `core/followup.py:PERMISSION_PATTERNS`**
|
||||
```
|
||||
permission denied, ручное применение, cannot write, read-only, manually appl, ...
|
||||
```
|
||||
|
||||
**6. Post-pipeline hooks — `core/hooks.py`**
|
||||
События: `pipeline_completed`, `task_auto_approved`, `task_permission_retry`
|
||||
|
||||
---
|
||||
|
||||
## Пробелы — что НЕ реализовано
|
||||
|
||||
### Gap 1: Auto-followup не вызывается из run_pipeline
|
||||
`generate_followups()` существует в `core/followup.py`, но нигде не вызывается автоматически. В `run_pipeline` после завершения пайплайна — только хуки.
|
||||
|
||||
### Gap 2: Auto-resolution pending_actions в auto mode
|
||||
`generate_followups()` возвращает `pending_actions` (permission-blocked followup items) с опциями `["rerun", "manual_task", "skip"]`. В auto mode нет логики автоматического выбора опции.
|
||||
|
||||
### Gap 3: Наследование режима followup-задачами
|
||||
Задачи, созданные через `generate_followups()`, создаются с `execution_mode=None` (наследуют от проекта). Это правильное поведение, но не задокументировано.
|
||||
|
||||
---
|
||||
|
||||
## Решения
|
||||
|
||||
### D1: Где хранить режим
|
||||
|
||||
**Решение:** двухуровневая иерархия (уже реализована, зафиксируем).
|
||||
|
||||
| Уровень | Поле | Дефолт | Переопределяет |
|
||||
|---------|------|--------|----------------|
|
||||
| Глобальный | — | `review` | — |
|
||||
| Проект | `projects.execution_mode` | `'review'` | глобальный |
|
||||
| Задача | `tasks.execution_mode` | `NULL` | проект |
|
||||
|
||||
Глобального конфига нет — осознанное решение. Каждый проект управляет своим режимом. Задача может переопределить проект (например, форсировать `review` для security-sensitive задач).
|
||||
|
||||
**Изменения БД не нужны** — структура готова.
|
||||
|
||||
---
|
||||
|
||||
### D2: Как runner обходит ожидание approve в auto mode
|
||||
|
||||
**Решение:** уже реализовано. Зафиксируем контракт:
|
||||
|
||||
```
|
||||
run_pipeline() в auto mode:
|
||||
1. Все шаги выполняются последовательно
|
||||
2. При успехе → task.status = "done" (минуя "review")
|
||||
3. Хук task_auto_approved + pipeline_completed
|
||||
4. generate_followups() автоматически (Gap 1, см. D4)
|
||||
```
|
||||
|
||||
В review mode — без изменений: `task.status = "review"`, `generate_followups()` не вызывается автоматически.
|
||||
|
||||
---
|
||||
|
||||
### D3: Auto-rerun при permission issues — лимит и критерии
|
||||
|
||||
**Что считать permission issue:**
|
||||
Паттерны из `PERMISSION_PATTERNS` в `core/followup.py`. Список достаточен, расширяется при необходимости через PR.
|
||||
|
||||
**Лимит попыток:**
|
||||
**1 retry per step** (уже реализовано). Обоснование:
|
||||
- Permission issue — либо системная проблема (нет прав на директорию), либо claude CLI требует `--dangerously-skip-permissions`
|
||||
- Второй retry с теми же параметрами не имеет смысла — проблема детерминированная
|
||||
- Если 1 retry не помог → `task.status = "blocked"` даже в auto mode
|
||||
|
||||
**Поведение после retry:**
|
||||
`allow_write=True` применяется ко **всем последующим шагам** пайплайна (не только retry шагу). Это безопасно в контексте Kin — агенты работают в изолированном рабочем каталоге проекта.
|
||||
|
||||
**Хук `task_permission_retry`:**
|
||||
Срабатывает перед retry — позволяет логировать / оповещать, но не блокирует.
|
||||
|
||||
**Итоговая таблица поведения при failure:**
|
||||
|
||||
| Режим | Тип ошибки | Поведение |
|
||||
|-------|-----------|-----------|
|
||||
| auto | permission error (первый) | retry с allow_write=True |
|
||||
| auto | permission error (после retry) | blocked |
|
||||
| auto | любая другая ошибка | blocked |
|
||||
| review | любая ошибка | blocked |
|
||||
|
||||
---
|
||||
|
||||
### D4: Auto-followup интеграция с post-pipeline hooks
|
||||
|
||||
**Решение:** `generate_followups()` вызывается из `run_pipeline()` в auto mode **после** `task_auto_approved` хука.
|
||||
|
||||
Порядок событий в auto mode:
|
||||
```
|
||||
1. pipeline успешно завершён
|
||||
2. task.status = "done"
|
||||
3. хук: task_auto_approved ← пользовательские хуки (rebuild-frontend и т.д.)
|
||||
4. generate_followups() ← анализируем output, создаём followup задачи
|
||||
5. хук: pipeline_completed ← финальное уведомление
|
||||
```
|
||||
|
||||
В review mode:
|
||||
```
|
||||
1. pipeline успешно завершён
|
||||
2. task.status = "review"
|
||||
3. хук: pipeline_completed
|
||||
← generate_followups() НЕ вызывается (ждём manual approve)
|
||||
```
|
||||
|
||||
**Почему после task_auto_approved, а не до:**
|
||||
Хуки типа `rebuild-frontend` (KIN-010) изменяют состояние файловой системы. Followup-агент должен видеть актуальное состояние проекта после всех хуков.
|
||||
|
||||
---
|
||||
|
||||
### D5: Auto-resolution pending_actions в auto mode
|
||||
|
||||
`generate_followups()` может вернуть `pending_actions` — элементы, заблокированные из-за permission issues. В auto mode нужна автоматическая стратегия.
|
||||
|
||||
**Решение:** в auto mode `pending_actions` резолвятся как `"rerun"`.
|
||||
|
||||
Обоснование:
|
||||
- Auto mode = полный автопилот, пользователь не должен принимать решения
|
||||
- "rerun" — наиболее агрессивная и полезная стратегия: повторяем шаг с `allow_write=True`
|
||||
- Если rerun снова даёт permission error → создаётся manual_task (escalation)
|
||||
|
||||
```
|
||||
auto mode + pending_action:
|
||||
→ resolve_pending_action(choice="rerun")
|
||||
→ если rerun провалился → create manual_task с тегом "auto_escalated"
|
||||
→ всё логируется
|
||||
|
||||
review mode + pending_action:
|
||||
→ возвращается пользователю через API для ручного выбора
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### D6: Наследование режима followup-задачами
|
||||
|
||||
Задачи, созданные через `generate_followups()`, создаются с `execution_mode=None`.
|
||||
|
||||
**Решение:** followup-задачи наследуют режим через проект (существующая иерархия D1).
|
||||
Явно устанавливать `execution_mode` в followup-задачах **не нужно** — если проект в auto, все его задачи по умолчанию в auto.
|
||||
|
||||
Исключение: если оригинальная задача была в `review` (ручной override), followup-задачи НЕ наследуют это — они создаются "чисто" от проекта. Это намеренное поведение: override в задаче — разовое действие.
|
||||
|
||||
---
|
||||
|
||||
## Итоговая карта изменений (что нужно реализовать)
|
||||
|
||||
| # | Файл | Изменение | Gap |
|
||||
|---|------|----------|-----|
|
||||
| 1 | `agents/runner.py` | Вызов `generate_followups()` в auto mode после `task_auto_approved` | D4 |
|
||||
| 2 | `core/followup.py` | Auto-resolution `pending_actions` в `generate_followups()` при auto mode | D5 |
|
||||
| 3 | `web/api.py` | Endpoint для смены `execution_mode` проекта/задачи | — |
|
||||
| 4 | `web/frontend` | UI переключатель Auto/Review (project settings + task detail) | — |
|
||||
|
||||
**Что НЕ нужно менять:**
|
||||
- `core/db.py` — схема готова
|
||||
- `core/models.py` — `get_effective_mode()` готов
|
||||
- `core/hooks.py` — события готовы
|
||||
- Permission detection в `runner.py` — готово
|
||||
|
||||
---
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
1. **Стоимость в auto mode**: `generate_followups()` добавляет один запуск агента после каждого пайплайна. При высокой нагрузке это существенный overhead. Митигация: `generate_followups()` можно сделать опциональным (флаг `auto_followup` в project settings).
|
||||
|
||||
2. **Permission retry scope**: `allow_write=True` после первого retry применяется ко всем последующим шагам. Это агрессивно, но допустимо, т.к. агент уже начал писать файлы.
|
||||
|
||||
3. **Infinite loop в auto-followup**: если followup создаёт задачи, а те создают ещё followup — нет механизма остановки. Митигация: `parent_task_id` позволяет отслеживать глубину. Задачи с `source: followup:*` глубже 1 уровня — не генерируют followup автоматически.
|
||||
|
||||
4. **Race condition**: если два пайплайна запускаются для одной задачи одновременно — БД-уровень не блокирует. SQLite WAL + `task.status = 'in_progress'` в начале пайплайна дают частичную защиту, но не полную.
|
||||
|
||||
---
|
||||
|
||||
## Статус реализации
|
||||
|
||||
- [x] DB schema: `execution_mode` в `projects` и `tasks`
|
||||
- [x] `get_effective_mode()` с приоритетом task > project > review
|
||||
- [x] Auto-approve: `task.status = "done"` в auto mode
|
||||
- [x] Permission retry: 1 попытка с `allow_write=True`
|
||||
- [x] Хуки: `task_auto_approved`, `pipeline_completed`, `task_permission_retry`
|
||||
- [ ] Auto-followup: вызов `generate_followups()` из `run_pipeline()` в auto mode (Gap 1)
|
||||
- [ ] Auto-resolution `pending_actions` в auto mode (Gap 2)
|
||||
- [ ] API endpoints для управления `execution_mode`
|
||||
- [ ] Frontend UI для Auto/Review переключателя
|
||||
|
|
@ -105,6 +105,18 @@ def test_approve_not_found(client):
|
|||
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):
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
|
|
@ -173,14 +185,15 @@ def test_run_not_found(client):
|
|||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_run_with_allow_write(client):
|
||||
"""POST /run with allow_write=true should be accepted."""
|
||||
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
|
||||
def test_run_kin_038_without_allow_write(client):
|
||||
"""Регрессионный тест KIN-038: allow_write удалён из схемы,
|
||||
эндпоинт принимает запросы с пустым телом без этого параметра."""
|
||||
r = client.post("/api/tasks/P1-001/run", json={})
|
||||
assert r.status_code == 202
|
||||
|
||||
|
||||
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={})
|
||||
assert r.status_code == 202
|
||||
|
||||
|
|
@ -256,14 +269,61 @@ def test_patch_task_status_persisted(client):
|
|||
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):
|
||||
"""Все 6 допустимых статусов должны приниматься."""
|
||||
"""Все 7 допустимых статусов должны приниматься (включая decomposed)."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": status})
|
||||
assert r.status_code == 200
|
||||
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):
|
||||
"""Недопустимый статус → 400."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": "flying"})
|
||||
|
|
@ -274,3 +334,258 @@ def test_patch_task_not_found(client):
|
|||
"""Несуществующая задача → 404."""
|
||||
r = client.patch("/api/tasks/NOPE-999", json={"status": "done"})
|
||||
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
|
||||
|
|
|
|||
478
tests/test_auto_mode.py
Normal file
478
tests/test_auto_mode.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""
|
||||
Tests for KIN-012 auto mode features:
|
||||
|
||||
- TestAutoApprove: pipeline auto-approves (status → done) без ручного review
|
||||
- TestAutoRerunOnPermissionDenied: runner делает retry при permission error,
|
||||
останавливается после одного retry (лимит = 1)
|
||||
- TestAutoFollowup: generate_followups вызывается сразу, без ожидания
|
||||
- Регрессия: review-режим работает как раньше
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
from agents.runner import run_pipeline, _is_permission_error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures & helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def conn():
|
||||
c = init_db(":memory:")
|
||||
models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek",
|
||||
tech_stack=["vue3"])
|
||||
models.create_task(c, "VDOL-001", "vdol", "Fix bug",
|
||||
brief={"route_type": "debug"})
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _mock_success(output="done"):
|
||||
"""Мок успешного subprocess.run (claude)."""
|
||||
mock = MagicMock()
|
||||
mock.stdout = json.dumps({"result": output})
|
||||
mock.stderr = ""
|
||||
mock.returncode = 0
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_permission_denied():
|
||||
"""Мок subprocess.run, возвращающего permission denied."""
|
||||
mock = MagicMock()
|
||||
mock.stdout = json.dumps({"result": "permission denied on write to config.js"})
|
||||
mock.stderr = "Error: permission denied"
|
||||
mock.returncode = 1
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_failure(error="Agent failed"):
|
||||
"""Мок subprocess.run, возвращающего общую ошибку."""
|
||||
mock = MagicMock()
|
||||
mock.stdout = ""
|
||||
mock.stderr = error
|
||||
mock.returncode = 1
|
||||
return mock
|
||||
|
||||
|
||||
def _get_hook_events(mock_hooks):
|
||||
"""Извлечь список event из всех вызовов mock_hooks."""
|
||||
return [c[1].get("event") for c in mock_hooks.call_args_list]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_auto_approve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAutoApprove:
|
||||
"""Pipeline auto-approve: в auto-режиме задача переходит в done без ручного review."""
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_mode_sets_status_done(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Auto-режим: статус задачи становится 'done', а не 'review'."""
|
||||
mock_run.return_value = _mock_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 bug"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "done", "Auto-mode должен auto-approve: status=done"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_mode_fires_task_auto_approved_hook(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""В auto-режиме срабатывает хук task_auto_approved."""
|
||||
mock_run.return_value = _mock_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 bug"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
events = _get_hook_events(mock_hooks)
|
||||
assert "task_auto_approved" in events, "Хук task_auto_approved должен сработать"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_review_mode_sets_status_review(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Регрессия: review-режим НЕ auto-approve — статус остаётся 'review'."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
# Проект остаётся в default "review" mode
|
||||
steps = [{"role": "debugger", "brief": "find bug"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "review", "Review-mode НЕ должен auto-approve"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_review_mode_does_not_fire_auto_approved_hook(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Регрессия: в review-режиме хук task_auto_approved НЕ срабатывает."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
steps = [{"role": "debugger", "brief": "find bug"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
events = _get_hook_events(mock_hooks)
|
||||
assert "task_auto_approved" not in events
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_task_level_auto_overrides_project_review(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Если у задачи execution_mode=auto, pipeline auto-approve, даже если проект в review."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
# Проект в review, но задача — auto
|
||||
models.update_task(conn, "VDOL-001", execution_mode="auto")
|
||||
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "done", "Task-level auto должен override project review"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_pipeline_result_includes_mode(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Pipeline result должен содержать поле mode."""
|
||||
mock_run.return_value = _mock_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.get("mode") == "auto"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_auto_rerun_on_permission_denied
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAutoRerunOnPermissionDenied:
|
||||
"""Runner повторяет шаг при permission issues, останавливается по лимиту (1 retry)."""
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_mode_retries_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Auto-режим: при permission denied runner делает 1 retry с allow_write=True."""
|
||||
mock_run.side_effect = [
|
||||
_mock_permission_denied(), # 1-й вызов: permission error
|
||||
_mock_success("fixed"), # 2-й вызов (retry): успех
|
||||
]
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "fix file"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
assert mock_run.call_count == 2, "Должен быть ровно 1 retry"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_retry_uses_dangerously_skip_permissions(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Retry при permission error использует --dangerously-skip-permissions."""
|
||||
mock_run.side_effect = [
|
||||
_mock_permission_denied(),
|
||||
_mock_success("fixed"),
|
||||
]
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "fix"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
# Второй вызов (retry) должен содержать --dangerously-skip-permissions
|
||||
second_cmd = mock_run.call_args_list[1][0][0]
|
||||
assert "--dangerously-skip-permissions" in second_cmd
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_retry_fires_permission_retry_hook(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""При авто-retry срабатывает хук task_permission_retry."""
|
||||
mock_run.side_effect = [
|
||||
_mock_permission_denied(),
|
||||
_mock_success(),
|
||||
]
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "fix"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
events = _get_hook_events(mock_hooks)
|
||||
assert "task_permission_retry" in events, "Хук task_permission_retry должен сработать"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_retry_failure_blocks_task(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Если retry тоже провалился → задача blocked (лимит в 1 retry исчерпан)."""
|
||||
mock_run.side_effect = [
|
||||
_mock_permission_denied(), # 1-й: permission error
|
||||
_mock_failure("still denied"), # retry: снова ошибка
|
||||
]
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "fix"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is False
|
||||
assert mock_run.call_count == 2, "Стоп после лимита: ровно 1 retry"
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "blocked"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_subsequent_steps_use_allow_write_after_retry(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""После успешного retry все следующие шаги тоже используют allow_write."""
|
||||
mock_run.side_effect = [
|
||||
_mock_permission_denied(), # Шаг 1: permission error
|
||||
_mock_success("fixed"), # Шаг 1 retry: успех
|
||||
_mock_success("tested"), # Шаг 2: должен получить allow_write
|
||||
]
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [
|
||||
{"role": "debugger", "brief": "fix"},
|
||||
{"role": "tester", "brief": "test"},
|
||||
]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
# Третий вызов (шаг 2) должен содержать --dangerously-skip-permissions
|
||||
third_cmd = mock_run.call_args_list[2][0][0]
|
||||
assert "--dangerously-skip-permissions" in third_cmd
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_normal_failure_does_not_trigger_retry(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Обычная ошибка (не permission) НЕ вызывает авто-retry даже в auto-режиме."""
|
||||
mock_run.return_value = _mock_failure("compilation error: undefined variable")
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "fix"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is False
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAutoFollowup:
|
||||
"""Followup запускается без ожидания сразу после pipeline в auto-режиме."""
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_followup_triggered_immediately(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""В auto-режиме generate_followups вызывается сразу после pipeline."""
|
||||
mock_run.return_value = _mock_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
|
||||
mock_followup.assert_called_once_with(conn, "VDOL-001")
|
||||
|
||||
@patch("core.followup.auto_resolve_pending_actions")
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_followup_resolves_pending_actions(
|
||||
self, mock_run, mock_hooks, mock_followup, mock_resolve, conn
|
||||
):
|
||||
"""Pending actions из followup авто-резолвятся без ожидания."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
pending = [{"type": "permission_fix", "description": "Fix nginx.conf",
|
||||
"original_item": {}, "options": ["rerun"]}]
|
||||
mock_followup.return_value = {"created": [], "pending_actions": pending}
|
||||
mock_resolve.return_value = [{"resolved": "rerun", "result": {}}]
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
mock_resolve.assert_called_once_with(conn, "VDOL-001", pending)
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_review_mode_no_auto_followup(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Регрессия: в review-режиме generate_followups НЕ вызывается."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
# Проект в default review mode
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_followup.assert_not_called()
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "review"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_followup_not_triggered_for_followup_tasks(
|
||||
self, mock_run, mock_hooks, mock_followup, conn
|
||||
):
|
||||
"""Для followup-задач generate_followups НЕ вызывается (защита от рекурсии)."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
models.update_task(conn, "VDOL-001", brief={"source": "followup:VDOL-000"})
|
||||
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_followup.assert_not_called()
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_followup_exception_does_not_block_pipeline(
|
||||
self, mock_run, mock_hooks, mock_followup, conn
|
||||
):
|
||||
"""Ошибка в followup не должна блокировать pipeline (success=True)."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.side_effect = Exception("followup PM crashed")
|
||||
|
||||
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 # Pipeline succeeded, followup failure absorbed
|
||||
|
||||
@patch("core.followup.auto_resolve_pending_actions")
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_no_pending_actions_skips_auto_resolve(
|
||||
self, mock_run, mock_hooks, mock_followup, mock_resolve, conn
|
||||
):
|
||||
"""Если pending_actions пустой, auto_resolve_pending_actions НЕ вызывается."""
|
||||
mock_run.return_value = _mock_success()
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
mock_resolve.return_value = []
|
||||
|
||||
models.update_project(conn, "vdol", execution_mode="auto")
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_permission_error unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsPermissionError:
|
||||
"""Unit-тесты для функции _is_permission_error."""
|
||||
|
||||
def test_detects_permission_denied_in_raw_output(self):
|
||||
result = {"raw_output": "Error: permission denied writing to nginx.conf",
|
||||
"returncode": 1}
|
||||
assert _is_permission_error(result) is True
|
||||
|
||||
def test_detects_read_only_in_output(self):
|
||||
result = {"raw_output": "File is read-only, cannot write",
|
||||
"returncode": 1}
|
||||
assert _is_permission_error(result) is True
|
||||
|
||||
def test_detects_manual_apply_in_output(self):
|
||||
result = {"raw_output": "Apply manually to /etc/nginx/nginx.conf",
|
||||
"returncode": 1}
|
||||
assert _is_permission_error(result) is True
|
||||
|
||||
def test_normal_failure_not_permission_error(self):
|
||||
result = {"raw_output": "Compilation error: undefined variable x",
|
||||
"returncode": 1}
|
||||
assert _is_permission_error(result) is False
|
||||
|
||||
def test_empty_output_not_permission_error(self):
|
||||
result = {"raw_output": "", "returncode": 1}
|
||||
assert _is_permission_error(result) is False
|
||||
|
||||
def test_success_with_permission_word_not_flagged(self):
|
||||
"""Если returncode=0 и текст содержит 'permission', это не ошибка."""
|
||||
# Функция проверяет только текст, не returncode
|
||||
# Но с success output вряд ли содержит "permission denied"
|
||||
result = {"raw_output": "All permissions granted, build successful",
|
||||
"returncode": 0}
|
||||
assert _is_permission_error(result) is False
|
||||
|
|
@ -333,7 +333,8 @@ def test_hook_setup_registers_rebuild_frontend(runner, tmp_path):
|
|||
r = invoke(runner, ["hook", "list", "--project", "p1"])
|
||||
assert r.exit_code == 0
|
||||
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):
|
||||
|
|
@ -352,3 +353,123 @@ def test_hook_setup_project_not_found(runner):
|
|||
r = invoke(runner, ["hook", "setup", "--project", "nope"])
|
||||
assert r.exit_code == 1
|
||||
assert "not found" in r.output
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
|
|||
from core.db import init_db
|
||||
from core import models
|
||||
from core.followup import (
|
||||
generate_followups, resolve_pending_action,
|
||||
generate_followups, resolve_pending_action, auto_resolve_pending_actions,
|
||||
_collect_pipeline_output, _next_task_id, _is_permission_blocked,
|
||||
)
|
||||
|
||||
|
|
@ -222,3 +222,48 @@ class TestResolvePendingAction:
|
|||
def test_nonexistent_task(self, conn):
|
||||
action = {"type": "permission_fix", "original_item": {}}
|
||||
assert resolve_pending_action(conn, "NOPE", action, "skip") is None
|
||||
|
||||
|
||||
class TestAutoResolvePendingActions:
|
||||
@patch("agents.runner._run_claude")
|
||||
def test_rerun_success_resolves_as_rerun(self, mock_claude, conn):
|
||||
"""Успешный rerun должен резолвиться как 'rerun'."""
|
||||
mock_claude.return_value = {
|
||||
"output": json.dumps({"result": "fixed"}),
|
||||
"returncode": 0,
|
||||
}
|
||||
action = {
|
||||
"type": "permission_fix",
|
||||
"description": "Fix X",
|
||||
"original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"},
|
||||
"options": ["rerun", "manual_task", "skip"],
|
||||
}
|
||||
results = auto_resolve_pending_actions(conn, "VDOL-001", [action])
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["resolved"] == "rerun"
|
||||
|
||||
@patch("agents.runner._run_claude")
|
||||
def test_rerun_failure_escalates_to_manual_task(self, mock_claude, conn):
|
||||
"""Провал rerun должен создавать manual_task для эскалации."""
|
||||
mock_claude.return_value = {"output": "", "returncode": 1}
|
||||
action = {
|
||||
"type": "permission_fix",
|
||||
"description": "Fix X",
|
||||
"original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"},
|
||||
"options": ["rerun", "manual_task", "skip"],
|
||||
}
|
||||
results = auto_resolve_pending_actions(conn, "VDOL-001", [action])
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["resolved"] == "manual_task"
|
||||
# Manual task должна быть создана в DB
|
||||
tasks = models.list_tasks(conn, project_id="vdol")
|
||||
assert len(tasks) == 2 # VDOL-001 + новая manual task
|
||||
|
||||
@patch("agents.runner._run_claude")
|
||||
def test_empty_pending_actions(self, mock_claude, conn):
|
||||
"""Пустой список — пустой результат."""
|
||||
results = auto_resolve_pending_actions(conn, "VDOL-001", [])
|
||||
assert results == []
|
||||
mock_claude.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from core.db import init_db
|
|||
from core import models
|
||||
from core.hooks import (
|
||||
create_hook, get_hooks, update_hook, delete_hook,
|
||||
run_hooks, get_hook_logs, HookResult,
|
||||
run_hooks, get_hook_logs, HookResult, _substitute_vars,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -273,3 +273,298 @@ class TestGetHookLogs:
|
|||
event="pipeline_completed", task_modules=modules)
|
||||
logs = get_hook_logs(conn, project_id="vdol", limit=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)
|
||||
|
|
|
|||
|
|
@ -289,6 +289,149 @@ class TestRunPipeline:
|
|||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAutoMode:
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_mode_generates_followups(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Auto mode должен вызывать generate_followups после 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
|
||||
mock_followup.assert_called_once_with(conn, "VDOL-001")
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "done"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_review_mode_skips_followups(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Review mode НЕ должен вызывать generate_followups автоматически."""
|
||||
mock_run.return_value = _mock_claude_success({"result": "done"})
|
||||
mock_hooks.return_value = []
|
||||
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||
|
||||
# Проект остаётся в default "review" mode
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_followup.assert_not_called()
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "review"
|
||||
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_mode_skips_followups_for_followup_tasks(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Auto mode НЕ должен генерировать followups для followup-задач (предотвращение рекурсии)."""
|
||||
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")
|
||||
models.update_task(conn, "VDOL-001", brief={"source": "followup:VDOL-000"})
|
||||
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is True
|
||||
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.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_auto_mode_resolves_pending_actions(self, mock_run, mock_hooks, mock_followup, mock_resolve, conn):
|
||||
"""Auto mode должен авто-резолвить pending_actions из followup generation."""
|
||||
mock_run.return_value = _mock_claude_success({"result": "done"})
|
||||
mock_hooks.return_value = []
|
||||
|
||||
pending = [{"type": "permission_fix", "description": "Fix X",
|
||||
"original_item": {}, "options": ["rerun"]}]
|
||||
mock_followup.return_value = {"created": [], "pending_actions": pending}
|
||||
mock_resolve.return_value = [{"resolved": "rerun", "result": {}}]
|
||||
|
||||
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
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -336,20 +479,22 @@ class TestNonInteractive:
|
|||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("timeout") == 300
|
||||
|
||||
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_interactive_uses_600s_timeout(self, mock_run, conn):
|
||||
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("timeout") == 300
|
||||
assert call_kwargs.get("timeout") == 600
|
||||
|
||||
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_interactive_no_stdin_override(self, mock_run, conn):
|
||||
"""In interactive mode, stdin should not be set to DEVNULL."""
|
||||
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("stdin") == subprocess.DEVNULL
|
||||
assert call_kwargs.get("stdin") is None
|
||||
|
||||
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"})
|
||||
@patch("agents.runner.subprocess.run")
|
||||
|
|
@ -501,3 +646,108 @@ class TestRunAudit:
|
|||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
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
|
||||
|
|
|
|||
195
tests/test_tech_researcher.py
Normal file
195
tests/test_tech_researcher.py
Normal 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
|
||||
66
web/api.py
66
web/api.py
|
|
@ -76,6 +76,25 @@ class ProjectCreate(BaseModel):
|
|||
priority: int = 5
|
||||
|
||||
|
||||
class ProjectPatch(BaseModel):
|
||||
execution_mode: str
|
||||
|
||||
|
||||
@app.patch("/api/projects/{project_id}")
|
||||
def patch_project(project_id: str, body: ProjectPatch):
|
||||
if body.execution_mode not in VALID_EXECUTION_MODES:
|
||||
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{project_id}' not found")
|
||||
models.update_project(conn, project_id, execution_mode=body.execution_mode)
|
||||
p = models.get_project(conn, project_id)
|
||||
conn.close()
|
||||
return p
|
||||
|
||||
|
||||
@app.post("/api/projects")
|
||||
def create_project(body: ProjectCreate):
|
||||
conn = get_conn()
|
||||
|
|
@ -138,22 +157,33 @@ def create_task(body: TaskCreate):
|
|||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
status: str
|
||||
status: 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"}
|
||||
|
||||
|
||||
@app.patch("/api/tasks/{task_id}")
|
||||
def patch_task(task_id: str, body: TaskPatch):
|
||||
if body.status not in VALID_STATUSES:
|
||||
if body.status is not None and body.status not in VALID_STATUSES:
|
||||
raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}")
|
||||
if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES:
|
||||
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
|
||||
if body.status is None and body.execution_mode is None:
|
||||
raise HTTPException(400, "Nothing to update. Provide status or execution_mode.")
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
models.update_task(conn, task_id, status=body.status)
|
||||
fields = {}
|
||||
if body.status is not None:
|
||||
fields["status"] = body.status
|
||||
if body.execution_mode is not None:
|
||||
fields["execution_mode"] = body.execution_mode
|
||||
models.update_task(conn, task_id, **fields)
|
||||
t = models.get_task(conn, task_id)
|
||||
conn.close()
|
||||
return t
|
||||
|
|
@ -218,6 +248,13 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
|
|||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
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
|
||||
if body and body.decision_title:
|
||||
decision = models.add_decision(
|
||||
|
|
@ -298,12 +335,8 @@ def is_task_running(task_id: str):
|
|||
return {"running": False}
|
||||
|
||||
|
||||
class TaskRun(BaseModel):
|
||||
allow_write: bool = False
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/run")
|
||||
def run_task(task_id: str, body: TaskRun | None = None):
|
||||
def run_task(task_id: str):
|
||||
"""Launch pipeline for a task in background. Returns 202."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
|
|
@ -317,8 +350,7 @@ def run_task(task_id: str, body: TaskRun | None = None):
|
|||
kin_root = Path(__file__).parent.parent
|
||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
||||
"run", task_id]
|
||||
if body and body.allow_write:
|
||||
cmd.append("--allow-write")
|
||||
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
|
||||
|
||||
import os
|
||||
env = os.environ.copy()
|
||||
|
|
@ -383,6 +415,18 @@ def create_decision(body: DecisionCreate):
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
1553
web/frontend/package-lock.json
generated
1553
web/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,9 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30",
|
||||
|
|
@ -15,12 +17,15 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
511
web/frontend/src/__tests__/filter-persistence.test.ts
Normal file
511
web/frontend/src/__tests__/filter-persistence.test.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
/**
|
||||
* KIN-011/KIN-014: Тесты фильтра статусов при навигации
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. Клик по кнопке статуса обновляет URL (?status=...)
|
||||
* 2. Прямая ссылка с query param активирует нужную кнопку
|
||||
* 3. Фильтр показывает только задачи с нужным статусом
|
||||
* 4. Сброс фильтра (✕) удаляет param из URL
|
||||
* 5. Без фильтра отображаются все задачи
|
||||
* 6. goBack() вызывает router.back() при наличии истории
|
||||
* 7. goBack() делает push на /project/:id без истории
|
||||
* 8. После router.back() URL проекта восстанавливается с фильтром
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ProjectView from '../views/ProjectView.vue'
|
||||
import TaskDetail from '../views/TaskDetail.vue'
|
||||
|
||||
// Мок api — factory без ссылок на внешние переменные (vi.mock хоистится)
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
project: vi.fn(),
|
||||
taskFull: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
auditProject: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Импортируем мок после объявления vi.mock
|
||||
import { api } from '../api'
|
||||
|
||||
const Stub = { template: '<div />' }
|
||||
|
||||
const MOCK_PROJECT = {
|
||||
id: 'KIN',
|
||||
name: 'Kin',
|
||||
path: '/projects/kin',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python', 'vue'],
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: 3,
|
||||
done_tasks: 1,
|
||||
active_tasks: 1,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
tasks: [
|
||||
{
|
||||
id: 'KIN-001', project_id: 'KIN', title: 'Task 1', status: 'pending',
|
||||
priority: 5, assigned_role: null, parent_task_id: null,
|
||||
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'KIN-002', project_id: 'KIN', title: 'Task 2', status: 'in_progress',
|
||||
priority: 3, assigned_role: null, parent_task_id: null,
|
||||
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'KIN-003', project_id: 'KIN', title: 'Task 3', status: 'done',
|
||||
priority: 1, assigned_role: null, parent_task_id: null,
|
||||
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
decisions: [],
|
||||
modules: [],
|
||||
}
|
||||
|
||||
const MOCK_TASK_FULL = {
|
||||
id: 'KIN-002',
|
||||
project_id: 'KIN',
|
||||
title: 'Task 2',
|
||||
status: 'in_progress',
|
||||
priority: 3,
|
||||
assigned_role: null,
|
||||
parent_task_id: null,
|
||||
brief: null,
|
||||
spec: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
pipeline_steps: [],
|
||||
related_decisions: [],
|
||||
}
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Stub },
|
||||
{ path: '/project/:id', component: ProjectView, props: true },
|
||||
{ path: '/task/:id', component: TaskDetail, props: true },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// localStorage mock для jsdom-окружения
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
|
||||
vi.mocked(api.taskFull).mockResolvedValue(MOCK_TASK_FULL as any)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ProjectView: фильтр ↔ URL
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-011/KIN-014: ProjectView — фильтр и URL', () => {
|
||||
it('1. Клик по кнопке статуса обновляет URL (?status=...)', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Изначально status нет в URL
|
||||
expect(router.currentRoute.value.query.status).toBeUndefined()
|
||||
|
||||
// Кликаем по кнопке in_progress
|
||||
const btn = wrapper.find('[data-status="in_progress"]')
|
||||
await btn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// URL должен содержать ?status=in_progress
|
||||
expect(router.currentRoute.value.query.status).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('2. Прямая ссылка ?status=in_progress активирует нужную кнопку', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Кнопка in_progress должна быть активна (иметь класс text-blue-300)
|
||||
const btn = wrapper.find('[data-status="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 () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Должна быть видна только KIN-002 (in_progress)
|
||||
const links = wrapper.findAll('a[href^="/task/"]')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].text()).toContain('KIN-002')
|
||||
})
|
||||
|
||||
it('4. Сброс фильтра (кнопка ✕) удаляет status из URL', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=done')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Кликаем кнопку сброса
|
||||
const clearBtn = wrapper.find('[data-action="clear-status"]')
|
||||
await clearBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// status должен исчезнуть из URL
|
||||
expect(router.currentRoute.value.query.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('5. Без фильтра отображаются все 3 задачи', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const links = wrapper.findAll('a[href^="/task/"]')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TaskDetail: goBack сохраняет URL проекта с фильтром
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-011: TaskDetail — возврат с сохранением URL', () => {
|
||||
it('6 (KIN-011). goBack() вызывает router.back() когда window.history.length > 1', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
await router.push('/task/KIN-002')
|
||||
|
||||
const backSpy = vi.spyOn(router, 'back')
|
||||
|
||||
// Эмулируем наличие истории
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { ...window.history, length: 3 },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(TaskDetail, {
|
||||
props: { id: 'KIN-002' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Первая кнопка — кнопка "назад" (← KIN)
|
||||
const backBtn = wrapper.find('button')
|
||||
await backBtn.trigger('click')
|
||||
|
||||
expect(backSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('7. goBack() без истории делает push на /project/:id', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/task/KIN-002')
|
||||
|
||||
const pushSpy = vi.spyOn(router, 'push')
|
||||
|
||||
// Эмулируем отсутствие истории
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { ...window.history, length: 1 },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(TaskDetail, {
|
||||
props: { id: 'KIN-002' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const backBtn = wrapper.find('button')
|
||||
await backBtn.trigger('click')
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: '/project/KIN', query: undefined })
|
||||
})
|
||||
|
||||
it('8. После router.back() URL проекта восстанавливается с query param ?status', async () => {
|
||||
const router = makeRouter()
|
||||
|
||||
// Навигация: проект с фильтром → задача
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
await router.push('/task/KIN-002')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/task/KIN-002')
|
||||
|
||||
// Возвращаемся назад
|
||||
router.back()
|
||||
await flushPromises()
|
||||
|
||||
// URL должен вернуться к /project/KIN?status=in_progress
|
||||
expect(router.currentRoute.value.path).toBe('/project/KIN')
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,12 @@ async function post<T>(path: string, body: unknown): Promise<T> {
|
|||
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 {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -33,6 +39,7 @@ export interface Project {
|
|||
status: string
|
||||
priority: number
|
||||
tech_stack: string[] | null
|
||||
execution_mode: string | null
|
||||
created_at: string
|
||||
total_tasks: number
|
||||
done_tasks: number
|
||||
|
|
@ -57,6 +64,8 @@ export interface Task {
|
|||
parent_task_id: string | null
|
||||
brief: Record<string, unknown> | null
|
||||
spec: Record<string, unknown> | null
|
||||
execution_mode: string | null
|
||||
blocked_reason: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -150,14 +159,18 @@ export const api = {
|
|||
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
|
||||
rejectTask: (id: string, reason: string) =>
|
||||
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
|
||||
runTask: (id: string, allowWrite = false) =>
|
||||
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }),
|
||||
runTask: (id: string) =>
|
||||
post<{ status: string }>(`/tasks/${id}/run`, {}),
|
||||
bootstrap: (data: { path: string; id: string; name: string }) =>
|
||||
post<{ project: Project }>('/bootstrap', data),
|
||||
auditProject: (projectId: string) =>
|
||||
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
||||
auditApply: (projectId: string, taskIds: string[]) =>
|
||||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
||||
patchTask: (id: string, data: { status: string }) =>
|
||||
patchTask: (id: string, data: { status?: string; execution_mode?: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, data),
|
||||
patchProject: (id: string, data: { execution_mode: string }) =>
|
||||
patch<Project>(`/projects/${id}`, data),
|
||||
deleteDecision: (projectId: string, decisionId: number) =>
|
||||
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type ProjectDetail, type AuditResult } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -12,7 +15,28 @@ const error = ref('')
|
|||
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
|
||||
|
||||
// Filters
|
||||
const taskStatusFilter = ref('')
|
||||
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 decisionSearch = ref('')
|
||||
|
||||
|
|
@ -20,12 +44,22 @@ const decisionSearch = ref('')
|
|||
const autoMode = ref(false)
|
||||
|
||||
function loadMode() {
|
||||
if (project.value?.execution_mode) {
|
||||
autoMode.value = project.value.execution_mode === 'auto'
|
||||
} else {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
async function toggleMode() {
|
||||
autoMode.value = !autoMode.value
|
||||
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
|
||||
try {
|
||||
await api.patchProject(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
|
||||
if (project.value) project.value = { ...project.value, execution_mode: autoMode.value ? 'auto' : 'review' }
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Audit
|
||||
|
|
@ -85,12 +119,17 @@ async function load() {
|
|||
}
|
||||
}
|
||||
|
||||
watch(selectedStatuses, (val) => {
|
||||
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() })
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (!project.value) return []
|
||||
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
|
||||
})
|
||||
|
||||
|
|
@ -128,12 +167,6 @@ function modTypeColor(t: string) {
|
|||
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(() => {
|
||||
if (!project.value) return []
|
||||
const s = new Set(project.value.decisions.map(d => d.type))
|
||||
|
|
@ -162,7 +195,7 @@ async function runTask(taskId: string, event: Event) {
|
|||
event.stopPropagation()
|
||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||
try {
|
||||
await api.runTask(taskId, autoMode.value)
|
||||
await api.runTask(taskId)
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
|
|
@ -236,12 +269,17 @@ async function addDecision() {
|
|||
<!-- Tasks Tab -->
|
||||
<div v-if="activeTab === 'tasks'">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex gap-2">
|
||||
<select v-model="taskStatusFilter"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<div class="flex gap-1 flex-wrap items-center">
|
||||
<button v-for="s in ALL_TASK_STATUSES" :key="s"
|
||||
:data-status="s"
|
||||
@click="toggleStatus(s)"
|
||||
class="px-2 py-0.5 text-xs rounded border transition-colors"
|
||||
: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 class="flex gap-2">
|
||||
<button @click="toggleMode"
|
||||
|
|
@ -267,12 +305,15 @@ async function addDecision() {
|
|||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||
<div v-else class="space-y-1">
|
||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||
:to="`/task/${t.id}`"
|
||||
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
||||
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
||||
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
||||
<span class="text-gray-300 truncate">{{ t.title }}</span>
|
||||
<span v-if="t.execution_mode === 'auto'"
|
||||
class="text-[10px] px-1 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded shrink-0"
|
||||
title="Auto mode">🔓</span>
|
||||
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const task = ref<TaskFull | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -25,17 +28,30 @@ const resolvingAction = ref(false)
|
|||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
// Auto/Review mode (persisted per project)
|
||||
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
|
||||
const autoMode = ref(false)
|
||||
|
||||
function loadMode(projectId: string) {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto'
|
||||
function loadMode(t: typeof task.value) {
|
||||
if (!t) return
|
||||
if (t.execution_mode) {
|
||||
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 {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
async function toggleMode() {
|
||||
if (!task.value) return
|
||||
autoMode.value = !autoMode.value
|
||||
if (task.value) {
|
||||
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
|
||||
try {
|
||||
const updated = await api.patchTask(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
|
||||
task.value = { ...task.value, ...updated }
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +59,7 @@ async function load() {
|
|||
try {
|
||||
const prev = task.value
|
||||
task.value = await api.taskFull(props.id)
|
||||
if (task.value?.project_id) loadMode(task.value.project_id)
|
||||
loadMode(task.value)
|
||||
// Auto-start polling if task is in_progress
|
||||
if (task.value.status === 'in_progress' && !polling.value) {
|
||||
startPolling()
|
||||
|
|
@ -175,7 +191,7 @@ async function reject() {
|
|||
|
||||
async function runPipeline() {
|
||||
try {
|
||||
await api.runTask(props.id, autoMode.value)
|
||||
await api.runTask(props.id)
|
||||
startPolling()
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
|
|
@ -186,6 +202,18 @@ async function runPipeline() {
|
|||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else if (task.value) {
|
||||
const backStatus = route.query.back_status as string | undefined
|
||||
router.push({
|
||||
path: `/project/${task.value.project_id}`,
|
||||
query: backStatus ? { status: backStatus } : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const statusChanging = ref(false)
|
||||
|
||||
async function changeStatus(newStatus: string) {
|
||||
|
|
@ -209,14 +237,17 @@ async function changeStatus(newStatus: string) {
|
|||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<router-link :to="`/project/${task.project_id}`" class="text-gray-600 hover:text-gray-400 text-sm no-underline">
|
||||
<button @click="goBack" class="text-gray-600 hover:text-gray-400 text-sm cursor-pointer bg-transparent border-none p-0">
|
||||
← {{ task.project_id }}
|
||||
</router-link>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
||||
<span class="text-gray-400">{{ task.title }}</span>
|
||||
<Badge :text="task.status" :color="statusColor(task.status)" />
|
||||
<span v-if="task.execution_mode === 'auto'"
|
||||
class="text-[10px] px-1.5 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded"
|
||||
title="Auto mode: agents can write files">🔓 auto</span>
|
||||
<select
|
||||
:value="task.status"
|
||||
@change="changeStatus(($event.target as HTMLSelectElement).value)"
|
||||
|
|
@ -236,6 +267,9 @@ async function changeStatus(newStatus: string) {
|
|||
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
|
||||
Brief: {{ JSON.stringify(task.brief) }}
|
||||
</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">
|
||||
Assigned: {{ task.assigned_role }}
|
||||
</div>
|
||||
|
|
@ -303,17 +337,22 @@ async function changeStatus(newStatus: string) {
|
|||
|
||||
<!-- Actions Bar -->
|
||||
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
|
||||
<button v-if="task.status === 'review'"
|
||||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-900/20 border border-yellow-800/50 rounded text-xs text-yellow-400">
|
||||
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
|
||||
Автопилот активен
|
||||
</div>
|
||||
<button v-if="task.status === 'review' && !autoMode"
|
||||
@click="showApprove = true"
|
||||
class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
✓ Approve
|
||||
</button>
|
||||
<button v-if="task.status === 'review' || task.status === 'in_progress'"
|
||||
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
|
||||
@click="showReject = true"
|
||||
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
✗ Reject
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
|
||||
@click="toggleMode"
|
||||
class="px-3 py-2 text-sm border rounded transition-colors"
|
||||
:class="autoMode
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue