mirror of
https://github.com/saltbo/agent-kanban.git
synced 2026-09-19 09:01:47 +08:00
b301aac96a
Pin the Biome version used by CI, align Cloudflare worker types, resolve the Assistant UI peer graph, and apply the matching formatter output.
232 lines
8.3 KiB
TypeScript
232 lines
8.3 KiB
TypeScript
import { type TaskReviewDecisionActor, taskReviewDecisionAuthority } from "@server/domain/tasks/taskReviewDecisionAuthority";
|
|
|
|
export type TaskReviewDecisionKind = "rejection" | "completion";
|
|
|
|
export interface StoredTaskReviewDecision {
|
|
kind: TaskReviewDecisionKind;
|
|
reason: string | null;
|
|
actor: TaskReviewDecisionActor;
|
|
reservationId: string;
|
|
state: "pending" | "accepted";
|
|
effectState: "pending" | "delivered";
|
|
actionId: string | null;
|
|
createdAt: string;
|
|
decidedAt: string | null;
|
|
effectDeliveredAt: string | null;
|
|
}
|
|
|
|
export interface TaskReviewDecisionTarget {
|
|
taskId: string;
|
|
status: string;
|
|
assignedTo: string | null;
|
|
assigneeIdentityType: string | null;
|
|
activeReviewSubmissionVersion: string | null;
|
|
decision: StoredTaskReviewDecision | null;
|
|
}
|
|
|
|
export interface TaskReviewDecisionRepository {
|
|
findCurrentTarget(ownerId: string, taskId: string): Promise<TaskReviewDecisionTarget | null>;
|
|
findTarget(ownerId: string, taskId: string, reviewSubmissionVersion: string): Promise<TaskReviewDecisionTarget | null>;
|
|
reserve(input: {
|
|
ownerId: string;
|
|
taskId: string;
|
|
reviewSubmissionVersion: string;
|
|
expectedAssignedTo: string;
|
|
kind: TaskReviewDecisionKind;
|
|
reason: string | null;
|
|
actor: TaskReviewDecisionActor;
|
|
}): Promise<StoredTaskReviewDecision | null>;
|
|
finalize(input: {
|
|
ownerId: string;
|
|
taskId: string;
|
|
reviewSubmissionVersion: string;
|
|
expectedAssignedTo: string;
|
|
decision: StoredTaskReviewDecision;
|
|
}): Promise<StoredTaskReviewDecision | null>;
|
|
markEffectDelivered(input: {
|
|
ownerId: string;
|
|
taskId: string;
|
|
reviewSubmissionVersion: string;
|
|
decision: StoredTaskReviewDecision;
|
|
}): Promise<StoredTaskReviewDecision | null>;
|
|
}
|
|
|
|
export interface TaskReviewRejection {
|
|
id: string;
|
|
taskId: string;
|
|
reviewSubmissionVersion: string;
|
|
rejectedByActorType: TaskReviewDecisionActor["type"];
|
|
rejectedByActorId: string;
|
|
reason: string | null;
|
|
rejectedAt: string;
|
|
}
|
|
|
|
export interface TaskReviewCompletion {
|
|
id: string;
|
|
taskId: string;
|
|
reviewSubmissionVersion: string;
|
|
completedByActorType: TaskReviewDecisionActor["type"];
|
|
completedByActorId: string;
|
|
completedAt: string;
|
|
}
|
|
|
|
export type TaskReviewDecisionFailureCode =
|
|
| "TASK_NOT_FOUND"
|
|
| "TASK_REVIEW_DECISION_FORBIDDEN"
|
|
| "TASK_REVIEW_PRECONDITION_FAILED"
|
|
| "TASK_REVIEW_DECISION_CONFLICT";
|
|
|
|
export class TaskReviewDecisionFailure extends Error {
|
|
constructor(
|
|
readonly code: TaskReviewDecisionFailureCode,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "TaskReviewDecisionFailure";
|
|
}
|
|
}
|
|
|
|
interface ReplaceDecisionInput {
|
|
ownerId: string;
|
|
taskId: string;
|
|
reviewSubmissionVersion: string;
|
|
actor: TaskReviewDecisionActor;
|
|
reason: string | null;
|
|
}
|
|
|
|
export async function replaceTaskReviewRejection(
|
|
repository: TaskReviewDecisionRepository,
|
|
input: ReplaceDecisionInput,
|
|
): Promise<{ rejection: TaskReviewRejection; version: string; created: boolean; assigneeActorId: string }> {
|
|
const result = await replaceDecision(repository, input, "rejection");
|
|
return {
|
|
rejection: {
|
|
id: input.taskId,
|
|
taskId: input.taskId,
|
|
reviewSubmissionVersion: input.reviewSubmissionVersion,
|
|
rejectedByActorType: result.decision.actor.type,
|
|
rejectedByActorId: result.decision.actor.id,
|
|
reason: result.decision.reason,
|
|
rejectedAt: result.decision.decidedAt!,
|
|
},
|
|
version: result.decision.actionId!,
|
|
created: result.created,
|
|
assigneeActorId: result.assigneeActorId,
|
|
};
|
|
}
|
|
|
|
export async function replaceTaskReviewCompletion(
|
|
repository: TaskReviewDecisionRepository,
|
|
input: Omit<ReplaceDecisionInput, "reason">,
|
|
): Promise<{ completion: TaskReviewCompletion; version: string; created: boolean; assigneeActorId: string }> {
|
|
const result = await replaceDecision(repository, { ...input, reason: null }, "completion");
|
|
return {
|
|
completion: {
|
|
id: input.taskId,
|
|
taskId: input.taskId,
|
|
reviewSubmissionVersion: input.reviewSubmissionVersion,
|
|
completedByActorType: result.decision.actor.type,
|
|
completedByActorId: result.decision.actor.id,
|
|
completedAt: result.decision.decidedAt!,
|
|
},
|
|
version: result.decision.actionId!,
|
|
created: result.created,
|
|
assigneeActorId: result.assigneeActorId,
|
|
};
|
|
}
|
|
|
|
async function replaceDecision(
|
|
repository: TaskReviewDecisionRepository,
|
|
input: ReplaceDecisionInput,
|
|
kind: TaskReviewDecisionKind,
|
|
): Promise<{ decision: StoredTaskReviewDecision; created: boolean; assigneeActorId: string }> {
|
|
let target = await repository.findTarget(input.ownerId, input.taskId, input.reviewSubmissionVersion);
|
|
validateTarget(target, input, kind);
|
|
|
|
let decision = target.decision;
|
|
let created = false;
|
|
if (!decision) {
|
|
decision = await repository.reserve({
|
|
...input,
|
|
expectedAssignedTo: target.assignedTo!,
|
|
kind,
|
|
});
|
|
if (!decision) {
|
|
target = await repository.findTarget(input.ownerId, input.taskId, input.reviewSubmissionVersion);
|
|
validateTarget(target, input, kind);
|
|
decision = target.decision;
|
|
if (!decision) throw conflict("Task state changed before the review decision was reserved");
|
|
} else {
|
|
created = true;
|
|
}
|
|
}
|
|
validateDecision(decision, kind, input.reason);
|
|
if (decision.state === "pending") {
|
|
const finalized = await repository.finalize({
|
|
ownerId: input.ownerId,
|
|
taskId: input.taskId,
|
|
reviewSubmissionVersion: input.reviewSubmissionVersion,
|
|
expectedAssignedTo: target.assignedTo!,
|
|
decision,
|
|
});
|
|
if (finalized) {
|
|
decision = finalized;
|
|
} else {
|
|
const winner = await repository.findTarget(input.ownerId, input.taskId, input.reviewSubmissionVersion);
|
|
if (winner?.decision?.state !== "accepted") {
|
|
throw conflict("Task state changed before the review decision was finalized");
|
|
}
|
|
validateDecision(winner.decision, kind, input.reason);
|
|
decision = winner.decision;
|
|
}
|
|
}
|
|
if (decision.effectState === "delivered") return { decision, created: false, assigneeActorId: target.assignedTo! };
|
|
|
|
const delivered = await repository.markEffectDelivered({
|
|
ownerId: input.ownerId,
|
|
taskId: input.taskId,
|
|
reviewSubmissionVersion: input.reviewSubmissionVersion,
|
|
decision,
|
|
});
|
|
if (delivered) return { decision: delivered, created, assigneeActorId: target.assignedTo! };
|
|
|
|
const winner = await repository.findTarget(input.ownerId, input.taskId, input.reviewSubmissionVersion);
|
|
if (winner?.decision?.effectState === "delivered") {
|
|
validateDecision(winner.decision, kind, input.reason);
|
|
return { decision: winner.decision, created, assigneeActorId: winner.assignedTo! };
|
|
}
|
|
throw conflict("The review decision effect was delivered but its acknowledgement could not be recorded");
|
|
}
|
|
|
|
function validateTarget(
|
|
target: TaskReviewDecisionTarget | null,
|
|
input: ReplaceDecisionInput,
|
|
kind: TaskReviewDecisionKind,
|
|
): asserts target is TaskReviewDecisionTarget {
|
|
if (!target) throw new TaskReviewDecisionFailure("TASK_NOT_FOUND", "Task not found");
|
|
const authority = taskReviewDecisionAuthority(target.assigneeIdentityType, target.assignedTo, input.actor);
|
|
if (authority === "self-review") {
|
|
throw new TaskReviewDecisionFailure("TASK_REVIEW_DECISION_FORBIDDEN", "An Agent cannot decide its own assigned Task review");
|
|
}
|
|
if (authority === "unsupported-assignee") {
|
|
throw new TaskReviewDecisionFailure("TASK_REVIEW_DECISION_FORBIDDEN", "The Task is not assigned in the Realmroot Agent identity domain");
|
|
}
|
|
if (target.activeReviewSubmissionVersion !== input.reviewSubmissionVersion) {
|
|
throw new TaskReviewDecisionFailure("TASK_REVIEW_PRECONDITION_FAILED", "The supplied Review Submission ETag is not current");
|
|
}
|
|
if (target.decision) {
|
|
validateDecision(target.decision, kind, input.reason);
|
|
return;
|
|
}
|
|
if (target.status !== "in_review") throw conflict(`Task review cannot be decided from ${target.status}`);
|
|
}
|
|
|
|
function validateDecision(decision: StoredTaskReviewDecision, kind: TaskReviewDecisionKind, reason: string | null): void {
|
|
if (decision.kind !== kind) throw conflict("The Review Submission already has a different decision");
|
|
if (decision.reason !== reason) throw conflict("The Review Rejection already has a different reason");
|
|
}
|
|
|
|
function conflict(message: string): TaskReviewDecisionFailure {
|
|
return new TaskReviewDecisionFailure("TASK_REVIEW_DECISION_CONFLICT", message);
|
|
}
|