mirror of
https://github.com/saltbo/agent-kanban.git
synced 2026-09-19 09:01:47 +08:00
96fdd70d2c
* feat(api)!: redesign task resource operations
Publish the complete resource-oriented OpenAPI contract, enforce scope-based authorization at the HTTP boundary, and replace lifecycle command routes with Task PATCH and nested Claim/Event resources.
Add server-side Task CAS, migration coverage, updated Agent Skills, and browser/API regression tests.
BREAKING CHANGE: remove the legacy task lifecycle endpoints and move bounded waits to /tasks/{taskId}/events.
* refactor(tasks)!: make claims create-only
Remove Task Claim retrieval and deletion routes, the release scope, live deletion logic, and release workflow guidance.
Keep historical release records readable for migration and audit compatibility.
BREAKING CHANGE: remove GET and DELETE /tasks/{taskId}/claims/{claimId} and the task:release scope.
* test(ci): align actor and projection contracts
Cover machine and service Task Action writes and update Agent detail fixtures to the canonical assignedTo query and paged lowerCamelCase response.
* fix(api): preserve merge patch fields and consume browser pagination
* test(ui): include terminal pagination in agent task fixture
---------
Co-authored-by: jarvis <jarvis@agents.realmroot.dev>
136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
export interface TaskReviewSubmissionCommit {
|
|
actionId: string;
|
|
submittedAt: string;
|
|
pullRequestUrl: string | null;
|
|
}
|
|
|
|
export interface TaskReviewSubmissionTarget {
|
|
version: number;
|
|
status: string;
|
|
assignedTo: string | null;
|
|
assigneeIdentityType: string | null;
|
|
pullRequestUrl: string | null;
|
|
activeSubmission: TaskReviewSubmissionCommit | null;
|
|
}
|
|
|
|
export interface TaskReviewSubmissionRepository {
|
|
findTarget(ownerId: string, taskId: string): Promise<TaskReviewSubmissionTarget | null>;
|
|
create(input: {
|
|
ownerId: string;
|
|
taskId: string;
|
|
agentActorId: string;
|
|
expectedPullRequestUrl: string | null;
|
|
pullRequestUrl: string | null;
|
|
expectedTaskVersion?: number;
|
|
}): Promise<TaskReviewSubmissionCommit | null>;
|
|
}
|
|
|
|
export interface TaskReviewSubmission {
|
|
id: string;
|
|
taskId: string;
|
|
agentActorId: string;
|
|
reviewSubmissionVersion: string;
|
|
pullRequestUrl: string | null;
|
|
submittedAt: string;
|
|
}
|
|
|
|
export type TaskReviewSubmissionFailureCode =
|
|
| "TASK_NOT_FOUND"
|
|
| "TASK_REVIEW_SUBMISSION_NOT_FOUND"
|
|
| "TASK_REVIEW_FORBIDDEN"
|
|
| "TASK_REVIEW_CONFLICT";
|
|
|
|
export class TaskReviewSubmissionFailure extends Error {
|
|
constructor(
|
|
readonly code: TaskReviewSubmissionFailureCode,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "TaskReviewSubmissionFailure";
|
|
}
|
|
}
|
|
|
|
export async function readTaskReviewSubmission(
|
|
repository: Pick<TaskReviewSubmissionRepository, "findTarget">,
|
|
input: { ownerId: string; taskId: string },
|
|
): Promise<{ submission: TaskReviewSubmission; version: string }> {
|
|
const target = await repository.findTarget(input.ownerId, input.taskId);
|
|
if (!target) throw new TaskReviewSubmissionFailure("TASK_NOT_FOUND", "Task not found");
|
|
if (target.assigneeIdentityType !== "realmroot_actor" || !target.assignedTo || !target.activeSubmission) {
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_SUBMISSION_NOT_FOUND", "Task has no current Realmroot Agent Review Submission");
|
|
}
|
|
const result = submissionResult(input.taskId, target.assignedTo, target.activeSubmission, false);
|
|
return { submission: result.submission, version: result.version };
|
|
}
|
|
|
|
export async function replaceTaskReviewSubmission(
|
|
repository: TaskReviewSubmissionRepository,
|
|
input: { ownerId: string; taskId: string; agentActorId: string; pullRequestUrl: string | null; expectedTaskVersion?: number },
|
|
): Promise<{ submission: TaskReviewSubmission; version: string; created: boolean }> {
|
|
const target = await repository.findTarget(input.ownerId, input.taskId);
|
|
if (!target) throw new TaskReviewSubmissionFailure("TASK_NOT_FOUND", "Task not found");
|
|
assertTaskVersion(target.version, input.expectedTaskVersion);
|
|
if (target.assigneeIdentityType !== "realmroot_actor" || target.assignedTo !== input.agentActorId) {
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_FORBIDDEN", "Only the assigned Agent may submit this Task for review");
|
|
}
|
|
if (target.status === "in_review" && target.activeSubmission) {
|
|
if (input.pullRequestUrl !== null && input.pullRequestUrl !== target.activeSubmission.pullRequestUrl) {
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_CONFLICT", "Task already has a different active review submission");
|
|
}
|
|
return submissionResult(input.taskId, input.agentActorId, target.activeSubmission, false);
|
|
}
|
|
if (target.status !== "in_progress") {
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_CONFLICT", `Task cannot be submitted for review from ${target.status}`);
|
|
}
|
|
|
|
const committed = await repository.create({
|
|
...input,
|
|
expectedPullRequestUrl: target.pullRequestUrl,
|
|
pullRequestUrl: input.pullRequestUrl ?? target.pullRequestUrl,
|
|
});
|
|
if (!committed) {
|
|
const winner = await repository.findTarget(input.ownerId, input.taskId);
|
|
if (winner) assertTaskVersion(winner.version, input.expectedTaskVersion);
|
|
if (
|
|
winner?.status === "in_review" &&
|
|
winner.assignedTo === input.agentActorId &&
|
|
winner.assigneeIdentityType === "realmroot_actor" &&
|
|
winner.activeSubmission
|
|
) {
|
|
if (input.pullRequestUrl !== null && input.pullRequestUrl !== winner.activeSubmission.pullRequestUrl) {
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_CONFLICT", "A different review submission was committed first");
|
|
}
|
|
return submissionResult(input.taskId, input.agentActorId, winner.activeSubmission, false);
|
|
}
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_CONFLICT", "Task state or assignment changed before review submission was committed");
|
|
}
|
|
|
|
return submissionResult(input.taskId, input.agentActorId, committed, true);
|
|
}
|
|
|
|
function assertTaskVersion(actual: number, expected?: number): void {
|
|
if (expected !== undefined && actual !== expected) {
|
|
throw new TaskReviewSubmissionFailure("TASK_REVIEW_CONFLICT", "Task changed before review submission was committed");
|
|
}
|
|
}
|
|
|
|
function submissionResult(
|
|
taskId: string,
|
|
agentActorId: string,
|
|
committed: TaskReviewSubmissionCommit,
|
|
created: boolean,
|
|
): { submission: TaskReviewSubmission; version: string; created: boolean } {
|
|
return {
|
|
submission: {
|
|
id: taskId,
|
|
taskId,
|
|
agentActorId,
|
|
reviewSubmissionVersion: committed.actionId,
|
|
pullRequestUrl: committed.pullRequestUrl,
|
|
submittedAt: committed.submittedAt,
|
|
},
|
|
version: committed.actionId,
|
|
created,
|
|
};
|
|
}
|