mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server enforces an issue execution policy. It gates status changes while a review or approval stage is active. > - A board user could not cancel a task while an agent reviewer held the active stage. The API returned "Only the active reviewer or approver can advance the current execution stage". > - Board users own the board. They must always be able to edit and cancel any task. > - This pull request adds a board override to the execution stage transition. A board cancel clears the pending stage state and proceeds instead of raising an error. > - The benefit is that board users can always stop work, even while a review is pending or the stored stage state has drifted. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. Description follows `bug_report.yml`: **What happened?** A board user set a task to `cancelled` while the task had an active reviewer stage held by an agent. The PATCH failed with "Task Update Failed... only the assigned approver or reviewer...". The same failure occurred when the stored stage state had drifted: the server silently forced the task back to `in_review` instead of honoring the cancel. **Expected behavior** A board user can always edit and cancel any task. A board cancel must clear the pending review stage and apply the requested status. **Steps to reproduce** 1. Create a task assigned to agent A with agent B configured as reviewer in the execution policy. 2. Let agent A hand the task off so the review stage becomes active. 3. As the board user, set the task status to Cancelled. 4. The update fails with the reviewer-only error. Related work: #5487 touches the execution-policy approver UI. It does not address the board cancel path. ## What Changed - `server/src/routes/issues.ts`: the issue PATCH route now passes `allowBoardOverride` when the actor is a board user. - `server/src/services/issue-execution-policy.ts`: when `allowBoardOverride` is set and the requested status is not `in_review` or `in_progress`, the transition clears `executionState` and proceeds. This applies both while a stage decision is pending and when the stage state has drifted, so a board cancel is no longer rejected or silently flipped back to `in_review`. - Reviewer gating is unchanged for everyone else: a board user who is the active participant still uses the normal approve / request-changes flow, and non-participant agents still receive the 422 guard. - Assignee-only board updates on an `in_review` task keep the stage state coherent: reassigning to an eligible stage participant re-pends the stage with them as the current participant, while reassigning to a non-participant (or unassigning) dissolves the review back to `in_progress` instead of persisting an `in_review` issue with no execution state or an ineligible participant. - New unit tests and route tests cover board cancellation of an active review stage and of a drifted pending review, plus reviewer swap, non-participant reassignment, and unassignment during an active review. ## Verification - In `server/`: `pnpm exec vitest run src/__tests__/issue-execution-policy.test.ts src/__tests__/issue-execution-policy-routes.test.ts` — 2 files, 73/73 tests pass on top of current `master`. - In `server/`: `pnpm run typecheck` passes. ## Risks - Low risk. The override branch runs only for board actors and only for target statuses other than `in_review` and `in_progress`. Cancelling clears `executionState`, so a later reopen starts from a fresh stage state. Agent-facing flows and reviewer gating are unchanged. ## Model Used - Claude Fable 5 (Anthropic), model ID `claude-fable-5`, running in Claude Code (Claude Agent SDK) with extended thinking and agentic tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
1227 lines
44 KiB
TypeScript
1227 lines
44 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
IssueExecutionDecision,
|
|
IssueExecutionMonitorClearReason,
|
|
IssueExecutionMonitorPolicy,
|
|
IssueExecutionMonitorState,
|
|
IssueExecutionPolicy,
|
|
IssueExecutionStage,
|
|
IssueExecutionStagePrincipal,
|
|
IssueExecutionState,
|
|
IssueMonitorScheduledBy,
|
|
} from "@paperclipai/shared";
|
|
import { issueExecutionPolicySchema, issueExecutionStateSchema } from "@paperclipai/shared";
|
|
import { unprocessable } from "../errors.js";
|
|
|
|
type AssigneeLike = {
|
|
assigneeAgentId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
};
|
|
|
|
type IssueLike = AssigneeLike & {
|
|
status: string;
|
|
responsibleUserId?: string | null;
|
|
createdByUserId?: string | null;
|
|
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
|
|
executionState?: IssueExecutionState | Record<string, unknown> | null;
|
|
monitorNextCheckAt?: Date | null;
|
|
monitorWakeRequestedAt?: Date | null;
|
|
monitorLastTriggeredAt?: Date | null;
|
|
monitorAttemptCount?: number | null;
|
|
monitorNotes?: string | null;
|
|
monitorScheduledBy?: string | null;
|
|
};
|
|
|
|
type ActorLike = {
|
|
agentId?: string | null;
|
|
userId?: string | null;
|
|
};
|
|
|
|
type RequestedAssigneePatch = {
|
|
assigneeAgentId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
};
|
|
|
|
type TransitionInput = {
|
|
issue: IssueLike;
|
|
policy: IssueExecutionPolicy | null;
|
|
previousPolicy?: IssueExecutionPolicy | null;
|
|
requestedStatus?: string;
|
|
requestedAssigneePatch: RequestedAssigneePatch;
|
|
actor: ActorLike;
|
|
allowBoardOverride?: boolean;
|
|
commentBody?: string | null;
|
|
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
|
monitorExplicitlyUpdated?: boolean;
|
|
};
|
|
|
|
type TransitionResult = {
|
|
patch: Record<string, unknown>;
|
|
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
|
workflowControlledAssignment?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Consecutive agent-initiated changes-requested rounds tolerated on one stage
|
|
* before the pending review escalates to the responsible human. Policies can
|
|
* override via `maxReviewRounds`; human decisions always reset the counter.
|
|
*/
|
|
export const DEFAULT_MAX_REVIEW_ROUNDS = 3;
|
|
|
|
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
|
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
|
|
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
|
|
const MONITOR_INVALID_MESSAGE = "Monitor can only be scheduled on issues assigned to an agent in in_progress or in_review";
|
|
const MONITOR_BOUNDS_EXHAUSTED_MESSAGE = "Monitor bounds are already exhausted";
|
|
const STAGE_DECISION_COMMENT_HINT = "Include the decision comment in the same PATCH request; prior comments are not considered.";
|
|
export const REDACTED_ISSUE_MONITOR_EXTERNAL_REF = "[redacted]";
|
|
|
|
function normalizeMonitorNotes(notes: string | null | undefined) {
|
|
if (typeof notes !== "string") return null;
|
|
const trimmed = notes.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function normalizeMonitorText(value: string | null | undefined) {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
export function redactIssueMonitorExternalRef(value: string | null | undefined) {
|
|
return normalizeMonitorText(value) ? REDACTED_ISSUE_MONITOR_EXTERNAL_REF : null;
|
|
}
|
|
|
|
function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) {
|
|
return {
|
|
kind: monitor.kind ?? null,
|
|
serviceName: normalizeMonitorText(monitor.serviceName),
|
|
externalRef: redactIssueMonitorExternalRef(monitor.externalRef),
|
|
timeoutAt: monitor.timeoutAt ?? null,
|
|
maxAttempts: monitor.maxAttempts ?? null,
|
|
recoveryPolicy: monitor.recoveryPolicy ?? null,
|
|
};
|
|
}
|
|
|
|
function monitorMetadataFromState(state: IssueExecutionMonitorState | null | undefined) {
|
|
return {
|
|
kind: state?.kind ?? null,
|
|
serviceName: normalizeMonitorText(state?.serviceName),
|
|
externalRef: redactIssueMonitorExternalRef(state?.externalRef),
|
|
timeoutAt: state?.timeoutAt ?? null,
|
|
maxAttempts: state?.maxAttempts ?? null,
|
|
recoveryPolicy: state?.recoveryPolicy ?? null,
|
|
};
|
|
}
|
|
|
|
function blankExecutionState(): IssueExecutionState {
|
|
return {
|
|
status: "idle",
|
|
currentStageId: null,
|
|
currentStageIndex: null,
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: null,
|
|
reviewRequest: null,
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
monitor: null,
|
|
};
|
|
}
|
|
|
|
function isoString(value: Date | string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
if (value instanceof Date) return value.toISOString();
|
|
return value;
|
|
}
|
|
|
|
function monitorStatesEqual(left: IssueExecutionMonitorState | null, right: IssueExecutionMonitorState | null): boolean {
|
|
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
|
}
|
|
|
|
function executionStateWithMonitor(
|
|
stageState: IssueExecutionState | null,
|
|
monitorState: IssueExecutionMonitorState | null,
|
|
): IssueExecutionState | null {
|
|
if (!stageState && !monitorState) return null;
|
|
const base = stageState ? { ...stageState } : blankExecutionState();
|
|
return {
|
|
...base,
|
|
monitor: monitorState,
|
|
};
|
|
}
|
|
|
|
function derivePersistedMonitorState(input: {
|
|
issue: IssueLike;
|
|
state: IssueExecutionState | null;
|
|
policy: IssueExecutionPolicy | null;
|
|
}): IssueExecutionMonitorState | null {
|
|
const fromState = input.state?.monitor ?? null;
|
|
const scheduledMonitor = input.policy?.monitor ?? null;
|
|
const nextCheckAt = isoString(input.issue.monitorNextCheckAt) ?? scheduledMonitor?.nextCheckAt ?? fromState?.nextCheckAt ?? null;
|
|
const lastTriggeredAt = isoString(input.issue.monitorLastTriggeredAt) ?? fromState?.lastTriggeredAt ?? null;
|
|
const attemptCount = input.issue.monitorAttemptCount ?? fromState?.attemptCount ?? 0;
|
|
const notes = scheduledMonitor?.notes ?? normalizeMonitorNotes(input.issue.monitorNotes) ?? fromState?.notes ?? null;
|
|
const scheduledByRaw = input.issue.monitorScheduledBy ?? scheduledMonitor?.scheduledBy ?? fromState?.scheduledBy ?? null;
|
|
const scheduledBy =
|
|
scheduledByRaw === "assignee" || scheduledByRaw === "board" ? scheduledByRaw : null;
|
|
const metadata = scheduledMonitor ? monitorMetadataFromPolicy(scheduledMonitor) : monitorMetadataFromState(fromState);
|
|
|
|
if (nextCheckAt) {
|
|
return {
|
|
status: "scheduled",
|
|
nextCheckAt,
|
|
lastTriggeredAt,
|
|
attemptCount,
|
|
notes,
|
|
scheduledBy,
|
|
...metadata,
|
|
clearedAt: null,
|
|
clearReason: null,
|
|
};
|
|
}
|
|
|
|
if (fromState?.status === "cleared") {
|
|
return {
|
|
...fromState,
|
|
notes,
|
|
scheduledBy,
|
|
attemptCount,
|
|
lastTriggeredAt,
|
|
...metadata,
|
|
};
|
|
}
|
|
|
|
if (fromState?.status === "triggered" || lastTriggeredAt || attemptCount > 0) {
|
|
return {
|
|
status: "triggered",
|
|
nextCheckAt: null,
|
|
lastTriggeredAt,
|
|
attemptCount,
|
|
notes,
|
|
scheduledBy,
|
|
...metadata,
|
|
clearedAt: null,
|
|
clearReason: null,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildScheduledMonitorState(
|
|
previous: IssueExecutionMonitorState | null,
|
|
monitor: IssueExecutionMonitorPolicy,
|
|
): IssueExecutionMonitorState {
|
|
return {
|
|
status: "scheduled",
|
|
nextCheckAt: monitor.nextCheckAt,
|
|
lastTriggeredAt: previous?.lastTriggeredAt ?? null,
|
|
attemptCount: previous?.attemptCount ?? 0,
|
|
notes: monitor.notes ?? null,
|
|
scheduledBy: monitor.scheduledBy,
|
|
...monitorMetadataFromPolicy(monitor),
|
|
clearedAt: null,
|
|
clearReason: null,
|
|
};
|
|
}
|
|
|
|
function buildTriggeredMonitorState(input: {
|
|
previous: IssueExecutionMonitorState | null;
|
|
triggeredAt: Date;
|
|
}): IssueExecutionMonitorState {
|
|
return {
|
|
status: "triggered",
|
|
nextCheckAt: null,
|
|
lastTriggeredAt: input.triggeredAt.toISOString(),
|
|
attemptCount: (input.previous?.attemptCount ?? 0) + 1,
|
|
notes: input.previous?.notes ?? null,
|
|
scheduledBy: input.previous?.scheduledBy ?? null,
|
|
...monitorMetadataFromState(input.previous),
|
|
clearedAt: null,
|
|
clearReason: null,
|
|
};
|
|
}
|
|
|
|
function buildClearedMonitorState(input: {
|
|
previous: IssueExecutionMonitorState | null;
|
|
clearReason: IssueExecutionMonitorClearReason;
|
|
clearedAt: Date;
|
|
}): IssueExecutionMonitorState {
|
|
return {
|
|
status: "cleared",
|
|
nextCheckAt: null,
|
|
lastTriggeredAt: input.previous?.lastTriggeredAt ?? null,
|
|
attemptCount: input.previous?.attemptCount ?? 0,
|
|
notes: input.previous?.notes ?? null,
|
|
scheduledBy: input.previous?.scheduledBy ?? null,
|
|
...monitorMetadataFromState(input.previous),
|
|
clearedAt: input.clearedAt.toISOString(),
|
|
clearReason: input.clearReason,
|
|
};
|
|
}
|
|
|
|
function issueAllowsMonitor(status: string, assigneeAgentId: string | null, assigneeUserId: string | null) {
|
|
return Boolean(assigneeAgentId) && !assigneeUserId && (status === "in_progress" || status === "in_review");
|
|
}
|
|
|
|
function monitorClearReasonForIssue(
|
|
status: string,
|
|
assigneeAgentId: string | null,
|
|
assigneeUserId: string | null,
|
|
): IssueExecutionMonitorClearReason | null {
|
|
if (status === "done") return "done";
|
|
if (status === "cancelled") return "cancelled";
|
|
if (!issueAllowsMonitor(status, assigneeAgentId, assigneeUserId)) {
|
|
if (assigneeUserId || !assigneeAgentId) return "invalid_assignee";
|
|
return "invalid_status";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseMonitorDate(value: string | null | undefined) {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
function exhaustedMonitorClearReason(input: {
|
|
monitor: IssueExecutionMonitorPolicy;
|
|
attemptCount: number;
|
|
now: Date;
|
|
}): IssueExecutionMonitorClearReason | null {
|
|
const timeoutAt = parseMonitorDate(input.monitor.timeoutAt ?? null);
|
|
if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) {
|
|
return "timeout_exceeded";
|
|
}
|
|
const maxAttempts = input.monitor.maxAttempts ?? null;
|
|
if (maxAttempts !== null && input.attemptCount >= maxAttempts) {
|
|
return "max_attempts_exhausted";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function nextAssigneeIds(input: {
|
|
issue: IssueLike;
|
|
requestedAssigneePatch: RequestedAssigneePatch;
|
|
stagePatch: Record<string, unknown>;
|
|
}) {
|
|
const assigneeAgentId =
|
|
input.stagePatch.assigneeAgentId !== undefined
|
|
? (input.stagePatch.assigneeAgentId as string | null)
|
|
: input.requestedAssigneePatch.assigneeAgentId !== undefined
|
|
? input.requestedAssigneePatch.assigneeAgentId ?? null
|
|
: input.issue.assigneeAgentId ?? null;
|
|
const assigneeUserId =
|
|
input.stagePatch.assigneeUserId !== undefined
|
|
? (input.stagePatch.assigneeUserId as string | null)
|
|
: input.requestedAssigneePatch.assigneeUserId !== undefined
|
|
? input.requestedAssigneePatch.assigneeUserId ?? null
|
|
: input.issue.assigneeUserId ?? null;
|
|
return { assigneeAgentId, assigneeUserId };
|
|
}
|
|
|
|
export function stripMonitorFromExecutionPolicy(policy: IssueExecutionPolicy | null): IssueExecutionPolicy | null {
|
|
if (!policy) return null;
|
|
if (!policy.monitor) return policy;
|
|
if (policy.stages.length === 0) return null;
|
|
return {
|
|
mode: policy.mode,
|
|
commentRequired: policy.commentRequired,
|
|
stages: policy.stages,
|
|
};
|
|
}
|
|
|
|
export function setIssueExecutionPolicyMonitorScheduledBy(
|
|
policy: IssueExecutionPolicy | null,
|
|
scheduledBy: IssueMonitorScheduledBy,
|
|
): IssueExecutionPolicy | null {
|
|
if (!policy?.monitor) return policy;
|
|
return {
|
|
...policy,
|
|
monitor: {
|
|
...policy.monitor,
|
|
scheduledBy,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPolicy | null {
|
|
if (input == null) return null;
|
|
const parsed = issueExecutionPolicySchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
throw unprocessable("Invalid execution policy", parsed.error.flatten());
|
|
}
|
|
|
|
const stages = parsed.data.stages
|
|
.map((stage) => {
|
|
const participants: IssueExecutionStage["participants"] = stage.participants
|
|
.map((participant) => ({
|
|
id: participant.id ?? randomUUID(),
|
|
type: participant.type,
|
|
agentId: participant.type === "agent" ? participant.agentId ?? null : null,
|
|
userId: participant.type === "user" ? participant.userId ?? null : null,
|
|
}))
|
|
.filter((participant) => (participant.type === "agent" ? Boolean(participant.agentId) : Boolean(participant.userId)));
|
|
|
|
const dedupedParticipants: IssueExecutionStage["participants"] = [];
|
|
const seen = new Set<string>();
|
|
for (const participant of participants) {
|
|
const key = participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
dedupedParticipants.push(participant);
|
|
}
|
|
|
|
if (dedupedParticipants.length === 0) return null;
|
|
return {
|
|
id: stage.id ?? randomUUID(),
|
|
type: stage.type,
|
|
approvalsNeeded: 1 as const,
|
|
participants: dedupedParticipants,
|
|
};
|
|
})
|
|
.filter((stage): stage is NonNullable<typeof stage> => stage !== null);
|
|
|
|
const monitor = parsed.data.monitor
|
|
? {
|
|
nextCheckAt: parsed.data.monitor.nextCheckAt,
|
|
notes: normalizeMonitorNotes(parsed.data.monitor.notes),
|
|
scheduledBy: parsed.data.monitor.scheduledBy,
|
|
kind: parsed.data.monitor.kind ?? null,
|
|
serviceName: normalizeMonitorText(parsed.data.monitor.serviceName),
|
|
externalRef: redactIssueMonitorExternalRef(parsed.data.monitor.externalRef),
|
|
timeoutAt: parsed.data.monitor.timeoutAt ?? null,
|
|
maxAttempts: parsed.data.monitor.maxAttempts ?? null,
|
|
recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null,
|
|
}
|
|
: null;
|
|
|
|
const reviewPreset = parsed.data.reviewPreset;
|
|
const authorizationPolicy = parsed.data.authorizationPolicy;
|
|
|
|
if (stages.length === 0 && !monitor && !reviewPreset && !authorizationPolicy) return null;
|
|
|
|
return {
|
|
mode: parsed.data.mode ?? "normal",
|
|
commentRequired: true,
|
|
stages,
|
|
...(monitor ? { monitor } : {}),
|
|
...(reviewPreset ? { reviewPreset } : {}),
|
|
...(authorizationPolicy ? { authorizationPolicy } : {}),
|
|
...(parsed.data.maxReviewRounds != null ? { maxReviewRounds: parsed.data.maxReviewRounds } : {}),
|
|
};
|
|
}
|
|
|
|
export function parseIssueExecutionState(input: unknown): IssueExecutionState | null {
|
|
if (input == null) return null;
|
|
const parsed = issueExecutionStateSchema.safeParse(input);
|
|
if (!parsed.success) return null;
|
|
return parsed.data;
|
|
}
|
|
|
|
export function assigneePrincipal(input: AssigneeLike): IssueExecutionStagePrincipal | null {
|
|
if (input.assigneeAgentId) {
|
|
return { type: "agent", agentId: input.assigneeAgentId, userId: null };
|
|
}
|
|
if (input.assigneeUserId) {
|
|
return { type: "user", userId: input.assigneeUserId, agentId: null };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function actorPrincipal(actor: ActorLike): IssueExecutionStagePrincipal | null {
|
|
if (actor.agentId) return { type: "agent", agentId: actor.agentId, userId: null };
|
|
if (actor.userId) return { type: "user", userId: actor.userId, agentId: null };
|
|
return null;
|
|
}
|
|
|
|
function principalsEqual(a: IssueExecutionStagePrincipal | null, b: IssueExecutionStagePrincipal | null): boolean {
|
|
if (!a || !b) return false;
|
|
if (a.type !== b.type) return false;
|
|
return a.type === "agent" ? a.agentId === b.agentId : a.userId === b.userId;
|
|
}
|
|
|
|
function resolveMaxReviewRounds(policy: IssueExecutionPolicy | null): number {
|
|
const configured = policy?.maxReviewRounds;
|
|
return typeof configured === "number" && configured > 0 ? configured : DEFAULT_MAX_REVIEW_ROUNDS;
|
|
}
|
|
|
|
/**
|
|
* The human a review stage escalates to when agents exhaust their
|
|
* changes-requested rounds. Without one the loop keeps handing back to the
|
|
* return assignee (pre-existing behavior) rather than stalling the stage.
|
|
*/
|
|
function reviewEscalationUserId(issue: IssueLike): string | null {
|
|
const responsible = issue.responsibleUserId?.trim();
|
|
if (responsible) return responsible;
|
|
const creator = issue.createdByUserId?.trim();
|
|
if (creator) return creator;
|
|
return null;
|
|
}
|
|
|
|
function findStageById(policy: IssueExecutionPolicy, stageId: string | null | undefined) {
|
|
if (!stageId) return null;
|
|
return policy.stages.find((stage) => stage.id === stageId) ?? null;
|
|
}
|
|
|
|
function nextPendingStage(policy: IssueExecutionPolicy, state: IssueExecutionState | null) {
|
|
const completed = new Set(state?.completedStageIds ?? []);
|
|
return policy.stages.find((stage) => !completed.has(stage.id)) ?? null;
|
|
}
|
|
|
|
function nextPendingStageAfter(
|
|
policy: IssueExecutionPolicy,
|
|
completedStage: IssueExecutionStage,
|
|
state: IssueExecutionState | null,
|
|
) {
|
|
const completed = new Set(state?.completedStageIds ?? []);
|
|
const completedIndex = policy.stages.findIndex((stage) => stage.id === completedStage.id);
|
|
return policy.stages.find((stage, index) => index > completedIndex && !completed.has(stage.id)) ?? null;
|
|
}
|
|
|
|
function selectStageParticipant(
|
|
stage: IssueExecutionStage,
|
|
opts?: {
|
|
preferred?: IssueExecutionStagePrincipal | null;
|
|
exclude?: IssueExecutionStagePrincipal | null;
|
|
},
|
|
): IssueExecutionStagePrincipal | null {
|
|
const participants = stage.participants.filter((participant) => !principalsEqual(participant, opts?.exclude ?? null));
|
|
if (participants.length === 0) return null;
|
|
if (opts?.preferred) {
|
|
const preferred = participants.find((participant) => principalsEqual(participant, opts.preferred ?? null));
|
|
if (preferred) return preferred;
|
|
}
|
|
const first = participants[0];
|
|
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
|
|
}
|
|
|
|
function stageHasParticipant(stage: IssueExecutionStage, participant: IssueExecutionStagePrincipal | null): boolean {
|
|
if (!participant) return false;
|
|
return stage.participants.some((candidate) => principalsEqual(candidate, participant));
|
|
}
|
|
|
|
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
|
|
if (!principal) {
|
|
return { assigneeAgentId: null, assigneeUserId: null };
|
|
}
|
|
return principal.type === "agent"
|
|
? { assigneeAgentId: principal.agentId ?? null, assigneeUserId: null }
|
|
: { assigneeAgentId: null, assigneeUserId: principal.userId ?? null };
|
|
}
|
|
|
|
function buildCompletedState(previous: IssueExecutionState | null, currentStage: IssueExecutionStage): IssueExecutionState {
|
|
const completedStageIds = Array.from(new Set([...(previous?.completedStageIds ?? []), currentStage.id]));
|
|
return {
|
|
status: COMPLETED_STATUS,
|
|
currentStageId: null,
|
|
currentStageIndex: null,
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: previous?.returnAssignee ?? null,
|
|
reviewRequest: null,
|
|
completedStageIds,
|
|
lastDecisionId: previous?.lastDecisionId ?? null,
|
|
lastDecisionOutcome: "approved",
|
|
monitor: previous?.monitor ?? null,
|
|
changesRequestedCount: 0,
|
|
};
|
|
}
|
|
|
|
function buildStateWithCompletedStages(input: {
|
|
previous: IssueExecutionState | null;
|
|
completedStageIds: string[];
|
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
|
}): IssueExecutionState {
|
|
return {
|
|
status: input.previous?.status ?? PENDING_STATUS,
|
|
currentStageId: input.previous?.currentStageId ?? null,
|
|
currentStageIndex: input.previous?.currentStageIndex ?? null,
|
|
currentStageType: input.previous?.currentStageType ?? null,
|
|
currentParticipant: input.previous?.currentParticipant ?? null,
|
|
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
|
|
reviewRequest: input.previous?.reviewRequest ?? null,
|
|
completedStageIds: input.completedStageIds,
|
|
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
|
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
|
monitor: input.previous?.monitor ?? null,
|
|
};
|
|
}
|
|
|
|
function buildSkippedStageCompletedState(input: {
|
|
previous: IssueExecutionState | null;
|
|
completedStageIds: string[];
|
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
|
}): IssueExecutionState {
|
|
return {
|
|
status: COMPLETED_STATUS,
|
|
currentStageId: null,
|
|
currentStageIndex: null,
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
|
|
reviewRequest: null,
|
|
completedStageIds: input.completedStageIds,
|
|
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
|
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
|
monitor: input.previous?.monitor ?? null,
|
|
};
|
|
}
|
|
|
|
function buildPendingState(input: {
|
|
previous: IssueExecutionState | null;
|
|
stage: IssueExecutionStage;
|
|
stageIndex: number;
|
|
participant: IssueExecutionStagePrincipal;
|
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
|
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
|
changesRequestedCount?: number;
|
|
}): IssueExecutionState {
|
|
return {
|
|
status: PENDING_STATUS,
|
|
currentStageId: input.stage.id,
|
|
currentStageIndex: input.stageIndex,
|
|
currentStageType: input.stage.type,
|
|
currentParticipant: input.participant,
|
|
returnAssignee: input.returnAssignee,
|
|
reviewRequest: input.reviewRequest ?? null,
|
|
completedStageIds: input.previous?.completedStageIds ?? [],
|
|
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
|
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
|
monitor: input.previous?.monitor ?? null,
|
|
changesRequestedCount: input.changesRequestedCount ?? input.previous?.changesRequestedCount ?? 0,
|
|
};
|
|
}
|
|
|
|
function buildChangesRequestedState(
|
|
previous: IssueExecutionState,
|
|
currentStage: IssueExecutionStage,
|
|
changesRequestedCount: number,
|
|
): IssueExecutionState {
|
|
return {
|
|
...previous,
|
|
status: CHANGES_REQUESTED_STATUS,
|
|
currentStageId: currentStage.id,
|
|
currentStageType: currentStage.type,
|
|
reviewRequest: null,
|
|
lastDecisionOutcome: "changes_requested",
|
|
changesRequestedCount,
|
|
};
|
|
}
|
|
|
|
function buildPendingStagePatch(input: {
|
|
patch: Record<string, unknown>;
|
|
previous: IssueExecutionState | null;
|
|
policy: IssueExecutionPolicy;
|
|
stage: IssueExecutionStage;
|
|
participant: IssueExecutionStagePrincipal;
|
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
|
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
|
changesRequestedCount?: number;
|
|
}) {
|
|
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,
|
|
reviewRequest: input.reviewRequest,
|
|
changesRequestedCount: input.changesRequestedCount,
|
|
});
|
|
}
|
|
|
|
function clearExecutionStatePatch(input: {
|
|
patch: Record<string, unknown>;
|
|
issueStatus: string;
|
|
requestedStatus?: string;
|
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
|
}) {
|
|
input.patch.executionState = null;
|
|
if (input.requestedStatus === undefined && input.issueStatus === "in_review" && input.returnAssignee) {
|
|
input.patch.status = "in_progress";
|
|
Object.assign(input.patch, patchForPrincipal(input.returnAssignee));
|
|
}
|
|
}
|
|
|
|
function canAutoSkipPendingStage(input: {
|
|
stage: IssueExecutionStage;
|
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
|
requestedStatus?: string;
|
|
}) {
|
|
if (input.requestedStatus !== "done" || input.stage.type !== "review" || !input.returnAssignee) {
|
|
return false;
|
|
}
|
|
return input.stage.participants.length > 0 &&
|
|
input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee));
|
|
}
|
|
|
|
function applyIssueExecutionStageTransition(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;
|
|
const effectiveReviewRequest = input.reviewRequest === undefined
|
|
? existingState?.reviewRequest ?? null
|
|
: input.reviewRequest;
|
|
|
|
if (!input.policy) {
|
|
if (existingState) {
|
|
patch.executionState = null;
|
|
if (input.issue.status === "in_review" && existingState.returnAssignee) {
|
|
patch.status = "in_progress";
|
|
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
|
}
|
|
}
|
|
return { patch };
|
|
}
|
|
|
|
if (
|
|
(input.issue.status === "done" || input.issue.status === "cancelled") &&
|
|
requestedStatus &&
|
|
requestedStatus !== "done" &&
|
|
requestedStatus !== "cancelled"
|
|
) {
|
|
patch.executionState = null;
|
|
return { patch };
|
|
}
|
|
|
|
if (existingState?.currentStageId && !currentStage) {
|
|
clearExecutionStatePatch({
|
|
patch,
|
|
issueStatus: input.issue.status,
|
|
requestedStatus,
|
|
returnAssignee: existingState.returnAssignee,
|
|
});
|
|
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`);
|
|
}
|
|
|
|
// An escalated review is deliberately held by a human who is not in the
|
|
// stage's configured participants. Re-selecting a configured (agent)
|
|
// participant would silently undo the escalation on the next unrelated
|
|
// PATCH, so the hold is sticky until the escalated human decides — their
|
|
// own decisions fall through to the participant decision branch below.
|
|
const escalatedHold =
|
|
currentParticipant.type === "user" &&
|
|
!stageHasParticipant(activeStage, currentParticipant) &&
|
|
(existingState?.changesRequestedCount ?? 0) >= resolveMaxReviewRounds(input.policy);
|
|
if (escalatedHold && !principalsEqual(currentParticipant, actor)) {
|
|
// An empty patch would not override the caller's own requested fields,
|
|
// so a status or assignee change from a non-escalated actor must be
|
|
// rejected outright — mirroring the "only the active participant can
|
|
// advance" rule below — and a drifted assignee must be re-asserted
|
|
// rather than left pointing away from the escalated human.
|
|
const attemptedAdvanceDuringHold =
|
|
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
|
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant));
|
|
if (attemptedAdvanceDuringHold) {
|
|
throw unprocessable("Only the escalated reviewer can advance the current execution stage");
|
|
}
|
|
const holdDrifted =
|
|
input.issue.status !== "in_review" ||
|
|
!principalsEqual(currentAssignee, currentParticipant);
|
|
if (holdDrifted) {
|
|
patch.status = "in_review";
|
|
Object.assign(patch, patchForPrincipal(currentParticipant));
|
|
patch.executionState = buildPendingState({
|
|
previous: existingState,
|
|
stage: activeStage,
|
|
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === activeStage.id),
|
|
participant: currentParticipant,
|
|
returnAssignee: existingState?.returnAssignee ?? null,
|
|
reviewRequest: effectiveReviewRequest,
|
|
});
|
|
}
|
|
return { patch };
|
|
}
|
|
if (!escalatedHold && !stageHasParticipant(activeStage, currentParticipant)) {
|
|
const participant = selectStageParticipant(activeStage, {
|
|
preferred: explicitAssignee ?? existingState?.currentParticipant ?? null,
|
|
exclude: existingState?.returnAssignee ?? null,
|
|
});
|
|
if (!participant) {
|
|
clearExecutionStatePatch({
|
|
patch,
|
|
issueStatus: input.issue.status,
|
|
requestedStatus,
|
|
returnAssignee: existingState?.returnAssignee ?? null,
|
|
});
|
|
return { patch };
|
|
}
|
|
|
|
buildPendingStagePatch({
|
|
patch,
|
|
previous: existingState,
|
|
policy: input.policy,
|
|
stage: activeStage,
|
|
participant,
|
|
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
|
reviewRequest: effectiveReviewRequest,
|
|
});
|
|
return {
|
|
patch,
|
|
workflowControlledAssignment: true,
|
|
};
|
|
}
|
|
|
|
if (principalsEqual(currentParticipant, actor)) {
|
|
if (requestedStatus === "done") {
|
|
if (!input.commentBody?.trim()) {
|
|
throw unprocessable(`Approving a review or approval stage requires a comment. ${STAGE_DECISION_COMMENT_HINT}`);
|
|
}
|
|
const approvedState = buildCompletedState(existingState, activeStage);
|
|
// Only stages after the stage being approved are advance candidates.
|
|
// Scanning the whole policy could wrap back to the first stage when
|
|
// earlier completedStageIds no longer match the policy (e.g. stage ids
|
|
// were regenerated by a mid-flow policy edit), turning a final-stage
|
|
// approval into an endless re-review loop (#7893).
|
|
const nextStage = nextPendingStageAfter(input.policy, activeStage, 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,
|
|
reviewRequest: input.reviewRequest ?? null,
|
|
});
|
|
return {
|
|
patch,
|
|
decision: {
|
|
stageId: activeStage.id,
|
|
stageType: activeStage.type,
|
|
outcome: "approved",
|
|
body: input.commentBody.trim(),
|
|
},
|
|
workflowControlledAssignment: true,
|
|
};
|
|
}
|
|
|
|
if (
|
|
input.allowBoardOverride &&
|
|
requestedStatus &&
|
|
requestedStatus !== "in_review" &&
|
|
requestedStatus !== "in_progress"
|
|
) {
|
|
patch.executionState = null;
|
|
return { patch };
|
|
}
|
|
|
|
if (requestedStatus && requestedStatus !== "in_review") {
|
|
if (!input.commentBody?.trim()) {
|
|
throw unprocessable(`Requesting changes requires a comment. ${STAGE_DECISION_COMMENT_HINT}`);
|
|
}
|
|
if (!existingState?.returnAssignee) {
|
|
throw unprocessable("This execution stage has no return assignee");
|
|
}
|
|
const decision = {
|
|
stageId: activeStage.id,
|
|
stageType: activeStage.type,
|
|
outcome: "changes_requested" as const,
|
|
body: input.commentBody.trim(),
|
|
};
|
|
// Human decisions reset the round counter: the cap exists to stop
|
|
// unattended agent↔agent ping-pong, not to limit human review.
|
|
const actorIsHuman = actor?.type === "user";
|
|
const nextRounds = actorIsHuman ? 0 : (existingState.changesRequestedCount ?? 0) + 1;
|
|
if (!actorIsHuman && nextRounds >= resolveMaxReviewRounds(input.policy)) {
|
|
const escalationUserId = reviewEscalationUserId(input.issue);
|
|
if (escalationUserId) {
|
|
// Rounds exhausted: keep the stage pending but hand it to the
|
|
// responsible human instead of bouncing back to the implementer.
|
|
// The recorded changes-requested decision carries the reviewer's
|
|
// reasoning; the human approves, requests changes (resetting the
|
|
// counter), or re-scopes.
|
|
buildPendingStagePatch({
|
|
patch,
|
|
previous: existingState,
|
|
policy: input.policy,
|
|
stage: activeStage,
|
|
participant: { type: "user", agentId: null, userId: escalationUserId },
|
|
returnAssignee: existingState.returnAssignee,
|
|
reviewRequest: effectiveReviewRequest,
|
|
changesRequestedCount: nextRounds,
|
|
});
|
|
return {
|
|
patch,
|
|
decision,
|
|
workflowControlledAssignment: true,
|
|
};
|
|
}
|
|
}
|
|
patch.status = "in_progress";
|
|
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
|
patch.executionState = buildChangesRequestedState(existingState, activeStage, nextRounds);
|
|
return {
|
|
patch,
|
|
decision,
|
|
workflowControlledAssignment: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const attemptedStageAdvance =
|
|
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
|
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant));
|
|
const stageStateDrifted =
|
|
input.issue.status !== "in_review" ||
|
|
!principalsEqual(currentAssignee, currentParticipant) ||
|
|
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant);
|
|
|
|
if (input.allowBoardOverride && attemptedStageAdvance) {
|
|
if (requestedStatus !== undefined && requestedStatus !== "in_review") {
|
|
patch.executionState = null;
|
|
return { patch };
|
|
}
|
|
// Assignee-only override: the issue stays in_review, so clearing the
|
|
// execution state would strand it with no participant or return
|
|
// assignment. Re-pend the stage when the board's chosen assignee is an
|
|
// eligible stage participant; otherwise (unassign or a non-participant)
|
|
// dissolve the review — storing an ineligible participant would be
|
|
// silently replaced by the stage-membership repair on the next
|
|
// transition.
|
|
if (explicitAssignee && stageHasParticipant(activeStage, explicitAssignee)) {
|
|
buildPendingStagePatch({
|
|
patch,
|
|
previous: existingState,
|
|
policy: input.policy,
|
|
stage: activeStage,
|
|
participant: explicitAssignee,
|
|
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
|
reviewRequest: effectiveReviewRequest,
|
|
});
|
|
return { patch };
|
|
}
|
|
patch.executionState = null;
|
|
if (input.issue.status === "in_review") {
|
|
patch.status = "in_progress";
|
|
}
|
|
return { patch };
|
|
}
|
|
|
|
if (attemptedStageAdvance && !stageStateDrifted) {
|
|
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
|
}
|
|
|
|
if (stageStateDrifted) {
|
|
buildPendingStagePatch({
|
|
patch,
|
|
previous: existingState,
|
|
policy: input.policy,
|
|
stage: activeStage,
|
|
participant: currentParticipant,
|
|
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
|
reviewRequest: effectiveReviewRequest,
|
|
});
|
|
return {
|
|
patch,
|
|
workflowControlledAssignment: true,
|
|
};
|
|
}
|
|
|
|
return { patch };
|
|
}
|
|
|
|
const shouldStartWorkflow =
|
|
requestedStatus === "done" ||
|
|
requestedStatus === "in_review";
|
|
|
|
if (!shouldStartWorkflow) {
|
|
return { patch };
|
|
}
|
|
|
|
// A workflow whose execution already completed is terminal for approve/done:
|
|
// closing the issue must not restart the chain at the first stage (#7893).
|
|
if (requestedStatus === "done" && existingState?.status === COMPLETED_STATUS) {
|
|
return { patch };
|
|
}
|
|
|
|
let pendingStage =
|
|
existingState?.status === CHANGES_REQUESTED_STATUS && currentStage
|
|
? currentStage
|
|
: nextPendingStage(input.policy, existingState);
|
|
if (!pendingStage) return { patch };
|
|
|
|
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
|
|
const skippedStageIds = [...(existingState?.completedStageIds ?? [])];
|
|
let participant = selectStageParticipant(pendingStage, {
|
|
preferred:
|
|
existingState?.status === CHANGES_REQUESTED_STATUS
|
|
? explicitAssignee ?? existingState.currentParticipant ?? null
|
|
: explicitAssignee,
|
|
exclude: returnAssignee,
|
|
});
|
|
while (!participant && canAutoSkipPendingStage({ stage: pendingStage, returnAssignee, requestedStatus })) {
|
|
skippedStageIds.push(pendingStage.id);
|
|
pendingStage = nextPendingStage(
|
|
input.policy,
|
|
buildStateWithCompletedStages({
|
|
previous: existingState,
|
|
completedStageIds: skippedStageIds,
|
|
returnAssignee,
|
|
}),
|
|
);
|
|
if (!pendingStage) {
|
|
patch.executionState = buildSkippedStageCompletedState({
|
|
previous: existingState,
|
|
completedStageIds: skippedStageIds,
|
|
returnAssignee,
|
|
});
|
|
return { patch };
|
|
}
|
|
participant = selectStageParticipant(pendingStage, {
|
|
preferred:
|
|
existingState?.status === CHANGES_REQUESTED_STATUS
|
|
? explicitAssignee ?? existingState.currentParticipant ?? null
|
|
: explicitAssignee,
|
|
exclude: returnAssignee,
|
|
});
|
|
}
|
|
if (!participant) {
|
|
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
|
|
}
|
|
|
|
buildPendingStagePatch({
|
|
patch,
|
|
previous:
|
|
skippedStageIds.length === (existingState?.completedStageIds ?? []).length
|
|
? existingState
|
|
: buildStateWithCompletedStages({
|
|
previous: existingState,
|
|
completedStageIds: skippedStageIds,
|
|
returnAssignee,
|
|
}),
|
|
policy: input.policy,
|
|
stage: pendingStage,
|
|
participant,
|
|
returnAssignee,
|
|
reviewRequest: input.reviewRequest ?? null,
|
|
});
|
|
return {
|
|
patch,
|
|
workflowControlledAssignment: true,
|
|
};
|
|
}
|
|
|
|
function applyMonitorTransition(input: TransitionInput, stagePatch: Record<string, unknown>) {
|
|
const patch: Record<string, unknown> = {};
|
|
const previousPolicy = input.previousPolicy ?? normalizeIssueExecutionPolicy(input.issue.executionPolicy ?? null);
|
|
const existingState = parseIssueExecutionState(input.issue.executionState);
|
|
const currentMonitorState = derivePersistedMonitorState({
|
|
issue: input.issue,
|
|
state: existingState,
|
|
policy: previousPolicy,
|
|
});
|
|
const nextStatus =
|
|
typeof stagePatch.status === "string"
|
|
? (stagePatch.status as string)
|
|
: input.requestedStatus ?? input.issue.status;
|
|
const { assigneeAgentId, assigneeUserId } = nextAssigneeIds({
|
|
issue: input.issue,
|
|
requestedAssigneePatch: input.requestedAssigneePatch,
|
|
stagePatch,
|
|
});
|
|
const stageState =
|
|
stagePatch.executionState !== undefined
|
|
? parseIssueExecutionState(stagePatch.executionState)
|
|
: existingState;
|
|
const invalidReason = input.policy?.monitor
|
|
? monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId)
|
|
: null;
|
|
|
|
let targetMonitorState = currentMonitorState;
|
|
|
|
if (input.policy?.monitor) {
|
|
if (invalidReason) {
|
|
if (input.monitorExplicitlyUpdated) {
|
|
throw unprocessable(MONITOR_INVALID_MESSAGE);
|
|
}
|
|
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
|
|
patch.monitorNextCheckAt = null;
|
|
patch.monitorWakeRequestedAt = null;
|
|
targetMonitorState = buildClearedMonitorState({
|
|
previous: currentMonitorState,
|
|
clearReason: invalidReason,
|
|
clearedAt: new Date(),
|
|
});
|
|
} else {
|
|
const exhaustedReason = exhaustedMonitorClearReason({
|
|
monitor: input.policy.monitor,
|
|
attemptCount: currentMonitorState?.attemptCount ?? 0,
|
|
now: new Date(),
|
|
});
|
|
if (exhaustedReason) {
|
|
if (input.monitorExplicitlyUpdated) {
|
|
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
|
|
}
|
|
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
|
|
patch.monitorNextCheckAt = null;
|
|
patch.monitorWakeRequestedAt = null;
|
|
targetMonitorState = buildClearedMonitorState({
|
|
previous: currentMonitorState,
|
|
clearReason: exhaustedReason,
|
|
clearedAt: new Date(),
|
|
});
|
|
} else {
|
|
patch.monitorNextCheckAt = new Date(input.policy.monitor.nextCheckAt);
|
|
patch.monitorWakeRequestedAt = null;
|
|
patch.monitorNotes = input.policy.monitor.notes ?? null;
|
|
patch.monitorScheduledBy = input.policy.monitor.scheduledBy;
|
|
targetMonitorState = buildScheduledMonitorState(currentMonitorState, input.policy.monitor);
|
|
}
|
|
}
|
|
} else if (previousPolicy?.monitor) {
|
|
patch.monitorNextCheckAt = null;
|
|
patch.monitorWakeRequestedAt = null;
|
|
targetMonitorState = buildClearedMonitorState({
|
|
previous: currentMonitorState,
|
|
clearReason:
|
|
input.monitorExplicitlyUpdated
|
|
? "manual"
|
|
: monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId) ?? "manual",
|
|
clearedAt: new Date(),
|
|
});
|
|
}
|
|
|
|
if (stagePatch.executionState !== undefined || !monitorStatesEqual(currentMonitorState, targetMonitorState)) {
|
|
patch.executionState = executionStateWithMonitor(stageState, targetMonitorState);
|
|
}
|
|
|
|
return patch;
|
|
}
|
|
|
|
export function buildInitialIssueMonitorFields(input: {
|
|
policy: IssueExecutionPolicy | null;
|
|
status: string;
|
|
assigneeAgentId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
}) {
|
|
if (!input.policy?.monitor) return {};
|
|
if (!issueAllowsMonitor(input.status, input.assigneeAgentId ?? null, input.assigneeUserId ?? null)) {
|
|
throw unprocessable(MONITOR_INVALID_MESSAGE);
|
|
}
|
|
const exhaustedReason = exhaustedMonitorClearReason({
|
|
monitor: input.policy.monitor,
|
|
attemptCount: 0,
|
|
now: new Date(),
|
|
});
|
|
if (exhaustedReason) {
|
|
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
|
|
}
|
|
|
|
const monitorState = buildScheduledMonitorState(null, input.policy.monitor);
|
|
return {
|
|
monitorNextCheckAt: new Date(input.policy.monitor.nextCheckAt),
|
|
monitorWakeRequestedAt: null,
|
|
monitorNotes: input.policy.monitor.notes ?? null,
|
|
monitorScheduledBy: input.policy.monitor.scheduledBy,
|
|
executionState: executionStateWithMonitor(null, monitorState) as Record<string, unknown> | null,
|
|
};
|
|
}
|
|
|
|
export function buildIssueMonitorTriggeredPatch(input: {
|
|
issue: IssueLike;
|
|
policy: IssueExecutionPolicy | null;
|
|
triggeredAt: Date;
|
|
}) {
|
|
const existingState = parseIssueExecutionState(input.issue.executionState);
|
|
const currentMonitorState = derivePersistedMonitorState({
|
|
issue: input.issue,
|
|
state: existingState,
|
|
policy: input.policy,
|
|
});
|
|
const nextMonitorState = buildTriggeredMonitorState({
|
|
previous: currentMonitorState,
|
|
triggeredAt: input.triggeredAt,
|
|
});
|
|
|
|
return {
|
|
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
|
|
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
|
|
monitorNextCheckAt: null,
|
|
monitorWakeRequestedAt: null,
|
|
monitorLastTriggeredAt: input.triggeredAt,
|
|
monitorAttemptCount: nextMonitorState.attemptCount,
|
|
monitorNotes: nextMonitorState.notes,
|
|
monitorScheduledBy: nextMonitorState.scheduledBy,
|
|
};
|
|
}
|
|
|
|
export function buildIssueMonitorClearedPatch(input: {
|
|
issue: IssueLike;
|
|
policy: IssueExecutionPolicy | null;
|
|
clearReason: IssueExecutionMonitorClearReason;
|
|
clearedAt?: Date;
|
|
}) {
|
|
const existingState = parseIssueExecutionState(input.issue.executionState);
|
|
const currentMonitorState = derivePersistedMonitorState({
|
|
issue: input.issue,
|
|
state: existingState,
|
|
policy: input.policy,
|
|
});
|
|
const nextMonitorState = buildClearedMonitorState({
|
|
previous: currentMonitorState,
|
|
clearReason: input.clearReason,
|
|
clearedAt: input.clearedAt ?? new Date(),
|
|
});
|
|
|
|
return {
|
|
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
|
|
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
|
|
monitorNextCheckAt: null,
|
|
monitorWakeRequestedAt: null,
|
|
};
|
|
}
|
|
|
|
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
|
const stageResult = applyIssueExecutionStageTransition(input);
|
|
const monitorPatch = applyMonitorTransition(input, stageResult.patch);
|
|
Object.assign(stageResult.patch, monitorPatch);
|
|
return stageResult;
|
|
}
|
|
|
|
export function applyIssueMonitorPolicyTransition(input: TransitionInput): TransitionResult {
|
|
return { patch: applyMonitorTransition(input, {}) };
|
|
}
|