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 00000000000..76c52f1691e --- /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 4e963522a2f..3fbd9e30ea0 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": 1776786127000, + "tag": "0066_issue_tree_holds", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 93ba2380661..fcf2ecc9471 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 00000000000..b28330636f7 --- /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 00000000000..d835649ce33 --- /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 ea3278be925..eef841f234a 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 b532167f5e4..b8b48a60cf4 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 adfb692686f..ddfccea9451 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 36bb5207d50..e336ac9700f 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 00000000000..f8dacd8d7f8 --- /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 ecb59427457..33ae113c303 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 00000000000..cb0dda9c75f --- /dev/null +++ b/packages/shared/src/validators/issue-tree-control.ts @@ -0,0 +1,43 @@ +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(), + }) + .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__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts new file mode 100644 index 00000000000..bf5af98de3a --- /dev/null +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -0,0 +1,84 @@ +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(), + getHold: vi.fn(), + releaseHold: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + 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", + }); + }); + + 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(); + }); +}); 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 00000000000..be675c68a7f --- /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 00000000000..42b996e9922 --- /dev/null +++ b/server/src/__tests__/issue-tree-control-service.test.ts @@ -0,0 +1,212 @@ +import { randomUUID } from "node:crypto"; +import { eq } 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"; + +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); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index b1a31401bcc..e8c58386a81 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 8d954a95703..983562e265a 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 00000000000..e8aac4feb1b --- /dev/null +++ b/server/src/routes/issue-tree-control.ts @@ -0,0 +1,157 @@ +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 { 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); + + 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 result = await treeControlSvc.createHold(root.companyId, root.id, { + ...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_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), + }, + }); + + res.status(201).json(result); + }); + + 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/index.ts b/server/src/services/index.ts index 8abe9eb630e..f4502658b60 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 00000000000..3226d33c927 --- /dev/null +++ b/server/src/services/issue-tree-control.ts @@ -0,0 +1,529 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + 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; +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; +}; + +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); +const ACTIVE_RUN_STATUSES = ["queued", "running"] as const; +const DEFAULT_RELEASE_POLICY: IssueTreeHoldReleasePolicy = { strategy: "manual" }; + +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[]; +}): string | null { + const status = coerceIssueStatus(input.issue.status); + if (input.mode === "restore") { + return status === "cancelled" ? null : "not_cancelled"; + } + 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.", + }); + } + + return warnings; +} + +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 activeRunsByIssueId(companyId: string, treeIssues: TreeIssue[]) { + const runIds = treeIssues + .map((issue) => issue.executionRunId) + .filter((id): id is string => typeof id === "string" && id.length > 0); + const byIssueId = new Map(); + if (runIds.length === 0) return byIssueId; + + const rows = await db + .select({ + id: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + startedAt: heartbeatRuns.startedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.id, [...new Set(runIds)]), + inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]), + ), + ); + const runById = new Map(); + for (const row of rows) { + if (row.status === "queued" || row.status === "running") { + runById.set(row.id, row); + } + } + for (const issue of treeIssues) { + if (!issue.executionRunId) continue; + const run = runById.get(issue.executionRunId); + if (!run) continue; + const status = run.status === "queued" || run.status === "running" ? run.status : null; + if (!status) continue; + byIssueId.set(issue.id, { + id: run.id, + issueId: issue.id, + agentId: run.agentId, + status, + startedAt: run.startedAt, + createdAt: run.createdAt, + }); + } + return byIssueId; + } + + 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 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 [runsByIssueId, holdsByIssueId] = await Promise.all([ + activeRunsByIssueId(companyId, treeIssues), + activeHoldsByIssueId(companyId, issueIds), + ]); + 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, + }); + 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 = [...runsByIssueId.values()] + .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 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 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); + } + + return { + listTreeIssues, + preview, + createHold, + getHold, + releaseHold, + }; +}