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:
Dotta 2026-04-07 18:41:51 -05:00 committed by GitHub
commit a13ac0d56f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 16268 additions and 41 deletions

View file

@ -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.

View file

@ -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

View file

@ -365,6 +365,13 @@
"when": 1775524651831, "when": 1775524651831,
"tag": "0051_young_korg", "tag": "0051_young_korg",
"breakpoints": true "breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1775571715162,
"tag": "0052_mushy_trauma",
"breakpoints": true
} }
] ]
} }

View file

@ -37,6 +37,9 @@ export const heartbeatRuns = pgTable(
onDelete: "set null", onDelete: "set null",
}), }),
processLossRetryCount: integer("process_loss_retry_count").notNull().default(0), 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>>(), contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View file

@ -32,6 +32,7 @@ export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js"; export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js"; export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js"; export { issueComments } from "./issue_comments.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js"; export { issueInboxArchives } from "./issue_inbox_archives.js";
export { feedbackVotes } from "./feedback_votes.js"; export { feedbackVotes } from "./feedback_votes.js";
export { feedbackExports } from "./feedback_exports.js"; export { feedbackExports } from "./feedback_exports.js";

View file

@ -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),
}),
);

View file

@ -47,6 +47,8 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0), requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"), billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(), 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") executionWorkspaceId: uuid("execution_workspace_id")
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }), .references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
executionWorkspacePreference: text("execution_workspace_preference"), executionWorkspacePreference: text("execution_workspace_preference"),

View file

@ -138,6 +138,18 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export const ISSUE_RELATION_TYPES = ["blocks"] as const; export const ISSUE_RELATION_TYPES = ["blocks"] as const;
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; 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 const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
export type GoalLevel = (typeof GOAL_LEVELS)[number]; export type GoalLevel = (typeof GOAL_LEVELS)[number];

View file

