mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Enforce execution-policy stage handoffs
This commit is contained in:
parent
9eaf72ab31
commit
ec75cabcd8
8 changed files with 949 additions and 138 deletions
|
|
@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
|
|||
if (contextSnapshot?.forceFreshSession === true) return true;
|
||||
|
||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||
if (wakeReason === "issue_assigned") return true;
|
||||
if (
|
||||
wakeReason === "issue_assigned" ||
|
||||
wakeReason === "execution_review_requested" ||
|
||||
wakeReason === "execution_approval_requested" ||
|
||||
wakeReason === "execution_changes_requested"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -714,6 +721,9 @@ function describeSessionResetReason(
|
|||
|
||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
|
||||
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
|
||||
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
|
||||
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
|
|||
}
|
||||
| null;
|
||||
}) {
|
||||
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||
if (commentIds.length === 0) return null;
|
||||
|
||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||
const issueSummary =
|
||||
input.issueSummary ??
|
||||
|
|
@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
|
|||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null);
|
||||
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
|
||||
|
||||
const commentRows = await input.db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.id, commentIds),
|
||||
),
|
||||
);
|
||||
const commentRows =
|
||||
commentIds.length === 0
|
||||
? []
|
||||
: await input.db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.id, commentIds),
|
||||
),
|
||||
);
|
||||
|
||||
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
||||
const comments: Array<Record<string, unknown>> = [];
|
||||
|
|
@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
|
|||
priority: issueSummary.priority,
|
||||
}
|
||||
: null,
|
||||
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||
commentIds,
|
||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||
comments,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type TransitionInput = {
|
|||
type TransitionResult = {
|
||||
patch: Record<string, unknown>;
|
||||
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
||||
workflowControlledAssignment?: boolean;
|
||||
};
|
||||
|
||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||
|
|
@ -198,14 +199,36 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
|
|||
};
|
||||
}
|
||||
|
||||
function buildPendingStagePatch(input: {
|
||||
patch: Record<string, unknown>;
|
||||
previous: IssueExecutionState | null;
|
||||
policy: IssueExecutionPolicy;
|
||||
stage: IssueExecutionStage;
|
||||
participant: IssueExecutionStagePrincipal;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
}) {
|
||||
input.patch.status = "in_review";
|
||||
Object.assign(input.patch, patchForPrincipal(input.participant));
|
||||
input.patch.executionState = buildPendingState({
|
||||
previous: input.previous,
|
||||
stage: input.stage,
|
||||
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
|
||||
participant: input.participant,
|
||||
returnAssignee: input.returnAssignee,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentAssignee = assigneePrincipal(input.issue);
|
||||
const actor = actorPrincipal(input.actor);
|
||||
const requestedAssigneePatchProvided =
|
||||
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
|
||||
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
|
||||
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
|
||||
const requestedStatus = input.requestedStatus;
|
||||
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
|
||||
|
||||
if (!input.policy) {
|
||||
if (existingState) {
|
||||
|
|
@ -228,90 +251,121 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
|||
return { patch };
|
||||
}
|
||||
|
||||
if (currentStage && input.issue.status === "in_review") {
|
||||
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
||||
}
|
||||
return { patch };
|
||||
if (activeStage) {
|
||||
const currentParticipant =
|
||||
existingState?.currentParticipant ??
|
||||
selectStageParticipant(activeStage, {
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!currentParticipant) {
|
||||
throw unprocessable(`No eligible ${activeStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
if (requestedStatus === "done") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||
}
|
||||
const approvedState = buildCompletedState(existingState, currentStage);
|
||||
const nextStage = nextPendingStage(
|
||||
input.policy,
|
||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||
);
|
||||
if (principalsEqual(currentParticipant, actor)) {
|
||||
if (requestedStatus === "done") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||
}
|
||||
const approvedState = buildCompletedState(existingState, activeStage);
|
||||
const nextStage = nextPendingStage(
|
||||
input.policy,
|
||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||
);
|
||||
|
||||
if (!nextStage) {
|
||||
patch.executionState = approvedState;
|
||||
if (!nextStage) {
|
||||
patch.executionState = approvedState;
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: activeStage.id,
|
||||
stageType: activeStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const participant = selectStageParticipant(nextStage, {
|
||||
preferred: explicitAssignee,
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!participant) {
|
||||
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: approvedState,
|
||||
policy: input.policy,
|
||||
stage: nextStage,
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
stageId: activeStage.id,
|
||||
stageType: activeStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
const participant = selectStageParticipant(nextStage, {
|
||||
preferred: explicitAssignee,
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!participant) {
|
||||
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Requesting changes requires a comment");
|
||||
}
|
||||
if (!existingState?.returnAssignee) {
|
||||
throw unprocessable("This execution stage has no return assignee");
|
||||
}
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
patch.executionState = buildChangesRequestedState(existingState, activeStage);
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: activeStage.id,
|
||||
stageType: activeStage.type,
|
||||
outcome: "changes_requested",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
previous: approvedState,
|
||||
stage: nextStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Requesting changes requires a comment");
|
||||
}
|
||||
if (!existingState?.returnAssignee) {
|
||||
throw unprocessable("This execution stage has no return assignee");
|
||||
}
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
patch.executionState = buildChangesRequestedState(existingState, currentStage);
|
||||
if (
|
||||
input.issue.status !== "in_review" ||
|
||||
!principalsEqual(currentAssignee, currentParticipant) ||
|
||||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) ||
|
||||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
||||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant))
|
||||
) {
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: existingState,
|
||||
policy: input.policy,
|
||||
stage: activeStage,
|
||||
participant: currentParticipant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "changes_requested",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus !== "done") {
|
||||
const shouldStartWorkflow =
|
||||
requestedStatus === "done" ||
|
||||
requestedStatus === "in_review" ||
|
||||
(input.issue.status === "in_review" && existingState == null);
|
||||
|
||||
if (!shouldStartWorkflow) {
|
||||
return { patch };
|
||||
}
|
||||
|
||||
|
|
@ -333,14 +387,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
|||
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: existingState,
|
||||
policy: input.policy,
|
||||
stage: pendingStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
|
||||
participant,
|
||||
returnAssignee,
|
||||
});
|
||||
return { patch };
|
||||
return {
|
||||
patch,
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue