feat(tasks): Enhance task streaming and comment validation with markdown support

This commit is contained in:
Abhimanyu Saharan
2026-02-05 03:05:14 +05:30
parent af3c437c0a
commit 5e342e6906
6 changed files with 1420 additions and 42 deletions

View File

@@ -1,9 +1,14 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
import asyncio
import json
from collections import deque
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool
from sqlalchemy import asc, desc
from sqlmodel import Session, col, select
@@ -15,7 +20,7 @@ from app.api.deps import (
require_admin_or_agent,
)
from app.core.auth import AuthContext
from app.db.session import get_session
from app.db.session import engine, get_session
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
@@ -25,8 +30,14 @@ from app.services.activity_log import record_activity
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:")
ALLOWED_STATUSES = {"inbox", "in_progress", "review", "done"}
TASK_EVENT_TYPES = {
"task.created",
"task.updated",
"task.status_changed",
"task.comment",
}
SSE_SEEN_MAX = 2000
def validate_task_status(status_value: str) -> None:
@@ -37,16 +48,11 @@ def validate_task_status(status_value: str) -> None:
)
def is_valid_markdown_comment(message: str) -> bool:
content = message.strip()
if not content:
return False
lowered = content.lower()
if not all(field in lowered for field in REQUIRED_COMMENT_FIELDS):
return False
if "- " not in content and "* " not in content:
return False
return True
def _comment_validation_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Comment is required.",
)
def has_valid_recent_comment(
@@ -68,7 +74,92 @@ def has_valid_recent_comment(
event = session.exec(statement).first()
if event is None or event.message is None:
return False
return is_valid_markdown_comment(event.message)
return bool(event.message.strip())
def _parse_since(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.strip()
if not normalized:
return None
normalized = normalized.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _fetch_task_events(
board_id: UUID,
since: datetime,
) -> list[tuple[ActivityEvent, Task | None]]:
with Session(engine) as session:
task_ids = list(
session.exec(select(Task.id).where(col(Task.board_id) == board_id))
)
if not task_ids:
return []
statement = (
select(ActivityEvent, Task)
.outerjoin(Task, ActivityEvent.task_id == Task.id)
.where(col(ActivityEvent.task_id).in_(task_ids))
.where(col(ActivityEvent.event_type).in_(TASK_EVENT_TYPES))
.where(col(ActivityEvent.created_at) >= since)
.order_by(asc(col(ActivityEvent.created_at)))
)
return list(session.exec(statement))
def _serialize_task(task: Task | None) -> dict[str, object] | None:
if task is None:
return None
return TaskRead.model_validate(task).model_dump(mode="json")
def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
return TaskCommentRead.model_validate(event).model_dump(mode="json")
@router.get("/stream")
async def stream_tasks(
request: Request,
board: Board = Depends(get_board_or_404),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
since_dt = _parse_since(since) or datetime.utcnow()
seen_ids: set[UUID] = set()
seen_queue: deque[UUID] = deque()
async def event_generator():
last_seen = since_dt
while True:
if await request.is_disconnected():
break
rows = await run_in_threadpool(_fetch_task_events, board.id, last_seen)
for event, task in rows:
if event.id in seen_ids:
continue
seen_ids.add(event.id)
seen_queue.append(event.id)
if len(seen_queue) > SSE_SEEN_MAX:
oldest = seen_queue.popleft()
seen_ids.discard(oldest)
if event.created_at > last_seen:
last_seen = event.created_at
payload: dict[str, object] = {"type": event.event_type}
if event.event_type == "task.comment":
payload["comment"] = _serialize_comment(event)
else:
payload["task"] = _serialize_task(task)
yield {"event": "task", "data": json.dumps(payload)}
await asyncio.sleep(2)
return EventSourceResponse(event_generator(), ping=15)
@router.get("", response_model=list[TaskRead])
@@ -85,7 +176,7 @@ def list_tasks(
if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
if statuses:
if any(status not in ALLOWED_STATUSES for status in statuses):
if any(status_value not in ALLOWED_STATUSES for status_value in statuses):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported task status filter.",
@@ -136,6 +227,8 @@ def update_task(
previous_status = task.status
updates = payload.model_dump(exclude_unset=True)
comment = updates.pop("comment", None)
if comment is not None and not comment.strip():
comment = None
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id:
if actor.agent.board_id != task.board_id:
@@ -171,8 +264,8 @@ def update_task(
if "status" in updates and updates["status"] == "review":
if comment is not None and comment.strip():
if not is_valid_markdown_comment(comment):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
if not comment.strip():
raise _comment_validation_error()
else:
if not has_valid_recent_comment(
session,
@@ -180,18 +273,16 @@ def update_task(
task.assigned_agent_id,
task.in_progress_at,
):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
raise _comment_validation_error()
session.add(task)
session.commit()
session.refresh(task)
if comment is not None and comment.strip():
if actor.actor_type == "agent" and not is_valid_markdown_comment(comment):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
event = ActivityEvent(
event_type="task.comment",
message=comment.strip(),
message=comment,
task_id=task.id,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
@@ -255,12 +346,10 @@ def create_task_comment(
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not payload.message.strip():
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
if actor.actor_type == "agent" and not is_valid_markdown_comment(payload.message):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
raise _comment_validation_error()
event = ActivityEvent(
event_type="task.comment",
message=payload.message.strip(),
message=payload.message,
task_id=task.id,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)

View File

@@ -102,6 +102,8 @@ async def get_auth_context_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: Session = Depends(get_session),
) -> AuthContext | None:
if request.headers.get("X-Agent-Token"):
return None
if credentials is None:
return None

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0"
},
"devDependencies": {

View File

@@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard";
@@ -44,6 +45,8 @@ type Task = {
priority: string;
due_at?: string | null;
assigned_agent_id?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
type Agent = {
@@ -86,6 +89,7 @@ export default function BoardDetailPage() {
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const tasksRef = useRef<Task[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState("");
@@ -99,6 +103,19 @@ export default function BoardDetailPage() {
[board],
);
const latestTaskTimestamp = (items: Task[]) => {
let latestTime = 0;
items.forEach((task) => {
const value = task.updated_at ?? task.created_at;
if (!value) return;
const time = new Date(value).getTime();
if (!Number.isNaN(time) && time > latestTime) {
latestTime = time;
}
});
return latestTime ? new Date(latestTime).toISOString() : null;
};
const loadBoard = async () => {
if (!isSignedIn || !boardId) return;
setIsLoading(true);
@@ -151,6 +168,106 @@ export default function BoardDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardId, isSignedIn]);
useEffect(() => {
tasksRef.current = tasks;
}, [tasks]);
useEffect(() => {
if (!isSignedIn || !boardId || !board) return;
let isCancelled = false;
const abortController = new AbortController();
const connect = async () => {
try {
const token = await getToken();
if (!token || isCancelled) return;
const url = new URL(`${apiBase}/api/v1/boards/${boardId}/tasks/stream`);
const since = latestTaskTimestamp(tasksRef.current);
if (since) {
url.searchParams.set("since", since);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error("Unable to connect task stream.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!isCancelled) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const lines = raw.split("\n");
let eventType = "message";
let data = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data += line.slice(5).trim();
}
}
if (eventType === "task" && data) {
try {
const payload = JSON.parse(data) as {
type?: string;
task?: Task;
comment?: TaskComment;
};
if (payload.comment?.task_id && payload.type === "task.comment") {
setComments((prev) => {
if (selectedTask?.id !== payload.comment?.task_id) {
return prev;
}
const exists = prev.some((item) => item.id === payload.comment?.id);
if (exists) {
return prev;
}
return [...prev, payload.comment as TaskComment];
});
} else if (payload.task) {
setTasks((prev) => {
const index = prev.findIndex((item) => item.id === payload.task?.id);
if (index === -1) {
return [payload.task as Task, ...prev];
}
const next = [...prev];
next[index] = { ...next[index], ...(payload.task as Task) };
return next;
});
}
} catch {
// Ignore malformed payloads.
}
}
boundary = buffer.indexOf("\n\n");
}
}
} catch {
if (!isCancelled) {
setTimeout(connect, 3000);
}
}
};
connect();
return () => {
isCancelled = true;
abortController.abort();
};
}, [board, boardId, getToken, isSignedIn]);
const resetForm = () => {
setTitle("");
setDescription("");
@@ -312,6 +429,7 @@ export default function BoardDetailPage() {
});
};
return (
<DashboardShell>
<SignedOut>
@@ -506,6 +624,7 @@ export default function BoardDetailPage() {
key={comment.id}
className="rounded-xl border border-slate-200 bg-white p-3"
>
<>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>
{comment.agent_id
@@ -514,9 +633,37 @@ export default function BoardDetailPage() {
</span>
<span>{formatCommentTimestamp(comment.created_at)}</span>
</div>
<p className="mt-2 text-sm text-slate-900">
{comment.message || "—"}
</p>
{comment.message?.trim() ? (
<div className="mt-2 text-sm text-slate-900">
<ReactMarkdown
components={{
p: ({ ...props }) => (
<p className="text-sm text-slate-900" {...props} />
),
ul: ({ ...props }) => (
<ul
className="list-disc pl-5 text-sm text-slate-900"
{...props}
/>
),
li: ({ ...props }) => (
<li className="mb-1 text-sm text-slate-900" {...props} />
),
strong: ({ ...props }) => (
<strong
className="font-semibold text-slate-900"
{...props}
/>
),
}}
>
{comment.message}
</ReactMarkdown>
</div>
) : (
<p className="mt-2 text-sm text-slate-900"></p>
)}
</>
</div>
))}
</div>

View File

@@ -18,7 +18,7 @@ If any required input is missing, stop and request a provisioning update.
## Nonnegotiable rules
- Task updates go only to task comments (never chat/web).
- Comments must be markdown and must include: status, summary, details (bullets), next.
- Comments must be markdown. Write naturally; be clear and concise.
- Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress.
@@ -58,13 +58,20 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&l
4) If you already have an in_progress task, continue working it and do not claim another.
5) If you do NOT have an in_progress task, claim one inbox task:
- Move it to in_progress AND add a markdown comment with required fields.
- Move it to in_progress AND add a markdown comment describing the update.
6) Work the task:
- Post progress comments as you go.
- Completion is a twostep sequence:
6a) Post the full response as a markdown comment (required fields + response) using:
6a) Post the full response as a markdown comment using:
POST $BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
Example:
```bash
curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":"- Update: ...\n- Result: ..."}'
```
6b) Move the task to review.
6b) Move the task to "review":
@@ -77,13 +84,14 @@ curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
## Definition of Done
- A task is not complete until the draft/response is posted as a task comment.
- Comments must be markdown and include: summary, details (bullets), next.
- Comments must be markdown.
## Common mistakes (avoid)
- Changing status without posting a comment.
- Posting updates in chat/web instead of task comments.
- Claiming a second task while one is already in progress.
- Moving to review before posting the full response.
- Sending Authorization header instead of X-Agent-Token.
## Success criteria (when to say HEARTBEAT_OK)
- Checkin succeeded.