@ -15,6 +15,10 @@ export {
ISSUE_PRIORITIES, ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS, ISSUE_ORIGIN_KINDS,
ISSUE_RELATION_TYPES, ISSUE_RELATION_TYPES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_EXECUTION_DECISION_OUTCOMES,
GOAL_LEVELS, GOAL_LEVELS,
GOAL_STATUSES, GOAL_STATUSES,
PROJECT_STATUSES, PROJECT_STATUSES,
@ -84,6 +88,10 @@ export {
type IssuePriority, type IssuePriority,
type IssueOriginKind, type IssueOriginKind,
type IssueRelationType, type IssueRelationType,
type IssueExecutionPolicyMode,
type IssueExecutionStageType,
type IssueExecutionStateStatus,
type IssueExecutionDecisionOutcome,
type GoalLevel, type GoalLevel,
type GoalStatus, type GoalStatus,
type ProjectStatus, type ProjectStatus,
@ -233,6 +241,12 @@ export type {
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,
IssueRelation, IssueRelation,
IssueRelationIssueSummary, IssueRelationIssueSummary,
IssueExecutionPolicy,
IssueExecutionState,
IssueExecutionStage,
IssueExecutionStageParticipant,
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment, IssueComment,
IssueDocument, IssueDocument,
IssueDocumentSummary, IssueDocumentSummary,
@ -425,6 +439,8 @@ export {
createIssueSchema, createIssueSchema,
createIssueLabelSchema, createIssueLabelSchema,
updateIssueSchema, updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
issueExecutionWorkspaceSettingsSchema, issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
addIssueCommentSchema, addIssueCommentSchema,

View file

@ -98,6 +98,12 @@ export type {
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,
IssueRelation, IssueRelation,
IssueRelationIssueSummary, IssueRelationIssueSummary,
IssueExecutionPolicy,
IssueExecutionState,
IssueExecutionStage,
IssueExecutionStageParticipant,
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment, IssueComment,
IssueDocument, IssueDocument,
IssueDocumentSummary, IssueDocumentSummary,

View file

@ -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 { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js"; import type { Project, ProjectWorkspace } from "./project.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
@ -115,6 +123,56 @@ export interface IssueRelation {
relatedIssue: IssueRelationIssueSummary; 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 { export interface Issue {
id: string; id: string;
companyId: string; companyId: string;
@ -143,6 +201,8 @@ export interface Issue {
requestDepth: number; requestDepth: number;
billingCode: string | null; billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null; assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionPolicy?: IssueExecutionPolicy | null;
executionState?: IssueExecutionState | null;
executionWorkspaceId: string | null; executionWorkspaceId: string | null;
executionWorkspacePreference: string | null; executionWorkspacePreference: string | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null; executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;

View file

@ -131,6 +131,8 @@ export {
createIssueSchema, createIssueSchema,
createIssueLabelSchema, createIssueLabelSchema,
updateIssueSchema, updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
issueExecutionWorkspaceSettingsSchema, issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
addIssueCommentSchema, addIssueCommentSchema,

View file

@ -1,5 +1,12 @@
import { z } from "zod"; 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 = [ export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
"inherit", "inherit",
@ -36,6 +43,76 @@ export const issueAssigneeAdapterOverridesSchema = z
}) })
.strict(); .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({ export const createIssueSchema = z.object({
projectId: z.string().uuid().optional().nullable(), projectId: z.string().uuid().optional().nullable(),
projectWorkspaceId: 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), requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(), billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(), assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
executionWorkspaceId: z.string().uuid().optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(),
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(), executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(), executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),

View file

@ -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);
});
});

View file

@ -4,7 +4,7 @@ import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { and, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import {
@ -222,7 +222,7 @@ describe("heartbeat comment wake batching", () => {
db = createDb(started.connectionString); db = createDb(started.connectionString);
instance = started.instance; instance = started.instance;
dataDir = started.dataDir; dataDir = started.dataDir;
}, 20_000); }, 45_000);
afterAll(async () => { afterAll(async () => {
await instance?.stop(); await instance?.stop();
@ -307,6 +307,14 @@ describe("heartbeat comment wake batching", () => {
expect(firstRun).not.toBeNull(); expect(firstRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1); 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 const comment2 = await db
.insert(issueComments) .insert(issueComments)
.values({ .values({
@ -398,7 +406,7 @@ describe("heartbeat comment wake batching", () => {
await waitFor(async () => { await waitFor(async () => {
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 2 && runs.every((run) => run.status === "succeeded"); return runs.length === 2 && runs.every((run) => run.status === "succeeded");
}); }, 30_000);
const secondPayload = gateway.getAgentPayloads()[1] ?? {}; const secondPayload = gateway.getAgentPayloads()[1] ?? {};
expect(secondPayload.paperclip).toMatchObject({ expect(secondPayload.paperclip).toMatchObject({
@ -414,5 +422,120 @@ describe("heartbeat comment wake batching", () => {
gateway.releaseFirstWait(); gateway.releaseFirstWait();
await gateway.close(); 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); }, 20_000);
}); });

View file

@ -3,12 +3,15 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js"; import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js"; import { errorHandler } from "../middleware/index.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(), getById: vi.fn(),
update: vi.fn(), update: vi.fn(),
addComment: vi.fn(), addComment: vi.fn(),
findMentionedAgents: vi.fn(), findMentionedAgents: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
})); }));
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
@ -29,6 +32,14 @@ const mockAgentService = vi.hoisted(() => ({
})); }));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); 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", () => ({ vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService, accessService: () => mockAccessService,
@ -74,7 +85,7 @@ function createApp() {
}; };
next(); next();
}); });
app.use("/api", issueRoutes({} as any, {} as any)); app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler); app.use(errorHandler);
return app; return app;
} }
@ -106,6 +117,8 @@ describe("issue comment reopen routes", () => {
authorUserId: "local-board", authorUserId: "local-board",
}); });
mockIssueService.findMentionedAgents.mockResolvedValue([]); 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 () => { 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",
}),
);
});
}); });

View file

@ -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);
});
});
});

View file

