diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 728fe01f0e..4ec8f3e0d8 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -325,11 +325,20 @@ type PaperclipWakeBlockerSummary = { priority: string | null; }; +type PaperclipWakeTreeHoldSummary = { + holdId: string | null; + rootIssueId: string | null; + mode: string | null; + reason: string | null; +}; + type PaperclipWakePayload = { reason: string | null; issue: PaperclipWakeIssue | null; checkedOutByHarness: boolean; dependencyBlockedInteraction: boolean; + treeHoldInteraction: boolean; + activeTreeHold: PaperclipWakeTreeHoldSummary | null; unresolvedBlockerIssueIds: string[]; unresolvedBlockerSummaries: PaperclipWakeBlockerSummary[]; executionStage: PaperclipWakeExecutionStage | null; @@ -435,6 +444,16 @@ function normalizePaperclipWakeBlockerSummary(value: unknown): PaperclipWakeBloc return { id, identifier, title, status, priority }; } +function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTreeHoldSummary | null { + const hold = parseObject(value); + const holdId = asString(hold.holdId, "").trim() || null; + const rootIssueId = asString(hold.rootIssueId, "").trim() || null; + const mode = asString(hold.mode, "").trim() || null; + const reason = asString(hold.reason, "").trim() || null; + if (!holdId && !rootIssueId && !mode && !reason) return null; + return { holdId, rootIssueId, mode, reason }; +} + function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null { const principal = parseObject(value); const typeRaw = asString(principal.type, "").trim().toLowerCase(); @@ -511,7 +530,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl .filter((entry): entry is PaperclipWakeBlockerSummary => Boolean(entry)) : []; - if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) { + const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold); + if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) { return null; } @@ -520,6 +540,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl issue: normalizePaperclipWakeIssue(payload.issue), checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false), dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false), + treeHoldInteraction: asBoolean(payload.treeHoldInteraction, false), + activeTreeHold, unresolvedBlockerIssueIds, unresolvedBlockerSummaries, executionStage, @@ -614,6 +636,14 @@ export function renderPaperclipWakePrompt( lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`); } } + if (normalized.treeHoldInteraction) { + lines.push("- tree-hold interaction: yes"); + lines.push("- execution scope: respond or triage the human comment; the subtree remains paused until an explicit resume action"); + if (normalized.activeTreeHold) { + const hold = normalized.activeTreeHold; + lines.push(`- active tree hold: ${hold.holdId ?? "unknown"}${hold.rootIssueId ? ` rooted at ${hold.rootIssueId}` : ""}${hold.mode ? ` (${hold.mode})` : ""}`); + } + } if (normalized.missingCount > 0) { lines.push(`- omitted comments: ${normalized.missingCount}`); } diff --git a/packages/db/src/migrations/0066_issue_tree_holds.sql b/packages/db/src/migrations/0066_issue_tree_holds.sql new file mode 100644 index 0000000000..76c52f1691 --- /dev/null +++ b/packages/db/src/migrations/0066_issue_tree_holds.sql @@ -0,0 +1,107 @@ +CREATE TABLE IF NOT EXISTS "issue_tree_holds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "root_issue_id" uuid NOT NULL, + "mode" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "reason" text, + "release_policy" jsonb, + "created_by_actor_type" text DEFAULT 'system' NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_by_run_id" uuid, + "released_at" timestamp with time zone, + "released_by_actor_type" text, + "released_by_agent_id" uuid, + "released_by_user_id" text, + "released_by_run_id" uuid, + "release_reason" text, + "release_metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "issue_tree_hold_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "hold_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "parent_issue_id" uuid, + "depth" integer DEFAULT 0 NOT NULL, + "issue_identifier" text, + "issue_title" text NOT NULL, + "issue_status" text NOT NULL, + "assignee_agent_id" uuid, + "assignee_user_id" text, + "active_run_id" uuid, + "active_run_status" text, + "skipped" boolean DEFAULT false NOT NULL, + "skip_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_holds_company_id_companies_id_fk') THEN + ALTER TABLE "issue_tree_holds" ADD CONSTRAINT "issue_tree_holds_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_holds_root_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_tree_holds" ADD CONSTRAINT "issue_tree_holds_root_issue_id_issues_id_fk" FOREIGN KEY ("root_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_holds_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_tree_holds" ADD CONSTRAINT "issue_tree_holds_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "issue_tree_holds" ADD CONSTRAINT "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_holds_released_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_tree_holds" ADD CONSTRAINT "issue_tree_holds_released_by_agent_id_agents_id_fk" FOREIGN KEY ("released_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "issue_tree_holds" ADD CONSTRAINT "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("released_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_hold_members_company_id_companies_id_fk') THEN + ALTER TABLE "issue_tree_hold_members" ADD CONSTRAINT "issue_tree_hold_members_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_hold_members_hold_id_issue_tree_holds_id_fk') THEN + ALTER TABLE "issue_tree_hold_members" ADD CONSTRAINT "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk" FOREIGN KEY ("hold_id") REFERENCES "public"."issue_tree_holds"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_hold_members_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_tree_hold_members" ADD CONSTRAINT "issue_tree_hold_members_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_hold_members_parent_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_tree_hold_members" ADD CONSTRAINT "issue_tree_hold_members_parent_issue_id_issues_id_fk" FOREIGN KEY ("parent_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_hold_members_assignee_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_tree_hold_members" ADD CONSTRAINT "issue_tree_hold_members_assignee_agent_id_agents_id_fk" FOREIGN KEY ("assignee_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "issue_tree_hold_members" ADD CONSTRAINT "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("active_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_tree_holds_company_root_status_idx" ON "issue_tree_holds" USING btree ("company_id","root_issue_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_tree_holds_company_status_mode_idx" ON "issue_tree_holds" USING btree ("company_id","status","mode");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_tree_hold_members_hold_issue_uq" ON "issue_tree_hold_members" USING btree ("hold_id","issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_tree_hold_members_company_issue_idx" ON "issue_tree_hold_members" USING btree ("company_id","issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_tree_hold_members_hold_depth_idx" ON "issue_tree_hold_members" USING btree ("hold_id","depth"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4e963522a2..2262a428be 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -463,6 +463,13 @@ "when": 1776903900000, "tag": "0065_environments", "breakpoints": true + }, + { + "idx": 66, + "version": "7", + "when": 1776903901000, + "tag": "0066_issue_tree_holds", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 93ba238066..fcf2ecc947 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -38,6 +38,8 @@ export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; export { issueThreadInteractions } from "./issue_thread_interactions.js"; +export { issueTreeHolds } from "./issue_tree_holds.js"; +export { issueTreeHoldMembers } from "./issue_tree_hold_members.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; export { inboxDismissals } from "./inbox_dismissals.js"; diff --git a/packages/db/src/schema/issue_tree_hold_members.ts b/packages/db/src/schema/issue_tree_hold_members.ts new file mode 100644 index 0000000000..b28330636f --- /dev/null +++ b/packages/db/src/schema/issue_tree_hold_members.ts @@ -0,0 +1,33 @@ +import { index, pgTable, text, timestamp, uniqueIndex, uuid, boolean, integer } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issues } from "./issues.js"; +import { issueTreeHolds } from "./issue_tree_holds.js"; + +export const issueTreeHoldMembers = pgTable( + "issue_tree_hold_members", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + holdId: uuid("hold_id").notNull().references(() => issueTreeHolds.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }), + depth: integer("depth").notNull().default(0), + issueIdentifier: text("issue_identifier"), + issueTitle: text("issue_title").notNull(), + issueStatus: text("issue_status").notNull(), + assigneeAgentId: uuid("assignee_agent_id").references(() => agents.id, { onDelete: "set null" }), + assigneeUserId: text("assignee_user_id"), + activeRunId: uuid("active_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + activeRunStatus: text("active_run_status"), + skipped: boolean("skipped").notNull().default(false), + skipReason: text("skip_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + holdIssueUniqueIdx: uniqueIndex("issue_tree_hold_members_hold_issue_uq").on(table.holdId, table.issueId), + companyIssueIdx: index("issue_tree_hold_members_company_issue_idx").on(table.companyId, table.issueId), + holdDepthIdx: index("issue_tree_hold_members_hold_depth_idx").on(table.holdId, table.depth), + }), +); diff --git a/packages/db/src/schema/issue_tree_holds.ts b/packages/db/src/schema/issue_tree_holds.ts new file mode 100644 index 0000000000..d835649ce3 --- /dev/null +++ b/packages/db/src/schema/issue_tree_holds.ts @@ -0,0 +1,39 @@ +import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issues } from "./issues.js"; + +export const issueTreeHolds = pgTable( + "issue_tree_holds", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + rootIssueId: uuid("root_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + mode: text("mode").notNull(), + status: text("status").notNull().default("active"), + reason: text("reason"), + releasePolicy: jsonb("release_policy").$type>(), + createdByActorType: text("created_by_actor_type").notNull().default("system"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + releasedAt: timestamp("released_at", { withTimezone: true }), + releasedByActorType: text("released_by_actor_type"), + releasedByAgentId: uuid("released_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + releasedByUserId: text("released_by_user_id"), + releasedByRunId: uuid("released_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + releaseReason: text("release_reason"), + releaseMetadata: jsonb("release_metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyRootStatusIdx: index("issue_tree_holds_company_root_status_idx").on( + table.companyId, + table.rootIssueId, + table.status, + ), + companyStatusModeIdx: index("issue_tree_holds_company_status_mode_idx").on(table.companyId, table.status, table.mode), + }), +); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index ea3278be92..eef841f234 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -6,6 +6,8 @@ export const API = { agents: `${API_PREFIX}/agents`, projects: `${API_PREFIX}/projects`, issues: `${API_PREFIX}/issues`, + issueTreeControl: `${API_PREFIX}/issues/:issueId/tree-control`, + issueTreeHolds: `${API_PREFIX}/issues/:issueId/tree-holds`, goals: `${API_PREFIX}/goals`, approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b532167f5e..b8b48a60cf 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -170,6 +170,15 @@ export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind; export const ISSUE_RELATION_TYPES = ["blocks"] as const; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; +export const ISSUE_TREE_CONTROL_MODES = ["pause", "resume", "cancel", "restore"] as const; +export type IssueTreeControlMode = (typeof ISSUE_TREE_CONTROL_MODES)[number]; + +export const ISSUE_TREE_HOLD_STATUSES = ["active", "released"] as const; +export type IssueTreeHoldStatus = (typeof ISSUE_TREE_HOLD_STATUSES)[number]; + +export const ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES = ["manual", "after_active_runs_finish"] as const; +export type IssueTreeHoldReleasePolicyStrategy = (typeof ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES)[number]; + export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const; export const SYSTEM_ISSUE_DOCUMENT_KEYS = [ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY] as const; export type SystemIssueDocumentKey = (typeof SYSTEM_ISSUE_DOCUMENT_KEYS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index adfb692686..ddfccea945 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,6 +21,9 @@ export { ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, + ISSUE_TREE_CONTROL_MODES, + ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES, + ISSUE_TREE_HOLD_STATUSES, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, SYSTEM_ISSUE_DOCUMENT_KEYS, isSystemIssueDocumentKey, @@ -120,6 +123,9 @@ export { type PluginIssueOriginKind, type IssueOriginKind, type IssueRelationType, + type IssueTreeControlMode, + type IssueTreeHoldReleasePolicyStrategy, + type IssueTreeHoldStatus, type SystemIssueDocumentKey, type IssueReferenceSourceKind, type IssueExecutionPolicyMode, @@ -348,6 +354,15 @@ export type { LegacyPlanDocument, IssueAttachment, IssueLabel, + IssueTreeControlPreview, + IssueTreeHold, + IssueTreeHoldMember, + IssueTreeHoldReleasePolicy, + IssueTreePreviewAgent, + IssueTreePreviewIssue, + IssueTreePreviewRun, + IssueTreePreviewTotals, + IssueTreePreviewWarning, Goal, Approval, ApprovalComment, @@ -644,6 +659,11 @@ export { issueDocumentKeySchema, upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, + createIssueTreeHoldSchema, + issueTreeControlModeSchema, + issueTreeHoldReleasePolicySchema, + previewIssueTreeControlSchema, + releaseIssueTreeHoldSchema, type CreateIssue, type CreateChildIssue, type CreateIssueLabel, @@ -662,6 +682,9 @@ export { type IssueDocumentFormat, type UpsertIssueDocument, type RestoreIssueDocumentRevision, + type CreateIssueTreeHold, + type PreviewIssueTreeControl, + type ReleaseIssueTreeHold, createGoalSchema, updateGoalSchema, type CreateGoal, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 36bb5207d5..e336ac9700 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -148,6 +148,17 @@ export type { IssueAttachment, IssueLabel, } from "./issue.js"; +export type { + IssueTreeControlPreview, + IssueTreeHold, + IssueTreeHoldMember, + IssueTreeHoldReleasePolicy, + IssueTreePreviewAgent, + IssueTreePreviewIssue, + IssueTreePreviewRun, + IssueTreePreviewTotals, + IssueTreePreviewWarning, +} from "./issue-tree-control.js"; export type { Goal } from "./goal.js"; export type { Approval, ApprovalComment } from "./approval.js"; export type { diff --git a/packages/shared/src/types/issue-tree-control.ts b/packages/shared/src/types/issue-tree-control.ts new file mode 100644 index 0000000000..f8dacd8d7f --- /dev/null +++ b/packages/shared/src/types/issue-tree-control.ts @@ -0,0 +1,115 @@ +import type { + IssueStatus, + IssueTreeControlMode, + IssueTreeHoldReleasePolicyStrategy, + IssueTreeHoldStatus, +} from "../constants.js"; + +export interface IssueTreeHoldReleasePolicy { + strategy: IssueTreeHoldReleasePolicyStrategy; + note?: string | null; +} + +export interface IssueTreePreviewRun { + id: string; + issueId: string; + agentId: string; + status: "queued" | "running"; + startedAt: Date | null; + createdAt: Date; +} + +export interface IssueTreePreviewAgent { + agentId: string; + issueCount: number; + activeRunCount: number; +} + +export interface IssueTreePreviewIssue { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + parentId: string | null; + depth: number; + assigneeAgentId: string | null; + assigneeUserId: string | null; + activeRun: IssueTreePreviewRun | null; + activeHoldIds: string[]; + action: IssueTreeControlMode; + skipped: boolean; + skipReason: string | null; +} + +export interface IssueTreePreviewWarning { + code: string; + message: string; + issueIds?: string[]; +} + +export interface IssueTreePreviewTotals { + totalIssues: number; + affectedIssues: number; + skippedIssues: number; + activeRuns: number; + queuedRuns: number; + affectedAgents: number; +} + +export interface IssueTreeControlPreview { + companyId: string; + rootIssueId: string; + mode: IssueTreeControlMode; + generatedAt: Date; + releasePolicy: IssueTreeHoldReleasePolicy | null; + totals: IssueTreePreviewTotals; + countsByStatus: Partial>; + issues: IssueTreePreviewIssue[]; + skippedIssues: IssueTreePreviewIssue[]; + activeRuns: IssueTreePreviewRun[]; + affectedAgents: IssueTreePreviewAgent[]; + warnings: IssueTreePreviewWarning[]; +} + +export interface IssueTreeHoldMember { + id: string; + companyId: string; + holdId: string; + issueId: string; + parentIssueId: string | null; + depth: number; + issueIdentifier: string | null; + issueTitle: string; + issueStatus: IssueStatus; + assigneeAgentId: string | null; + assigneeUserId: string | null; + activeRunId: string | null; + activeRunStatus: string | null; + skipped: boolean; + skipReason: string | null; + createdAt: Date; +} + +export interface IssueTreeHold { + id: string; + companyId: string; + rootIssueId: string; + mode: IssueTreeControlMode; + status: IssueTreeHoldStatus; + reason: string | null; + releasePolicy: IssueTreeHoldReleasePolicy | null; + createdByActorType: "user" | "agent" | "system"; + createdByAgentId: string | null; + createdByUserId: string | null; + createdByRunId: string | null; + releasedAt: Date | null; + releasedByActorType: "user" | "agent" | "system" | null; + releasedByAgentId: string | null; + releasedByUserId: string | null; + releasedByRunId: string | null; + releaseReason: string | null; + releaseMetadata: Record | null; + createdAt: Date; + updatedAt: Date; + members?: IssueTreeHoldMember[]; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ecb5942745..33ae113c30 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -197,6 +197,17 @@ export { type RestoreIssueDocumentRevision, } from "./issue.js"; +export { + createIssueTreeHoldSchema, + issueTreeControlModeSchema, + issueTreeHoldReleasePolicySchema, + previewIssueTreeControlSchema, + releaseIssueTreeHoldSchema, + type CreateIssueTreeHold, + type PreviewIssueTreeControl, + type ReleaseIssueTreeHold, +} from "./issue-tree-control.js"; + export { createIssueWorkProductSchema, updateIssueWorkProductSchema, diff --git a/packages/shared/src/validators/issue-tree-control.ts b/packages/shared/src/validators/issue-tree-control.ts new file mode 100644 index 0000000000..48d78123cb --- /dev/null +++ b/packages/shared/src/validators/issue-tree-control.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { + ISSUE_TREE_CONTROL_MODES, + ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES, +} from "../constants.js"; + +export const issueTreeControlModeSchema = z.enum(ISSUE_TREE_CONTROL_MODES); + +export const issueTreeHoldReleasePolicySchema = z + .object({ + strategy: z.enum(ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES).default("manual"), + note: z.string().trim().min(1).max(500).optional().nullable(), + }) + .strict(); + +export const previewIssueTreeControlSchema = z + .object({ + mode: issueTreeControlModeSchema, + releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), + }) + .strict(); + +export type PreviewIssueTreeControl = z.infer; + +export const createIssueTreeHoldSchema = z + .object({ + mode: issueTreeControlModeSchema, + reason: z.string().trim().min(1).max(1000).optional().nullable(), + releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), + metadata: z.record(z.unknown()).optional().nullable(), + }) + .strict(); + +export type CreateIssueTreeHold = z.infer; + +export const releaseIssueTreeHoldSchema = z + .object({ + reason: z.string().trim().min(1).max(1000).optional().nullable(), + releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), + metadata: z.record(z.unknown()).optional().nullable(), + }) + .strict(); + +export type ReleaseIssueTreeHold = z.infer; diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index dfb4fcd528..c08c200833 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -16,6 +16,7 @@ import { issueComments, issueDocuments, issueRelations, + issueTreeHolds, issues, } from "@paperclipai/db"; import { @@ -119,6 +120,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = await db.delete(documentRevisions); await db.delete(documents); await db.delete(issueRelations); + await db.delete(issueTreeHolds); await db.delete(issues); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); @@ -343,4 +345,175 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = expect(promotedBlockedRun?.status).toBe("succeeded"); expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2); }); + + it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "SecurityEngineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Paused root", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + { + id: childIssueId, + companyId, + parentId: rootIssueId, + title: "Paused child", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + ]); + const [hold] = await db + .insert(issueTreeHolds) + .values({ + companyId, + rootIssueId, + mode: "pause", + status: "active", + reason: "security test hold", + releasePolicy: { strategy: "manual" }, + }) + .returning(); + + const blockedWake = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_blockers_resolved", + payload: { issueId: childIssueId }, + contextSnapshot: { issueId: childIssueId, wakeReason: "issue_blockers_resolved" }, + }); + + expect(blockedWake).toBeNull(); + const skippedWake = await db + .select({ + status: agentWakeupRequests.status, + reason: agentWakeupRequests.reason, + }) + .from(agentWakeupRequests) + .where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${childIssueId}`) + .then((rows) => rows[0] ?? null); + expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" }); + + const childCommentWake = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId: childIssueId, commentId: randomUUID() }, + contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented" }, + }); + + expect(childCommentWake).not.toBeNull(); + const childRun = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, childCommentWake!.id)) + .then((rows) => rows[0] ?? null); + expect(childRun?.contextSnapshot).toMatchObject({ + treeHoldInteraction: true, + activeTreeHold: { + holdId: hold.id, + rootIssueId, + mode: "pause", + interaction: true, + }, + }); + }); + + it("allows comment interaction wakes when a legacy hold has a full_pause note", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const rootIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "SecurityEngineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + await db.insert(issues).values({ + id: rootIssueId, + companyId, + title: "Paused root", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }); + await db.insert(issueTreeHolds).values({ + companyId, + rootIssueId, + mode: "pause", + status: "active", + reason: "full pause", + releasePolicy: { strategy: "manual", note: "full_pause" }, + }); + + const rootCommentWake = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId: rootIssueId, commentId: randomUUID() }, + contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" }, + }); + + expect(rootCommentWake).not.toBeNull(); + const rootRun = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, rootCommentWake!.id)) + .then((rows) => rows[0] ?? null); + expect(rootRun?.contextSnapshot).toMatchObject({ + treeHoldInteraction: true, + activeTreeHold: { + rootIssueId, + mode: "pause", + interaction: true, + }, + }); + }); }); diff --git a/server/src/__tests__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts new file mode 100644 index 0000000000..efdf6b9b17 --- /dev/null +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -0,0 +1,303 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockTreeControlService = vi.hoisted(() => ({ + preview: vi.fn(), + createHold: vi.fn(), + cancelIssueStatusesForHold: vi.fn(), + restoreIssueStatusesForHold: vi.fn(), + getHold: vi.fn(), + releaseHold: vi.fn(), + cancelUnclaimedWakeupsForTree: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockHeartbeatService = vi.hoisted(() => ({ + cancelRun: vi.fn(), + wakeup: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + heartbeatService: () => mockHeartbeatService, + issueService: () => mockIssueService, + issueTreeControlService: () => mockTreeControlService, + logActivity: mockLogActivity, +})); + +async function createApp(actor: Record) { + const [{ errorHandler }, { issueTreeControlRoutes }] = await Promise.all([ + import("../middleware/index.js"), + import("../routes/issue-tree-control.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueTreeControlRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("issue tree control routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-2", + }); + mockTreeControlService.cancelUnclaimedWakeupsForTree.mockResolvedValue([]); + mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [] }); + mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: [], + updatedIssues: [], + releasedCancelHoldIds: [], + restoreHold: null, + }); + mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockHeartbeatService.wakeup.mockResolvedValue(null); + }); + + it("rejects cross-company preview requests before calling the preview service", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-control/preview") + .send({ mode: "pause" }); + + expect(res.status).toBe(403); + expect(mockTreeControlService.preview).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("requires board access for hold creation", async () => { + const app = await createApp({ + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + companyId: "company-2", + runId: null, + source: "api_key", + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "pause" }); + + expect(res.status).toBe(403); + expect(mockIssueService.getById).not.toHaveBeenCalled(); + expect(mockTreeControlService.createHold).not.toHaveBeenCalled(); + }); + + it("cancels active descendant runs when creating a pause hold", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockTreeControlService.createHold.mockResolvedValue({ + hold: { + id: "33333333-3333-4333-8333-333333333333", + mode: "pause", + reason: "pause subtree", + }, + preview: { + mode: "pause", + totals: { affectedIssues: 1 }, + warnings: [], + activeRuns: [ + { + id: "44444444-4444-4444-8444-444444444444", + issueId: "11111111-1111-4111-8111-111111111111", + }, + ], + }, + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "pause", reason: "pause subtree" }); + + expect(res.status).toBe(201); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("44444444-4444-4444-8444-444444444444"); + expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).toHaveBeenCalledWith( + "company-2", + "11111111-1111-4111-8111-111111111111", + "Cancelled because an active subtree pause hold was created", + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.tree_hold_run_interrupted", + entityId: "44444444-4444-4444-8444-444444444444", + }), + ); + }); + + it("marks affected issues cancelled when creating a cancel hold", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockTreeControlService.createHold.mockResolvedValue({ + hold: { + id: "33333333-3333-4333-8333-333333333333", + mode: "cancel", + reason: "cancel subtree", + }, + preview: { + mode: "cancel", + totals: { affectedIssues: 2 }, + warnings: [], + activeRuns: [], + }, + }); + mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: [ + "11111111-1111-4111-8111-111111111111", + "55555555-5555-4555-8555-555555555555", + ], + updatedIssues: [], + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "cancel", reason: "cancel subtree" }); + + expect(res.status).toBe(201); + expect(mockTreeControlService.cancelIssueStatusesForHold).toHaveBeenCalledWith( + "company-2", + "11111111-1111-4111-8111-111111111111", + "33333333-3333-4333-8333-333333333333", + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.tree_cancel_status_updated", + details: expect.objectContaining({ cancelledIssueCount: 2 }), + }), + ); + }); + + it("restores affected issues and can request explicit wakeups", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockTreeControlService.createHold.mockResolvedValue({ + hold: { + id: "66666666-6666-4666-8666-666666666666", + mode: "restore", + reason: "restore subtree", + }, + preview: { + mode: "restore", + totals: { affectedIssues: 1 }, + warnings: [], + activeRuns: [], + }, + }); + mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: ["55555555-5555-4555-8555-555555555555"], + updatedIssues: [ + { + id: "55555555-5555-4555-8555-555555555555", + status: "todo", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + }, + ], + releasedCancelHoldIds: ["33333333-3333-4333-8333-333333333333"], + restoreHold: { + id: "66666666-6666-4666-8666-666666666666", + mode: "restore", + status: "released", + }, + }); + mockHeartbeatService.wakeup.mockResolvedValue({ + id: "77777777-7777-4777-8777-777777777777", + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "restore", reason: "restore subtree", metadata: { wakeAgents: true } }); + + expect(res.status).toBe(200); + expect(mockTreeControlService.restoreIssueStatusesForHold).toHaveBeenCalledWith( + "company-2", + "11111111-1111-4111-8111-111111111111", + "66666666-6666-4666-8666-666666666666", + expect.objectContaining({ reason: "restore subtree" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_tree_restored", + payload: expect.objectContaining({ issueId: "55555555-5555-4555-8555-555555555555" }), + }), + ); + expect(res.body.hold.status).toBe("released"); + }); + + it("releases a restore hold if the restore application fails", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockTreeControlService.createHold.mockResolvedValue({ + hold: { + id: "66666666-6666-4666-8666-666666666666", + mode: "restore", + reason: "restore subtree", + }, + preview: { + mode: "restore", + totals: { affectedIssues: 1 }, + warnings: [], + activeRuns: [], + }, + }); + mockTreeControlService.restoreIssueStatusesForHold.mockRejectedValue(new Error("restore failed")); + mockTreeControlService.releaseHold.mockResolvedValue({ + id: "66666666-6666-4666-8666-666666666666", + mode: "restore", + status: "released", + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "restore", reason: "restore subtree" }); + + expect(res.status).toBe(500); + expect(mockTreeControlService.releaseHold).toHaveBeenCalledWith( + "company-2", + "11111111-1111-4111-8111-111111111111", + "66666666-6666-4666-8666-666666666666", + expect.objectContaining({ + reason: "Restore operation failed before subtree status updates completed", + metadata: { cleanup: "restore_failed_before_apply" }, + }), + ); + }); +}); diff --git a/server/src/__tests__/issue-tree-control-service-unit.test.ts b/server/src/__tests__/issue-tree-control-service-unit.test.ts new file mode 100644 index 0000000000..be675c68a7 --- /dev/null +++ b/server/src/__tests__/issue-tree-control-service-unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { issueTreeControlService } from "../services/issue-tree-control.js"; + +function emptySelectDb() { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(resolve([])), + })), + })), + })), + }; +} + +describe("issueTreeControlService unit guards", () => { + it("rejects cross-company roots before traversing descendants", async () => { + const db = emptySelectDb(); + const svc = issueTreeControlService(db as any); + + await expect(svc.preview("company-2", "issue-from-company-1", { mode: "pause" })).rejects.toMatchObject({ + status: 404, + }); + expect(db.select).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/__tests__/issue-tree-control-service.test.ts b/server/src/__tests__/issue-tree-control-service.test.ts new file mode 100644 index 0000000000..d9db94724b --- /dev/null +++ b/server/src/__tests__/issue-tree-control-service.test.ts @@ -0,0 +1,452 @@ +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + heartbeatRuns, + issueTreeHoldMembers, + issueTreeHolds, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { issueTreeControlService } from "../services/issue-tree-control.js"; +import { issueService } from "../services/issues.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue tree control service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("issueTreeControlService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-tree-control-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueTreeHoldMembers); + await db.delete(issueTreeHolds); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("previews a subtree without changing issue statuses", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const rootIssueId = randomUUID(); + const runningChildId = randomUUID(); + const doneChildId = randomUUID(); + const cancelledChildId = randomUUID(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "OtherCo", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "running", + contextSnapshot: { issueId: runningChildId }, + }); + + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root", + status: "todo", + priority: "medium", + createdAt: new Date("2026-04-21T10:00:00.000Z"), + }, + { + id: runningChildId, + companyId, + parentId: rootIssueId, + title: "Running child", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: runId, + createdAt: new Date("2026-04-21T10:01:00.000Z"), + }, + { + id: doneChildId, + companyId, + parentId: rootIssueId, + title: "Done child", + status: "done", + priority: "medium", + createdAt: new Date("2026-04-21T10:02:00.000Z"), + }, + { + id: cancelledChildId, + companyId, + parentId: rootIssueId, + title: "Cancelled child", + status: "cancelled", + priority: "medium", + createdAt: new Date("2026-04-21T10:03:00.000Z"), + }, + ]); + + const svc = issueTreeControlService(db); + const preview = await svc.preview(companyId, rootIssueId, { mode: "pause" }); + + expect(preview.issues.map((issue) => [issue.id, issue.depth, issue.skipped, issue.skipReason])).toEqual([ + [rootIssueId, 0, false, null], + [runningChildId, 1, false, null], + [doneChildId, 1, true, "terminal_status"], + [cancelledChildId, 1, true, "terminal_status"], + ]); + expect(preview.totals).toMatchObject({ + totalIssues: 4, + affectedIssues: 2, + skippedIssues: 2, + activeRuns: 1, + queuedRuns: 0, + affectedAgents: 1, + }); + expect(preview.countsByStatus).toMatchObject({ todo: 1, in_progress: 1, done: 1, cancelled: 1 }); + expect(preview.activeRuns).toEqual([ + expect.objectContaining({ id: runId, issueId: runningChildId, agentId, status: "running" }), + ]); + expect(preview.warnings.map((warning) => warning.code)).toContain("running_runs_present"); + + const [runningChildAfterPreview] = await db + .select() + .from(issues) + .where(eq(issues.id, runningChildId)); + expect(runningChildAfterPreview.status).toBe("in_progress"); + + await expect(svc.preview(otherCompanyId, rootIssueId, { mode: "pause" })).rejects.toMatchObject({ + status: 404, + }); + }); + + it("creates and releases normalized hold snapshots", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: rootIssueId, + companyId, + title: "Root", + status: "todo", + priority: "medium", + }); + + const svc = issueTreeControlService(db); + const created = await svc.createHold(companyId, rootIssueId, { + mode: "pause", + reason: "operator requested pause", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + + expect(created.hold.status).toBe("active"); + expect(created.hold.members).toHaveLength(1); + expect(created.hold.members?.[0]).toMatchObject({ + issueId: rootIssueId, + issueStatus: "todo", + skipped: false, + }); + + const released = await svc.releaseHold(companyId, rootIssueId, created.hold.id, { + reason: "operator resumed", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + + expect(released.status).toBe("released"); + expect(released.releaseReason).toBe("operator resumed"); + expect(released.members).toHaveLength(1); + }); + + it("cancels non-terminal issue statuses and restores from the cancel snapshot", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const runningChildId = randomUUID(); + const todoChildId = randomUUID(); + const doneChildId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root", + status: "done", + priority: "medium", + createdAt: new Date("2026-04-21T10:00:00.000Z"), + }, + { + id: runningChildId, + companyId, + parentId: rootIssueId, + title: "Running child", + status: "in_progress", + priority: "medium", + createdAt: new Date("2026-04-21T10:01:00.000Z"), + }, + { + id: todoChildId, + companyId, + parentId: rootIssueId, + title: "Todo child", + status: "todo", + priority: "medium", + createdAt: new Date("2026-04-21T10:02:00.000Z"), + }, + { + id: doneChildId, + companyId, + parentId: rootIssueId, + title: "Done child", + status: "done", + priority: "medium", + createdAt: new Date("2026-04-21T10:03:00.000Z"), + }, + ]); + + const svc = issueTreeControlService(db); + const cancel = await svc.createHold(companyId, rootIssueId, { + mode: "cancel", + reason: "bad plan", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + expect(cancel.preview.issues.map((issue) => [issue.id, issue.skipped, issue.skipReason])).toEqual([ + [rootIssueId, true, "terminal_status"], + [runningChildId, false, null], + [todoChildId, false, null], + [doneChildId, true, "terminal_status"], + ]); + + const cancelled = await svc.cancelIssueStatusesForHold(companyId, rootIssueId, cancel.hold.id); + expect(cancelled.updatedIssueIds.sort()).toEqual([runningChildId, todoChildId].sort()); + + const afterCancel = await db + .select({ id: issues.id, status: issues.status }) + .from(issues) + .where(inArray(issues.id, [runningChildId, todoChildId, doneChildId])); + expect(Object.fromEntries(afterCancel.map((issue) => [issue.id, issue.status]))).toMatchObject({ + [runningChildId]: "cancelled", + [todoChildId]: "cancelled", + [doneChildId]: "done", + }); + + await db + .update(issues) + .set({ status: "blocked", cancelledAt: null, updatedAt: new Date() }) + .where(eq(issues.id, todoChildId)); + + const restorePreview = await svc.preview(companyId, rootIssueId, { mode: "restore" }); + expect(restorePreview.issues.map((issue) => [issue.id, issue.skipped, issue.skipReason])).toEqual([ + [rootIssueId, true, "not_cancelled"], + [runningChildId, false, null], + [todoChildId, true, "changed_after_cancel"], + [doneChildId, true, "not_cancelled"], + ]); + expect(restorePreview.warnings.map((warning) => warning.code)).toContain("restore_conflicts_present"); + + const restore = await svc.createHold(companyId, rootIssueId, { + mode: "restore", + reason: "resume useful work", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + const restored = await svc.restoreIssueStatusesForHold(companyId, rootIssueId, restore.hold.id, { + reason: "resume useful work", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + expect(restored.updatedIssueIds).toEqual([runningChildId]); + + const afterRestore = await db + .select({ id: issues.id, status: issues.status, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId }) + .from(issues) + .where(inArray(issues.id, [runningChildId, todoChildId, doneChildId])); + expect(Object.fromEntries(afterRestore.map((issue) => [issue.id, issue.status]))).toMatchObject({ + [runningChildId]: "todo", + [todoChildId]: "blocked", + [doneChildId]: "done", + }); + + const holds = await db + .select({ id: issueTreeHolds.id, mode: issueTreeHolds.mode, status: issueTreeHolds.status }) + .from(issueTreeHolds) + .where(inArray(issueTreeHolds.id, [cancel.hold.id, restore.hold.id])); + expect(Object.fromEntries(holds.map((hold) => [hold.mode, hold.status]))).toMatchObject({ + cancel: "released", + restore: "released", + }); + }); + + it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const rootRunId = randomUUID(); + const childRunId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "SecurityEngineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Paused root", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + { + id: childIssueId, + companyId, + parentId: rootIssueId, + title: "Paused child", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + ]); + await db.insert(heartbeatRuns).values([ + { + id: rootRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented", commentId: randomUUID() }, + }, + { + id: childRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented", commentId: randomUUID() }, + }, + ]); + + const treeSvc = issueTreeControlService(db); + await treeSvc.createHold(companyId, rootIssueId, { + mode: "pause", + reason: "operator requested pause", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + + const issueSvc = issueService(db); + await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], randomUUID())).rejects.toMatchObject({ + status: 409, + details: expect.objectContaining({ + rootIssueId, + mode: "pause", + }), + }); + + const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId); + expect(checkedOutChild.status).toBe("in_progress"); + expect(checkedOutChild.checkoutRunId).toBe(childRunId); + + const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId); + expect(checkedOutRoot.status).toBe("in_progress"); + expect(checkedOutRoot.checkoutRunId).toBe(rootRunId); + + await db.update(issues).set({ + status: "todo", + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }).where(eq(issues.id, rootIssueId)); + await db.update(issueTreeHolds).set({ + status: "released", + releasedAt: new Date(), + releasedByActorType: "user", + releasedByUserId: "board-user", + releaseReason: "switch to full pause", + updatedAt: new Date(), + }).where(eq(issueTreeHolds.rootIssueId, rootIssueId)); + await treeSvc.createHold(companyId, rootIssueId, { + mode: "pause", + reason: "full pause", + releasePolicy: { strategy: "manual", note: "full_pause" }, + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + + const checkedOutLegacyFullPauseRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId); + expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress"); + expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index b1a31401bc..e8c58386a8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,7 @@ import { companySkillRoutes } from "./routes/company-skills.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; +import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { routineRoutes } from "./routes/routines.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; import { goalRoutes } from "./routes/goals.js"; @@ -189,6 +190,7 @@ export async function createApp( api.use(issueRoutes(db, opts.storageService, { feedbackExportService: opts.feedbackExportService, })); + api.use(issueTreeControlRoutes(db)); api.use(routineRoutes(db)); api.use(executionWorkspaceRoutes(db)); api.use(goalRoutes(db)); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 8d954a9570..983562e265 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -4,6 +4,7 @@ export { companySkillRoutes } from "./company-skills.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; export { issueRoutes } from "./issues.js"; +export { issueTreeControlRoutes } from "./issue-tree-control.js"; export { routineRoutes } from "./routines.js"; export { goalRoutes } from "./goals.js"; export { approvalRoutes } from "./approvals.js"; diff --git a/server/src/routes/issue-tree-control.ts b/server/src/routes/issue-tree-control.ts new file mode 100644 index 0000000000..1a0ff062c2 --- /dev/null +++ b/server/src/routes/issue-tree-control.ts @@ -0,0 +1,347 @@ +import { Router } from "express"; +import type { Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { + createIssueTreeHoldSchema, + previewIssueTreeControlSchema, + releaseIssueTreeHoldSchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function issueTreeControlRoutes(db: Db) { + const router = Router(); + const issuesSvc = issueService(db); + const treeControlSvc = issueTreeControlService(db); + const heartbeat = heartbeatService(db); + + async function resolveRootIssue(req: Request) { + const rootIssueId = req.params.id as string; + const root = await issuesSvc.getById(rootIssueId); + return root; + } + + router.post("/issues/:id/tree-control/preview", validate(previewIssueTreeControlSchema), async (req, res) => { + assertBoard(req); + const root = await resolveRootIssue(req); + if (!root) { + res.status(404).json({ error: "Root issue not found" }); + return; + } + assertCompanyAccess(req, root.companyId); + + const preview = await treeControlSvc.preview(root.companyId, root.id, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_control_previewed", + entityType: "issue", + entityId: root.id, + details: { + mode: preview.mode, + totals: preview.totals, + warningCodes: preview.warnings.map((warning) => warning.code), + }, + }); + + res.json(preview); + }); + + router.post("/issues/:id/tree-holds", validate(createIssueTreeHoldSchema), async (req, res) => { + assertBoard(req); + const root = await resolveRootIssue(req); + if (!root) { + res.status(404).json({ error: "Root issue not found" }); + return; + } + assertCompanyAccess(req, root.companyId); + + const actor = getActorInfo(req); + const actorInput = { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }; + let result = await treeControlSvc.createHold(root.companyId, root.id, { + ...req.body, + actor: actorInput, + }); + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_hold_created", + entityType: "issue", + entityId: root.id, + details: { + holdId: result.hold.id, + mode: result.hold.mode, + reason: result.hold.reason, + totals: result.preview.totals, + warningCodes: result.preview.warnings.map((warning) => warning.code), + }, + }); + + if (result.hold.mode === "pause" || result.hold.mode === "cancel") { + const interruptedRunIds = [...new Set(result.preview.activeRuns.map((run) => run.id))]; + for (const runId of interruptedRunIds) { + await heartbeat.cancelRun(runId); + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_hold_run_interrupted", + entityType: "heartbeat_run", + entityId: runId, + details: { + holdId: result.hold.id, + rootIssueId: root.id, + reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation", + }, + }); + } + + const cancelledWakeups = await treeControlSvc.cancelUnclaimedWakeupsForTree( + root.companyId, + root.id, + result.hold.mode === "pause" + ? "Cancelled because an active subtree pause hold was created" + : "Cancelled because a subtree cancel operation was applied", + ); + for (const wakeup of cancelledWakeups) { + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_hold_wakeup_deferred", + entityType: "agent_wakeup_request", + entityId: wakeup.id, + details: { + holdId: result.hold.id, + rootIssueId: root.id, + agentId: wakeup.agentId, + previousReason: wakeup.reason, + }, + }); + } + } + + if (result.hold.mode === "cancel") { + const statusUpdate = await treeControlSvc.cancelIssueStatusesForHold(root.companyId, root.id, result.hold.id); + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_cancel_status_updated", + entityType: "issue", + entityId: root.id, + details: { + holdId: result.hold.id, + cancelledIssueIds: statusUpdate.updatedIssueIds, + cancelledIssueCount: statusUpdate.updatedIssueIds.length, + }, + }); + } + + if (result.hold.mode === "restore") { + let statusUpdate; + try { + statusUpdate = await treeControlSvc.restoreIssueStatusesForHold(root.companyId, root.id, result.hold.id, { + reason: result.hold.reason, + actor: actorInput, + }); + } catch (error) { + await treeControlSvc.releaseHold(root.companyId, root.id, result.hold.id, { + reason: "Restore operation failed before subtree status updates completed", + metadata: { + cleanup: "restore_failed_before_apply", + }, + actor: actorInput, + }).catch(() => null); + throw error; + } + if (statusUpdate.restoreHold) { + result = { ...result, hold: statusUpdate.restoreHold }; + } + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_restore_status_updated", + entityType: "issue", + entityId: root.id, + details: { + holdId: result.hold.id, + restoredIssueIds: statusUpdate.updatedIssueIds, + restoredIssueCount: statusUpdate.updatedIssueIds.length, + releasedCancelHoldIds: statusUpdate.releasedCancelHoldIds, + }, + }); + + const wakeAgents = typeof req.body.metadata === "object" + && req.body.metadata !== null + && (req.body.metadata as Record).wakeAgents === true; + if (wakeAgents) { + for (const restoredIssue of statusUpdate.updatedIssues) { + if (!restoredIssue.assigneeAgentId) continue; + const wakeRun = await heartbeat + .wakeup(restoredIssue.assigneeAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_tree_restored", + payload: { + issueId: restoredIssue.id, + rootIssueId: root.id, + restoreHoldId: result.hold.id, + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: restoredIssue.id, + taskId: restoredIssue.id, + wakeReason: "issue_tree_restored", + source: "issue.tree_restore", + rootIssueId: root.id, + restoreHoldId: result.hold.id, + }, + }) + .catch(() => null); + if (!wakeRun) continue; + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_restore_wakeup_requested", + entityType: "heartbeat_run", + entityId: wakeRun.id, + details: { + holdId: result.hold.id, + rootIssueId: root.id, + issueId: restoredIssue.id, + agentId: restoredIssue.assigneeAgentId, + }, + }); + } + } + } + + res.status(result.hold.mode === "restore" ? 200 : 201).json(result); + }); + + router.get("/issues/:id/tree-control/state", async (req, res) => { + assertBoard(req); + const issueId = req.params.id as string; + const issue = await issuesSvc.getById(issueId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id); + res.json({ activePauseHold }); + }); + + router.get("/issues/:id/tree-holds", async (req, res) => { + assertBoard(req); + const root = await resolveRootIssue(req); + if (!root) { + res.status(404).json({ error: "Root issue not found" }); + return; + } + assertCompanyAccess(req, root.companyId); + const statusParam = typeof req.query.status === "string" ? req.query.status : null; + const modeParam = typeof req.query.mode === "string" ? req.query.mode : null; + const includeMembers = req.query.includeMembers === "true"; + const holds = await treeControlSvc.listHolds(root.companyId, root.id, { + status: statusParam === "active" || statusParam === "released" ? statusParam : undefined, + mode: + modeParam === "pause" || modeParam === "resume" || modeParam === "cancel" || modeParam === "restore" + ? modeParam + : undefined, + includeMembers, + }); + res.json(holds); + }); + + router.get("/issues/:id/tree-holds/:holdId", async (req, res) => { + assertBoard(req); + const root = await resolveRootIssue(req); + if (!root) { + res.status(404).json({ error: "Root issue not found" }); + return; + } + assertCompanyAccess(req, root.companyId); + + const hold = await treeControlSvc.getHold(root.companyId, req.params.holdId as string); + if (!hold || hold.rootIssueId !== root.id) { + res.status(404).json({ error: "Issue tree hold not found" }); + return; + } + res.json(hold); + }); + + router.post( + "/issues/:id/tree-holds/:holdId/release", + validate(releaseIssueTreeHoldSchema), + async (req, res) => { + assertBoard(req); + const root = await resolveRootIssue(req); + if (!root) { + res.status(404).json({ error: "Root issue not found" }); + return; + } + assertCompanyAccess(req, root.companyId); + + const actor = getActorInfo(req); + const hold = await treeControlSvc.releaseHold(root.companyId, root.id, req.params.holdId as string, { + ...req.body, + actor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, + }); + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_hold_released", + entityType: "issue", + entityId: root.id, + details: { + holdId: hold.id, + mode: hold.mode, + reason: hold.releaseReason, + memberCount: hold.members?.length ?? 0, + }, + }); + + res.json(hold); + }, + ); + + return router; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 06707b2119..0618bea9be 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -80,6 +80,10 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; +import { + ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, + issueTreeControlService, +} from "./issue-tree-control.js"; import { getIssueContinuationSummaryDocument, refreshIssueContinuationSummary, @@ -1251,17 +1255,11 @@ function shouldRequireIssueCommentForWake( ); } -const BLOCKED_INTERACTION_WAKE_REASONS = new Set([ - "issue_commented", - "issue_reopened_via_comment", - "issue_comment_mentioned", -]); - -function allowsBlockedIssueInteractionWake( +function allowsIssueInteractionWake( contextSnapshot: Record | null | undefined, ) { const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); - if (!wakeReason || !BLOCKED_INTERACTION_WAKE_REASONS.has(wakeReason)) return false; + if (!wakeReason || !ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS.has(wakeReason)) return false; return Boolean(deriveCommentId(contextSnapshot, null)); } @@ -1630,6 +1628,8 @@ async function buildPaperclipWakePayload(input: { : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, + treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, + activeTreeHold: parseObject(input.contextSnapshot.activeTreeHold), unresolvedBlockerIssueIds: Array.isArray(input.contextSnapshot.unresolvedBlockerIssueIds) ? input.contextSnapshot.unresolvedBlockerIssueIds.filter((value): value is string => typeof value === "string" && value.length > 0) : [], @@ -1843,6 +1843,7 @@ export function heartbeatService(db: Db) { const secretsSvc = secretService(db); const companySkills = companySkillService(db); const issuesSvc = issueService(db); + const treeControlSvc = issueTreeControlService(db); const executionWorkspacesSvc = executionWorkspaceService(db); const environmentsSvc = environmentService(db); const workspaceOperationsSvc = workspaceOperationService(db); @@ -3499,9 +3500,33 @@ export function heartbeatService(db: Db) { const issueId = readNonEmptyString(context.issueId); if (issueId) { + const activePauseHold = await treeControlSvc.getActivePauseHoldGate(run.companyId, issueId); + const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(context); + if (activePauseHold && !treeHoldInteractionWake) { + await cancelRunInternal(run.id, "Cancelled because issue is held by an active subtree pause hold"); + await logActivity(db, { + companyId: run.companyId, + actorType: "system", + actorId: "system", + agentId: run.agentId, + runId: run.id, + action: "issue.tree_hold_run_interrupted", + entityType: "heartbeat_run", + entityId: run.id, + details: { + issueId, + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + source: "heartbeat.claim_queued_run", + securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"], + }, + }); + return null; + } + const dependencyReadiness = await issuesSvc.listDependencyReadiness(run.companyId, [issueId]); const unresolvedBlockerCount = dependencyReadiness.get(issueId)?.unresolvedBlockerCount ?? 0; - if (unresolvedBlockerCount > 0 && !allowsBlockedIssueInteractionWake(context)) { + if (unresolvedBlockerCount > 0 && !allowsIssueInteractionWake(context)) { logger.debug({ runId: run.id, issueId, unresolvedBlockerCount }, "claimQueuedRun: skipping blocked run"); return null; } @@ -6083,7 +6108,33 @@ export function heartbeatService(db: Db) { const deferredPayload = parseObject(deferred.payload); const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]); + const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id); + const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(deferredContextSeed); + if (activePauseHold && !treeHoldInteractionWake) { + await tx + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: new Date(), + error: "Deferred wake suppressed by active subtree pause hold", + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, deferred.id)); + continue; + } + const promotedContextSeed: Record = { ...deferredContextSeed }; + if (activePauseHold) { + promotedContextSeed.treeHoldInteraction = true; + promotedContextSeed.activeTreeHold = { + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + mode: activePauseHold.mode, + reason: activePauseHold.reason, + releasePolicy: activePauseHold.releasePolicy, + interaction: true, + }; + } const deferredCommentIds = extractWakeCommentIds(deferredContextSeed); const shouldReopenDeferredCommentWake = deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled"); @@ -6472,6 +6523,46 @@ export function heartbeatService(db: Db) { return null; } + if (issueId) { + const activePauseHold = await treeControlSvc.getActivePauseHoldGate(agent.companyId, issueId); + if (activePauseHold) { + const treeHoldInteractionWake = allowsIssueInteractionWake(enrichedContextSnapshot); + + if (!treeHoldInteractionWake) { + await writeSkippedRequest("issue_tree_hold_active"); + await logActivity(db, { + companyId: agent.companyId, + actorType: "system", + actorId: "system", + agentId, + runId: null, + action: "issue.tree_hold_wakeup_deferred", + entityType: "issue", + entityId: issueId, + details: { + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + requestedReason: reason, + source, + triggerDetail, + securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"], + }, + }); + return null; + } + + enrichedContextSnapshot.treeHoldInteraction = true; + enrichedContextSnapshot.activeTreeHold = { + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + mode: activePauseHold.mode, + reason: activePauseHold.reason, + releasePolicy: activePauseHold.releasePolicy, + interaction: true, + }; + } + } + if (issueId) { // Mention-triggered wakes can request input from another agent, but they must // still respect the issue execution lock so a second agent cannot start on the @@ -6589,7 +6680,7 @@ export function heartbeatService(db: Db) { const blockedInteractionWake = dependencyReadiness && !dependencyReadiness.isDependencyReady && - allowsBlockedIssueInteractionWake(enrichedContextSnapshot); + allowsIssueInteractionWake(enrichedContextSnapshot); if (blockedInteractionWake) { enrichedContextSnapshot.dependencyBlockedInteraction = true; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 8abe9eb630..f4502658b6 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -20,6 +20,7 @@ export { type IssueFilters, } from "./issues.js"; export { issueThreadInteractionService } from "./issue-thread-interactions.js"; +export { issueTreeControlService } from "./issue-tree-control.js"; export { issueApprovalService } from "./issue-approvals.js"; export { issueReferenceService } from "./issue-references.js"; export { goalService } from "./goals.js"; diff --git a/server/src/services/issue-tree-control.ts b/server/src/services/issue-tree-control.ts new file mode 100644 index 0000000000..ca57ebb4b0 --- /dev/null +++ b/server/src/services/issue-tree-control.ts @@ -0,0 +1,947 @@ +import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agentWakeupRequests, + heartbeatRuns, + issueTreeHoldMembers, + issueTreeHolds, + issues, +} from "@paperclipai/db"; +import { + ISSUE_STATUSES, + type IssueStatus, + type IssueTreeControlMode, + type IssueTreeControlPreview, + type IssueTreeHold, + type IssueTreeHoldMember, + type IssueTreeHoldReleasePolicy, + type IssueTreePreviewAgent, + type IssueTreePreviewIssue, + type IssueTreePreviewRun, + type IssueTreePreviewWarning, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +type IssueRow = typeof issues.$inferSelect; +type HoldRow = typeof issueTreeHolds.$inferSelect; +type HoldMemberRow = typeof issueTreeHoldMembers.$inferSelect; +export type ActiveIssueTreePauseHoldGate = { + holdId: string; + rootIssueId: string; + issueId: string; + isRoot: boolean; + mode: "pause"; + reason: string | null; + releasePolicy: IssueTreeHoldReleasePolicy | null; +}; +type ActorInput = { + actorType: "user" | "agent" | "system"; + actorId: string; + agentId?: string | null; + userId?: string | null; + runId?: string | null; +}; +type TreeIssue = IssueRow & { depth: number }; +type ActiveRunRow = { + id: string; + issueId: string; + agentId: string; + status: "queued" | "running"; + startedAt: Date | null; + createdAt: Date; +}; +type ActiveCancelSnapshot = { + holdIds: string[]; + member: IssueTreeHoldMember | null; +}; +type TreeStatusUpdateResult = { + updatedIssueIds: string[]; + updatedIssues: Array<{ + id: string; + status: IssueStatus; + assigneeAgentId: string | null; + }>; +}; +type RestoreTreeStatusResult = TreeStatusUpdateResult & { + releasedCancelHoldIds: string[]; + restoreHold: IssueTreeHold | null; +}; + +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); +const ACTIVE_RUN_STATUSES = ["queued", "running"] as const; +const DEFAULT_RELEASE_POLICY: IssueTreeHoldReleasePolicy = { strategy: "manual" }; +const MAX_PAUSE_HOLD_GATE_DEPTH = 15; +export const ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS: ReadonlySet = new Set([ + "issue_commented", + "issue_reopened_via_comment", + "issue_comment_mentioned", +] as const); + +function normalizeReleasePolicy( + releasePolicy: IssueTreeHoldReleasePolicy | null | undefined, +): IssueTreeHoldReleasePolicy { + return releasePolicy ?? DEFAULT_RELEASE_POLICY; +} + +function coerceIssueStatus(status: string): IssueStatus { + return ISSUE_STATUSES.includes(status as IssueStatus) ? (status as IssueStatus) : "backlog"; +} + +function isTerminalIssue(status: string): status is IssueStatus { + return TERMINAL_ISSUE_STATUSES.has(coerceIssueStatus(status)); +} + +function toPreviewRun(row: ActiveRunRow): IssueTreePreviewRun { + return { + id: row.id, + issueId: row.issueId, + agentId: row.agentId, + status: row.status, + startedAt: row.startedAt, + createdAt: row.createdAt, + }; +} + +function toHold(row: HoldRow, members?: HoldMemberRow[]): IssueTreeHold { + return { + id: row.id, + companyId: row.companyId, + rootIssueId: row.rootIssueId, + mode: row.mode as IssueTreeControlMode, + status: row.status as IssueTreeHold["status"], + reason: row.reason, + releasePolicy: (row.releasePolicy as IssueTreeHoldReleasePolicy | null) ?? null, + createdByActorType: row.createdByActorType as IssueTreeHold["createdByActorType"], + createdByAgentId: row.createdByAgentId, + createdByUserId: row.createdByUserId, + createdByRunId: row.createdByRunId, + releasedAt: row.releasedAt, + releasedByActorType: row.releasedByActorType as IssueTreeHold["releasedByActorType"], + releasedByAgentId: row.releasedByAgentId, + releasedByUserId: row.releasedByUserId, + releasedByRunId: row.releasedByRunId, + releaseReason: row.releaseReason, + releaseMetadata: row.releaseMetadata ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(members ? { members: members.map(toHoldMember) } : {}), + }; +} + +function toHoldMember(row: HoldMemberRow): IssueTreeHoldMember { + return { + id: row.id, + companyId: row.companyId, + holdId: row.holdId, + issueId: row.issueId, + parentIssueId: row.parentIssueId, + depth: row.depth, + issueIdentifier: row.issueIdentifier, + issueTitle: row.issueTitle, + issueStatus: coerceIssueStatus(row.issueStatus), + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + activeRunId: row.activeRunId, + activeRunStatus: row.activeRunStatus, + skipped: row.skipped, + skipReason: row.skipReason, + createdAt: row.createdAt, + }; +} + +function issueSkipReason(input: { + mode: IssueTreeControlMode; + issue: TreeIssue; + activePauseHoldIds: string[]; + activeCancelSnapshot?: ActiveCancelSnapshot | null; +}): string | null { + const status = coerceIssueStatus(input.issue.status); + if (input.mode === "restore") { + if (input.activeCancelSnapshot?.member && status !== "cancelled") { + return "changed_after_cancel"; + } + if (status !== "cancelled") return "not_cancelled"; + if (!input.activeCancelSnapshot?.member) return "not_cancelled_by_tree_control"; + const snapshotStatus = coerceIssueStatus(input.activeCancelSnapshot.member.issueStatus); + return isTerminalIssue(snapshotStatus) ? "terminal_status" : null; + } + if (isTerminalIssue(status)) { + return "terminal_status"; + } + if (input.mode === "pause" && input.activePauseHoldIds.length > 0) { + return "already_held"; + } + if (input.mode === "resume" && input.activePauseHoldIds.length === 0) { + return "not_held"; + } + return null; +} + +function buildAffectedAgents(issuesToPreview: IssueTreePreviewIssue[]): IssueTreePreviewAgent[] { + const byAgentId = new Map(); + for (const issue of issuesToPreview) { + if (issue.skipped) continue; + const agentIds = new Set(); + if (issue.assigneeAgentId) agentIds.add(issue.assigneeAgentId); + if (issue.activeRun) agentIds.add(issue.activeRun.agentId); + for (const agentId of agentIds) { + const current = byAgentId.get(agentId) ?? { agentId, issueCount: 0, activeRunCount: 0 }; + current.issueCount += 1; + if (issue.activeRun?.agentId === agentId) current.activeRunCount += 1; + byAgentId.set(agentId, current); + } + } + return [...byAgentId.values()].sort((a, b) => a.agentId.localeCompare(b.agentId)); +} + +function buildWarnings(input: { + mode: IssueTreeControlMode; + issuesToPreview: IssueTreePreviewIssue[]; + activeRuns: IssueTreePreviewRun[]; +}): IssueTreePreviewWarning[] { + const affectedIssues = input.issuesToPreview.filter((issue) => !issue.skipped); + const affectedIssueIds = new Set(affectedIssues.map((issue) => issue.id)); + const affectedRuns = input.activeRuns.filter((run) => affectedIssueIds.has(run.issueId)); + const warnings: IssueTreePreviewWarning[] = []; + + if (affectedIssues.length === 0) { + warnings.push({ + code: "no_affected_issues", + message: "No issues in this subtree match the requested control action.", + }); + } + + const runningRunIssueIds = affectedRuns + .filter((run) => run.status === "running") + .map((run) => run.issueId); + if ((input.mode === "pause" || input.mode === "cancel") && runningRunIssueIds.length > 0) { + warnings.push({ + code: "running_runs_present", + message: "Some affected issues have running heartbeat runs.", + issueIds: [...new Set(runningRunIssueIds)].sort(), + }); + } + + const queuedRunIssueIds = affectedRuns + .filter((run) => run.status === "queued") + .map((run) => run.issueId); + if ((input.mode === "pause" || input.mode === "cancel") && queuedRunIssueIds.length > 0) { + warnings.push({ + code: "queued_runs_present", + message: "Some affected issues have queued heartbeat runs.", + issueIds: [...new Set(queuedRunIssueIds)].sort(), + }); + } + + if (input.mode === "resume" && affectedIssues.length === 0) { + warnings.push({ + code: "no_active_pause_holds", + message: "No active pause holds were found in this subtree.", + }); + } + + if (input.mode === "restore") { + const changedIssueIds = input.issuesToPreview + .filter((issue) => issue.skipReason === "changed_after_cancel") + .map((issue) => issue.id); + if (changedIssueIds.length > 0) { + warnings.push({ + code: "restore_conflicts_present", + message: "Some issues changed after subtree cancellation and will be skipped.", + issueIds: changedIssueIds, + }); + } + } + + return warnings; +} + +function restoreStatusFromCancelSnapshot(status: IssueStatus): IssueStatus | null { + if (status === "in_progress") return "todo"; + if (isTerminalIssue(status)) return null; + return status; +} + +export function issueTreeControlService(db: Db) { + async function listTreeIssues(companyId: string, rootIssueId: string): Promise { + const root = await db + .select() + .from(issues) + .where(and(eq(issues.id, rootIssueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!root) { + throw notFound("Root issue not found"); + } + + const result: TreeIssue[] = [{ ...root, depth: 0 }]; + const visited = new Set([root.id]); + let frontier = [{ id: root.id, depth: 0 }]; + + while (frontier.length > 0) { + const parentIds = frontier.map((item) => item.id); + const depthByParentId = new Map(frontier.map((item) => [item.id, item.depth])); + const children = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.parentId, parentIds))) + .orderBy(asc(issues.createdAt), asc(issues.id)); + + const nextFrontier: typeof frontier = []; + for (const child of children) { + if (visited.has(child.id)) continue; + const depth = (depthByParentId.get(child.parentId ?? "") ?? 0) + 1; + visited.add(child.id); + result.push({ ...child, depth }); + nextFrontier.push({ id: child.id, depth }); + } + frontier = nextFrontier; + } + + return result; + } + + async function activeRunsForTree(companyId: string, treeIssues: TreeIssue[]) { + const issueIds = treeIssues.map((issue) => issue.id); + if (issueIds.length === 0) return []; + const runIds = treeIssues + .map((issue) => issue.executionRunId) + .filter((id): id is string => typeof id === "string" && id.length > 0); + const uniqueRunIds = [...new Set(runIds)]; + const issueIdFromContext = sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`; + const issueIdSet = new Set(issueIds); + + const rows = await db + .select({ + id: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + issueIdFromContext, + startedAt: heartbeatRuns.startedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]), + uniqueRunIds.length > 0 + ? or(inArray(heartbeatRuns.id, uniqueRunIds), inArray(issueIdFromContext, issueIds)) + : inArray(issueIdFromContext, issueIds), + ), + ); + + const issueIdByExecutionRunId = new Map( + treeIssues + .filter((issue) => issue.executionRunId) + .map((issue) => [issue.executionRunId as string, issue.id]), + ); + return rows + .map((run) => { + if (run.status !== "queued" && run.status !== "running") return null; + const issueId = run.issueIdFromContext && issueIdSet.has(run.issueIdFromContext) + ? run.issueIdFromContext + : issueIdByExecutionRunId.get(run.id) ?? null; + if (!issueId) return null; + return { + id: run.id, + issueId, + agentId: run.agentId, + status: run.status, + startedAt: run.startedAt, + createdAt: run.createdAt, + } satisfies ActiveRunRow; + }) + .filter((run): run is ActiveRunRow => run !== null) + .sort((a, b) => a.issueId.localeCompare(b.issueId) || a.createdAt.getTime() - b.createdAt.getTime()); + } + + async function activeHoldsByIssueId(companyId: string, issueIds: string[]) { + const byIssueId = new Map(); + if (issueIds.length === 0) return byIssueId; + const rows = await db + .select({ + issueId: issueTreeHoldMembers.issueId, + holdId: issueTreeHolds.id, + mode: issueTreeHolds.mode, + }) + .from(issueTreeHoldMembers) + .innerJoin(issueTreeHolds, eq(issueTreeHoldMembers.holdId, issueTreeHolds.id)) + .where( + and( + eq(issueTreeHoldMembers.companyId, companyId), + eq(issueTreeHolds.status, "active"), + inArray(issueTreeHoldMembers.issueId, issueIds), + ), + ) + .orderBy(asc(issueTreeHolds.createdAt), asc(issueTreeHolds.id)); + + for (const row of rows) { + const current = byIssueId.get(row.issueId) ?? { all: [], pause: [] }; + current.all.push(row.holdId); + if (row.mode === "pause") current.pause.push(row.holdId); + byIssueId.set(row.issueId, current); + } + return byIssueId; + } + + async function activeCancelSnapshotsByIssueId(companyId: string, rootIssueId: string) { + const activeCancelHolds = await listHolds(companyId, rootIssueId, { + status: "active", + mode: "cancel", + includeMembers: true, + }); + const byIssueId = new Map(); + for (const hold of [...activeCancelHolds].reverse()) { + for (const member of hold.members ?? []) { + const current = byIssueId.get(member.issueId) ?? { holdIds: [], member: null }; + if (!current.holdIds.includes(hold.id)) current.holdIds.push(hold.id); + if (!current.member && !member.skipped) current.member = member; + byIssueId.set(member.issueId, current); + } + } + return byIssueId; + } + + async function getActivePauseHoldGate( + companyId: string, + issueId: string, + ): Promise { + const activePauseHolds = await db + .select({ + id: issueTreeHolds.id, + rootIssueId: issueTreeHolds.rootIssueId, + reason: issueTreeHolds.reason, + releasePolicy: issueTreeHolds.releasePolicy, + }) + .from(issueTreeHolds) + .where( + and( + eq(issueTreeHolds.companyId, companyId), + eq(issueTreeHolds.status, "active"), + eq(issueTreeHolds.mode, "pause"), + ), + ) + .orderBy(asc(issueTreeHolds.createdAt), asc(issueTreeHolds.id)); + if (activePauseHolds.length === 0) return null; + + const holdByRootIssueId = new Map(activePauseHolds.map((hold) => [hold.rootIssueId, hold])); + let currentIssueId: string | null = issueId; + const visited = new Set(); + let depth = 0; + + while (currentIssueId && !visited.has(currentIssueId) && depth < MAX_PAUSE_HOLD_GATE_DEPTH) { + visited.add(currentIssueId); + const hold = holdByRootIssueId.get(currentIssueId); + if (hold) { + return { + holdId: hold.id, + rootIssueId: hold.rootIssueId, + issueId, + isRoot: hold.rootIssueId === issueId, + mode: "pause", + reason: hold.reason, + releasePolicy: (hold.releasePolicy as IssueTreeHoldReleasePolicy | null) ?? null, + }; + } + + const parent: { parentId: string | null } | null = await db + .select({ parentId: issues.parentId }) + .from(issues) + .where(and(eq(issues.id, currentIssueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + currentIssueId = parent?.parentId ?? null; + depth += 1; + } + + return null; + } + + async function preview( + companyId: string, + rootIssueId: string, + input: { + mode: IssueTreeControlMode; + releasePolicy?: IssueTreeHoldReleasePolicy | null; + }, + ): Promise { + const treeIssues = await listTreeIssues(companyId, rootIssueId); + const issueIds = treeIssues.map((issue) => issue.id); + const [activeRunRows, holdsByIssueId, activeCancelSnapshots] = await Promise.all([ + activeRunsForTree(companyId, treeIssues), + activeHoldsByIssueId(companyId, issueIds), + input.mode === "restore" + ? activeCancelSnapshotsByIssueId(companyId, rootIssueId) + : Promise.resolve(new Map()), + ]); + const runsByIssueId = new Map(); + for (const run of activeRunRows) { + if (!runsByIssueId.has(run.issueId)) runsByIssueId.set(run.issueId, run); + } + const countsByStatus: Partial> = {}; + + const issuesToPreview = treeIssues.map((issue) => { + const status = coerceIssueStatus(issue.status); + countsByStatus[status] = (countsByStatus[status] ?? 0) + 1; + const holdState = holdsByIssueId.get(issue.id) ?? { all: [], pause: [] }; + const skipReason = issueSkipReason({ + mode: input.mode, + issue, + activePauseHoldIds: holdState.pause, + activeCancelSnapshot: activeCancelSnapshots.get(issue.id) ?? null, + }); + const run = runsByIssueId.get(issue.id); + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + status, + parentId: issue.parentId, + depth: issue.depth, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + activeRun: run ? toPreviewRun(run) : null, + activeHoldIds: holdState.all, + action: input.mode, + skipped: skipReason !== null, + skipReason, + } satisfies IssueTreePreviewIssue; + }); + const skippedIssues = issuesToPreview.filter((issue) => issue.skipped); + const activeRuns = activeRunRows + .map(toPreviewRun) + .sort((a, b) => a.issueId.localeCompare(b.issueId) || a.id.localeCompare(b.id)); + const affectedAgents = buildAffectedAgents(issuesToPreview); + + return { + companyId, + rootIssueId, + mode: input.mode, + generatedAt: new Date(), + releasePolicy: normalizeReleasePolicy(input.releasePolicy), + totals: { + totalIssues: issuesToPreview.length, + affectedIssues: issuesToPreview.length - skippedIssues.length, + skippedIssues: skippedIssues.length, + activeRuns: activeRuns.filter((run) => run.status === "running").length, + queuedRuns: activeRuns.filter((run) => run.status === "queued").length, + affectedAgents: affectedAgents.length, + }, + countsByStatus, + issues: issuesToPreview, + skippedIssues, + activeRuns, + affectedAgents, + warnings: buildWarnings({ mode: input.mode, issuesToPreview, activeRuns }), + }; + } + + async function createHold( + companyId: string, + rootIssueId: string, + input: { + mode: IssueTreeControlMode; + reason?: string | null; + releasePolicy?: IssueTreeHoldReleasePolicy | null; + actor: ActorInput; + }, + ) { + const holdReleasePolicy = normalizeReleasePolicy(input.releasePolicy); + const holdPreview = await preview(companyId, rootIssueId, { + mode: input.mode, + releasePolicy: holdReleasePolicy, + }); + + const { hold, members } = await db.transaction(async (tx) => { + const [createdHold] = await tx + .insert(issueTreeHolds) + .values({ + companyId, + rootIssueId, + mode: input.mode, + status: "active", + reason: input.reason ?? null, + releasePolicy: holdReleasePolicy as unknown as Record, + createdByActorType: input.actor.actorType, + createdByAgentId: input.actor.agentId ?? null, + createdByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null), + createdByRunId: input.actor.runId ?? null, + }) + .returning(); + + const memberRows = holdPreview.issues.map((issue) => ({ + companyId, + holdId: createdHold.id, + issueId: issue.id, + parentIssueId: issue.parentId, + depth: issue.depth, + issueIdentifier: issue.identifier, + issueTitle: issue.title, + issueStatus: issue.status, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + activeRunId: issue.activeRun?.id ?? null, + activeRunStatus: issue.activeRun?.status ?? null, + skipped: issue.skipped, + skipReason: issue.skipReason, + })); + + const createdMembers = memberRows.length > 0 + ? await tx.insert(issueTreeHoldMembers).values(memberRows).returning() + : []; + + return { hold: createdHold, members: createdMembers }; + }); + + return { + hold: toHold(hold, members), + preview: holdPreview, + }; + } + + async function cancelIssueStatusesForHold( + companyId: string, + rootIssueId: string, + holdId: string, + ): Promise { + const hold = await getHold(companyId, holdId); + if (!hold) throw notFound("Issue tree hold not found"); + if (hold.rootIssueId !== rootIssueId) { + throw unprocessable("Issue tree hold does not belong to the requested root issue"); + } + if (hold.mode !== "cancel") { + throw unprocessable("Issue tree hold is not a cancel operation"); + } + + const issueIds = [...new Set((hold.members ?? []) + .filter((member) => !member.skipped) + .map((member) => member.issueId))]; + if (issueIds.length === 0) return { updatedIssueIds: [], updatedIssues: [] }; + + const now = new Date(); + const updated = await db + .update(issues) + .set({ + status: "cancelled", + cancelledAt: now, + completedAt: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, companyId), + inArray(issues.id, issueIds), + notInArray(issues.status, ["done", "cancelled"]), + ), + ) + .returning({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + }); + + return { + updatedIssueIds: updated.map((issue) => issue.id), + updatedIssues: updated.map((issue) => ({ + id: issue.id, + status: coerceIssueStatus(issue.status), + assigneeAgentId: issue.assigneeAgentId, + })), + }; + } + + async function restoreIssueStatusesForHold( + companyId: string, + rootIssueId: string, + restoreHoldId: string, + input: { + reason?: string | null; + actor: ActorInput; + }, + ): Promise { + const restoreHold = await getHold(companyId, restoreHoldId); + if (!restoreHold) throw notFound("Issue tree hold not found"); + if (restoreHold.rootIssueId !== rootIssueId) { + throw unprocessable("Issue tree hold does not belong to the requested root issue"); + } + if (restoreHold.mode !== "restore") { + throw unprocessable("Issue tree hold is not a restore operation"); + } + + const activeCancelHolds = await listHolds(companyId, rootIssueId, { + status: "active", + mode: "cancel", + includeMembers: true, + }); + const cancelSnapshotByIssueId = new Map(); + for (const hold of [...activeCancelHolds].reverse()) { + for (const member of hold.members ?? []) { + if (!member.skipped && !cancelSnapshotByIssueId.has(member.issueId)) { + cancelSnapshotByIssueId.set(member.issueId, member); + } + } + } + + const restoreIssueIds = [...new Set((restoreHold.members ?? []) + .filter((member) => !member.skipped) + .map((member) => member.issueId))]; + const restoreStatusByIssueId = new Map(); + for (const issueId of restoreIssueIds) { + const snapshot = cancelSnapshotByIssueId.get(issueId); + if (!snapshot) continue; + const restoredStatus = restoreStatusFromCancelSnapshot(coerceIssueStatus(snapshot.issueStatus)); + if (restoredStatus) restoreStatusByIssueId.set(issueId, restoredStatus); + } + + const issueIdsByStatus = new Map(); + for (const [issueId, status] of restoreStatusByIssueId) { + const current = issueIdsByStatus.get(status) ?? []; + current.push(issueId); + issueIdsByStatus.set(status, current); + } + + const now = new Date(); + const releasedCancelHoldIds = activeCancelHolds.map((hold) => hold.id); + const updatedIssues = await db.transaction(async (tx) => { + const restored: TreeStatusUpdateResult["updatedIssues"] = []; + for (const [status, issueIdsForStatus] of issueIdsByStatus) { + if (issueIdsForStatus.length === 0) continue; + const rows = await tx + .update(issues) + .set({ + status, + cancelledAt: null, + completedAt: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, companyId), + inArray(issues.id, issueIdsForStatus), + eq(issues.status, "cancelled"), + ), + ) + .returning({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + }); + restored.push(...rows.map((issue) => ({ + id: issue.id, + status: coerceIssueStatus(issue.status), + assigneeAgentId: issue.assigneeAgentId, + }))); + } + + if (releasedCancelHoldIds.length > 0) { + await tx + .update(issueTreeHolds) + .set({ + status: "released", + releasedAt: now, + releasedByActorType: input.actor.actorType, + releasedByAgentId: input.actor.agentId ?? null, + releasedByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null), + releasedByRunId: input.actor.runId ?? null, + releaseReason: input.reason ?? "Restored by subtree restore operation", + releaseMetadata: { + restoreHoldId, + restoredIssueIds: restored.map((issue) => issue.id), + }, + updatedAt: now, + }) + .where(and(eq(issueTreeHolds.companyId, companyId), inArray(issueTreeHolds.id, releasedCancelHoldIds))); + } + + await tx + .update(issueTreeHolds) + .set({ + status: "released", + releasedAt: now, + releasedByActorType: input.actor.actorType, + releasedByAgentId: input.actor.agentId ?? null, + releasedByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null), + releasedByRunId: input.actor.runId ?? null, + releaseReason: input.reason ?? "Restore operation applied", + releaseMetadata: { + restoredIssueIds: restored.map((issue) => issue.id), + releasedCancelHoldIds, + }, + updatedAt: now, + }) + .where(and(eq(issueTreeHolds.companyId, companyId), eq(issueTreeHolds.id, restoreHoldId))); + + return restored; + }); + + return { + updatedIssueIds: updatedIssues.map((issue) => issue.id), + updatedIssues, + releasedCancelHoldIds, + restoreHold: await getHold(companyId, restoreHoldId), + }; + } + + async function getHold(companyId: string, holdId: string) { + const hold = await db + .select() + .from(issueTreeHolds) + .where(and(eq(issueTreeHolds.id, holdId), eq(issueTreeHolds.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!hold) return null; + const members = await db + .select() + .from(issueTreeHoldMembers) + .where(and(eq(issueTreeHoldMembers.companyId, companyId), eq(issueTreeHoldMembers.holdId, holdId))) + .orderBy(asc(issueTreeHoldMembers.depth), asc(issueTreeHoldMembers.createdAt), asc(issueTreeHoldMembers.issueId)); + return toHold(hold, members); + } + + async function listHolds( + companyId: string, + rootIssueId: string, + input?: { + status?: IssueTreeHold["status"]; + mode?: IssueTreeControlMode; + includeMembers?: boolean; + }, + ) { + const whereClauses = [ + eq(issueTreeHolds.companyId, companyId), + eq(issueTreeHolds.rootIssueId, rootIssueId), + ]; + if (input?.status) whereClauses.push(eq(issueTreeHolds.status, input.status)); + if (input?.mode) whereClauses.push(eq(issueTreeHolds.mode, input.mode)); + + const holds = await db + .select() + .from(issueTreeHolds) + .where(and(...whereClauses)) + .orderBy(asc(issueTreeHolds.createdAt), asc(issueTreeHolds.id)); + if (!input?.includeMembers || holds.length === 0) { + return holds.map((hold) => toHold(hold)); + } + + const holdIds = holds.map((hold) => hold.id); + const members = await db + .select() + .from(issueTreeHoldMembers) + .where( + and( + eq(issueTreeHoldMembers.companyId, companyId), + inArray(issueTreeHoldMembers.holdId, holdIds), + ), + ) + .orderBy(asc(issueTreeHoldMembers.depth), asc(issueTreeHoldMembers.createdAt), asc(issueTreeHoldMembers.issueId)); + + const membersByHoldId = new Map(); + for (const member of members) { + const existing = membersByHoldId.get(member.holdId) ?? []; + existing.push(member); + membersByHoldId.set(member.holdId, existing); + } + + return holds.map((hold) => toHold(hold, membersByHoldId.get(hold.id) ?? [])); + } + + async function releaseHold( + companyId: string, + rootIssueId: string, + holdId: string, + input: { + reason?: string | null; + releasePolicy?: IssueTreeHoldReleasePolicy | null; + metadata?: Record | null; + actor: ActorInput; + }, + ) { + const existing = await db + .select() + .from(issueTreeHolds) + .where(and(eq(issueTreeHolds.id, holdId), eq(issueTreeHolds.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Issue tree hold not found"); + if (existing.rootIssueId !== rootIssueId) { + throw unprocessable("Issue tree hold does not belong to the requested root issue"); + } + if (existing.status === "released") { + throw conflict("Issue tree hold is already released"); + } + + const [updated] = await db + .update(issueTreeHolds) + .set({ + status: "released", + releasedAt: new Date(), + releasedByActorType: input.actor.actorType, + releasedByAgentId: input.actor.agentId ?? null, + releasedByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null), + releasedByRunId: input.actor.runId ?? null, + releaseReason: input.reason ?? null, + releasePolicy: input.releasePolicy + ? (normalizeReleasePolicy(input.releasePolicy) as unknown as Record) + : existing.releasePolicy, + releaseMetadata: input.metadata ?? null, + updatedAt: new Date(), + }) + .where(and(eq(issueTreeHolds.id, holdId), eq(issueTreeHolds.companyId, companyId))) + .returning(); + + const members = await db + .select() + .from(issueTreeHoldMembers) + .where(and(eq(issueTreeHoldMembers.companyId, companyId), eq(issueTreeHoldMembers.holdId, holdId))) + .orderBy(asc(issueTreeHoldMembers.depth), asc(issueTreeHoldMembers.createdAt), asc(issueTreeHoldMembers.issueId)); + + return toHold(updated, members); + } + + async function cancelUnclaimedWakeupsForTree(companyId: string, rootIssueId: string, reason: string) { + const treeIssues = await listTreeIssues(companyId, rootIssueId); + const issueIds = treeIssues.map((issue) => issue.id); + if (issueIds.length === 0) return []; + const now = new Date(); + return db + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + updatedAt: now, + }) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]), + isNull(agentWakeupRequests.runId), + inArray(sql`${agentWakeupRequests.payload} ->> 'issueId'`, issueIds), + ), + ) + .returning({ + id: agentWakeupRequests.id, + agentId: agentWakeupRequests.agentId, + reason: agentWakeupRequests.reason, + payload: agentWakeupRequests.payload, + }); + } + + return { + listTreeIssues, + preview, + createHold, + cancelIssueStatusesForHold, + restoreIssueStatusesForHold, + getHold, + listHolds, + getActivePauseHoldGate, + releaseHold, + cancelUnclaimedWakeupsForTree, + }; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 751be6231f..c611c73d51 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -36,6 +36,11 @@ import { instanceSettingsService } from "./instance-settings.js"; import { redactCurrentUserText } from "../log-redaction.js"; import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; import { getDefaultCompanyGoal } from "./goals.js"; +import { + ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, + issueTreeControlService, + type ActiveIssueTreePauseHoldGate, +} from "./issue-tree-control.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; @@ -45,7 +50,6 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; - function assertTransition(from: string, to: string) { if (from === to) return; if (!ALL_ISSUE_STATUSES.includes(to)) { @@ -71,6 +75,24 @@ function applyStatusSideEffects( return patch; } +function readStringFromRecord(record: unknown, key: string) { + if (!record || typeof record !== "object") return null; + const value = (record as Record)[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readLatestWakeCommentId(record: unknown) { + if (!record || typeof record !== "object") return null; + const value = (record as Record).wakeCommentIds; + if (Array.isArray(value)) { + const latest = value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .at(-1); + if (latest) return latest.trim(); + } + return readStringFromRecord(record, "wakeCommentId") ?? readStringFromRecord(record, "commentId"); +} + export interface IssueFilters { status?: string; assigneeAgentId?: string; @@ -871,6 +893,7 @@ async function lastActivityStatsForIssues( export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); + const treeControlSvc = issueTreeControlService(db); async function getIssueByUuid(id: string) { const row = await db @@ -924,6 +947,27 @@ export function issueService(db: Db) { } } + async function isTreeHoldInteractionCheckoutAllowed( + companyId: string, + checkoutRunId: string | null, + _gate: ActiveIssueTreePauseHoldGate, + ) { + if (!checkoutRunId) return false; + const run = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, checkoutRunId), eq(heartbeatRuns.companyId, companyId))) + .then((rows) => rows[0] ?? null); + const wakeReason = + readStringFromRecord(run?.contextSnapshot, "wakeReason") ?? + readStringFromRecord(run?.contextSnapshot, "reason"); + return Boolean( + wakeReason && + ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS.has(wakeReason) && + readLatestWakeCommentId(run?.contextSnapshot), + ); + } + async function assertAssignableUser(companyId: string, userId: string) { const membership = await db .select({ id: companyMemberships.id }) @@ -2191,6 +2235,19 @@ export function issueService(db: Db) { await assertAssignableAgent(issueCompany.companyId, agentId); const now = new Date(); + const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id); + if ( + activePauseHold && + !(await isTreeHoldInteractionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold)) + ) { + throw conflict("Issue checkout blocked by active subtree pause hold", { + issueId: id, + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + mode: activePauseHold.mode, + securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"], + }); + } await clearExecutionRunIfTerminal(id); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index c2704128df..b285651374 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,6 +1,7 @@ import type { AskUserQuestionsAnswer, Approval, + CreateIssueTreeHold, DocumentRevision, FeedbackTargetType, FeedbackTrace, @@ -11,7 +12,11 @@ import type { IssueDocument, IssueLabel, IssueThreadInteraction, + IssueTreeControlPreview, + IssueTreeHold, IssueWorkProduct, + PreviewIssueTreeControl, + ReleaseIssueTreeHold, UpsertIssueDocument, } from "@paperclipai/shared"; import { api } from "./client"; @@ -79,6 +84,41 @@ export const issuesApi = { api.post(`/companies/${companyId}/issues`, data), update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + previewTreeControl: (id: string, data: PreviewIssueTreeControl) => + api.post(`/issues/${id}/tree-control/preview`, data), + createTreeHold: (id: string, data: CreateIssueTreeHold) => + api.post<{ hold: IssueTreeHold; preview: IssueTreeControlPreview }>(`/issues/${id}/tree-holds`, data), + getTreeHold: (id: string, holdId: string) => + api.get(`/issues/${id}/tree-holds/${holdId}`), + listTreeHolds: ( + id: string, + filters?: { + status?: "active" | "released"; + mode?: "pause" | "resume" | "cancel" | "restore"; + includeMembers?: boolean; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.status) params.set("status", filters.status); + if (filters?.mode) params.set("mode", filters.mode); + if (filters?.includeMembers) params.set("includeMembers", "true"); + const qs = params.toString(); + return api.get(`/issues/${id}/tree-holds${qs ? `?${qs}` : ""}`); + }, + getTreeControlState: (id: string) => + api.get<{ + activePauseHold: { + holdId: string; + rootIssueId: string; + issueId: string; + isRoot: boolean; + mode: "pause"; + reason: string | null; + releasePolicy: { strategy: "manual" | "after_active_runs_finish"; note?: string | null } | null; + } | null; + }>(`/issues/${id}/tree-control/state`), + releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) => + api.post(`/issues/${id}/tree-holds/${holdId}/release`, data), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index c6b163ac68..9a85a862e8 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -571,6 +571,73 @@ describe("IssueChatThread", () => { }); }); + it("shows deferred wake badge only for hold-deferred queued comments", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("Deferred wake"); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("Queued"); + expect(container.textContent).not.toContain("Deferred wake"); + + act(() => { + root.unmount(); + }); + }); + it("stores and restores the composer draft per issue key", () => { vi.useFakeTimers(); const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 058bf5ad50..6c5de990bc 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -227,6 +227,7 @@ interface IssueChatComposerProps { mentions?: MentionOption[]; agentMap?: Map; composerDisabledReason?: string | null; + composerHint?: string | null; issueStatus?: string; } @@ -265,6 +266,7 @@ interface IssueChatThreadProps { suggestedAssigneeValue?: string; mentions?: MentionOption[]; composerDisabledReason?: string | null; + composerHint?: string | null; showComposer?: boolean; showJumpToLatest?: boolean; emptyMessage?: string; @@ -1153,6 +1155,8 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { const authorName = typeof custom.authorName === "string" ? custom.authorName : null; const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; + const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null; + const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued"; const pending = custom.clientStatus === "pending"; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); @@ -1189,7 +1193,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { {queued ? (
- Queued + {queueBadgeLabel} {queueTargetRunId && onInterruptQueued ? ( + ), +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ?
{children}
: null), + DialogContent: ({ children, className }: { children?: ReactNode; className?: string }) => ( +
{children}
+ ), + DialogDescription: ({ children, className }: { children?: ReactNode; className?: string }) =>

{children}

, + DialogFooter: ({ children, className }: { children?: ReactNode; className?: string }) =>
{children}
, + DialogHeader: ({ children, className }: { children?: ReactNode; className?: string }) =>
{children}
, + DialogTitle: ({ children, className }: { children?: ReactNode; className?: string }) =>

{children}

, +})); + +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ?
{children}
: null), + SheetContent: ({ children }: { children?: ReactNode }) =>
{children}
, + SheetHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/skeleton", () => ({ + Skeleton: () =>
, +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsContent: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsList: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsTrigger: ({ children }: { children?: ReactNode }) => , +})); + +vi.mock("@/components/ui/textarea", () => ({ + Textarea: (props: React.TextareaHTMLAttributes) =>