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 __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 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 sqlalchemy import asc, desc
from sqlmodel import Session, col, select from sqlmodel import Session, col, select
@@ -15,7 +20,7 @@ from app.api.deps import (
require_admin_or_agent, require_admin_or_agent,
) )
from app.core.auth import AuthContext 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.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.boards import Board 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"]) router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:")
ALLOWED_STATUSES = {"inbox", "in_progress", "review", "done"} 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: 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: def _comment_validation_error() -> HTTPException:
content = message.strip() return HTTPException(
if not content: status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
return False detail="Comment is required.",
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 has_valid_recent_comment( def has_valid_recent_comment(
@@ -68,7 +74,92 @@ def has_valid_recent_comment(
event = session.exec(statement).first() event = session.exec(statement).first()
if event is None or event.message is None: if event is None or event.message is None:
return False 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]) @router.get("", response_model=list[TaskRead])
@@ -85,7 +176,7 @@ def list_tasks(
if status_filter: if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()] statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
if statuses: 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( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported task status filter.", detail="Unsupported task status filter.",
@@ -136,6 +227,8 @@ def update_task(
previous_status = task.status previous_status = task.status
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
comment = updates.pop("comment", None) comment = updates.pop("comment", None)
if comment is not None and not comment.strip():
comment = None
if actor.actor_type == "agent": if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id: if actor.agent and actor.agent.board_id and task.board_id:
if actor.agent.board_id != 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 "status" in updates and updates["status"] == "review":
if comment is not None and comment.strip(): if comment is not None and comment.strip():
if not is_valid_markdown_comment(comment): if not comment.strip():
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) raise _comment_validation_error()
else: else:
if not has_valid_recent_comment( if not has_valid_recent_comment(
session, session,
@@ -180,18 +273,16 @@ def update_task(
task.assigned_agent_id, task.assigned_agent_id,
task.in_progress_at, task.in_progress_at,
): ):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) raise _comment_validation_error()
session.add(task) session.add(task)
session.commit() session.commit()
session.refresh(task) session.refresh(task)
if comment is not None and comment.strip(): 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 = ActivityEvent(
event_type="task.comment", event_type="task.comment",
message=comment.strip(), message=comment,
task_id=task.id, task_id=task.id,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None, 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: 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) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not payload.message.strip(): if not payload.message.strip():
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) raise _comment_validation_error()
if actor.actor_type == "agent" and not is_valid_markdown_comment(payload.message):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
event = ActivityEvent( event = ActivityEvent(
event_type="task.comment", event_type="task.comment",
message=payload.message.strip(), message=payload.message,
task_id=task.id, task_id=task.id,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None, 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), credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> AuthContext | None: ) -> AuthContext | None:
if request.headers.get("X-Agent-Token"):
return None
if credentials is None: if credentials is None:
return None return None

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { X } from "lucide-react"; import { X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard"; import { TaskBoard } from "@/components/organisms/TaskBoard";
@@ -44,6 +45,8 @@ type Task = {
priority: string; priority: string;
due_at?: string | null; due_at?: string | null;
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
created_at?: string | null;
updated_at?: string | null;
}; };
type Agent = { type Agent = {
@@ -86,6 +89,7 @@ export default function BoardDetailPage() {
const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null); const [commentsError, setCommentsError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
const tasksRef = useRef<Task[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -99,6 +103,19 @@ export default function BoardDetailPage() {
[board], [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 () => { const loadBoard = async () => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setIsLoading(true); setIsLoading(true);
@@ -151,6 +168,106 @@ export default function BoardDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardId, isSignedIn]); }, [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 = () => { const resetForm = () => {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
@@ -312,6 +429,7 @@ export default function BoardDetailPage() {
}); });
}; };
return ( return (
<DashboardShell> <DashboardShell>
<SignedOut> <SignedOut>
@@ -506,6 +624,7 @@ export default function BoardDetailPage() {
key={comment.id} key={comment.id}
className="rounded-xl border border-slate-200 bg-white p-3" className="rounded-xl border border-slate-200 bg-white p-3"
> >
<>
<div className="flex items-center justify-between text-xs text-slate-500"> <div className="flex items-center justify-between text-xs text-slate-500">
<span> <span>
{comment.agent_id {comment.agent_id
@@ -514,9 +633,37 @@ export default function BoardDetailPage() {
</span> </span>
<span>{formatCommentTimestamp(comment.created_at)}</span> <span>{formatCommentTimestamp(comment.created_at)}</span>
</div> </div>
<p className="mt-2 text-sm text-slate-900"> {comment.message?.trim() ? (
{comment.message || "—"} <div className="mt-2 text-sm text-slate-900">
</p> <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>
))} ))}
</div> </div>

View File

@@ -18,7 +18,7 @@ If any required input is missing, stop and request a provisioning update.
## Nonnegotiable rules ## Nonnegotiable rules
- Task updates go only to task comments (never chat/web). - 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. - Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress. - 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. 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: 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: 6) Work the task:
- Post progress comments as you go. - Post progress comments as you go.
- Completion is a twostep sequence: - 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 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.
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 ## Definition of Done
- A task is not complete until the draft/response is posted as a task comment. - 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) ## Common mistakes (avoid)
- Changing status without posting a comment. - Changing status without posting a comment.
- Posting updates in chat/web instead of task comments. - Posting updates in chat/web instead of task comments.
- Claiming a second task while one is already in progress. - Claiming a second task while one is already in progress.
- Moving to review before posting the full response. - Moving to review before posting the full response.
- Sending Authorization header instead of X-Agent-Token.
## Success criteria (when to say HEARTBEAT_OK) ## Success criteria (when to say HEARTBEAT_OK)
- Checkin succeeded. - Checkin succeeded.