@ -1,7 +1,9 @@
import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express"; import { Router, type Request, type Response } from "express";
import multer from "multer"; import multer from "multer";
import { z } from "zod"; import { z } from "zod";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
createIssueAttachmentMetadataSchema, createIssueAttachmentMetadataSchema,
@ -54,6 +56,7 @@ import {
SVG_CONTENT_TYPE, SVG_CONTENT_TYPE,
} from "../attachment-types.js"; } from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.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 MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({ const updateIssueRouteSchema = updateIssueSchema.extend({
@ -1065,6 +1068,7 @@ export function issueRoutes(
const actor = getActorInfo(req); const actor = getActorInfo(req);
const issue = await svc.create(companyId, { const issue = await svc.create(companyId, {
...req.body, ...req.body,
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
createdByAgentId: actor.agentId, createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null, createdByUserId: actor.actorType === "user" ? actor.actorId : null,
}); });
@ -1184,13 +1188,80 @@ export function issueRoutes(
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
updateFields.status = "todo"; 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; let issue;
try { try {
issue = await svc.update(id, { if (transition.decision && decisionId) {
...updateFields, const decision = transition.decision;
actorAgentId: actor.agentId ?? null, issue = await db.transaction(async (tx) => {
actorUserId: actor.actorType === "user" ? actor.actorId : null, 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) { } catch (err) {
if (err instanceof HttpError && err.status === 422) { if (err instanceof HttpError && err.status === 422) {
logger.warn( logger.warn(
@ -1337,8 +1408,8 @@ export function issueRoutes(
}); });
} }
const assigneeChanged =
const assigneeChanged = assigneeWillChange; issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
const statusChangedFromBacklog = const statusChangedFromBacklog =
existing.status === "backlog" && existing.status === "backlog" &&
issue.status !== "backlog" && issue.status !== "backlog" &&

View file

@ -1,5 +1,5 @@
import { createHash, randomBytes } from "node:crypto"; 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 type { Db } from "@paperclipai/db";
import { import {
agents, agents,
@ -8,9 +8,13 @@ import {
agentRuntimeState, agentRuntimeState,
agentTaskSessions, agentTaskSessions,
agentWakeupRequests, agentWakeupRequests,
activityLog,
costEvents, costEvents,
heartbeatRunEvents, heartbeatRunEvents,
heartbeatRuns, heartbeatRuns,
issueExecutionDecisions,
issues,
issueComments,
} from "@paperclipai/db"; } from "@paperclipai/db";
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js"; import { conflict, notFound, unprocessable } from "../errors.js";
@ -474,8 +478,20 @@ export function agentService(db: Db) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); 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(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.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(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id)); await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id)); await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));

View file

@ -17,6 +17,7 @@ import {
heartbeatRunEvents, heartbeatRunEvents,
costEvents, costEvents,
financeEvents, financeEvents,
issueReadStates,
approvalComments, approvalComments,
approvals, approvals,
activityLog, activityLog,
@ -25,6 +26,7 @@ import {
invites, invites,
principalPermissionGrants, principalPermissionGrants,
companyMemberships, companyMemberships,
companySkills,
} from "@paperclipai/db"; } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
@ -260,6 +262,7 @@ export function companyService(db: Db) {
// Delete from child tables in dependency order // Delete from child tables in dependency order
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id)); await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.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(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id)); await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.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(invites).where(eq(invites.companyId, id));
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id)); await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
await tx.delete(companyMemberships).where(eq(companyMemberships.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(issues).where(eq(issues.companyId, id));
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id)); await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
await tx.delete(assets).where(eq(assets.companyId, id)); await tx.delete(assets).where(eq(assets.companyId, id));
await tx.delete(goals).where(eq(goals.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id));
await tx.delete(projects).where(eq(projects.companyId, id)); await tx.delete(projects).where(eq(projects.companyId, id));
await tx.delete(agents).where(eq(agents.companyId, id)); await tx.delete(agents).where(eq(agents.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
const rows = await tx const rows = await tx
.delete(companies) .delete(companies)
.where(eq(companies.id, id)) .where(eq(companies.id, id))

View file

@ -1835,6 +1835,210 @@ export function heartbeatService(db: Db) {
return updated; 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( async function enqueueProcessLossRetry(
run: typeof heartbeatRuns.$inferSelect, run: typeof heartbeatRuns.$inferSelect,
agent: typeof agents.$inferSelect, agent: typeof agents.$inferSelect,
@ -3085,7 +3289,7 @@ export function heartbeatService(db: Db) {
try { try {
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null); const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
if (issueComment) { if (issueComment) {
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id }); await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
} }
} catch (err) { } catch (err) {
await onLog( await onLog(
@ -3094,6 +3298,7 @@ export function heartbeatService(db: Db) {
); );
} }
} }
await finalizeIssueCommentPolicy(finalizedRun, agent);
await releaseIssueExecutionAndPromote(finalizedRun); await releaseIssueExecutionAndPromote(finalizedRun);
} }
@ -3160,6 +3365,7 @@ export function heartbeatService(db: Db) {
level: "error", level: "error",
message, message,
}); });
await finalizeIssueCommentPolicy(failedRun, agent);
await releaseIssueExecutionAndPromote(failedRun); await releaseIssueExecutionAndPromote(failedRun);
await updateRuntimeState(agent, failedRun, { await updateRuntimeState(agent, failedRun, {
@ -3211,6 +3417,10 @@ export function heartbeatService(db: Db) {
level: "error", level: "error",
message, message,
}).catch(() => undefined); }).catch(() => undefined);
const failedAgent = await getAgent(run.agentId).catch(() => null);
if (failedAgent) {
await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined);
}
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined); await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
} }
// Ensure the agent is not left stuck in "running" if the inner catch handler's // Ensure the agent is not left stuck in "running" if the inner catch handler's

View file

@ -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 };
}

View file

@ -1562,12 +1562,13 @@ export function issueService(db: Db) {
actorAgentId?: string | null; actorAgentId?: string | null;
actorUserId?: string | null; actorUserId?: string | null;
}, },
dbOrTx: any = db,
) => { ) => {
const existing = await db const existing = await dbOrTx
.select() .select()
.from(issues) .from(issues)
.where(eq(issues.id, id)) .where(eq(issues.id, id))
.then((rows) => rows[0] ?? null); .then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!existing) return null; if (!existing) return null;
const { const {
@ -1639,7 +1640,7 @@ export function issueService(db: Db) {
patch.checkoutRunId = null; patch.checkoutRunId = null;
} }
return db.transaction(async (tx) => { const runUpdate = async (tx: any) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([ const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId), getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
@ -1663,7 +1664,7 @@ export function issueService(db: Db) {
.set(patch) .set(patch)
.where(eq(issues.id, id)) .where(eq(issues.id, id))
.returning() .returning()
.then((rows) => rows[0] ?? null); .then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null; if (!updated) return null;
if (nextLabelIds !== undefined) { if (nextLabelIds !== undefined) {
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx); await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
@ -1682,7 +1683,9 @@ export function issueService(db: Db) {
} }
const [enriched] = await withIssueLabels(tx, [updated]); const [enriched] = await withIssueLabels(tx, [updated]);
return enriched; return enriched;
}); };
return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx);
}, },
remove: (id: string) => remove: (id: string) =>

View file

@ -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. 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 7 — Do the work.** Use your tools and capabilities.
**Step 8 — Update status and communicate.** Always include the run ID header. **Step 8 — Update status and communicate.** Always include the run ID header.

View file

@ -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. 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 ## 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)." } { "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 ## Worked Example: Manager Heartbeat

View file

