diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 58a1dcdb..610bcb47 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -37,6 +37,9 @@ export const heartbeatRuns = pgTable( onDelete: "set null", }), processLossRetryCount: integer("process_loss_retry_count").notNull().default(0), + issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"), + issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"), + issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }), contextSnapshot: jsonb("context_snapshot").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 6c88e73e..505bc2e5 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -32,6 +32,7 @@ export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; +export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; export { feedbackVotes } from "./feedback_votes.js"; export { feedbackExports } from "./feedback_exports.js"; diff --git a/packages/db/src/schema/issue_execution_decisions.ts b/packages/db/src/schema/issue_execution_decisions.ts new file mode 100644 index 00000000..57214639 --- /dev/null +++ b/packages/db/src/schema/issue_execution_decisions.ts @@ -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), + }), +); diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 4581ee62..f32e292d 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -47,6 +47,8 @@ export const issues = pgTable( requestDepth: integer("request_depth").notNull().default(0), billingCode: text("billing_code"), assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type>(), + executionPolicy: jsonb("execution_policy").$type>(), + executionState: jsonb("execution_state").$type>(), executionWorkspaceId: uuid("execution_workspace_id") .references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }), executionWorkspacePreference: text("execution_workspace_preference"), diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index fd80f43b..e9bc3491 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -138,6 +138,18 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export const ISSUE_RELATION_TYPES = ["blocks"] as const; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; +export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; +export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; + +export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const; +export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number]; + +export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const; +export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number]; + +export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const; +export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number]; + export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const; export type GoalLevel = (typeof GOAL_LEVELS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fe2dac24..a5a01b36 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,10 @@ export { ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, + ISSUE_EXECUTION_POLICY_MODES, + ISSUE_EXECUTION_STAGE_TYPES, + ISSUE_EXECUTION_STATE_STATUSES, + ISSUE_EXECUTION_DECISION_OUTCOMES, GOAL_LEVELS, GOAL_STATUSES, PROJECT_STATUSES, @@ -84,6 +88,10 @@ export { type IssuePriority, type IssueOriginKind, type IssueRelationType, + type IssueExecutionPolicyMode, + type IssueExecutionStageType, + type IssueExecutionStateStatus, + type IssueExecutionDecisionOutcome, type GoalLevel, type GoalStatus, type ProjectStatus, @@ -233,6 +241,12 @@ export type { IssueAssigneeAdapterOverrides, IssueRelation, IssueRelationIssueSummary, + IssueExecutionPolicy, + IssueExecutionState, + IssueExecutionStage, + IssueExecutionStageParticipant, + IssueExecutionStagePrincipal, + IssueExecutionDecision, IssueComment, IssueDocument, IssueDocumentSummary, @@ -425,6 +439,8 @@ export { createIssueSchema, createIssueLabelSchema, updateIssueSchema, + issueExecutionPolicySchema, + issueExecutionStateSchema, issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2eeb8f9d..17570116 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -98,6 +98,12 @@ export type { IssueAssigneeAdapterOverrides, IssueRelation, IssueRelationIssueSummary, + IssueExecutionPolicy, + IssueExecutionState, + IssueExecutionStage, + IssueExecutionStageParticipant, + IssueExecutionStagePrincipal, + IssueExecutionDecision, IssueComment, IssueDocument, IssueDocumentSummary, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index fe28c034..9c408022 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,4 +1,12 @@ -import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js"; +import type { + IssueExecutionDecisionOutcome, + IssueExecutionPolicyMode, + IssueExecutionStageType, + IssueExecutionStateStatus, + IssueOriginKind, + IssuePriority, + IssueStatus, +} from "../constants.js"; import type { Goal } from "./goal.js"; import type { Project, ProjectWorkspace } from "./project.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; @@ -115,6 +123,56 @@ export interface IssueRelation { relatedIssue: IssueRelationIssueSummary; } +export interface IssueExecutionStagePrincipal { + type: "agent" | "user"; + agentId?: string | null; + userId?: string | null; +} + +export interface IssueExecutionStageParticipant extends IssueExecutionStagePrincipal { + id: string; +} + +export interface IssueExecutionStage { + id: string; + type: IssueExecutionStageType; + approvalsNeeded: 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 { id: string; companyId: string; @@ -143,6 +201,8 @@ export interface Issue { requestDepth: number; billingCode: string | null; assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null; + executionPolicy?: IssueExecutionPolicy | null; + executionState?: IssueExecutionState | null; executionWorkspaceId: string | null; executionWorkspacePreference: string | null; executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 20c26ddb..aca19625 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -131,6 +131,8 @@ export { createIssueSchema, createIssueLabelSchema, updateIssueSchema, + issueExecutionPolicySchema, + issueExecutionStateSchema, issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a1d24557..055591ff 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1,5 +1,12 @@ import { z } from "zod"; -import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js"; +import { + ISSUE_EXECUTION_DECISION_OUTCOMES, + ISSUE_EXECUTION_POLICY_MODES, + ISSUE_EXECUTION_STAGE_TYPES, + ISSUE_EXECUTION_STATE_STATUSES, + ISSUE_PRIORITIES, + ISSUE_STATUSES, +} from "../constants.js"; export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ "inherit", @@ -36,6 +43,76 @@ export const issueAssigneeAdapterOverridesSchema = z }) .strict(); +const issueExecutionStagePrincipalBaseSchema = z.object({ + type: z.enum(["agent", "user"]), + agentId: z.string().uuid().optional().nullable(), + userId: z.string().optional().nullable(), +}); + +export const issueExecutionStagePrincipalSchema = issueExecutionStagePrincipalBaseSchema + .superRefine((value, ctx) => { + if (value.type === "agent") { + if (!value.agentId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] }); + } + if (value.userId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] }); + } + return; + } + if (!value.userId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] }); + } + if (value.agentId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] }); + } + }); + +export const issueExecutionStageParticipantSchema = issueExecutionStagePrincipalBaseSchema.extend({ + id: z.string().uuid().optional(), +}).superRefine((value, ctx) => { + if (value.type === "agent") { + if (!value.agentId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] }); + } + if (value.userId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] }); + } + return; + } + if (!value.userId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] }); + } + if (value.agentId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] }); + } +}); + +export const issueExecutionStageSchema = z.object({ + id: z.string().uuid().optional(), + type: z.enum(ISSUE_EXECUTION_STAGE_TYPES), + approvalsNeeded: z.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({ projectId: z.string().uuid().optional().nullable(), projectWorkspaceId: z.string().uuid().optional().nullable(), @@ -52,6 +129,7 @@ export const createIssueSchema = z.object({ requestDepth: z.number().int().nonnegative().optional().default(0), billingCode: z.string().optional().nullable(), assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(), + executionPolicy: issueExecutionPolicySchema.optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(), executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(), executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(), diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index ac205b7b..8f5c250b 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -4,7 +4,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { createServer } from "node:http"; -import { and, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { WebSocketServer } from "ws"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { @@ -307,6 +307,14 @@ describe("heartbeat comment wake batching", () => { expect(firstRun).not.toBeNull(); await waitFor(() => gateway.getAgentPayloads().length === 1); + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: agentId, + createdByRunId: firstRun?.id ?? null, + body: "Heartbeat acknowledged", + }); + const comment2 = await db .insert(issueComments) .values({ @@ -415,4 +423,114 @@ describe("heartbeat comment wake batching", () => { await gateway.close(); } }, 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); }); diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts new file mode 100644 index 00000000..1d66840f --- /dev/null +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -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", + }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0b3b612a..482639d7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; +import { issueExecutionDecisions } from "@paperclipai/db"; import { addIssueCommentSchema, createIssueAttachmentMetadataSchema, @@ -54,6 +55,7 @@ import { SVG_CONTENT_TYPE, } from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; +import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ @@ -1065,6 +1067,7 @@ export function issueRoutes( const actor = getActorInfo(req); const issue = await svc.create(companyId, { ...req.body, + executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy), createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); @@ -1184,6 +1187,31 @@ export function issueRoutes( if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { updateFields.status = "todo"; } + if (req.body.executionPolicy !== undefined) { + updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); + } + + const transition = applyIssueExecutionPolicyTransition({ + issue: existing, + policy: + updateFields.executionPolicy !== undefined + ? (updateFields.executionPolicy as NonNullable | 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; try { 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 = existing.status === "backlog" && issue.status !== "backlog" && diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 365d2225..4492c84a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1835,6 +1835,210 @@ export function heartbeatService(db: Db) { return updated; } + async function patchRunIssueCommentStatus( + runId: string, + patch: Partial>, + ) { + return db + .update(heartbeatRuns) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(heartbeatRuns.id, runId)) + .returning() + .then((rows) => rows[0] ?? null); + } + + async function findRunIssueComment(runId: string, companyId: string, issueId: string) { + return db + .select({ + id: issueComments.id, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + eq(issueComments.issueId, issueId), + eq(issueComments.createdByRunId, runId), + ), + ) + .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function enqueueMissingIssueCommentRetry( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + issueId: string, + ) { + const contextSnapshot = parseObject(run.contextSnapshot); + const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); + const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); + const retryContextSnapshot = { + ...contextSnapshot, + retryOfRunId: run.id, + wakeReason: "missing_issue_comment", + retryReason: "missing_issue_comment", + missingIssueCommentForRunId: run.id, + }; + const now = new Date(); + + const retryRun = await db.transaction(async (tx) => { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, + ); + + const issue = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))) + .then((rows) => rows[0] ?? null); + if (!issue) return null; + + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + companyId: run.companyId, + agentId: run.agentId, + source: "automation", + triggerDetail: "system", + reason: "missing_issue_comment", + payload: { + issueId, + retryOfRunId: run.id, + retryReason: "missing_issue_comment", + }, + status: "queued", + requestedByActorType: "system", + requestedByActorId: null, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + const queuedRun = await tx + .insert(heartbeatRuns) + .values({ + companyId: run.companyId, + agentId: run.agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + wakeupRequestId: wakeupRequest.id, + contextSnapshot: retryContextSnapshot, + sessionIdBefore: sessionBefore, + retryOfRunId: run.id, + issueCommentStatus: "not_applicable", + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: queuedRun.id, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + await tx + .update(issues) + .set({ + executionRunId: queuedRun.id, + executionAgentNameKey: normalizeAgentNameKey(agent.name), + executionLockedAt: now, + updatedAt: now, + }) + .where(eq(issues.id, issue.id)); + + await tx + .update(heartbeatRuns) + .set({ + issueCommentStatus: "retry_queued", + issueCommentRetryQueuedAt: now, + updatedAt: now, + }) + .where(eq(heartbeatRuns.id, run.id)); + + return queuedRun; + }); + + if (!retryRun) return null; + + publishLiveEvent({ + companyId: retryRun.companyId, + type: "heartbeat.run.queued", + payload: { + runId: retryRun.id, + agentId: retryRun.agentId, + invocationSource: retryRun.invocationSource, + triggerDetail: retryRun.triggerDetail, + wakeupRequestId: retryRun.wakeupRequestId, + }, + }); + + return retryRun; + } + + async function finalizeIssueCommentPolicy( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + ) { + const contextSnapshot = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(contextSnapshot.issueId); + if (!issueId) { + if (run.issueCommentStatus !== "not_applicable") { + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "not_applicable", + issueCommentSatisfiedByCommentId: null, + issueCommentRetryQueuedAt: null, + }); + } + return { outcome: "not_applicable" as const, queuedRun: null }; + } + + const postedComment = await findRunIssueComment(run.id, run.companyId, issueId); + if (postedComment) { + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "satisfied", + issueCommentSatisfiedByCommentId: postedComment.id, + issueCommentRetryQueuedAt: null, + }); + return { outcome: "satisfied" as const, queuedRun: null }; + } + + if (readNonEmptyString(contextSnapshot.retryReason) === "missing_issue_comment") { + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "retry_exhausted", + issueCommentSatisfiedByCommentId: null, + }); + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Run ended without an issue comment after one retry; no further comment wake will be queued", + }); + return { outcome: "retry_exhausted" as const, queuedRun: null }; + } + + const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId); + if (queuedRun) { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Run ended without an issue comment; queued one follow-up wake to require a comment", + }); + return { outcome: "retry_queued" as const, queuedRun }; + } + + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "retry_exhausted", + issueCommentSatisfiedByCommentId: null, + }); + return { outcome: "retry_exhausted" as const, queuedRun: null }; + } + async function enqueueProcessLossRetry( run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect, @@ -3085,7 +3289,7 @@ export function heartbeatService(db: Db) { try { const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null); if (issueComment) { - await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id }); + await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id }); } } catch (err) { await onLog( @@ -3094,6 +3298,7 @@ export function heartbeatService(db: Db) { ); } } + await finalizeIssueCommentPolicy(finalizedRun, agent); await releaseIssueExecutionAndPromote(finalizedRun); } @@ -3160,6 +3365,7 @@ export function heartbeatService(db: Db) { level: "error", message, }); + await finalizeIssueCommentPolicy(failedRun, agent); await releaseIssueExecutionAndPromote(failedRun); await updateRuntimeState(agent, failedRun, { @@ -3211,6 +3417,10 @@ export function heartbeatService(db: Db) { level: "error", message, }).catch(() => undefined); + const failedAgent = await getAgent(run.agentId).catch(() => null); + if (failedAgent) { + await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined); + } await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined); } // Ensure the agent is not left stuck in "running" if the inner catch handler's diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts new file mode 100644 index 00000000..de37df6f --- /dev/null +++ b/server/src/services/issue-execution-policy.ts @@ -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 | null; + executionState?: IssueExecutionState | Record | 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; + decision?: Pick; +}; + +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(); + 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 => 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 = {}; + 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 }; +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 221c4889..937e4628 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -12,6 +12,7 @@ import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { formatAssigneeUserLabel } from "../lib/assignees"; +import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -166,6 +167,10 @@ export function IssueProperties({ const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); + const [reviewersOpen, setReviewersOpen] = useState(false); + const [reviewerSearch, setReviewerSearch] = useState(""); + const [approversOpen, setApproversOpen] = useState(false); + const [approverSearch, setApproverSearch] = useState(""); const [labelsOpen, setLabelsOpen] = useState(false); const [labelSearch, setLabelSearch] = useState(""); const [newLabelName, setNewLabelName] = useState(""); @@ -265,9 +270,59 @@ export function IssueProperties({ const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; + const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); + const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); + const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { + onUpdate({ + executionPolicy: buildExecutionPolicy({ + existingPolicy: issue.executionPolicy ?? null, + reviewerValues: nextReviewers, + approverValues: nextApprovers, + }), + }); + }; + const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => { + const currentValues = stageType === "review" ? reviewerValues : approverValues; + const nextValues = currentValues.includes(value) + ? currentValues.filter((candidate) => candidate !== value) + : [...currentValues, value]; + updateExecutionPolicy( + stageType === "review" ? nextValues : reviewerValues, + stageType === "approval" ? nextValues : approverValues, + ); + }; + const executionParticipantLabel = (value: string) => { + if (value.startsWith("agent:")) { + return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8); + } + if (value.startsWith("user:")) { + return userLabel(value.slice("user:".length)) ?? "User"; + } + return value; + }; + const reviewerTrigger = reviewerValues.length > 0 + ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} + : None; + const approverTrigger = approverValues.length > 0 + ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} + : None; + 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 ? (
@@ -454,6 +509,80 @@ export function IssueProperties({ ); + const executionParticipantsContent = ( + stageType: "review" | "approval", + values: string[], + search: string, + setSearch: (value: string) => void, + onClear: () => void, + ) => ( + <> + setSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( + + )} + {sortedAgents + .filter((agent) => { + if (!search.trim()) return true; + return agent.name.toLowerCase().includes(search.toLowerCase()); + }) + .map((agent) => { + const encoded = `agent:${agent.id}`; + return ( + + ); + })} +
+ + ); + const projectTrigger = issue.projectId ? ( <> + { setReviewersOpen(open); if (!open) setReviewerSearch(""); }} + triggerContent={reviewerTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-56" + > + {executionParticipantsContent( + "review", + reviewerValues, + reviewerSearch, + setReviewerSearch, + () => updateExecutionPolicy([], approverValues), + )} + + + { setApproversOpen(open); if (!open) setApproverSearch(""); }} + triggerContent={approverTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-56" + > + {executionParticipantsContent( + "approval", + approverValues, + approverSearch, + setApproverSearch, + () => updateExecutionPolicy(reviewerValues, []), + )} + + + {currentExecutionLabel && ( + + {currentExecutionLabel} + + )} + {issue.parentId && ( + + option ? ( + {`Reviewer: ${option.label}`} + ) : ( + Reviewer + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const reviewer = parseAssigneeValue(option.id).assigneeAgentId + ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId) + : null; + return ( + <> + {reviewer ? : null} + {option.label} + + ); + }} + /> + + option ? ( + {`Approver: ${option.label}`} + ) : ( + Approver + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const approver = parseAssigneeValue(option.id).assigneeAgentId + ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId) + : null; + return ( + <> + {approver ? : null} + {option.label} + + ); + }} + />
diff --git a/ui/src/lib/issue-execution-policy.ts b/ui/src/lib/issue-execution-policy.ts new file mode 100644 index 00000000..8db0279a --- /dev/null +++ b/ui/src/lib/issue-execution-policy.ts @@ -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, + }; +}