From 38c252fc1b09017e013c738cc5b4e6343246c772 Mon Sep 17 00:00:00 2001 From: johnfrum1234 Date: Sun, 15 Mar 2026 14:32:29 +0200 Subject: [PATCH] Add task detail view, pipeline visualization, approve/reject workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API (web/api.py) — 5 new endpoints: GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps GET /api/tasks/{id}/full — task + steps + related decisions POST /api/tasks/{id}/approve — mark done, optionally add decision POST /api/tasks/{id}/reject — return to pending with reason POST /api/tasks/{id}/run — launch pipeline in background (202) Frontend: TaskDetail (/task/:id) — full task page with: - Pipeline graph: role cards with icons, arrows, status colors - Click step → expand output (pre-formatted, JSON detected) - Action bar: Approve (with optional decision), Reject, Run Pipeline - Polling for live pipeline updates Dashboard: review_tasks badge ("awaiting review" in yellow) ProjectView: task rows are now clickable links to /task/:id Runner: output_summary no longer truncated (full output for GUI). Models: get_project_summary includes review_tasks count. 13 new API tests, 105 total, all passing. Frontend builds clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- agents/runner.py | 2 +- core/models.py | 3 +- tests/test_api.py | 147 ++++++++++++++ web/api.py | 107 +++++++++++ web/frontend/src/api.ts | 28 +++ web/frontend/src/main.ts | 2 + web/frontend/src/views/Dashboard.vue | 5 +- web/frontend/src/views/ProjectView.vue | 7 +- web/frontend/src/views/TaskDetail.vue | 256 +++++++++++++++++++++++++ 9 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 tests/test_api.py create mode 100644 web/frontend/src/views/TaskDetail.vue diff --git a/agents/runner.py b/agents/runner.py index 3cd4f69..705fe7c 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -78,7 +78,7 @@ def run_agent( agent_role=role, action="execute", input_summary=f"task={task_id}, model={model}", - output_summary=output_text[:500] if output_text else None, + output_summary=output_text or None, tokens_used=result.get("tokens_used"), model=model, cost_usd=result.get("cost_usd"), diff --git a/core/models.py b/core/models.py index 713d08c..4eef709 100644 --- a/core/models.py +++ b/core/models.py @@ -416,7 +416,8 @@ def get_project_summary(conn: sqlite3.Connection) -> list[dict]: COUNT(t.id) AS total_tasks, SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS done_tasks, SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) AS active_tasks, - SUM(CASE WHEN t.status = 'blocked' THEN 1 ELSE 0 END) AS blocked_tasks + SUM(CASE WHEN t.status = 'blocked' THEN 1 ELSE 0 END) AS blocked_tasks, + SUM(CASE WHEN t.status = 'review' THEN 1 ELSE 0 END) AS review_tasks FROM projects p LEFT JOIN tasks t ON t.project_id = p.id GROUP BY p.id diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5527797 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,147 @@ +"""Tests for web/api.py — new task endpoints (pipeline, approve, reject, full).""" + +import pytest +from pathlib import Path +from fastapi.testclient import TestClient + +# Patch DB_PATH before importing app +import web.api as api_module + +@pytest.fixture +def client(tmp_path): + db_path = tmp_path / "test.db" + api_module.DB_PATH = db_path + from web.api import app + c = TestClient(app) + # Seed data + c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) + c.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"}) + return c + + +def test_get_task(client): + r = client.get("/api/tasks/P1-001") + assert r.status_code == 200 + assert r.json()["title"] == "Fix bug" + + +def test_get_task_not_found(client): + r = client.get("/api/tasks/NOPE") + assert r.status_code == 404 + + +def test_task_pipeline_empty(client): + r = client.get("/api/tasks/P1-001/pipeline") + assert r.status_code == 200 + assert r.json() == [] + + +def test_task_pipeline_with_logs(client): + # Insert agent logs directly + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.log_agent_run(conn, "p1", "debugger", "execute", + task_id="P1-001", output_summary="Found bug", + tokens_used=1000, duration_seconds=5, success=True) + models.log_agent_run(conn, "p1", "tester", "execute", + task_id="P1-001", output_summary="Tests pass", + tokens_used=500, duration_seconds=3, success=True) + conn.close() + + r = client.get("/api/tasks/P1-001/pipeline") + assert r.status_code == 200 + steps = r.json() + assert len(steps) == 2 + assert steps[0]["agent_role"] == "debugger" + assert steps[0]["output_summary"] == "Found bug" + assert steps[1]["agent_role"] == "tester" + + +def test_task_full(client): + r = client.get("/api/tasks/P1-001/full") + assert r.status_code == 200 + data = r.json() + assert data["id"] == "P1-001" + assert "pipeline_steps" in data + assert "related_decisions" in data + + +def test_task_full_not_found(client): + r = client.get("/api/tasks/NOPE/full") + assert r.status_code == 404 + + +def test_approve_task(client): + # First set task to review + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", status="review") + conn.close() + + r = client.post("/api/tasks/P1-001/approve", json={}) + assert r.status_code == 200 + assert r.json()["status"] == "done" + + # Verify task is done + r = client.get("/api/tasks/P1-001") + assert r.json()["status"] == "done" + + +def test_approve_with_decision(client): + r = client.post("/api/tasks/P1-001/approve", json={ + "decision_title": "Use AbortController", + "decision_description": "Fix race condition with AbortController", + "decision_type": "decision", + }) + assert r.status_code == 200 + assert r.json()["decision"] is not None + assert r.json()["decision"]["title"] == "Use AbortController" + + +def test_approve_not_found(client): + r = client.post("/api/tasks/NOPE/approve", json={}) + assert r.status_code == 404 + + +def test_reject_task(client): + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", status="review") + conn.close() + + r = client.post("/api/tasks/P1-001/reject", json={ + "reason": "Didn't fix the root cause" + }) + assert r.status_code == 200 + assert r.json()["status"] == "pending" + + # Verify task is pending with review reason + r = client.get("/api/tasks/P1-001") + data = r.json() + assert data["status"] == "pending" + assert data["review"]["rejected"] == "Didn't fix the root cause" + + +def test_reject_not_found(client): + r = client.post("/api/tasks/NOPE/reject", json={"reason": "bad"}) + assert r.status_code == 404 + + +def test_task_pipeline_not_found(client): + r = client.get("/api/tasks/NOPE/pipeline") + assert r.status_code == 404 + + +def test_project_summary_includes_review(client): + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", status="review") + conn.close() + + r = client.get("/api/projects") + projects = r.json() + assert projects[0]["review_tasks"] == 1 diff --git a/web/api.py b/web/api.py index c067163..6b0d001 100644 --- a/web/api.py +++ b/web/api.py @@ -3,6 +3,7 @@ Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models. Run: uvicorn web.api:app --reload --port 8420 """ +import subprocess import sys from pathlib import Path @@ -11,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from pydantic import BaseModel from core.db import init_db @@ -134,6 +136,111 @@ def create_task(body: TaskCreate): return t +@app.get("/api/tasks/{task_id}/pipeline") +def get_task_pipeline(task_id: str): + """Get agent_logs for a task (pipeline steps).""" + conn = get_conn() + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + rows = conn.execute( + """SELECT id, agent_role, action, output_summary, success, + duration_seconds, tokens_used, model, cost_usd, created_at + FROM agent_logs WHERE task_id = ? ORDER BY created_at""", + (task_id,), + ).fetchall() + steps = [dict(r) for r in rows] + conn.close() + return steps + + +@app.get("/api/tasks/{task_id}/full") +def get_task_full(task_id: str): + """Task + pipeline steps + related decisions.""" + conn = get_conn() + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + rows = conn.execute( + """SELECT id, agent_role, action, output_summary, success, + duration_seconds, tokens_used, model, cost_usd, created_at + FROM agent_logs WHERE task_id = ? ORDER BY created_at""", + (task_id,), + ).fetchall() + steps = [dict(r) for r in rows] + decisions = models.get_decisions(conn, t["project_id"]) + # Filter to decisions linked to this task + task_decisions = [d for d in decisions if d.get("task_id") == task_id] + conn.close() + return {**t, "pipeline_steps": steps, "related_decisions": task_decisions} + + +class TaskApprove(BaseModel): + decision_title: str | None = None + decision_description: str | None = None + decision_type: str = "decision" + + +@app.post("/api/tasks/{task_id}/approve") +def approve_task(task_id: str, body: TaskApprove | None = None): + """Approve a task: set status=done, optionally add a decision.""" + 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="done") + decision = None + if body and body.decision_title: + decision = models.add_decision( + conn, t["project_id"], body.decision_type, + body.decision_title, body.decision_description or body.decision_title, + task_id=task_id, + ) + conn.close() + return {"status": "done", "decision": decision} + + +class TaskReject(BaseModel): + reason: str + + +@app.post("/api/tasks/{task_id}/reject") +def reject_task(task_id: str, body: TaskReject): + """Reject a task: set status=pending with reason in review field.""" + 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="pending", review={"rejected": body.reason}) + conn.close() + return {"status": "pending", "reason": body.reason} + + +@app.post("/api/tasks/{task_id}/run") +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) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + conn.close() + # Launch kin run in background subprocess + kin_root = Path(__file__).parent.parent + subprocess.Popen( + [sys.executable, "-m", "cli.main", "run", task_id, "--db", + str(DB_PATH)], + cwd=str(kin_root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return JSONResponse({"status": "started", "task_id": task_id}, status_code=202) + + # --------------------------------------------------------------------------- # Decisions # --------------------------------------------------------------------------- diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 378d61f..fd6ed2b 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -28,6 +28,7 @@ export interface Project { done_tasks: number active_tasks: number blocked_tasks: number + review_tasks: number } export interface ProjectDetail extends Project { @@ -73,6 +74,24 @@ export interface Module { dependencies: string[] | null } +export interface PipelineStep { + id: number + agent_role: string + action: string + output_summary: string | null + success: boolean | number + duration_seconds: number | null + tokens_used: number | null + model: string | null + cost_usd: number | null + created_at: string +} + +export interface TaskFull extends Task { + pipeline_steps: PipelineStep[] + related_decisions: Decision[] +} + export interface CostEntry { project_id: string project_name: string @@ -85,11 +104,20 @@ export interface CostEntry { export const api = { projects: () => get('/projects'), project: (id: string) => get(`/projects/${id}`), + task: (id: string) => get(`/tasks/${id}`), + taskFull: (id: string) => get(`/tasks/${id}/full`), + taskPipeline: (id: string) => get(`/tasks/${id}/pipeline`), cost: (days = 7) => get(`/cost?days=${days}`), createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) => post('/projects', data), createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) => post('/tasks', data), + approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string }) => + post<{ status: string }>(`/tasks/${id}/approve`, data || {}), + rejectTask: (id: string, reason: string) => + post<{ status: string }>(`/tasks/${id}/reject`, { reason }), + runTask: (id: string) => + post<{ status: string }>(`/tasks/${id}/run`, {}), bootstrap: (data: { path: string; id: string; name: string }) => post<{ project: Project }>('/bootstrap', data), } diff --git a/web/frontend/src/main.ts b/web/frontend/src/main.ts index c008fc1..91cc08d 100644 --- a/web/frontend/src/main.ts +++ b/web/frontend/src/main.ts @@ -4,12 +4,14 @@ import './style.css' import App from './App.vue' import Dashboard from './views/Dashboard.vue' import ProjectView from './views/ProjectView.vue' +import TaskDetail from './views/TaskDetail.vue' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: Dashboard }, { path: '/project/:id', component: ProjectView, props: true }, + { path: '/task/:id', component: TaskDetail, props: true }, ], }) diff --git a/web/frontend/src/views/Dashboard.vue b/web/frontend/src/views/Dashboard.vue index de66f90..7c7edc9 100644 --- a/web/frontend/src/views/Dashboard.vue +++ b/web/frontend/src/views/Dashboard.vue @@ -116,10 +116,11 @@ async function runBootstrap() {
{{ p.total_tasks }} tasks {{ p.active_tasks }} active + {{ p.review_tasks }} awaiting review {{ p.blocked_tasks }} blocked {{ p.done_tasks }} done - - {{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks }} pending + + {{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) }} pending
diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 682dbc3..3fed2e6 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -190,8 +190,9 @@ async function addDecision() {
No tasks.
-
+
{{ t.id }} @@ -201,7 +202,7 @@ async function addDecision() { {{ t.assigned_role }} pri {{ t.priority }}
-
+
diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue new file mode 100644 index 0000000..b8a4467 --- /dev/null +++ b/web/frontend/src/views/TaskDetail.vue @@ -0,0 +1,256 @@ + + +