forked from farhoodlabs/paperclip
Merge pull request #3039 from paperclipai/PAP-1139-consider-a-signoff-required-execution-policy
Add execution policy review and approval gates
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# Execution Policy: Review & Approval Workflows
|
||||
|
||||
Paperclip's execution policy system ensures tasks are completed with the right level of oversight. Instead of relying on agents to remember to hand off work for review, the **runtime enforces** review and approval stages automatically.
|
||||
|
||||
## Overview
|
||||
|
||||
An execution policy is an optional structured object on any issue that defines what must happen after the executor finishes their work. It supports three layers of enforcement:
|
||||
|
||||
| Layer | Purpose | Scope |
|
||||
|---|---|---|
|
||||
| **Comment required** | Every agent run must post a comment back to the issue | Runtime invariant (always on) |
|
||||
| **Review stage** | A reviewer checks quality/correctness and can request changes | Per-issue, optional |
|
||||
| **Approval stage** | A manager/stakeholder gives final sign-off | Per-issue, optional |
|
||||
|
||||
These layers compose. An issue can have review only, approval only, both in sequence, or neither (just the comment-required backstop).
|
||||
|
||||
## Data Model
|
||||
|
||||
### Execution Policy (issue field: `executionPolicy`)
|
||||
|
||||
```ts
|
||||
interface IssueExecutionPolicy {
|
||||
mode: "normal" | "auto";
|
||||
commentRequired: boolean; // always true, enforced by runtime
|
||||
stages: IssueExecutionStage[]; // ordered list of review/approval stages
|
||||
}
|
||||
|
||||
interface IssueExecutionStage {
|
||||
id: string; // auto-generated UUID
|
||||
type: "review" | "approval"; // stage kind
|
||||
approvalsNeeded: 1; // multi-approval is not supported yet
|
||||
participants: IssueExecutionStageParticipant[];
|
||||
}
|
||||
|
||||
interface IssueExecutionStageParticipant {
|
||||
id: string;
|
||||
type: "agent" | "user";
|
||||
agentId?: string | null; // set when type is "agent"
|
||||
userId?: string | null; // set when type is "user"
|
||||
}
|
||||
```
|
||||
|
||||
Participants can be either agents or board users. Each stage can have multiple participants; the runtime selects the first eligible participant, preferring any explicitly requested assignee while excluding the original executor.
|
||||
|
||||
### Execution State (issue field: `executionState`)
|
||||
|
||||
Tracks where the issue currently sits in its policy workflow:
|
||||
|
||||
```ts
|
||||
interface IssueExecutionState {
|
||||
status: "idle" | "pending" | "changes_requested" | "completed";
|
||||
currentStageId: string | null;
|
||||
currentStageIndex: number | null;
|
||||
currentStageType: "review" | "approval" | null;
|
||||
currentParticipant: IssueExecutionStagePrincipal | null;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
completedStageIds: string[];
|
||||
lastDecisionId: string | null;
|
||||
lastDecisionOutcome: "approved" | "changes_requested" | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Decisions (table: `issue_execution_decisions`)
|
||||
|
||||
An audit trail of every review/approval action:
|
||||
|
||||
```ts
|
||||
interface IssueExecutionDecision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
stageId: string;
|
||||
stageType: "review" | "approval";
|
||||
actorAgentId: string | null;
|
||||
actorUserId: string | null;
|
||||
outcome: "approved" | "changes_requested";
|
||||
body: string; // required comment explaining the decision
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Happy Path: Review + Approval
|
||||
|
||||
```
|
||||
┌──────────┐ executor ┌───────────┐ reviewer ┌───────────┐ approver ┌──────┐
|
||||
│ todo │───completes───▶│ in_review │───approves───▶│ in_review │───approves───▶│ done │
|
||||
│ (Coder) │ work │ (QA) │ │ (CTO) │ │ │
|
||||
└──────────┘ └───────────┘ └───────────┘ └──────┘
|
||||
```
|
||||
|
||||
1. **Issue created** with `executionPolicy` specifying a review stage (e.g., QA) and an approval stage (e.g., CTO).
|
||||
2. **Executor works** on the issue in `in_progress` status.
|
||||
3. **Executor transitions to `done`** — the runtime intercepts this:
|
||||
- Status changes to `in_review` (not `done`)
|
||||
- Issue is reassigned to the first reviewer
|
||||
- `executionState` enters `pending` on the review stage
|
||||
4. **Reviewer reviews** and transitions to `done` with a comment:
|
||||
- A decision record is created: `{ outcome: "approved" }`
|
||||
- Issue stays `in_review`, reassigned to the approver
|
||||
- `executionState` advances to the approval stage
|
||||
5. **Approver approves** and transitions to `done` with a comment:
|
||||
- A decision record is created: `{ outcome: "approved" }`
|
||||
- `executionState.status` becomes `completed`
|
||||
- Issue reaches actual `done` status
|
||||
|
||||
### Changes Requested Flow
|
||||
|
||||
```
|
||||
┌───────────┐ reviewer requests ┌─────────────┐ executor ┌───────────┐
|
||||
│ in_review │───changes────────────▶│ in_progress │───resubmits──▶│ in_review │
|
||||
│ (QA) │ │ (Coder) │ │ (QA) │
|
||||
└───────────┘ └──────────────┘ └───────────┘
|
||||
```
|
||||
|
||||
1. **Reviewer requests changes** by transitioning to any status other than `done` (typically `in_progress`), with a comment explaining what needs to change.
|
||||
2. Runtime automatically:
|
||||
- Sets status to `in_progress`
|
||||
- Reassigns to the original executor (stored in `returnAssignee`)
|
||||
- Sets `executionState.status` to `changes_requested`
|
||||
3. **Executor makes changes** and transitions to `done` again.
|
||||
4. Runtime routes back to the **same review stage** (not the beginning), with the same reviewer.
|
||||
5. This loop continues until the reviewer approves.
|
||||
|
||||
### Policy Variants
|
||||
|
||||
**Review only** (no approval stage):
|
||||
```json
|
||||
{
|
||||
"stages": [
|
||||
{ "type": "review", "participants": [{ "type": "agent", "agentId": "qa-agent-id" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
Executor finishes → reviewer approves → done.
|
||||
|
||||
**Approval only** (no review stage):
|
||||
```json
|
||||
{
|
||||
"stages": [
|
||||
{ "type": "approval", "participants": [{ "type": "user", "userId": "manager-user-id" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
Executor finishes → approver signs off → done.
|
||||
|
||||
**Multiple reviewers/approvers:**
|
||||
Each stage supports multiple participants. The runtime selects one to act, excluding the original executor to prevent self-review.
|
||||
|
||||
## Comment Required Backstop
|
||||
|
||||
Independent of review stages, every issue-bound agent run must leave a comment. This is enforced at the runtime level:
|
||||
|
||||
1. **Run completes** — runtime checks if the agent posted a comment for this run.
|
||||
2. **If no comment**: `issueCommentStatus` is set to `retry_queued`, and the agent is woken once more with reason `missing_issue_comment`.
|
||||
3. **If still no comment after retry**: `issueCommentStatus` is set to `retry_exhausted`. No further retries. The failure is recorded.
|
||||
4. **If comment posted**: `issueCommentStatus` is set to `satisfied` and linked to the comment ID.
|
||||
|
||||
This prevents silent completions where an agent finishes work but leaves no trace of what happened.
|
||||
|
||||
### Run-level tracking fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `issueCommentStatus` | `satisfied`, `retry_queued`, or `retry_exhausted` |
|
||||
| `issueCommentSatisfiedByCommentId` | Links to the comment that fulfilled the requirement |
|
||||
| `issueCommentRetryQueuedAt` | Timestamp when the retry wake was scheduled |
|
||||
|
||||
## Access Control
|
||||
|
||||
- Only the **active reviewer/approver** (the `currentParticipant` in execution state) can advance or reject the current stage.
|
||||
- Non-participants who attempt to transition the issue receive a `422 Unprocessable Entity` error.
|
||||
- Both approvals and change requests **require a comment** — empty or whitespace-only comments are rejected.
|
||||
|
||||
## API Usage
|
||||
|
||||
### Setting an execution policy on issue creation
|
||||
|
||||
```bash
|
||||
POST /api/companies/{companyId}/issues
|
||||
{
|
||||
"title": "Implement feature X",
|
||||
"assigneeAgentId": "coder-agent-id",
|
||||
"executionPolicy": {
|
||||
"mode": "normal",
|
||||
"commentRequired": true,
|
||||
"stages": [
|
||||
{
|
||||
"type": "review",
|
||||
"participants": [
|
||||
{ "type": "agent", "agentId": "qa-agent-id" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "approval",
|
||||
"participants": [
|
||||
{ "type": "user", "userId": "cto-user-id" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stage IDs and participant IDs are auto-generated if omitted. Duplicate participants within a stage are automatically deduplicated. Stages with no valid participants are removed. If no valid stages remain, the policy is set to `null`.
|
||||
|
||||
### Updating execution policy on an existing issue
|
||||
|
||||
```bash
|
||||
PATCH /api/issues/{issueId}
|
||||
{
|
||||
"executionPolicy": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
If the policy is removed (`null`) while a review is in progress, the execution state is cleared and the issue is returned to the original executor.
|
||||
|
||||
### Advancing a stage (reviewer/approver approves)
|
||||
|
||||
The active reviewer or approver transitions the issue to `done` with a comment:
|
||||
|
||||
```bash
|
||||
PATCH /api/issues/{issueId}
|
||||
{
|
||||
"status": "done",
|
||||
"comment": "Reviewed — implementation looks correct, tests pass."
|
||||
}
|
||||
```
|
||||
|
||||
The runtime determines whether this completes the workflow or advances to the next stage.
|
||||
|
||||
### Requesting changes
|
||||
|
||||
The active reviewer transitions to any non-`done` status with a comment:
|
||||
|
||||
```bash
|
||||
PATCH /api/issues/{issueId}
|
||||
{
|
||||
"status": "in_progress",
|
||||
"comment": "Button alignment is off on mobile. Please fix the flex container."
|
||||
}
|
||||
```
|
||||
|
||||
The runtime reassigns to the original executor automatically.
|
||||
|
||||
## UI
|
||||
|
||||
### New Issue Dialog
|
||||
|
||||
When creating a new issue, **Reviewer** and **Approver** buttons appear alongside the assignee selector. Clicking either opens a participant picker with:
|
||||
- "No reviewer" / "No approver" (to clear)
|
||||
- "Me" (current user)
|
||||
- Full list of agents and board users
|
||||
|
||||
Selections build the `executionPolicy.stages` array automatically.
|
||||
|
||||
### Issue Properties Pane
|
||||
|
||||
For existing issues, the properties panel shows editable **Reviewer** and **Approver** fields. Multiple participants can be added per stage. Changes persist to the issue's `executionPolicy` via the API.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Runtime-enforced, not prompt-dependent.** Agents don't need to remember to hand off work. The runtime intercepts status transitions and routes accordingly.
|
||||
2. **Iterative, not terminal.** Review is a loop (request changes → revise → re-review), not a one-shot gate. The system returns to the same stage on re-submission.
|
||||
3. **Flexible roles.** Participants can be agents or users. Not every organization has "QA" — the reviewer/approver pattern is generic enough for peer review, manager sign-off, compliance checks, or any multi-party workflow.
|
||||
4. **Auditable.** Every decision is recorded with actor, outcome, comment, and run ID. The full review history is queryable per issue.
|
||||
5. **Single execution invariant preserved.** Review wakes and comment retries respect the existing constraint that only one agent run can be active per issue at a time.
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE "issue_execution_decisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"stage_id" uuid NOT NULL,
|
||||
"stage_type" text NOT NULL,
|
||||
"actor_agent_id" uuid,
|
||||
"actor_user_id" text,
|
||||
"outcome" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"created_by_run_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "issue_comment_status" text DEFAULT 'not_applicable' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "issue_comment_satisfied_by_comment_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "issue_comment_retry_queued_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "execution_policy" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "execution_state" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "issue_execution_decisions" ADD CONSTRAINT "issue_execution_decisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_execution_decisions" ADD CONSTRAINT "issue_execution_decisions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_execution_decisions" ADD CONSTRAINT "issue_execution_decisions_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_execution_decisions" ADD CONSTRAINT "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "issue_execution_decisions_company_issue_idx" ON "issue_execution_decisions" USING btree ("company_id","issue_id");--> statement-breakpoint
|
||||
CREATE INDEX "issue_execution_decisions_stage_idx" ON "issue_execution_decisions" USING btree ("issue_id","stage_id","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -365,6 +365,13 @@
|
||||
"when": 1775524651831,
|
||||
"tag": "0051_young_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "7",
|
||||
"when": 1775571715162,
|
||||
"tag": "0052_mushy_trauma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export const heartbeatRuns = pgTable(
|
||||
onDelete: "set null",
|
||||
}),
|
||||
processLossRetryCount: integer("process_loss_retry_count").notNull().default(0),
|
||||
issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"),
|
||||
issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"),
|
||||
issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }),
|
||||
contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -32,6 +32,7 @@ export { labels } from "./labels.js";
|
||||
export { issueLabels } from "./issue_labels.js";
|
||||
export { issueApprovals } from "./issue_approvals.js";
|
||||
export { issueComments } from "./issue_comments.js";
|
||||
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
||||
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
||||
export { feedbackVotes } from "./feedback_votes.js";
|
||||
export { feedbackExports } from "./feedback_exports.js";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
||||
export const issueExecutionDecisions = pgTable(
|
||||
"issue_execution_decisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
stageId: uuid("stage_id").notNull(),
|
||||
stageType: text("stage_type").notNull(),
|
||||
actorAgentId: uuid("actor_agent_id").references(() => agents.id),
|
||||
actorUserId: text("actor_user_id"),
|
||||
outcome: text("outcome").notNull(),
|
||||
body: text("body").notNull(),
|
||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueIdx: index("issue_execution_decisions_company_issue_idx").on(table.companyId, table.issueId),
|
||||
stageIdx: index("issue_execution_decisions_stage_idx").on(table.issueId, table.stageId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
@@ -47,6 +47,8 @@ export const issues = pgTable(
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
executionPolicy: jsonb("execution_policy").$type<Record<string, unknown>>(),
|
||||
executionState: jsonb("execution_state").$type<Record<string, unknown>>(),
|
||||
executionWorkspaceId: uuid("execution_workspace_id")
|
||||
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
executionWorkspacePreference: text("execution_workspace_preference"),
|
||||
|
||||
@@ -138,6 +138,18 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const;
|
||||
export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const;
|
||||
export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const;
|
||||
export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number];
|
||||
|
||||
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
|
||||
export type GoalLevel = (typeof GOAL_LEVELS)[number];
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export {
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
ISSUE_RELATION_TYPES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
GOAL_LEVELS,
|
||||
GOAL_STATUSES,
|
||||
PROJECT_STATUSES,
|
||||
@@ -84,6 +88,10 @@ export {
|
||||
type IssuePriority,
|
||||
type IssueOriginKind,
|
||||
type IssueRelationType,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueExecutionStateStatus,
|
||||
type IssueExecutionDecisionOutcome,
|
||||
type GoalLevel,
|
||||
type GoalStatus,
|
||||
type ProjectStatus,
|
||||
@@ -233,6 +241,12 @@ export type {
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueExecutionStage,
|
||||
IssueExecutionStageParticipant,
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionDecision,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
@@ -425,6 +439,8 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionPolicySchema,
|
||||
issueExecutionStateSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
|
||||
@@ -98,6 +98,12 @@ export type {
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueExecutionStage,
|
||||
IssueExecutionStageParticipant,
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionDecision,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js";
|
||||
import type {
|
||||
IssueExecutionDecisionOutcome,
|
||||
IssueExecutionPolicyMode,
|
||||
IssueExecutionStageType,
|
||||
IssueExecutionStateStatus,
|
||||
IssueOriginKind,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
} from "../constants.js";
|
||||
import type { Goal } from "./goal.js";
|
||||
import type { Project, ProjectWorkspace } from "./project.js";
|
||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||
@@ -115,6 +123,56 @@ export interface IssueRelation {
|
||||
relatedIssue: IssueRelationIssueSummary;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStagePrincipal {
|
||||
type: "agent" | "user";
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStageParticipant extends IssueExecutionStagePrincipal {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStage {
|
||||
id: string;
|
||||
type: IssueExecutionStageType;
|
||||
approvalsNeeded: 1;
|
||||
participants: IssueExecutionStageParticipant[];
|
||||
}
|
||||
|
||||
export interface IssueExecutionPolicy {
|
||||
mode: IssueExecutionPolicyMode;
|
||||
commentRequired: boolean;
|
||||
stages: IssueExecutionStage[];
|
||||
}
|
||||
|
||||
export interface IssueExecutionState {
|
||||
status: IssueExecutionStateStatus;
|
||||
currentStageId: string | null;
|
||||
currentStageIndex: number | null;
|
||||
currentStageType: IssueExecutionStageType | null;
|
||||
currentParticipant: IssueExecutionStagePrincipal | null;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
completedStageIds: string[];
|
||||
lastDecisionId: string | null;
|
||||
lastDecisionOutcome: IssueExecutionDecisionOutcome | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionDecision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
stageId: string;
|
||||
stageType: IssueExecutionStageType;
|
||||
actorAgentId: string | null;
|
||||
actorUserId: string | null;
|
||||
outcome: IssueExecutionDecisionOutcome;
|
||||
body: string;
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -143,6 +201,8 @@ export interface Issue {
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionPolicy?: IssueExecutionPolicy | null;
|
||||
executionState?: IssueExecutionState | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspacePreference: string | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
|
||||
@@ -131,6 +131,8 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionPolicySchema,
|
||||
issueExecutionStateSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
||||
import {
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_STATUSES,
|
||||
} from "../constants.js";
|
||||
|
||||
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
|
||||
"inherit",
|
||||
@@ -36,6 +43,76 @@ export const issueAssigneeAdapterOverridesSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const issueExecutionStagePrincipalBaseSchema = z.object({
|
||||
type: z.enum(["agent", "user"]),
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
userId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const issueExecutionStagePrincipalSchema = issueExecutionStagePrincipalBaseSchema
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.type === "agent") {
|
||||
if (!value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] });
|
||||
}
|
||||
if (value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] });
|
||||
}
|
||||
if (value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] });
|
||||
}
|
||||
});
|
||||
|
||||
export const issueExecutionStageParticipantSchema = issueExecutionStagePrincipalBaseSchema.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.type === "agent") {
|
||||
if (!value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] });
|
||||
}
|
||||
if (value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] });
|
||||
}
|
||||
if (value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] });
|
||||
}
|
||||
});
|
||||
|
||||
export const issueExecutionStageSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
type: z.enum(ISSUE_EXECUTION_STAGE_TYPES),
|
||||
approvalsNeeded: z.literal(1).optional().default(1),
|
||||
participants: z.array(issueExecutionStageParticipantSchema).default([]),
|
||||
});
|
||||
|
||||
export const issueExecutionPolicySchema = z.object({
|
||||
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
|
||||
commentRequired: z.boolean().optional().default(true),
|
||||
stages: z.array(issueExecutionStageSchema).default([]),
|
||||
});
|
||||
|
||||
export const issueExecutionStateSchema = z.object({
|
||||
status: z.enum(ISSUE_EXECUTION_STATE_STATUSES),
|
||||
currentStageId: z.string().uuid().nullable(),
|
||||
currentStageIndex: z.number().int().nonnegative().nullable(),
|
||||
currentStageType: z.enum(ISSUE_EXECUTION_STAGE_TYPES).nullable(),
|
||||
currentParticipant: issueExecutionStagePrincipalSchema.nullable(),
|
||||
returnAssignee: issueExecutionStagePrincipalSchema.nullable(),
|
||||
completedStageIds: z.array(z.string().uuid()).default([]),
|
||||
lastDecisionId: z.string().uuid().nullable(),
|
||||
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
|
||||
});
|
||||
|
||||
export const createIssueSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
@@ -52,6 +129,7 @@ export const createIssueSchema = z.object({
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
|
||||
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueExecutionDecisions,
|
||||
issueReadStates,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import { companyService } from "../services/companies.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping cleanup removal service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cleanup-removal-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueReadStates);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueExecutionDecisions);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Regression fixture",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
createdByUserId: "user-1",
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "completed",
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
|
||||
return { agentId, companyId, issueId, runId };
|
||||
}
|
||||
|
||||
it("removes agent-owned issue comments and run-linked activity before deleting the agent", async () => {
|
||||
const { agentId, companyId, issueId, runId } = await seedFixture();
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
body: "Agent-authored comment",
|
||||
});
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
action: "heartbeat.completed",
|
||||
entityType: "issue",
|
||||
entityId: issueId,
|
||||
runId,
|
||||
details: {},
|
||||
});
|
||||
|
||||
await db.insert(issueExecutionDecisions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
stageId: randomUUID(),
|
||||
stageType: "review",
|
||||
actorAgentId: agentId,
|
||||
outcome: "approved",
|
||||
body: "Looks good",
|
||||
createdByRunId: runId,
|
||||
});
|
||||
|
||||
const removed = await agentService(db).remove(agentId);
|
||||
|
||||
expect(removed?.id).toBe(agentId);
|
||||
await expect(db.select().from(agents).where(eq(agents.id, agentId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes issue read states and activity rows before deleting the company", async () => {
|
||||
const { companyId, issueId, runId } = await seedFixture();
|
||||
|
||||
await db.insert(issueReadStates).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
await db.insert(companySkills).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
slug: "paperclip",
|
||||
name: "Paperclip",
|
||||
markdown: "# Paperclip",
|
||||
});
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "run.created",
|
||||
entityType: "run",
|
||||
entityId: runId,
|
||||
runId,
|
||||
details: {},
|
||||
});
|
||||
|
||||
const removed = await companyService(db).remove(companyId);
|
||||
|
||||
expect(removed?.id).toBe(companyId);
|
||||
await expect(db.select().from(companies).where(eq(companies.id, companyId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -222,7 +222,7 @@ describe("heartbeat comment wake batching", () => {
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
}, 45_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
@@ -307,6 +307,14 @@ describe("heartbeat comment wake batching", () => {
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
createdByRunId: firstRun?.id ?? null,
|
||||
body: "Heartbeat acknowledged",
|
||||
});
|
||||
|
||||
const comment2 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
@@ -398,7 +406,7 @@ describe("heartbeat comment wake batching", () => {
|
||||
await waitFor(async () => {
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
||||
expect(secondPayload.paperclip).toMatchObject({
|
||||
@@ -414,5 +422,120 @@ describe("heartbeat comment wake batching", () => {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Require a comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
gateway.releaseFirstWait();
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId))
|
||||
.orderBy(asc(heartbeatRuns.createdAt));
|
||||
return (
|
||||
runs.length === 2 &&
|
||||
runs.every((run) => run.status === "succeeded") &&
|
||||
runs[0]?.issueCommentStatus === "retry_queued" &&
|
||||
runs[1]?.issueCommentStatus === "retry_exhausted"
|
||||
);
|
||||
});
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId))
|
||||
.orderBy(asc(heartbeatRuns.createdAt));
|
||||
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs[0]?.issueCommentStatus).toBe("retry_queued");
|
||||
expect(runs[1]?.retryOfRunId).toBe(runs[0]?.id);
|
||||
expect(runs[1]?.issueCommentStatus).toBe("retry_exhausted");
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(0);
|
||||
|
||||
await waitFor(async () => {
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
return wakeups.length >= 2;
|
||||
});
|
||||
|
||||
const payloads = gateway.getAgentPayloads();
|
||||
expect(payloads).toHaveLength(2);
|
||||
expect(runs[1]?.contextSnapshot).toMatchObject({
|
||||
retryReason: "missing_issue_comment",
|
||||
});
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -3,12 +3,15 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
@@ -29,6 +32,14 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockTxInsertValues = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues })));
|
||||
const mockTx = vi.hoisted(() => ({
|
||||
insert: mockTxInsert,
|
||||
}));
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
@@ -74,7 +85,7 @@ function createApp() {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes(mockDb as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -106,6 +117,8 @@ describe("issue comment reopen routes", () => {
|
||||
authorUserId: "local-board",
|
||||
});
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
@@ -212,4 +225,73 @@ describe("issue comment reopen routes", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes decision ids into executionState and inserts the decision inside the transaction", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "approval",
|
||||
participants: [{ type: "user", userId: "local-board" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "approval",
|
||||
currentParticipant: { type: "user", userId: "local-board" },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionState: patch.executionState,
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_tx: tx,
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ status: "done", comment: "Approved for ship" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
executionState: expect.objectContaining({
|
||||
status: "completed",
|
||||
lastDecisionId: expect.any(String),
|
||||
lastDecisionOutcome: "approved",
|
||||
}),
|
||||
}),
|
||||
mockTx,
|
||||
);
|
||||
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, any>;
|
||||
const decisionId = updatePatch.executionState.lastDecisionId;
|
||||
expect(mockTxInsert).toHaveBeenCalledTimes(1);
|
||||
expect(mockTxInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: decisionId,
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
outcome: "approved",
|
||||
body: "Approved for ship",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
|
||||
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
||||
|
||||
const coderAgentId = "11111111-1111-4111-8111-111111111111";
|
||||
const qaAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const ctoAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const ctoUserId = "cto-user";
|
||||
const boardUserId = "board-user";
|
||||
|
||||
function makePolicy(
|
||||
stages: Array<{ type: "review" | "approval"; participants: Array<{ type: "agent" | "user"; agentId?: string; userId?: string }> }>,
|
||||
) {
|
||||
return normalizeIssueExecutionPolicy({ stages })!;
|
||||
}
|
||||
|
||||
function twoStagePolicy() {
|
||||
return makePolicy([
|
||||
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
||||
{ type: "approval", participants: [{ type: "user", userId: ctoUserId }] },
|
||||
]);
|
||||
}
|
||||
|
||||
function reviewOnlyPolicy() {
|
||||
return makePolicy([
|
||||
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
||||
]);
|
||||
}
|
||||
|
||||
function approvalOnlyPolicy() {
|
||||
return makePolicy([
|
||||
{ type: "approval", participants: [{ type: "user", userId: ctoUserId }] },
|
||||
]);
|
||||
}
|
||||
|
||||
describe("normalizeIssueExecutionPolicy", () => {
|
||||
it("returns null for null/undefined input", () => {
|
||||
expect(normalizeIssueExecutionPolicy(null)).toBeNull();
|
||||
expect(normalizeIssueExecutionPolicy(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when stages are empty", () => {
|
||||
expect(normalizeIssueExecutionPolicy({ stages: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when all participants are invalid (missing agentId)", () => {
|
||||
expect(() =>
|
||||
normalizeIssueExecutionPolicy({
|
||||
stages: [{ type: "review", participants: [{ type: "agent" }] }],
|
||||
}),
|
||||
).toThrow("Invalid execution policy");
|
||||
});
|
||||
|
||||
it("deduplicates participants within a stage", () => {
|
||||
const result = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result!.stages[0].participants).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("assigns UUIDs to stages and participants", () => {
|
||||
const result = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
||||
],
|
||||
});
|
||||
expect(result!.stages[0].id).toBeDefined();
|
||||
expect(result!.stages[0].participants[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
it("always sets commentRequired to true", () => {
|
||||
const result = normalizeIssueExecutionPolicy({
|
||||
commentRequired: false,
|
||||
stages: [
|
||||
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
||||
],
|
||||
});
|
||||
expect(result!.commentRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults mode to normal", () => {
|
||||
const result = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
||||
],
|
||||
});
|
||||
expect(result!.mode).toBe("normal");
|
||||
});
|
||||
|
||||
it("rejects approvalsNeeded values above 1", () => {
|
||||
expect(() =>
|
||||
normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
type: "review",
|
||||
approvalsNeeded: 2,
|
||||
participants: [{ type: "agent", agentId: qaAgentId }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("Invalid execution policy");
|
||||
});
|
||||
|
||||
it("throws for invalid input", () => {
|
||||
expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIssueExecutionState", () => {
|
||||
it("returns null for null/undefined", () => {
|
||||
expect(parseIssueExecutionState(null)).toBeNull();
|
||||
expect(parseIssueExecutionState(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid shape", () => {
|
||||
expect(parseIssueExecutionState({ status: "bogus" })).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a valid state", () => {
|
||||
const state = parseIssueExecutionState({
|
||||
status: "pending",
|
||||
currentStageId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
});
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue execution policy transitions", () => {
|
||||
describe("happy path: executor → review → approval → done", () => {
|
||||
const policy = twoStagePolicy();
|
||||
|
||||
it("routes executor completion into review", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Implemented the feature",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
});
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reviewer approves → advances to approval stage", () => {
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "QA signoff complete",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBeNull();
|
||||
expect(result.patch.assigneeUserId).toBe(ctoUserId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "approval",
|
||||
completedStageIds: [reviewStageId],
|
||||
currentParticipant: { type: "user", userId: ctoUserId },
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageId: reviewStageId,
|
||||
stageType: "review",
|
||||
outcome: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("approver approves → marks completed (allows done)", () => {
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
const approvalStageId = policy.stages[1].id;
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: ctoUserId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: approvalStageId,
|
||||
currentStageIndex: 1,
|
||||
currentStageType: "approval",
|
||||
currentParticipant: { type: "user", userId: ctoUserId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [reviewStageId],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: ctoUserId },
|
||||
commentBody: "Approved, ship it",
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "completed",
|
||||
completedStageIds: expect.arrayContaining([reviewStageId, approvalStageId]),
|
||||
lastDecisionOutcome: "approved",
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageId: approvalStageId,
|
||||
stageType: "approval",
|
||||
outcome: "approved",
|
||||
});
|
||||
// status should NOT be overridden — caller can set done
|
||||
expect(result.patch.status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes requested flow", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("reviewer requests changes → returns to executor", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "Needs another pass on edge cases",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_progress");
|
||||
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "changes_requested",
|
||||
currentStageType: "review",
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageId: reviewStageId,
|
||||
stageType: "review",
|
||||
outcome: "changes_requested",
|
||||
});
|
||||
});
|
||||
|
||||
it("executor re-submits after changes → returns to same review stage", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "changes_requested",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Fixed edge cases",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("review-only policy (no approval stage)", () => {
|
||||
const policy = reviewOnlyPolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("reviewer approval completes the policy", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "LGTM",
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "completed",
|
||||
completedStageIds: [reviewStageId],
|
||||
lastDecisionOutcome: "approved",
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageType: "review",
|
||||
outcome: "approved",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("approval-only policy (no review stage)", () => {
|
||||
const policy = approvalOnlyPolicy();
|
||||
|
||||
it("executor completion routes directly to approval", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Done",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeUserId).toBe(ctoUserId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "approval",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("non-participant cannot advance stage via status change", () => {
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Trying to bypass review",
|
||||
}),
|
||||
).toThrow("Only the active reviewer or approver can advance");
|
||||
});
|
||||
|
||||
it("non-participant can still post non-advancing updates", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Just a note",
|
||||
});
|
||||
|
||||
// No error — just no patch modifications
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment requirements", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("approval without comment throws", () => {
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "",
|
||||
}),
|
||||
).toThrow("requires a comment");
|
||||
});
|
||||
|
||||
it("changes requested without comment throws", () => {
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: null,
|
||||
}),
|
||||
).toThrow("requires a comment");
|
||||
});
|
||||
|
||||
it("whitespace-only comment is treated as empty", () => {
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: " ",
|
||||
}),
|
||||
).toThrow("requires a comment");
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy removal mid-flow", () => {
|
||||
it("clears execution state when policy removed and returns to executor", () => {
|
||||
// Use a real UUID for currentStageId so parseIssueExecutionState succeeds
|
||||
const stageId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: stageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy: null,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toBeNull();
|
||||
expect(result.patch.status).toBe("in_progress");
|
||||
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
||||
});
|
||||
|
||||
it("clears execution state without assignee change when not in_review", () => {
|
||||
const stageId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: {
|
||||
status: "changes_requested",
|
||||
currentStageId: stageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
},
|
||||
},
|
||||
policy: null,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toBeNull();
|
||||
// Not in_review, so no status/assignee change
|
||||
expect(result.patch.status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reopening from done/cancelled clears state", () => {
|
||||
it("reopening a done issue clears execution state", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "done",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "completed",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [policy.stages[0].id, policy.stages[1].id],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: "approved",
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "todo",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-op transitions", () => {
|
||||
const policy = twoStagePolicy();
|
||||
|
||||
it("non-done status change without review context is a no-op", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "blocked",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
|
||||
it("no policy and no state is a no-op", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
},
|
||||
policy: null,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-participant stages", () => {
|
||||
it("selects the preferred participant when explicitly requested", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
{ type: "agent", agentId: ctoAgentId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: { assigneeAgentId: ctoAgentId },
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Ready for review",
|
||||
});
|
||||
|
||||
expect(result.patch.assigneeAgentId).toBe(ctoAgentId);
|
||||
});
|
||||
|
||||
it("falls back to first participant when no preference given", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
{ type: "agent", agentId: ctoAgentId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Ready for review",
|
||||
});
|
||||
|
||||
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
||||
});
|
||||
|
||||
it("excludes the return assignee from participant selection", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: coderAgentId },
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Done",
|
||||
});
|
||||
|
||||
// coderAgentId is the returnAssignee, so QA should be selected
|
||||
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes requested with no return assignee", () => {
|
||||
it("throws when requesting changes with no return assignee", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "Changes needed",
|
||||
}),
|
||||
).toThrow("no return assignee");
|
||||
});
|
||||
});
|
||||
|
||||
describe("approval stage changes requested → bounces back to executor", () => {
|
||||
it("approver requests changes during approval stage", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
const approvalStageId = policy.stages[1].id;
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: ctoUserId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: approvalStageId,
|
||||
currentStageIndex: 1,
|
||||
currentStageType: "approval",
|
||||
currentParticipant: { type: "user", userId: ctoUserId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [reviewStageId],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: ctoUserId },
|
||||
commentBody: "Not happy with the approach, needs rework",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_progress");
|
||||
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "changes_requested",
|
||||
currentStageType: "approval",
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageId: approvalStageId,
|
||||
stageType: "approval",
|
||||
outcome: "changes_requested",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("user participants", () => {
|
||||
it("handles user-type reviewer participant correctly", () => {
|
||||
const policy = makePolicy([
|
||||
{ type: "review", participants: [{ type: "user", userId: boardUserId }] },
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Done",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBeNull();
|
||||
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issueExecutionDecisions } from "@paperclipai/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
@@ -1065,6 +1068,7 @@ export function issueRoutes(
|
||||
const actor = getActorInfo(req);
|
||||
const issue = await svc.create(companyId, {
|
||||
...req.body,
|
||||
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
@@ -1184,13 +1188,80 @@ export function issueRoutes(
|
||||
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
|
||||
updateFields.status = "todo";
|
||||
}
|
||||
if (req.body.executionPolicy !== undefined) {
|
||||
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
}
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy:
|
||||
updateFields.executionPolicy !== undefined
|
||||
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
|
||||
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||
assigneeUserId:
|
||||
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
|
||||
},
|
||||
actor: {
|
||||
agentId: actor.agentId ?? null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
commentBody,
|
||||
});
|
||||
const decisionId = transition.decision ? randomUUID() : null;
|
||||
if (decisionId) {
|
||||
const nextExecutionState = transition.patch.executionState;
|
||||
if (!nextExecutionState || typeof nextExecutionState !== "object") {
|
||||
throw new Error("Execution policy decision patch is missing executionState");
|
||||
}
|
||||
transition.patch.executionState = {
|
||||
...nextExecutionState,
|
||||
lastDecisionId: decisionId,
|
||||
};
|
||||
}
|
||||
Object.assign(updateFields, transition.patch);
|
||||
|
||||
let issue;
|
||||
try {
|
||||
issue = await svc.update(id, {
|
||||
...updateFields,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
if (transition.decision && decisionId) {
|
||||
const decision = transition.decision;
|
||||
issue = await db.transaction(async (tx) => {
|
||||
const updated = await svc.update(
|
||||
id,
|
||||
{
|
||||
...updateFields,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (!updated) return null;
|
||||
|
||||
await tx.insert(issueExecutionDecisions).values({
|
||||
id: decisionId,
|
||||
companyId: updated.companyId,
|
||||
issueId: updated.id,
|
||||
stageId: decision.stageId,
|
||||
stageType: decision.stageType,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
outcome: decision.outcome,
|
||||
body: decision.body,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
issue = await svc.update(id, {
|
||||
...updateFields,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 422) {
|
||||
logger.warn(
|
||||
@@ -1337,8 +1408,8 @@ export function issueRoutes(
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const assigneeChanged = assigneeWillChange;
|
||||
const assigneeChanged =
|
||||
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
||||
const statusChangedFromBacklog =
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
activityLog,
|
||||
costEvents,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueExecutionDecisions,
|
||||
issues,
|
||||
issueComments,
|
||||
} from "@paperclipai/db";
|
||||
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
@@ -474,8 +478,20 @@ export function agentService(db: Db) {
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ assigneeAgentId: null, createdByAgentId: null })
|
||||
.where(or(eq(issues.assigneeAgentId, id), eq(issues.createdByAgentId, id)));
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id));
|
||||
await tx.delete(activityLog).where(
|
||||
or(
|
||||
eq(activityLog.agentId, id),
|
||||
sql`${activityLog.runId} in (select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${id})`,
|
||||
),
|
||||
);
|
||||
await tx.delete(issueExecutionDecisions).where(eq(issueExecutionDecisions.actorAgentId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.authorAgentId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
heartbeatRunEvents,
|
||||
costEvents,
|
||||
financeEvents,
|
||||
issueReadStates,
|
||||
approvalComments,
|
||||
approvals,
|
||||
activityLog,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
invites,
|
||||
principalPermissionGrants,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
} from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
@@ -260,6 +262,7 @@ export function companyService(db: Db) {
|
||||
// Delete from child tables in dependency order
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
||||
@@ -274,13 +277,14 @@ export function companyService(db: Db) {
|
||||
await tx.delete(invites).where(eq(invites.companyId, id));
|
||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
|
||||
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await tx.delete(agents).where(eq(agents.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
const rows = await tx
|
||||
.delete(companies)
|
||||
.where(eq(companies.id, id))
|
||||
|
||||
@@ -1835,6 +1835,210 @@ export function heartbeatService(db: Db) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function patchRunIssueCommentStatus(
|
||||
runId: string,
|
||||
patch: Partial<Pick<typeof heartbeatRuns.$inferInsert, "issueCommentStatus" | "issueCommentSatisfiedByCommentId" | "issueCommentRetryQueuedAt">>,
|
||||
) {
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function findRunIssueComment(runId: string, companyId: string, issueId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.createdByRunId, runId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function enqueueMissingIssueCommentRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
issueId: string,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
const retryContextSnapshot = {
|
||||
...contextSnapshot,
|
||||
retryOfRunId: run.id,
|
||||
wakeReason: "missing_issue_comment",
|
||||
retryReason: "missing_issue_comment",
|
||||
missingIssueCommentForRunId: run.id,
|
||||
};
|
||||
const now = new Date();
|
||||
|
||||
const retryRun = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
|
||||
);
|
||||
|
||||
const issue = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) return null;
|
||||
|
||||
const wakeupRequest = await tx
|
||||
.insert(agentWakeupRequests)
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "missing_issue_comment",
|
||||
payload: {
|
||||
issueId,
|
||||
retryOfRunId: run.id,
|
||||
retryReason: "missing_issue_comment",
|
||||
},
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const queuedRun = await tx
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: retryContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
retryOfRunId: run.id,
|
||||
issueCommentStatus: "not_applicable",
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
runId: queuedRun.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
|
||||
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRun.id,
|
||||
executionAgentNameKey: normalizeAgentNameKey(agent.name),
|
||||
executionLockedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
issueCommentStatus: "retry_queued",
|
||||
issueCommentRetryQueuedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
|
||||
return queuedRun;
|
||||
});
|
||||
|
||||
if (!retryRun) return null;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: retryRun.companyId,
|
||||
type: "heartbeat.run.queued",
|
||||
payload: {
|
||||
runId: retryRun.id,
|
||||
agentId: retryRun.agentId,
|
||||
invocationSource: retryRun.invocationSource,
|
||||
triggerDetail: retryRun.triggerDetail,
|
||||
wakeupRequestId: retryRun.wakeupRequestId,
|
||||
},
|
||||
});
|
||||
|
||||
return retryRun;
|
||||
}
|
||||
|
||||
async function finalizeIssueCommentPolicy(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
if (!issueId) {
|
||||
if (run.issueCommentStatus !== "not_applicable") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "not_applicable",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
}
|
||||
return { outcome: "not_applicable" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const postedComment = await findRunIssueComment(run.id, run.companyId, issueId);
|
||||
if (postedComment) {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "satisfied",
|
||||
issueCommentSatisfiedByCommentId: postedComment.id,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
return { outcome: "satisfied" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
if (readNonEmptyString(contextSnapshot.retryReason) === "missing_issue_comment") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "retry_exhausted",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
});
|
||||
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Run ended without an issue comment after one retry; no further comment wake will be queued",
|
||||
});
|
||||
return { outcome: "retry_exhausted" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId);
|
||||
if (queuedRun) {
|
||||
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Run ended without an issue comment; queued one follow-up wake to require a comment",
|
||||
});
|
||||
return { outcome: "retry_queued" as const, queuedRun };
|
||||
}
|
||||
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "retry_exhausted",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
});
|
||||
return { outcome: "retry_exhausted" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
async function enqueueProcessLossRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
@@ -3085,7 +3289,7 @@ export function heartbeatService(db: Db) {
|
||||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id });
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
}
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
@@ -3094,6 +3298,7 @@ export function heartbeatService(db: Db) {
|
||||
);
|
||||
}
|
||||
}
|
||||
await finalizeIssueCommentPolicy(finalizedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
}
|
||||
|
||||
@@ -3160,6 +3365,7 @@ export function heartbeatService(db: Db) {
|
||||
level: "error",
|
||||
message,
|
||||
});
|
||||
await finalizeIssueCommentPolicy(failedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(failedRun);
|
||||
|
||||
await updateRuntimeState(agent, failedRun, {
|
||||
@@ -3211,6 +3417,10 @@ export function heartbeatService(db: Db) {
|
||||
level: "error",
|
||||
message,
|
||||
}).catch(() => undefined);
|
||||
const failedAgent = await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
|
||||
}
|
||||
// Ensure the agent is not left stuck in "running" if the inner catch handler's
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IssueExecutionDecision, IssueExecutionPolicy, IssueExecutionStage, IssueExecutionStagePrincipal, IssueExecutionState } 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;
|
||||
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
|
||||
executionState?: IssueExecutionState | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type ActorLike = {
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
type RequestedAssigneePatch = {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
};
|
||||
|
||||
type TransitionInput = {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
requestedStatus?: string;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
actor: ActorLike;
|
||||
commentBody?: string | null;
|
||||
};
|
||||
|
||||
type TransitionResult = {
|
||||
patch: Record<string, unknown>;
|
||||
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
||||
};
|
||||
|
||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
|
||||
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
|
||||
|
||||
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);
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
|
||||
return {
|
||||
mode: parsed.data.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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 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,
|
||||
completedStageIds,
|
||||
lastDecisionId: previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: "approved",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingState(input: {
|
||||
previous: IssueExecutionState | null;
|
||||
stage: IssueExecutionStage;
|
||||
stageIndex: number;
|
||||
participant: IssueExecutionStagePrincipal;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
}): IssueExecutionState {
|
||||
return {
|
||||
status: PENDING_STATUS,
|
||||
currentStageId: input.stage.id,
|
||||
currentStageIndex: input.stageIndex,
|
||||
currentStageType: input.stage.type,
|
||||
currentParticipant: input.participant,
|
||||
returnAssignee: input.returnAssignee,
|
||||
completedStageIds: input.previous?.completedStageIds ?? [],
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildChangesRequestedState(previous: IssueExecutionState, currentStage: IssueExecutionStage): IssueExecutionState {
|
||||
return {
|
||||
...previous,
|
||||
status: CHANGES_REQUESTED_STATUS,
|
||||
currentStageId: currentStage.id,
|
||||
currentStageType: currentStage.type,
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
};
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentAssignee = assigneePrincipal(input.issue);
|
||||
const actor = actorPrincipal(input.actor);
|
||||
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
|
||||
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
|
||||
const requestedStatus = input.requestedStatus;
|
||||
|
||||
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 (currentStage && input.issue.status === "in_review") {
|
||||
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
||||
}
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus === "done") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||
}
|
||||
const approvedState = buildCompletedState(existingState, currentStage);
|
||||
const nextStage = nextPendingStage(
|
||||
input.policy,
|
||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||
);
|
||||
|
||||
if (!nextStage) {
|
||||
patch.executionState = approvedState;
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.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`);
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
previous: approvedState,
|
||||
stage: nextStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Requesting changes requires a comment");
|
||||
}
|
||||
if (!existingState?.returnAssignee) {
|
||||
throw unprocessable("This execution stage has no return assignee");
|
||||
}
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
patch.executionState = buildChangesRequestedState(existingState, currentStage);
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "changes_requested",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus !== "done") {
|
||||
return { patch };
|
||||
}
|
||||
|
||||
const pendingStage =
|
||||
existingState?.status === CHANGES_REQUESTED_STATUS && currentStage
|
||||
? currentStage
|
||||
: nextPendingStage(input.policy, existingState);
|
||||
if (!pendingStage) return { patch };
|
||||
|
||||
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
|
||||
const 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`);
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
previous: existingState,
|
||||
stage: pendingStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
|
||||
participant,
|
||||
returnAssignee,
|
||||
});
|
||||
return { patch };
|
||||
}
|
||||
@@ -1562,12 +1562,13 @@ export function issueService(db: Db) {
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
},
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
const existing = await db
|
||||
const existing = await dbOrTx
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const {
|
||||
@@ -1639,7 +1640,7 @@ export function issueService(db: Db) {
|
||||
patch.checkoutRunId = null;
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const runUpdate = async (tx: any) => {
|
||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
|
||||
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
|
||||
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
|
||||
@@ -1663,7 +1664,7 @@ export function issueService(db: Db) {
|
||||
.set(patch)
|
||||
.where(eq(issues.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
if (nextLabelIds !== undefined) {
|
||||
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
|
||||
@@ -1682,7 +1683,9 @@ export function issueService(db: Db) {
|
||||
}
|
||||
const [enriched] = await withIssueLabels(tx, [updated]);
|
||||
return enriched;
|
||||
});
|
||||
};
|
||||
|
||||
return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx);
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
|
||||
@@ -72,6 +72,35 @@ Use comments incrementally:
|
||||
|
||||
Read enough ancestor/comment context to understand _why_ the task exists and what changed. Do not reflexively reload the whole thread on every heartbeat.
|
||||
|
||||
**Execution-policy review/approval wakes.** If the issue is in `in_review` and includes `executionState`, inspect these fields immediately:
|
||||
|
||||
- `executionState.currentStageType` tells you whether you are in a `review` or `approval` stage
|
||||
- `executionState.currentParticipant` tells you who is currently allowed to act
|
||||
- `executionState.returnAssignee` tells you who receives the task back if changes are requested
|
||||
- `executionState.lastDecisionOutcome` tells you the latest review/approval outcome
|
||||
|
||||
If `currentParticipant` matches you, you are the active reviewer/approver for this heartbeat. There is **no separate execution-decision endpoint**. Submit your decision through the normal issue update route:
|
||||
|
||||
```json
|
||||
PATCH /api/issues/{issueId}
|
||||
Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
|
||||
{ "status": "done", "comment": "Approved: what you reviewed and why it passes." }
|
||||
```
|
||||
|
||||
That approves the current stage. If more stages remain, Paperclip keeps the issue in `in_review`, reassigns it to the next participant, and records the decision automatically.
|
||||
|
||||
To request changes, send a non-`done` status with a required comment. Prefer `in_progress`:
|
||||
|
||||
```json
|
||||
PATCH /api/issues/{issueId}
|
||||
Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
|
||||
{ "status": "in_progress", "comment": "Changes requested: exactly what must be fixed." }
|
||||
```
|
||||
|
||||
Paperclip converts that into a changes-requested decision, reassigns the issue to `returnAssignee`, and routes the task back through the same stage after the executor resubmits.
|
||||
|
||||
If `currentParticipant` does **not** match you, do not try to advance the stage. Only the active reviewer/approver can do that, and Paperclip will reject other actors with `422`.
|
||||
|
||||
**Step 7 — Do the work.** Use your tools and capabilities.
|
||||
|
||||
**Step 8 — Update status and communicate.** Always include the run ID header.
|
||||
|
||||
@@ -191,6 +191,58 @@ The response also includes `blockedBy` and `blocks` arrays showing first-class d
|
||||
|
||||
Blocker wake semantics are strict: `issue_blockers_resolved` only fires when every blocker reaches `done`. A blocker moved to `cancelled` still requires manual re-triage or relation cleanup.
|
||||
|
||||
### Execution Policy Fields On An Issue
|
||||
|
||||
When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "in_review",
|
||||
"executionPolicy": {
|
||||
"mode": "normal",
|
||||
"commentRequired": true,
|
||||
"stages": [
|
||||
{
|
||||
"id": "stage-review",
|
||||
"type": "review",
|
||||
"approvalsNeeded": 1,
|
||||
"participants": [
|
||||
{ "id": "participant-qa", "type": "agent", "agentId": "qa-agent-id" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stage-approval",
|
||||
"type": "approval",
|
||||
"approvalsNeeded": 1,
|
||||
"participants": [
|
||||
{ "id": "participant-cto", "type": "user", "userId": "cto-user-id" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"executionState": {
|
||||
"status": "pending",
|
||||
"currentStageId": "stage-review",
|
||||
"currentStageIndex": 0,
|
||||
"currentStageType": "review",
|
||||
"currentParticipant": { "type": "agent", "agentId": "qa-agent-id" },
|
||||
"returnAssignee": { "type": "agent", "agentId": "coder-agent-id" },
|
||||
"completedStageIds": [],
|
||||
"lastDecisionId": null,
|
||||
"lastDecisionOutcome": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `currentStageType` tells you whether the active gate is `review` or `approval`
|
||||
- `currentParticipant` is the only actor allowed to advance the stage
|
||||
- `returnAssignee` is who gets the task back when changes are requested
|
||||
- `lastDecisionOutcome` shows the latest gate decision
|
||||
|
||||
There is **no separate execution-decision endpoint**. Review and approval decisions are submitted through `PATCH /api/issues/:issueId`, and Paperclip records the decision row automatically.
|
||||
|
||||
---
|
||||
|
||||
## Worked Example: IC Heartbeat
|
||||
@@ -262,6 +314,43 @@ PATCH /api/issues/issue-200
|
||||
{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." }
|
||||
```
|
||||
|
||||
### Worked Example: Reviewer / Approver Heartbeat
|
||||
|
||||
When you wake up on an issue in `in_review`, inspect `executionState` first:
|
||||
|
||||
```
|
||||
GET /api/issues/issue-77
|
||||
-> {
|
||||
id: "issue-77",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "qa-agent-id",
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent-id" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent-id" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `currentParticipant` is you, approve the current stage by patching the issue to `done` with a required comment:
|
||||
|
||||
```
|
||||
PATCH /api/issues/issue-77
|
||||
{ "status": "done", "comment": "QA signoff complete. Verified the regression and test coverage." }
|
||||
```
|
||||
|
||||
Paperclip writes the execution decision automatically. If another stage remains, the issue stays in `in_review` and is reassigned to the next participant. If this was the final stage, the issue reaches actual `done`.
|
||||
|
||||
To request changes, use a non-`done` status with a required comment. Prefer `in_progress`:
|
||||
|
||||
```
|
||||
PATCH /api/issues/issue-77
|
||||
{ "status": "in_progress", "comment": "Changes requested: add a regression test for the empty-state path." }
|
||||
```
|
||||
|
||||
Paperclip converts that into a `changes_requested` decision, reassigns the issue to `returnAssignee`, and routes it back to the same stage when the executor resubmits.
|
||||
|
||||
---
|
||||
|
||||
## Worked Example: Manager Heartbeat
|
||||
|
||||
@@ -22,18 +22,9 @@ const TASK_TITLE = "E2E test task";
|
||||
|
||||
test.describe("Onboarding wizard", () => {
|
||||
test("completes full wizard flow", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.goto("/onboarding");
|
||||
|
||||
const wizardHeading = page.locator("h3", { hasText: "Name your company" });
|
||||
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
|
||||
|
||||
await expect(
|
||||
wizardHeading.or(newCompanyBtn)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
if (await newCompanyBtn.isVisible()) {
|
||||
await newCompanyBtn.click();
|
||||
}
|
||||
|
||||
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
@@ -45,7 +36,7 @@ test.describe("Onboarding wizard", () => {
|
||||
|
||||
await expect(
|
||||
page.locator("h3", { hasText: "Create your first agent" })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const agentNameInput = page.locator('input[placeholder="CEO"]');
|
||||
await expect(agentNameInput).toHaveValue(AGENT_NAME);
|
||||
@@ -61,7 +52,7 @@ test.describe("Onboarding wizard", () => {
|
||||
|
||||
await expect(
|
||||
page.locator("h3", { hasText: "Give it something to do" })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const taskTitleInput = page.locator(
|
||||
'input[placeholder="e.g. Research competitor pricing"]'
|
||||
@@ -73,7 +64,7 @@ test.describe("Onboarding wizard", () => {
|
||||
|
||||
await expect(
|
||||
page.locator("h3", { hasText: "Ready to launch" })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible();
|
||||
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
|
||||
@@ -81,7 +72,7 @@ test.describe("Onboarding wizard", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Create & Open Issue" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
|
||||
|
||||
const baseUrl = page.url().split("/").slice(0, 3).join("/");
|
||||
|
||||
|
||||
@@ -138,7 +138,16 @@ async function setupCompany(boardRequest: APIRequestContext): Promise<TestContex
|
||||
// Helper: create agent + API key + request context
|
||||
async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> {
|
||||
const agentRes = await boardRequest.post(`${BASE_URL}/api/companies/${companyId}/agents`, {
|
||||
data: { name, role, title, adapterType: "process", adapterConfig: { command: "echo done" } },
|
||||
data: {
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write('done\\n')"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(agentRes.ok()).toBe(true);
|
||||
const agent = await agentRes.json();
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees";
|
||||
import {
|
||||
buildExecutionPolicy,
|
||||
stageParticipantValues,
|
||||
} from "../lib/issue-execution-policy";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Eye, ShieldCheck } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
type StageType = "review" | "approval";
|
||||
|
||||
interface ExecutionParticipantPickerProps {
|
||||
issue: Issue;
|
||||
stageType: StageType;
|
||||
agents: Agent[];
|
||||
currentUserId: string | null;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ExecutionParticipantPicker({
|
||||
issue,
|
||||
stageType,
|
||||
agents,
|
||||
currentUserId,
|
||||
onUpdate,
|
||||
}: ExecutionParticipantPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const values = stageType === "review" ? reviewerValues : approverValues;
|
||||
|
||||
const sortedAgents = sortAgentsByRecency(
|
||||
agents.filter((a) => a.status !== "terminated"),
|
||||
getRecentAssigneeIds(),
|
||||
);
|
||||
|
||||
const userLabel = (userId: string | null | undefined) =>
|
||||
formatAssigneeUserLabel(userId, currentUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
|
||||
const agentName = (id: string) => {
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
return agent?.name ?? id.slice(0, 8);
|
||||
};
|
||||
|
||||
const participantLabel = (value: string) => {
|
||||
if (value.startsWith("agent:")) return agentName(value.slice("agent:".length));
|
||||
if (value.startsWith("user:")) return userLabel(value.slice("user:".length)) ?? "User";
|
||||
return value;
|
||||
};
|
||||
|
||||
const updatePolicy = (nextValues: string[]) => {
|
||||
onUpdate({
|
||||
executionPolicy: buildExecutionPolicy({
|
||||
existingPolicy: issue.executionPolicy ?? null,
|
||||
reviewerValues: stageType === "review" ? nextValues : reviewerValues,
|
||||
approverValues: stageType === "approval" ? nextValues : approverValues,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = (value: string) => {
|
||||
const next = values.includes(value)
|
||||
? values.filter((v) => v !== value)
|
||||
: [...values, value];
|
||||
updatePolicy(next);
|
||||
};
|
||||
|
||||
const label = stageType === "review" ? "Reviewers" : "Approvers";
|
||||
const Icon = stageType === "review" ? Eye : ShieldCheck;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setSearch(""); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition-colors cursor-pointer",
|
||||
values.length > 0
|
||||
? "border-border text-foreground hover:bg-accent/50"
|
||||
: "border-dashed border-border/60 text-muted-foreground hover:border-border hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{values.length > 0 ? (
|
||||
<span className="truncate max-w-[100px]">
|
||||
{values.map(participantLabel).join(", ")}
|
||||
</span>
|
||||
) : (
|
||||
<span>{label}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-1 w-56" align="start" collisionPadding={16}>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.length === 0 && "bg-accent",
|
||||
)}
|
||||
onClick={() => updatePolicy([])}
|
||||
>
|
||||
No {label.toLowerCase()}
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(`user:${currentUserId}`) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggle(`user:${currentUserId}`)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
Assign to me
|
||||
</button>
|
||||
)}
|
||||
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(`user:${issue.createdByUserId}`) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggle(`user:${issue.createdByUserId}`)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{creatorUserLabel ?? "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((agent) => {
|
||||
const encoded = `agent:${agent.id}`;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(encoded) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggle(encoded)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{agent.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
@@ -166,6 +167,10 @@ export function IssueProperties({
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [blockedByOpen, setBlockedByOpen] = useState(false);
|
||||
const [blockedBySearch, setBlockedBySearch] = useState("");
|
||||
const [reviewersOpen, setReviewersOpen] = useState(false);
|
||||
const [reviewerSearch, setReviewerSearch] = useState("");
|
||||
const [approversOpen, setApproversOpen] = useState(false);
|
||||
const [approverSearch, setApproverSearch] = useState("");
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const [labelSearch, setLabelSearch] = useState("");
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
@@ -265,9 +270,59 @@ export function IssueProperties({
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
||||
onUpdate({
|
||||
executionPolicy: buildExecutionPolicy({
|
||||
existingPolicy: issue.executionPolicy ?? null,
|
||||
reviewerValues: nextReviewers,
|
||||
approverValues: nextApprovers,
|
||||
}),
|
||||
});
|
||||
};
|
||||
const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => {
|
||||
const currentValues = stageType === "review" ? reviewerValues : approverValues;
|
||||
const nextValues = currentValues.includes(value)
|
||||
? currentValues.filter((candidate) => candidate !== value)
|
||||
: [...currentValues, value];
|
||||
updateExecutionPolicy(
|
||||
stageType === "review" ? nextValues : reviewerValues,
|
||||
stageType === "approval" ? nextValues : approverValues,
|
||||
);
|
||||
};
|
||||
const executionParticipantLabel = (value: string) => {
|
||||
if (value.startsWith("agent:")) {
|
||||
return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8);
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
return userLabel(value.slice("user:".length)) ?? "User";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const reviewerTrigger = reviewerValues.length > 0
|
||||
? <span className="text-sm truncate">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const approverTrigger = approverValues.length > 0
|
||||
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const currentExecutionLabel = (() => {
|
||||
if (!issue.executionState?.currentStageType) return null;
|
||||
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
||||
const participant = issue.executionState.currentParticipant;
|
||||
const participantLabel = participant
|
||||
? (participant.type === "agent"
|
||||
? agentName(participant.agentId ?? null)
|
||||
: userLabel(participant.userId ?? null))
|
||||
: null;
|
||||
if (issue.executionState.status === "changes_requested") {
|
||||
return `${stageLabel} requested changes${participantLabel ? ` by ${participantLabel}` : ""}`;
|
||||
}
|
||||
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
|
||||
})();
|
||||
|
||||
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
@@ -454,6 +509,80 @@ export function IssueProperties({
|
||||
</>
|
||||
);
|
||||
|
||||
const executionParticipantsContent = (
|
||||
stageType: "review" | "approval",
|
||||
values: string[],
|
||||
search: string,
|
||||
setSearch: (value: string) => void,
|
||||
onClear: () => void,
|
||||
) => (
|
||||
<>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder={`Search ${stageType === "review" ? "reviewers" : "approvers"}...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.length === 0 && "bg-accent",
|
||||
)}
|
||||
onClick={onClear}
|
||||
>
|
||||
No {stageType === "review" ? "reviewers" : "approvers"}
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(`user:${currentUserId}`) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, `user:${currentUserId}`)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
Assign to me
|
||||
</button>
|
||||
)}
|
||||
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(`user:${issue.createdByUserId}`) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, `user:${issue.createdByUserId}`)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{creatorUserLabel ? creatorUserLabel : "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((agent) => {
|
||||
const encoded = `agent:${agent.id}`;
|
||||
return (
|
||||
<button
|
||||
key={`${stageType}:${agent.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(encoded) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, encoded)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{agent.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const projectTrigger = issue.projectId ? (
|
||||
<>
|
||||
<span
|
||||
@@ -750,6 +879,48 @@ export function IssueProperties({
|
||||
</div>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Reviewers"
|
||||
open={reviewersOpen}
|
||||
onOpenChange={(open) => { setReviewersOpen(open); if (!open) setReviewerSearch(""); }}
|
||||
triggerContent={reviewerTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-56"
|
||||
>
|
||||
{executionParticipantsContent(
|
||||
"review",
|
||||
reviewerValues,
|
||||
reviewerSearch,
|
||||
setReviewerSearch,
|
||||
() => updateExecutionPolicy([], approverValues),
|
||||
)}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Approvers"
|
||||
open={approversOpen}
|
||||
onOpenChange={(open) => { setApproversOpen(open); if (!open) setApproverSearch(""); }}
|
||||
triggerContent={approverTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-56"
|
||||
>
|
||||
{executionParticipantsContent(
|
||||
"approval",
|
||||
approverValues,
|
||||
approverSearch,
|
||||
setApproverSearch,
|
||||
() => updateExecutionPolicy(reviewerValues, []),
|
||||
)}
|
||||
</PropertyPicker>
|
||||
|
||||
{currentExecutionLabel && (
|
||||
<PropertyRow label="Execution">
|
||||
<span className="text-sm">{currentExecutionLabel}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
|
||||
@@ -13,6 +13,7 @@ import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
@@ -48,6 +49,8 @@ import {
|
||||
Loader2,
|
||||
ListTree,
|
||||
X,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
@@ -66,6 +69,8 @@ interface IssueDraft {
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeValue: string;
|
||||
reviewerValue: string;
|
||||
approverValue: string;
|
||||
assigneeId?: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId?: string;
|
||||
@@ -281,6 +286,11 @@ export function NewIssueDialog() {
|
||||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeValue, setAssigneeValue] = useState("");
|
||||
const [reviewerValue, setReviewerValue] = useState("");
|
||||
const [approverValue, setApproverValue] = useState("");
|
||||
const [showReviewerRow, setShowReviewerRow] = useState(false);
|
||||
const [showApproverRow, setShowApproverRow] = useState(false);
|
||||
const [participantMenuOpen, setParticipantMenuOpen] = useState(false);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
@@ -484,6 +494,8 @@ export function NewIssueDialog() {
|
||||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
@@ -498,6 +510,8 @@ export function NewIssueDialog() {
|
||||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
@@ -547,6 +561,10 @@ export function NewIssueDialog() {
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
@@ -565,6 +583,10 @@ export function NewIssueDialog() {
|
||||
? assigneeValueFromSelection(newIssueDefaults)
|
||||
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
||||
);
|
||||
setReviewerValue(draft.reviewerValue ?? "");
|
||||
setApproverValue(draft.approverValue ?? "");
|
||||
setShowReviewerRow(!!(draft.reviewerValue));
|
||||
setShowApproverRow(!!(draft.approverValue));
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
@@ -584,6 +606,10 @@ export function NewIssueDialog() {
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
@@ -626,6 +652,10 @@ export function NewIssueDialog() {
|
||||
setStatus("todo");
|
||||
setPriority("");
|
||||
setAssigneeValue("");
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
@@ -647,6 +677,10 @@ export function NewIssueDialog() {
|
||||
if (companyId === effectiveCompanyId) return;
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeValue("");
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeModelOverride("");
|
||||
@@ -685,6 +719,10 @@ export function NewIssueDialog() {
|
||||
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
|
||||
? { mode: requestedExecutionWorkspaceMode }
|
||||
: null;
|
||||
const executionPolicy = buildExecutionPolicy({
|
||||
reviewerValues: reviewerValue ? [reviewerValue] : [],
|
||||
approverValues: approverValue ? [approverValue] : [],
|
||||
});
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
@@ -704,6 +742,7 @@ export function NewIssueDialog() {
|
||||
? { executionWorkspaceId: selectedExecutionWorkspaceId }
|
||||
: {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
...(executionPolicy ? { executionPolicy } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1061,7 +1100,7 @@ export function NewIssueDialog() {
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
||||
<span>For</span>
|
||||
<span className="w-6 shrink-0 text-center">For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
value={assigneeValue}
|
||||
@@ -1153,8 +1192,139 @@ export function NewIssueDialog() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Three-dot menu to add Reviewer / Approver rows */}
|
||||
<Popover open={participantMenuOpen} onOpenChange={setParticipantMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent/50 transition-colors"
|
||||
title="Add reviewer or approver"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
showReviewerRow && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowReviewerRow((v) => !v);
|
||||
if (showReviewerRow) setReviewerValue("");
|
||||
setParticipantMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
Reviewer
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
showApproverRow && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowApproverRow((v) => !v);
|
||||
if (showApproverRow) setApproverValue("");
|
||||
setParticipantMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Approver
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviewer row */}
|
||||
{showReviewerRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
value={reviewerValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Reviewer"
|
||||
disablePortal
|
||||
noneLabel="No reviewer"
|
||||
searchPlaceholder="Search reviewers..."
|
||||
emptyMessage="No reviewers found."
|
||||
onChange={setReviewerValue}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
<>
|
||||
{(() => {
|
||||
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
||||
})()}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Reviewer</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approver row */}
|
||||
{showApproverRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
value={approverValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Approver"
|
||||
disablePortal
|
||||
noneLabel="No approver"
|
||||
searchPlaceholder="Search approvers..."
|
||||
emptyMessage="No approvers found."
|
||||
onChange={setApproverValue}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
<>
|
||||
{(() => {
|
||||
const approver = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
||||
})()}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Approver</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const approver = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSubIssueMode ? (
|
||||
@@ -1441,11 +1611,11 @@ export function NewIssueDialog() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Labels chip (placeholder) */}
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
||||
{/* Labels chip — disabled, not wired up yet */}
|
||||
{/* <button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
||||
<Tag className="h-3 w-3" />
|
||||
Labels
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
<input
|
||||
ref={stageFileInputRef}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { IssueExecutionPolicy, IssueExecutionStageParticipant, IssueExecutionStagePrincipal } from "@paperclipai/shared";
|
||||
import { parseAssigneeValue } from "./assignees";
|
||||
|
||||
type StageType = "review" | "approval";
|
||||
|
||||
function newId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `stage-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function principalKey(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant) {
|
||||
return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`;
|
||||
}
|
||||
|
||||
export function principalFromSelectionValue(value: string): IssueExecutionStagePrincipal | null {
|
||||
const selection = parseAssigneeValue(value);
|
||||
if (selection.assigneeAgentId) {
|
||||
return { type: "agent", agentId: selection.assigneeAgentId, userId: null };
|
||||
}
|
||||
if (selection.assigneeUserId) {
|
||||
return { type: "user", userId: selection.assigneeUserId, agentId: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function selectionValueFromPrincipal(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant): string {
|
||||
return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`;
|
||||
}
|
||||
|
||||
export function stageParticipantValues(policy: IssueExecutionPolicy | null | undefined, stageType: StageType): string[] {
|
||||
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
|
||||
return stage?.participants.map((participant) => selectionValueFromPrincipal(participant)) ?? [];
|
||||
}
|
||||
|
||||
function mergeParticipants(
|
||||
existing: IssueExecutionStageParticipant[] | undefined,
|
||||
values: string[],
|
||||
): IssueExecutionStageParticipant[] {
|
||||
const existingByKey = new Map((existing ?? []).map((participant) => [principalKey(participant), participant]));
|
||||
const participants: IssueExecutionStageParticipant[] = [];
|
||||
for (const value of values) {
|
||||
const principal = principalFromSelectionValue(value);
|
||||
if (!principal) continue;
|
||||
const key = principalKey(principal);
|
||||
const previous = existingByKey.get(key);
|
||||
participants.push({
|
||||
id: previous?.id ?? newId(),
|
||||
type: principal.type,
|
||||
agentId: principal.type === "agent" ? principal.agentId ?? null : null,
|
||||
userId: principal.type === "user" ? principal.userId ?? null : null,
|
||||
});
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
export function buildExecutionPolicy(input: {
|
||||
existingPolicy?: IssueExecutionPolicy | null;
|
||||
reviewerValues: string[];
|
||||
approverValues: string[];
|
||||
}): IssueExecutionPolicy | null {
|
||||
const mode = input.existingPolicy?.mode ?? "normal";
|
||||
const stages: IssueExecutionPolicy["stages"] = [];
|
||||
|
||||
const existingReviewStage = input.existingPolicy?.stages.find((stage) => stage.type === "review");
|
||||
const reviewParticipants = mergeParticipants(existingReviewStage?.participants, input.reviewerValues);
|
||||
if (reviewParticipants.length > 0) {
|
||||
stages.push({
|
||||
id: existingReviewStage?.id ?? newId(),
|
||||
type: "review" as const,
|
||||
approvalsNeeded: 1 as const,
|
||||
participants: reviewParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
const existingApprovalStage = input.existingPolicy?.stages.find((stage) => stage.type === "approval");
|
||||
const approvalParticipants = mergeParticipants(existingApprovalStage?.participants, input.approverValues);
|
||||
if (approvalParticipants.length > 0) {
|
||||
stages.push({
|
||||
id: existingApprovalStage?.id ?? newId(),
|
||||
type: "approval" as const,
|
||||
approvalsNeeded: 1 as const,
|
||||
participants: approvalParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
|
||||
return {
|
||||
mode,
|
||||
commentRequired: true,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user