mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Integrations can prepare a backlog task before its assigned agent starts. > - A person can change its status or assignment during preparation. > - An unconditional promotion can replace that newer decision. > - Safe promotion needs a saved revision that includes every assignment path. > - This pull request checks that revision under the task row lock and derives policy cleanup from the locked row. > - The benefit is safe initialization recovery with the existing company, task, and run authority. ## Linked Issues or Issue Description Prerequisite #16896 has merged. This PR now targets master and contains only the conditional promotion API. The prerequisite fixed the confirmed baseline first-upload browser regression. **What happened?** A client that saves a task's initial documents and then sends an unconditional status update can override a concurrent status or assignment decision. The update validator previously dropped an expected version, so clients could not make this promotion conditional. Some direct assignment and release paths also did not advance the revision. **Expected behavior** A prepared task moves from backlog to todo only if its original status and assignment revision still matches. A stale request returns 409 before a task mutation, audit publication, or wake. Policy cleanup preserves unrelated saved settings. **Steps to reproduce** 1. Create a backlog task and save its statusVersion before preparing it. 2. Release its assignment, or change an assignee and then restore that assignee. 3. Send `{ "status": "todo", "expectedStatusVersion": <original revision> }` to its PATCH endpoint. 4. The stale promotion must return 409. A matching revision on an unchanged backlog task must succeed. Duplicate and related PR searches found no existing prepared-task promotion fix. ## What Changed - Accept an optional nonnegative expectedStatusVersion for exactly a status-only backlog-to-todo promotion. Reject extra public fields before parsing can discard them. - Check the current revision and backlog state under the issue row lock. Reject stale requests before checkout adoption or execution-control effects. - Add SQL migration 0358 to advance the revision for both assignee fields at the database boundary. Release, administrative release, direct writes, and assignment round trips now invalidate an old version. - Derive guarded policy cleanup from the locked row. Preserve review limits and protected authorization when removing a monitor. Reject derived ownership changes or review decisions. - Add schema, real HTTP/PostgreSQL, concurrency, migration replay, and policy regression coverage. Update the response type, OpenAPI, developer docs, and access map. ## Verification - Red before the initial fix: stale requests returned 200 instead of 409, validation removed the expected version, and a stale active task could adopt a checkout. - Review found two further defects. Four new release/direct-assignment tests showed unchanged revisions. The real HTTP/service test returned 422 for a valid two-field promotion after monitor cleanup. Helper tests also proved that cleanup lost valid review limits and protected authorization. All now pass after focused repairs. - Green: 124 route and policy tests, including 7 real Express/PostgreSQL cases, 37 existing route tests, and 80 policy/helper tests. The real row-lock race preserves a policy edit committed while promotion waits. - Green: all 23 database client tests, plus 9 focused assignment and replay checks. Historical migration 0227 receipt recovery cannot remove the new assignment trigger. Reapplying migration 0358 leaves existing rows unchanged and installs one trigger. Combined status/assignment writes increment once; unchanged assignees do not increment. - Earlier verification passed all 218 schema/route/service cases and the generated OpenAPI capability test. Rebased focused regressions passed all 27 selected cases. Final server TypeScript passed after all repairs; shared TypeScript passed. Migration safety/numbering, five-snapshot retention, map validation, and diff checks pass. - The baseline browser attachment case failed twice on the earlier head. Separate #16896 proved the lost-composer cause, passed 335 component tests and the real first-upload journey, and has green CI with a 5/5 review. No assertion or timeout was weakened. - Some early local checks were blocked by missing or stale sibling dependency artifacts. This worktree now has its own frozen offline install and standard SDK prerequisites. The failures and successful reruns remain in the task evidence. - Before rebasing, head `3304bb00de6ad6a248b5e046c008072ad20ac8f5` passed all CI (55 successful checks and one neutral), Greptile 5/5, and a secondary source review at 5/5 with no unresolved findings. The secondary reviewer did not execute project tests, inspect CI, or exercise a live agent journey. Earlier incomplete and must-fix assessments remain visible; both established findings are repaired above. - After #16896 merged, all five CAS commits were rebased onto master `87d9386ae637c652d1fed09d2046be1bcaeb413b`. Migration 0358 was regenerated because master added another 0357. Range comparison shows no other change to the CAS patches; inherited UI commits are absent. The original head is preserved locally. - Rebased head `1c0264e51a280b1dbf597725a7e42e72d4be1b1b` passes all 124 route/policy tests, 9 selected assignment/promotion tests, and all 24 database client/snapshot-drift tests. Database build, migration checks, generated snapshot linkage, five-snapshot retention (0354–0358), maps, and diff checks pass. Fresh-head CI and review are pending. No deployment or live agent journey was performed for this API. ## Risks - The guard supports prepared backlog promotion only. Other statuses or mixed public mutation fields return 422. A stale revision or non-backlog row returns 409. - Preserve the original version from a server that already advertises this guard before initialization. Older versions did not track every assignment path. Reconcile older or uncertain initialization instead of fetching a new revision and blindly retrying. - Migration 0358 is SQL-only and idempotent. It adds no table, column, or task backfill. Its separate trigger survives historical status-migration replay. - Existing authorization and unguarded updates remain in place. Guarded promotion does not provide a general lifecycle or review-stage control API. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context window are not exposed by this session. Used tool calls, code execution, and test-first regression verification. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1242 lines
45 KiB
TypeScript
1242 lines
45 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;
|
|
const { monitor: _monitor, ...remainingPolicy } = policy;
|
|
return normalizeIssueExecutionPolicy(remainingPolicy);
|
|
}
|
|
|
|
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
|
|
&& parsed.data.maxReviewRounds == null) 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) {
|
|
// The stored stage id no longer resolves against the (possibly just
|
|
// replaced) policy — e.g. the caller PATCHed a brand-new executionPolicy
|
|
// in the same request that regenerated stage ids. When a review cycle is
|
|
// genuinely live (pending, or awaiting the assignee's response to
|
|
// changes requested), letting `done` ride along on that request would
|
|
// silently drop the cycle and mark the issue complete through a review
|
|
// that never ran — so `done` is rejected. Every other requested status,
|
|
// including a board cancellation that deliberately abandons the cycle,
|
|
// keeps the established clear/return-to-executor behavior below, exactly
|
|
// like a bare policy edit with no requested status.
|
|
const hasLiveReviewCycle =
|
|
existingState.status === PENDING_STATUS || existingState.status === CHANGES_REQUESTED_STATUS;
|
|
if (hasLiveReviewCycle && requestedStatus === "done") {
|
|
throw unprocessable(
|
|
"This issue has an unexecuted review stage from a live review cycle. Resolve the review before marking it done.",
|
|
{ code: "unexecuted_review_stage" },
|
|
);
|
|
}
|
|
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, {}) };
|
|
}
|