mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
Add issue review policy and comment retry
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
4b39b0cc14
commit
b3e0c31239
18 changed files with 1409 additions and 5 deletions
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
27
packages/db/src/schema/issue_execution_decisions.ts
Normal file
27
packages/db/src/schema/issue_execution_decisions.ts
Normal 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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,12 @@ export type {
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
IssueRelationIssueSummary,
|
IssueRelationIssueSummary,
|
||||||
|
IssueExecutionPolicy,
|
||||||
|
IssueExecutionState,
|
||||||
|
IssueExecutionStage,
|
||||||
|
IssueExecutionStageParticipant,
|
||||||
|
IssueExecutionStagePrincipal,
|
||||||
|
IssueExecutionDecision,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
IssueDocumentSummary,
|
IssueDocumentSummary,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js";
|
import type {
|
||||||
|
IssueExecutionDecisionOutcome,
|
||||||
|
IssueExecutionPolicyMode,
|
||||||
|
IssueExecutionStageType,
|
||||||
|
IssueExecutionStateStatus,
|
||||||
|
IssueOriginKind,
|
||||||
|
IssuePriority,
|
||||||
|
IssueStatus,
|
||||||
|
} from "../constants.js";
|
||||||
import type { Goal } from "./goal.js";
|
import type { 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: number;
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ export {
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
createIssueLabelSchema,
|
createIssueLabelSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
|
issueExecutionPolicySchema,
|
||||||
|
issueExecutionStateSchema,
|
||||||
issueExecutionWorkspaceSettingsSchema,
|
issueExecutionWorkspaceSettingsSchema,
|
||||||
checkoutIssueSchema,
|
checkoutIssueSchema,
|
||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
|
|
|
||||||
|
|
@ -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.number().int().positive().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(),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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({
|
||||||
|
|
@ -415,4 +423,114 @@ describe("heartbeat comment wake batching", () => {
|
||||||
await gateway.close();
|
await gateway.close();
|
||||||
}
|
}
|
||||||
}, 20_000);
|
}, 20_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");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
131
server/src/__tests__/issue-execution-policy.test.ts
Normal file
131
server/src/__tests__/issue-execution-policy.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||||
|
|
||||||
|
describe("issue execution policy transitions", () => {
|
||||||
|
const coderAgentId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const qaAgentId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const ctoUserId = "cto-user";
|
||||||
|
const policy = normalizeIssueExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: qaAgentId }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "approval",
|
||||||
|
participants: [{ type: "user", userId: ctoUserId }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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("returns review changes to the prior executor", () => {
|
||||||
|
const reviewStageId = policy?.stages[0]?.id ?? "review-stage";
|
||||||
|
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("advances approved review work into approval", () => {
|
||||||
|
const reviewStageId = policy?.stages[0]?.id ?? "review-stage";
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ 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 +55,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 +1067,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,6 +1187,31 @@ 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,
|
||||||
|
});
|
||||||
|
Object.assign(updateFields, transition.patch);
|
||||||
|
|
||||||
let issue;
|
let issue;
|
||||||
try {
|
try {
|
||||||
issue = await svc.update(id, {
|
issue = await svc.update(id, {
|
||||||
|
|
@ -1338,7 +1366,22 @@ export function issueRoutes(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const assigneeChanged = assigneeWillChange;
|
if (transition.decision) {
|
||||||
|
await db.insert(issueExecutionDecisions).values({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
issueId: issue.id,
|
||||||
|
stageId: transition.decision.stageId,
|
||||||
|
stageType: transition.decision.stageType,
|
||||||
|
actorAgentId: actor.agentId ?? null,
|
||||||
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
outcome: transition.decision.outcome,
|
||||||
|
body: transition.decision.body,
|
||||||
|
createdByRunId: actor.runId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assigneeChanged =
|
||||||
|
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
||||||
const statusChangedFromBacklog =
|
const statusChangedFromBacklog =
|
||||||
existing.status === "backlog" &&
|
existing.status === "backlog" &&
|
||||||
issue.status !== "backlog" &&
|
issue.status !== "backlog" &&
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
347
server/src/services/issue-execution-policy.ts
Normal file
347
server/src/services/issue-execution-policy.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
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 IDLE_STATUS: IssueExecutionState["status"] = "idle";
|
||||||
|
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,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -66,6 +67,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 +284,8 @@ 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 [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 +489,8 @@ export function NewIssueDialog() {
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
assigneeValue,
|
assigneeValue,
|
||||||
|
reviewerValue,
|
||||||
|
approverValue,
|
||||||
projectId,
|
projectId,
|
||||||
projectWorkspaceId,
|
projectWorkspaceId,
|
||||||
assigneeModelOverride,
|
assigneeModelOverride,
|
||||||
|
|
@ -498,6 +505,8 @@ export function NewIssueDialog() {
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
assigneeValue,
|
assigneeValue,
|
||||||
|
reviewerValue,
|
||||||
|
approverValue,
|
||||||
projectId,
|
projectId,
|
||||||
projectWorkspaceId,
|
projectWorkspaceId,
|
||||||
assigneeModelOverride,
|
assigneeModelOverride,
|
||||||
|
|
@ -547,6 +556,8 @@ export function NewIssueDialog() {
|
||||||
setProjectId(defaultProjectId);
|
setProjectId(defaultProjectId);
|
||||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
|
setReviewerValue("");
|
||||||
|
setApproverValue("");
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
|
|
@ -565,6 +576,8 @@ export function NewIssueDialog() {
|
||||||
? assigneeValueFromSelection(newIssueDefaults)
|
? assigneeValueFromSelection(newIssueDefaults)
|
||||||
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
||||||
);
|
);
|
||||||
|
setReviewerValue(draft.reviewerValue ?? "");
|
||||||
|
setApproverValue(draft.approverValue ?? "");
|
||||||
setProjectId(restoredProjectId);
|
setProjectId(restoredProjectId);
|
||||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||||
|
|
@ -584,6 +597,8 @@ export function NewIssueDialog() {
|
||||||
setProjectId(defaultProjectId);
|
setProjectId(defaultProjectId);
|
||||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
|
setReviewerValue("");
|
||||||
|
setApproverValue("");
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
|
|
@ -626,6 +641,8 @@ export function NewIssueDialog() {
|
||||||
setStatus("todo");
|
setStatus("todo");
|
||||||
setPriority("");
|
setPriority("");
|
||||||
setAssigneeValue("");
|
setAssigneeValue("");
|
||||||
|
setReviewerValue("");
|
||||||
|
setApproverValue("");
|
||||||
setProjectId("");
|
setProjectId("");
|
||||||
setProjectWorkspaceId("");
|
setProjectWorkspaceId("");
|
||||||
setAssigneeOptionsOpen(false);
|
setAssigneeOptionsOpen(false);
|
||||||
|
|
@ -647,6 +664,8 @@ export function NewIssueDialog() {
|
||||||
if (companyId === effectiveCompanyId) return;
|
if (companyId === effectiveCompanyId) return;
|
||||||
setDialogCompanyId(companyId);
|
setDialogCompanyId(companyId);
|
||||||
setAssigneeValue("");
|
setAssigneeValue("");
|
||||||
|
setReviewerValue("");
|
||||||
|
setApproverValue("");
|
||||||
setProjectId("");
|
setProjectId("");
|
||||||
setProjectWorkspaceId("");
|
setProjectWorkspaceId("");
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
|
|
@ -685,6 +704,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 +727,7 @@ export function NewIssueDialog() {
|
||||||
? { executionWorkspaceId: selectedExecutionWorkspaceId }
|
? { executionWorkspaceId: selectedExecutionWorkspaceId }
|
||||||
: {}),
|
: {}),
|
||||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||||
|
...(executionPolicy ? { executionPolicy } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1153,6 +1177,64 @@ export function NewIssueDialog() {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<InlineEntitySelector
|
||||||
|
value={reviewerValue}
|
||||||
|
options={assigneeOptions}
|
||||||
|
placeholder="Reviewer"
|
||||||
|
disablePortal
|
||||||
|
noneLabel="No reviewer"
|
||||||
|
searchPlaceholder="Search reviewers..."
|
||||||
|
emptyMessage="No reviewers found."
|
||||||
|
onChange={setReviewerValue}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option ? (
|
||||||
|
<span className="truncate">{`Reviewer: ${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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InlineEntitySelector
|
||||||
|
value={approverValue}
|
||||||
|
options={assigneeOptions}
|
||||||
|
placeholder="Approver"
|
||||||
|
disablePortal
|
||||||
|
noneLabel="No approver"
|
||||||
|
searchPlaceholder="Search approvers..."
|
||||||
|
emptyMessage="No approvers found."
|
||||||
|
onChange={setApproverValue}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option ? (
|
||||||
|
<span className="truncate">{`Approver: ${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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
95
ui/src/lib/issue-execution-policy.ts
Normal file
95
ui/src/lib/issue-execution-policy.ts
Normal 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 = [];
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
participants: approvalParticipants,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stages.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
commentRequired: true,
|
||||||
|
stages,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue