Full pipeline flow through web interface with live updates
API:
POST /api/tasks/{id}/run — sets task to in_progress immediately,
launches subprocess with error handling and logging.
GET /api/tasks/{id}/running — checks pipelines table for active run.
Fixed --db flag position in subprocess command.
TaskDetail (live pipeline):
- Run button starts pipeline, auto-starts 3s polling
- Pipeline cards update in real-time as agent_logs appear
- Pulsing blue dot on header while in_progress
- Spinner on run button during execution
- Auto-stops polling when status changes from in_progress
- Cleanup on component unmount (no leaked timers)
ProjectView (run from list):
- [>] button on each pending task row
- Confirm dialog before starting
- Pulsing blue dot for in_progress tasks
- Click task row → /task/:id with live view
Dashboard (live statuses):
- Pulsing blue dot next to active task count
- Auto-poll every 5s when any project has active tasks
- Stops polling when no active tasks
5 new API tests (running endpoint, run sets status, not found).
141 tests total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab693d3c4d
commit
db1729730f
5 changed files with 145 additions and 33 deletions
|
|
@ -135,6 +135,44 @@ def test_task_pipeline_not_found(client):
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_endpoint_no_pipeline(client):
|
||||||
|
r = client.get("/api/tasks/P1-001/running")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["running"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_endpoint_with_pipeline(client):
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models
|
||||||
|
conn = init_db(api_module.DB_PATH)
|
||||||
|
models.create_pipeline(conn, "P1-001", "p1", "debug",
|
||||||
|
[{"role": "debugger"}])
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
r = client.get("/api/tasks/P1-001/running")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["running"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_endpoint_not_found(client):
|
||||||
|
r = client.get("/api/tasks/NOPE/running")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_sets_in_progress(client):
|
||||||
|
"""POST /run should set task to in_progress immediately."""
|
||||||
|
r = client.post("/api/tasks/P1-001/run")
|
||||||
|
assert r.status_code == 202
|
||||||
|
|
||||||
|
r = client.get("/api/tasks/P1-001")
|
||||||
|
assert r.json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_not_found(client):
|
||||||
|
r = client.post("/api/tasks/NOPE/run")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_project_summary_includes_review(client):
|
def test_project_summary_includes_review(client):
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
|
|
|
||||||
31
web/api.py
31
web/api.py
|
|
@ -257,6 +257,24 @@ def reject_task(task_id: str, body: TaskReject):
|
||||||
return {"status": "pending", "reason": body.reason}
|
return {"status": "pending", "reason": body.reason}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tasks/{task_id}/running")
|
||||||
|
def is_task_running(task_id: str):
|
||||||
|
"""Check if task has an active (running) pipeline."""
|
||||||
|
conn = get_conn()
|
||||||
|
t = models.get_task(conn, task_id)
|
||||||
|
if not t:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, status FROM pipelines WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row and row["status"] == "running":
|
||||||
|
return {"running": True, "pipeline_id": row["id"]}
|
||||||
|
return {"running": False}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tasks/{task_id}/run")
|
@app.post("/api/tasks/{task_id}/run")
|
||||||
def run_task(task_id: str):
|
def run_task(task_id: str):
|
||||||
"""Launch pipeline for a task in background. Returns 202."""
|
"""Launch pipeline for a task in background. Returns 202."""
|
||||||
|
|
@ -265,15 +283,22 @@ def run_task(task_id: str):
|
||||||
if not t:
|
if not t:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||||
|
# Set task to in_progress immediately so UI updates
|
||||||
|
models.update_task(conn, task_id, status="in_progress")
|
||||||
conn.close()
|
conn.close()
|
||||||
# Launch kin run in background subprocess
|
# Launch kin run in background subprocess
|
||||||
kin_root = Path(__file__).parent.parent
|
kin_root = Path(__file__).parent.parent
|
||||||
subprocess.Popen(
|
try:
|
||||||
[sys.executable, "-m", "cli.main", "run", task_id, "--db",
|
proc = subprocess.Popen(
|
||||||
str(DB_PATH)],
|
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
||||||
|
"run", task_id],
|
||||||
cwd=str(kin_root),
|
cwd=str(kin_root),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
import logging
|
||||||
|
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Failed to start pipeline: {e}")
|
||||||
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
|
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,23 @@ async function load() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
let dashPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await load()
|
||||||
|
// Poll if there are running tasks
|
||||||
|
checkAndPoll()
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkAndPoll() {
|
||||||
|
const hasRunning = projects.value.some(p => p.active_tasks > 0)
|
||||||
|
if (hasRunning && !dashPollTimer) {
|
||||||
|
dashPollTimer = setInterval(load, 5000)
|
||||||
|
} else if (!hasRunning && dashPollTimer) {
|
||||||
|
clearInterval(dashPollTimer)
|
||||||
|
dashPollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const costMap = computed(() => {
|
const costMap = computed(() => {
|
||||||
const m: Record<string, number> = {}
|
const m: Record<string, number> = {}
|
||||||
|
|
@ -115,7 +131,10 @@ async function runBootstrap() {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 text-xs">
|
<div class="flex gap-4 text-xs">
|
||||||
<span class="text-gray-500">{{ p.total_tasks }} tasks</span>
|
<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.active_tasks" class="text-blue-400">
|
||||||
|
<span class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse mr-0.5"></span>
|
||||||
|
{{ 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.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.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.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,18 @@ async function addTask() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runTask(taskId: string, event: Event) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||||
|
try {
|
||||||
|
await api.runTask(taskId)
|
||||||
|
await load()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addDecision() {
|
async function addDecision() {
|
||||||
decFormError.value = ''
|
decFormError.value = ''
|
||||||
try {
|
try {
|
||||||
|
|
@ -202,6 +214,12 @@ async function addDecision() {
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||||
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
||||||
<span>pri {{ t.priority }}</span>
|
<span>pri {{ t.priority }}</span>
|
||||||
|
<button v-if="t.status === 'pending'"
|
||||||
|
@click="runTask(t.id, $event)"
|
||||||
|
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
|
||||||
|
title="Run pipeline">▶</button>
|
||||||
|
<span v-if="t.status === 'in_progress'"
|
||||||
|
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
|
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
@ -27,8 +27,16 @@ const rejectReason = ref('')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
const prev = task.value
|
||||||
task.value = await api.taskFull(props.id)
|
task.value = await api.taskFull(props.id)
|
||||||
|
// Auto-start polling if task is in_progress
|
||||||
|
if (task.value.status === 'in_progress' && !polling.value) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
// Stop polling when pipeline done
|
||||||
|
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -36,7 +44,19 @@ async function load() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (polling.value) return
|
||||||
|
polling.value = true
|
||||||
|
pollTimer = setInterval(load, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
polling.value = false
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
function statusColor(s: string) {
|
function statusColor(s: string) {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
|
|
@ -49,7 +69,7 @@ function statusColor(s: string) {
|
||||||
const roleIcons: Record<string, string> = {
|
const roleIcons: Record<string, string> = {
|
||||||
pm: '\u{1F9E0}', security: '\u{1F6E1}', debugger: '\u{1F50D}',
|
pm: '\u{1F9E0}', security: '\u{1F6E1}', debugger: '\u{1F50D}',
|
||||||
frontend_dev: '\u{1F4BB}', backend_dev: '\u{2699}', tester: '\u{2705}',
|
frontend_dev: '\u{1F4BB}', backend_dev: '\u{2699}', tester: '\u{2705}',
|
||||||
reviewer: '\u{1F4CB}', architect: '\u{1F3D7}',
|
reviewer: '\u{1F4CB}', architect: '\u{1F3D7}', followup_pm: '\u{1F4DD}',
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepStatusClass(step: PipelineStep) {
|
function stepStatusClass(step: PipelineStep) {
|
||||||
|
|
@ -67,7 +87,6 @@ function stepStatusColor(step: PipelineStep) {
|
||||||
|
|
||||||
function formatOutput(text: string | null): string {
|
function formatOutput(text: string | null): string {
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
// Try to detect and format JSON
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text)
|
const parsed = JSON.parse(text)
|
||||||
return JSON.stringify(parsed, null, 2)
|
return JSON.stringify(parsed, null, 2)
|
||||||
|
|
@ -113,7 +132,6 @@ async function resolveAction(action: PendingAction, choice: string) {
|
||||||
resolvingAction.value = true
|
resolvingAction.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.resolveAction(props.id, action, choice)
|
const res = await api.resolveAction(props.id, action, choice)
|
||||||
// Remove resolved action from list
|
|
||||||
pendingActions.value = pendingActions.value.filter(a => a !== action)
|
pendingActions.value = pendingActions.value.filter(a => a !== action)
|
||||||
if (choice === 'manual_task' && res.result && typeof res.result === 'object' && 'id' in res.result) {
|
if (choice === 'manual_task' && res.result && typeof res.result === 'object' && 'id' in res.result) {
|
||||||
followupResults.value.push({ id: (res.result as any).id, title: (res.result as any).title })
|
followupResults.value.push({ id: (res.result as any).id, title: (res.result as any).title })
|
||||||
|
|
@ -143,29 +161,20 @@ async function reject() {
|
||||||
async function runPipeline() {
|
async function runPipeline() {
|
||||||
try {
|
try {
|
||||||
await api.runTask(props.id)
|
await api.runTask(props.id)
|
||||||
polling.value = true
|
startPolling()
|
||||||
pollTimer = setInterval(async () => {
|
|
||||||
await load()
|
await load()
|
||||||
if (task.value && !['in_progress'].includes(task.value.status)) {
|
|
||||||
stopPolling()
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
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)
|
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
|
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
|
<div v-if="loading && !task" 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="error && !task" class="text-red-400 text-sm">{{ error }}</div>
|
||||||
<div v-else-if="task">
|
<div v-else-if="task">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
|
@ -178,6 +187,7 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
||||||
<span class="text-gray-400">{{ task.title }}</span>
|
<span class="text-gray-400">{{ task.title }}</span>
|
||||||
<Badge :text="task.status" :color="statusColor(task.status)" />
|
<Badge :text="task.status" :color="statusColor(task.status)" />
|
||||||
|
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
||||||
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
|
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
|
||||||
|
|
@ -189,13 +199,14 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pipeline Graph -->
|
<!-- Pipeline Graph -->
|
||||||
<div v-if="hasSteps" class="mb-6">
|
<div v-if="hasSteps || isRunning" class="mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-3">Pipeline</h2>
|
<h2 class="text-sm font-semibold text-gray-300 mb-3">
|
||||||
|
Pipeline
|
||||||
|
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">running...</span>
|
||||||
|
</h2>
|
||||||
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||||
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
<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">→</div>
|
<div v-if="i > 0" class="text-gray-600 text-lg shrink-0 px-1">→</div>
|
||||||
<!-- Step card -->
|
|
||||||
<button
|
<button
|
||||||
@click="selectedStep = selectedStep?.id === step.id ? null : step"
|
@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="border rounded-lg px-3 py-2 min-w-[120px] text-left transition-all shrink-0"
|
||||||
|
|
@ -220,7 +231,7 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No pipeline -->
|
<!-- No pipeline -->
|
||||||
<div v-else class="mb-6 text-sm text-gray-600">
|
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
|
||||||
No pipeline steps yet.
|
No pipeline steps yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -263,7 +274,8 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
@click="runPipeline"
|
@click="runPipeline"
|
||||||
:disabled="polling"
|
: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">
|
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...' : '▶ Run Pipeline' }}
|
<span v-if="polling" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
|
{{ polling ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue