feat(tasks): Enhance task streaming and comment validation with markdown support
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
1147
frontend/package-lock.json
generated
1147
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
|
|
||||||
## Non‑negotiable rules
|
## Non‑negotiable 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 two‑step sequence:
|
- Completion is a two‑step 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)
|
||||||
- Check‑in succeeded.
|
- Check‑in succeeded.
|
||||||
|
|||||||
Reference in New Issue
Block a user