Add task detail view, pipeline visualization, approve/reject workflow

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) <noreply@anthropic.com>
This commit is contained in:
johnfrum1234 2026-03-15 14:32:29 +02:00
parent fabae74c19
commit 38c252fc1b
9 changed files with 550 additions and 7 deletions

View file

@ -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"),

View file

@ -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

147
tests/test_api.py Normal file
View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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<Project[]>('/projects'),
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
task: (id: string) => get<Task>(`/tasks/${id}`),
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
post<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
post<Task>('/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),
}

View file

@ -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 },
],
})

View file

@ -116,10 +116,11 @@ async function runBootstrap() {
<div class="flex gap-4 text-xs">
<span class="text-gray-500">{{ p.total_tasks }} tasks</span>
<span v-if="p.active_tasks" class="text-blue-400">{{ p.active_tasks }} active</span>
<span v-if="p.review_tasks" class="text-yellow-400">{{ p.review_tasks }} awaiting review</span>
<span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span>
<span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks > 0" class="text-gray-500">
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks }} pending
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) > 0" class="text-gray-500">
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) }} pending
</span>
</div>
</router-link>

View file

@ -190,8 +190,9 @@ async function addDecision() {
</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1">
<div v-for="t in filteredTasks" :key="t.id"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-700">
<router-link v-for="t in filteredTasks" :key="t.id"
:to="`/task/${t.id}`"
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)" />
@ -201,7 +202,7 @@ async function addDecision() {
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
<span>pri {{ t.priority }}</span>
</div>
</div>
</router-link>
</div>
</div>

View file

@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api, type TaskFull, type PipelineStep } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>()
const task = ref<TaskFull | null>(null)
const loading = ref(true)
const error = ref('')
const selectedStep = ref<PipelineStep | null>(null)
const polling = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Approve modal
const showApprove = ref(false)
const approveForm = ref({ title: '', description: '', type: 'decision' })
// Reject modal
const showReject = ref(false)
const rejectReason = ref('')
async function load() {
try {
loading.value = true
task.value = await api.taskFull(props.id)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(load)
function statusColor(s: string) {
const m: Record<string, string> = {
pending: 'gray', in_progress: 'blue', review: 'yellow',
done: 'green', blocked: 'red', decomposed: 'purple',
}
return m[s] || 'gray'
}
const roleIcons: Record<string, string> = {
pm: '\u{1F9E0}', security: '\u{1F6E1}', debugger: '\u{1F50D}',
frontend_dev: '\u{1F4BB}', backend_dev: '\u{2699}', tester: '\u{2705}',
reviewer: '\u{1F4CB}', architect: '\u{1F3D7}',
}
function stepStatusClass(step: PipelineStep) {
if (step.success) return 'border-green-700 bg-green-950/30'
return 'border-red-700 bg-red-950/30'
}
function stepStatusIcon(step: PipelineStep) {
return step.success ? '\u2713' : '\u2717'
}
function stepStatusColor(step: PipelineStep) {
return step.success ? 'text-green-400' : 'text-red-400'
}
function formatOutput(text: string | null): string {
if (!text) return ''
// Try to detect and format JSON
try {
const parsed = JSON.parse(text)
return JSON.stringify(parsed, null, 2)
} catch {
return text
}
}
async function approve() {
if (!task.value) return
try {
const data = approveForm.value.title
? { decision_title: approveForm.value.title, decision_description: approveForm.value.description, decision_type: approveForm.value.type }
: undefined
await api.approveTask(props.id, data)
showApprove.value = false
approveForm.value = { title: '', description: '', type: 'decision' }
await load()
} catch (e: any) {
error.value = e.message
}
}
async function reject() {
if (!task.value || !rejectReason.value) return
try {
await api.rejectTask(props.id, rejectReason.value)
showReject.value = false
rejectReason.value = ''
await load()
} catch (e: any) {
error.value = e.message
}
}
async function runPipeline() {
try {
await api.runTask(props.id)
polling.value = true
pollTimer = setInterval(async () => {
await load()
if (task.value && !['in_progress'].includes(task.value.status)) {
stopPolling()
}
}, 3000)
} catch (e: any) {
error.value = e.message
}
}
function stopPolling() {
polling.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
</script>
<template>
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
<div v-else-if="task">
<!-- 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">
&larr; {{ task.project_id }}
</router-link>
</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 class="text-xs text-gray-600">pri {{ task.priority }}</span>
</div>
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }}
</div>
<div v-if="task.assigned_role" class="text-xs text-gray-500">
Assigned: {{ task.assigned_role }}
</div>
</div>
<!-- Pipeline Graph -->
<div v-if="hasSteps" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-3">Pipeline</h2>
<div class="flex items-center gap-1 overflow-x-auto pb-2">
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
<!-- Arrow -->
<div v-if="i > 0" class="text-gray-600 text-lg shrink-0 px-1">&rarr;</div>
<!-- Step card -->
<button
@click="selectedStep = selectedStep?.id === step.id ? null : step"
class="border rounded-lg px-3 py-2 min-w-[120px] text-left transition-all shrink-0"
:class="[
stepStatusClass(step),
selectedStep?.id === step.id ? 'ring-1 ring-blue-500' : '',
]"
>
<div class="flex items-center gap-1.5 mb-1">
<span class="text-base">{{ roleIcons[step.agent_role] || '\u{1F916}' }}</span>
<span class="text-xs font-medium text-gray-300">{{ step.agent_role }}</span>
<span :class="stepStatusColor(step)" class="text-xs ml-auto">{{ stepStatusIcon(step) }}</span>
</div>
<div class="flex gap-2 text-[10px] text-gray-500">
<span v-if="step.duration_seconds">{{ step.duration_seconds }}s</span>
<span v-if="step.tokens_used">{{ step.tokens_used?.toLocaleString() }}tk</span>
<span v-if="step.cost_usd">${{ step.cost_usd?.toFixed(3) }}</span>
</div>
</button>
</template>
</div>
</div>
<!-- No pipeline -->
<div v-else class="mb-6 text-sm text-gray-600">
No pipeline steps yet.
</div>
<!-- Selected step output -->
<div v-if="selectedStep" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">
Output: {{ selectedStep.agent_role }}
<span class="text-xs text-gray-600 font-normal ml-2">{{ selectedStep.created_at }}</span>
</h2>
<div class="border border-gray-800 rounded-lg bg-gray-900/50 overflow-hidden">
<pre class="p-4 text-xs text-gray-300 overflow-x-auto whitespace-pre-wrap max-h-[600px] overflow-y-auto">{{ formatOutput(selectedStep.output_summary) }}</pre>
</div>
</div>
<!-- Related Decisions -->
<div v-if="task.related_decisions?.length" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">Related Decisions</h2>
<div class="space-y-1">
<div v-for="d in task.related_decisions" :key="d.id"
class="px-3 py-2 border border-gray-800 rounded text-xs">
<Badge :text="d.type" :color="d.type === 'gotcha' ? 'red' : 'blue'" />
<span class="text-gray-300 ml-2">{{ d.title }}</span>
</div>
</div>
</div>
<!-- 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'"
@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">
&#10003; Approve
</button>
<button v-if="task.status === 'review' || task.status === 'in_progress'"
@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">
&#10007; Reject
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="runPipeline"
:disabled="polling"
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ polling ? 'Running...' : '&#9654; Run Pipeline' }}
</button>
</div>
<!-- Approve Modal -->
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false">
<form @submit.prevent="approve" class="space-y-3">
<p class="text-sm text-gray-400">Optionally record a decision from this task:</p>
<input v-model="approveForm.title" placeholder="Decision title (optional)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<textarea v-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y" rows="2"></textarea>
<button type="submit"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900">
Approve &amp; mark done
</button>
</form>
</Modal>
<!-- Reject Modal -->
<Modal v-if="showReject" title="Reject Task" @close="showReject = false">
<form @submit.prevent="reject" class="space-y-3">
<textarea v-model="rejectReason" placeholder="Why are you rejecting this?" rows="3" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
<button type="submit"
class="w-full py-2 bg-red-900/50 text-red-400 border border-red-800 rounded text-sm hover:bg-red-900">
Reject &amp; return to pending
</button>
</form>
</Modal>
</div>
</template>