@ -22,18 +22,9 @@ const TASK_TITLE = "E2E test task";
test.describe("Onboarding wizard", () => { test.describe("Onboarding wizard", () => {
test("completes full wizard flow", async ({ page }) => { test("completes full wizard flow", async ({ page }) => {
await page.goto("/"); await page.goto("/onboarding");
const wizardHeading = page.locator("h3", { hasText: "Name your company" }); 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 }); await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
@ -45,7 +36,7 @@ test.describe("Onboarding wizard", () => {
await expect( await expect(
page.locator("h3", { hasText: "Create your first agent" }) page.locator("h3", { hasText: "Create your first agent" })
).toBeVisible({ timeout: 10_000 }); ).toBeVisible({ timeout: 30_000 });
const agentNameInput = page.locator('input[placeholder="CEO"]'); const agentNameInput = page.locator('input[placeholder="CEO"]');
await expect(agentNameInput).toHaveValue(AGENT_NAME); await expect(agentNameInput).toHaveValue(AGENT_NAME);
@ -61,7 +52,7 @@ test.describe("Onboarding wizard", () => {
await expect( await expect(
page.locator("h3", { hasText: "Give it something to do" }) page.locator("h3", { hasText: "Give it something to do" })
).toBeVisible({ timeout: 10_000 }); ).toBeVisible({ timeout: 30_000 });
const taskTitleInput = page.locator( const taskTitleInput = page.locator(
'input[placeholder="e.g. Research competitor pricing"]' 'input[placeholder="e.g. Research competitor pricing"]'
@ -73,7 +64,7 @@ test.describe("Onboarding wizard", () => {
await expect( await expect(
page.locator("h3", { hasText: "Ready to launch" }) 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=" + COMPANY_NAME)).toBeVisible();
await expect(page.locator("text=" + AGENT_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 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("/"); const baseUrl = page.url().split("/").slice(0, 3).join("/");

View file

@ -138,7 +138,16 @@ async function setupCompany(boardRequest: APIRequestContext): Promise<TestContex
// Helper: create agent + API key + request context // Helper: create agent + API key + request context
async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> { async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> {
const agentRes = await boardRequest.post(`${BASE_URL}/api/companies/${companyId}/agents`, { 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); expect(agentRes.ok()).toBe(true);
const agent = await agentRes.json(); const agent = await agentRes.json();

View file

@ -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>
);
}

View file

@ -12,6 +12,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
@ -166,6 +167,10 @@ export function IssueProperties({
const [projectSearch, setProjectSearch] = useState(""); const [projectSearch, setProjectSearch] = useState("");
const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedByOpen, setBlockedByOpen] = useState(false);
const [blockedBySearch, setBlockedBySearch] = useState(""); 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 [labelsOpen, setLabelsOpen] = useState(false);
const [labelSearch, setLabelSearch] = useState(""); const [labelSearch, setLabelSearch] = useState("");
const [newLabelName, setNewLabelName] = useState(""); const [newLabelName, setNewLabelName] = useState("");
@ -265,9 +270,59 @@ export function IssueProperties({
const assignee = issue.assigneeAgentId const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId) ? agents?.find((a) => a.id === issue.assigneeAgentId)
: null; : null;
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const assigneeUserLabel = userLabel(issue.assigneeUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId); 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 ? ( const labelsTrigger = (issue.labels ?? []).length > 0 ? (
<div className="flex items-center gap-1 flex-wrap"> <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 ? ( const projectTrigger = issue.projectId ? (
<> <>
<span <span
@ -750,6 +879,48 @@ export function IssueProperties({
</div> </div>
</PropertyRow> </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 && ( {issue.parentId && (
<PropertyRow label="Parent"> <PropertyRow label="Parent">
<Link <Link

View file

@ -13,6 +13,7 @@ import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { import {
assigneeValueFromSelection, assigneeValueFromSelection,
@ -48,6 +49,8 @@ import {
Loader2, Loader2,
ListTree, ListTree,
X, X,
Eye,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils"; import { extractProviderIdWithFallback } from "../lib/model-utils";
@ -66,6 +69,8 @@ interface IssueDraft {
status: string; status: string;
priority: string; priority: string;
assigneeValue: string; assigneeValue: string;
reviewerValue: string;
approverValue: string;
assigneeId?: string; assigneeId?: string;
projectId: string; projectId: string;
projectWorkspaceId?: string; projectWorkspaceId?: string;
@ -281,6 +286,11 @@ export function NewIssueDialog() {
const [status, setStatus] = useState("todo"); const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState(""); const [priority, setPriority] = useState("");
const [assigneeValue, setAssigneeValue] = 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 [projectId, setProjectId] = useState("");
const [projectWorkspaceId, setProjectWorkspaceId] = useState(""); const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
@ -484,6 +494,8 @@ export function NewIssueDialog() {
status, status,
priority, priority,
assigneeValue, assigneeValue,
reviewerValue,
approverValue,
projectId, projectId,
projectWorkspaceId, projectWorkspaceId,
assigneeModelOverride, assigneeModelOverride,
@ -498,6 +510,8 @@ export function NewIssueDialog() {
status, status,
priority, priority,
assigneeValue, assigneeValue,
reviewerValue,
approverValue,
projectId, projectId,
projectWorkspaceId, projectWorkspaceId,
assigneeModelOverride, assigneeModelOverride,
@ -547,6 +561,10 @@ export function NewIssueDialog() {
setProjectId(defaultProjectId); setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject)); setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
@ -565,6 +583,10 @@ export function NewIssueDialog() {
? assigneeValueFromSelection(newIssueDefaults) ? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""), : (draft.assigneeValue ?? draft.assigneeId ?? ""),
); );
setReviewerValue(draft.reviewerValue ?? "");
setApproverValue(draft.approverValue ?? "");
setShowReviewerRow(!!(draft.reviewerValue));
setShowApproverRow(!!(draft.approverValue));
setProjectId(restoredProjectId); setProjectId(restoredProjectId);
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)); setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
@ -584,6 +606,10 @@ export function NewIssueDialog() {
setProjectId(defaultProjectId); setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject)); setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
@ -626,6 +652,10 @@ export function NewIssueDialog() {
setStatus("todo"); setStatus("todo");
setPriority(""); setPriority("");
setAssigneeValue(""); setAssigneeValue("");
setReviewerValue("");
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setProjectId(""); setProjectId("");
setProjectWorkspaceId(""); setProjectWorkspaceId("");
setAssigneeOptionsOpen(false); setAssigneeOptionsOpen(false);
@ -647,6 +677,10 @@ export function NewIssueDialog() {
if (companyId === effectiveCompanyId) return; if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId); setDialogCompanyId(companyId);
setAssigneeValue(""); setAssigneeValue("");
setReviewerValue("");
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setProjectId(""); setProjectId("");
setProjectWorkspaceId(""); setProjectWorkspaceId("");
setAssigneeModelOverride(""); setAssigneeModelOverride("");
@ -685,6 +719,10 @@ export function NewIssueDialog() {
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? { mode: requestedExecutionWorkspaceMode } ? { mode: requestedExecutionWorkspaceMode }
: null; : null;
const executionPolicy = buildExecutionPolicy({
reviewerValues: reviewerValue ? [reviewerValue] : [],
approverValues: approverValue ? [approverValue] : [],
});
createIssue.mutate({ createIssue.mutate({
companyId: effectiveCompanyId, companyId: effectiveCompanyId,
stagedFiles, stagedFiles,
@ -704,6 +742,7 @@ export function NewIssueDialog() {
? { executionWorkspaceId: selectedExecutionWorkspaceId } ? { executionWorkspaceId: selectedExecutionWorkspaceId }
: {}), : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
...(executionPolicy ? { executionPolicy } : {}),
}); });
} }
@ -1061,7 +1100,7 @@ export function NewIssueDialog() {
<div className="px-4 pb-2 shrink-0"> <div className="px-4 pb-2 shrink-0">
<div className="overflow-x-auto overscroll-x-contain"> <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"> <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 <InlineEntitySelector
ref={assigneeSelectorRef} ref={assigneeSelectorRef}
value={assigneeValue} 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>
</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> </div>
{isSubIssueMode ? ( {isSubIssueMode ? (
@ -1441,11 +1611,11 @@ export function NewIssueDialog() {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* Labels chip (placeholder) */} {/* 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"> {/* <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" /> <Tag className="h-3 w-3" />
Labels Labels
</button> </button> */}
<input <input
ref={stageFileInputRef} ref={stageFileInputRef}

View file

@ -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,
};
}