feat(workflow): enforce review-only done for agent assignees + reviewer decision endpoint

This commit is contained in:
Abhimanyu Saharan
2026-02-02 21:33:22 +05:30
parent 52b2797b1d
commit b59a08f632
5 changed files with 248 additions and 1 deletions

View File

@@ -13,7 +13,7 @@ from app.integrations.notify import NotifyContext, notify_openclaw
from app.integrations.openclaw import OpenClawClient from app.integrations.openclaw import OpenClawClient
from app.models.org import Employee from app.models.org import Employee
from app.models.work import Task, TaskComment from app.models.work import Task, TaskComment
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
logger = logging.getLogger("app.work") logger = logging.getLogger("app.work")
@@ -149,6 +149,97 @@ def dispatch_task(
return {"ok": True} return {"ok": True}
def _require_reviewer_comment(body: str | None) -> str:
if body is None or not body.strip():
raise HTTPException(status_code=400, detail="Reviewer must provide a comment for audit")
return body.strip()
@router.post("/tasks/{task_id}/review", response_model=Task)
def review_task(
task_id: int,
payload: TaskReviewDecision,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
"""Reviewer approves or requests changes.
- Approve => status=done
- Changes => status=in_progress
Always writes a TaskComment by the reviewer for audit.
"""
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.reviewer_employee_id is None:
raise HTTPException(status_code=400, detail="Task has no reviewer")
if actor_employee_id != task.reviewer_employee_id:
raise HTTPException(status_code=403, detail="Only the reviewer can approve/request changes")
decision = (payload.decision or "").strip().lower()
if decision not in {"approve", "changes"}:
raise HTTPException(status_code=400, detail="Invalid decision")
comment_body = _require_reviewer_comment(payload.comment_body)
new_status = "done" if decision == "approve" else "in_progress"
before_status = task.status
task.status = new_status
task.updated_at = datetime.utcnow()
session.add(task)
c = TaskComment(task_id=task.id, author_employee_id=actor_employee_id, body=comment_body)
session.add(c)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task.id,
verb="reviewed",
payload={"decision": decision, "status": new_status},
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Review action violates constraints")
session.refresh(task)
session.refresh(c)
# Notify assignee (comment.created will exclude author)
background.add_task(
notify_openclaw,
session,
NotifyContext(
event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c
),
)
# Notify reviewer/PMs about status change
if before_status != task.status:
background.add_task(
notify_openclaw,
session,
NotifyContext(
event="status.changed",
actor_employee_id=actor_employee_id,
task=task,
changed_fields={"status": {"from": before_status, "to": task.status}},
),
)
return Task.model_validate(task)
@router.patch("/tasks/{task_id}", response_model=Task) @router.patch("/tasks/{task_id}", response_model=Task)
def update_task( def update_task(
task_id: int, task_id: int,
@@ -174,6 +265,20 @@ def update_task(
if "status" in data and data["status"] not in ALLOWED_STATUSES: if "status" in data and data["status"] not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status") raise HTTPException(status_code=400, detail="Invalid status")
# Enforce review workflow: agent assignees cannot mark tasks done directly.
if data.get("status") == "done":
assignee = (
session.get(Employee, task.assignee_employee_id) if task.assignee_employee_id else None
)
if assignee is not None and assignee.employee_type == "agent":
if actor_employee_id == task.assignee_employee_id:
raise HTTPException(
status_code=403,
detail="Assignee agents cannot mark tasks done; set status=review for manager approval",
)
if task.reviewer_employee_id is not None and actor_employee_id != task.reviewer_employee_id:
raise HTTPException(status_code=403, detail="Only the reviewer can mark a task done")
# If a task is sent to review and no reviewer is set, default reviewer to assignee's manager. # If a task is sent to review and no reviewer is set, default reviewer to assignee's manager.
if ( if (
data.get("status") in {"review", "ready_for_review"} data.get("status") in {"review", "ready_for_review"}

View File

@@ -26,3 +26,8 @@ class TaskCommentCreate(SQLModel):
author_employee_id: int | None = None author_employee_id: int | None = None
reply_to_comment_id: int | None = None reply_to_comment_id: int | None = None
body: str body: str
class TaskReviewDecision(SQLModel):
decision: str # approve | changes
comment_body: str

View File

@@ -32,6 +32,7 @@ export * from "./task";
export * from "./taskComment"; export * from "./taskComment";
export * from "./taskCommentCreate"; export * from "./taskCommentCreate";
export * from "./taskCreate"; export * from "./taskCreate";
export * from "./taskReviewDecision";
export * from "./taskUpdate"; export * from "./taskUpdate";
export * from "./team"; export * from "./team";
export * from "./teamCreate"; export * from "./teamCreate";

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskReviewDecision {
decision: string;
comment_body: string;
}

View File

@@ -28,6 +28,7 @@ import type {
TaskComment, TaskComment,
TaskCommentCreate, TaskCommentCreate,
TaskCreate, TaskCreate,
TaskReviewDecision,
TaskUpdate, TaskUpdate,
} from ".././model"; } from ".././model";
@@ -467,6 +468,130 @@ export const useDispatchTaskTasksTaskIdDispatchPost = <
queryClient, queryClient,
); );
}; };
/**
* Reviewer approves or requests changes.
- Approve => status=done
- Changes => status=in_progress
Always writes a TaskComment by the reviewer for audit.
* @summary Review Task
*/
export type reviewTaskTasksTaskIdReviewPostResponse200 = {
data: Task;
status: 200;
};
export type reviewTaskTasksTaskIdReviewPostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type reviewTaskTasksTaskIdReviewPostResponseSuccess =
reviewTaskTasksTaskIdReviewPostResponse200 & {
headers: Headers;
};
export type reviewTaskTasksTaskIdReviewPostResponseError =
reviewTaskTasksTaskIdReviewPostResponse422 & {
headers: Headers;
};
export type reviewTaskTasksTaskIdReviewPostResponse =
| reviewTaskTasksTaskIdReviewPostResponseSuccess
| reviewTaskTasksTaskIdReviewPostResponseError;
export const getReviewTaskTasksTaskIdReviewPostUrl = (taskId: number) => {
return `/tasks/${taskId}/review`;
};
export const reviewTaskTasksTaskIdReviewPost = async (
taskId: number,
taskReviewDecision: TaskReviewDecision,
options?: RequestInit,
): Promise<reviewTaskTasksTaskIdReviewPostResponse> => {
return customFetch<reviewTaskTasksTaskIdReviewPostResponse>(
getReviewTaskTasksTaskIdReviewPostUrl(taskId),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(taskReviewDecision),
},
);
};
export const getReviewTaskTasksTaskIdReviewPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
> => {
const mutationKey = ["reviewTaskTasksTaskIdReviewPost"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
{ taskId: number; data: TaskReviewDecision }
> = (props) => {
const { taskId, data } = props ?? {};
return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>
>;
export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision;
export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError;
/**
* @summary Review Task
*/
export const useReviewTaskTasksTaskIdReviewPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
> => {
return useMutation(
getReviewTaskTasksTaskIdReviewPostMutationOptions(options),
queryClient,
);
};
/** /**
* @summary Update Task * @summary Update Task
*/ */