mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Add exact-once accepted plan decomposition claims
This commit is contained in:
1 parent
6b53e1bf7d
commit
ca0407cbb7
13 files changed
+19193
-1
No files matched your search
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE "issue_plan_decompositions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"source_issue_id" uuid NOT NULL,
|
||||
"accepted_plan_revision_id" uuid NOT NULL,
|
||||
"accepted_interaction_id" uuid,
|
||||
"status" text DEFAULT 'in_flight' NOT NULL,
|
||||
"request_fingerprint" text NOT NULL,
|
||||
"requested_child_count" integer DEFAULT 0 NOT NULL,
|
||||
"requested_children" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"child_issue_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"owner_agent_id" uuid,
|
||||
"owner_user_id" text,
|
||||
"owner_run_id" uuid,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk" FOREIGN KEY ("accepted_plan_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk" FOREIGN KEY ("accepted_interaction_id") REFERENCES "public"."issue_thread_interactions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("owner_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "issue_plan_decompositions_company_source_status_idx" ON "issue_plan_decompositions" USING btree ("company_id","source_issue_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "issue_plan_decompositions_source_revision_uq" ON "issue_plan_decompositions" USING btree ("company_id","source_issue_id","accepted_plan_revision_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -631,6 +631,13 @@
|
||||
"when": 1779129600000,
|
||||
"tag": "0089_cloud_upstreams",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 90,
|
||||
"version": "7",
|
||||
"when": 1779597488245,
|
||||
"tag": "0090_demonic_grey_gargoyle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
|
||||
export { issueRecoveryActions } from "./issue_recovery_actions.js";
|
||||
export { issueReferenceMentions } from "./issue_reference_mentions.js";
|
||||
export { issueRelations } from "./issue_relations.js";
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, uuid, text, integer, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { documentRevisions } from "./document_revisions.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issueThreadInteractions } from "./issue_thread_interactions.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const issuePlanDecompositions = pgTable(
|
||||
"issue_plan_decompositions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
acceptedPlanRevisionId: uuid("accepted_plan_revision_id")
|
||||
.notNull()
|
||||
.references(() => documentRevisions.id, { onDelete: "cascade" }),
|
||||
acceptedInteractionId: uuid("accepted_interaction_id")
|
||||
.references(() => issueThreadInteractions.id, { onDelete: "set null" }),
|
||||
status: text("status").notNull().default("in_flight"),
|
||||
requestFingerprint: text("request_fingerprint").notNull(),
|
||||
requestedChildCount: integer("requested_child_count").notNull().default(0),
|
||||
requestedChildren: jsonb("requested_children").$type<Record<string, unknown>[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
childIssueIds: jsonb("child_issue_ids").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
ownerUserId: text("owner_user_id"),
|
||||
ownerRunId: uuid("owner_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companySourceStatusIdx: index("issue_plan_decompositions_company_source_status_idx").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.status,
|
||||
),
|
||||
sourceRevisionUq: uniqueIndex("issue_plan_decompositions_source_revision_uq").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.acceptedPlanRevisionId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -438,6 +438,10 @@ export type {
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
AcceptedPlanDecompositionStatus,
|
||||
AcceptedPlanDecompositionChild,
|
||||
AcceptedPlanDecomposition,
|
||||
AcceptedPlanDecompositionResult,
|
||||
IssueThreadInteractionBase,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
@@ -793,6 +797,7 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueInputSchema,
|
||||
createChildIssueSchema,
|
||||
createAcceptedPlanDecompositionSchema,
|
||||
resolveCreateIssueStatusDefault,
|
||||
createIssueLabelSchema,
|
||||
issueBlockedInboxAttentionSchema,
|
||||
@@ -861,6 +866,7 @@ export {
|
||||
releaseIssueTreeHoldSchema,
|
||||
type CreateIssue,
|
||||
type CreateChildIssue,
|
||||
type CreateAcceptedPlanDecomposition,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type ResolveIssueRecoveryAction,
|
||||
|
||||
@@ -207,6 +207,10 @@ export type {
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
AcceptedPlanDecompositionStatus,
|
||||
AcceptedPlanDecompositionChild,
|
||||
AcceptedPlanDecomposition,
|
||||
AcceptedPlanDecompositionResult,
|
||||
IssueThreadInteractionBase,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
|
||||
@@ -129,6 +129,56 @@ export interface LegacyPlanDocument {
|
||||
source: "issue_description";
|
||||
}
|
||||
|
||||
export type AcceptedPlanDecompositionStatus = "in_flight" | "completed";
|
||||
|
||||
export interface AcceptedPlanDecompositionChild {
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
goalId?: string | null;
|
||||
blockedByIssueIds?: string[];
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: IssueStatus;
|
||||
workMode: IssueWorkMode;
|
||||
priority: IssuePriority;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
requestDepth?: number;
|
||||
billingCode?: string | null;
|
||||
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
||||
executionPolicy?: IssueExecutionPolicy | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
||||
labelIds?: string[];
|
||||
acceptanceCriteria?: string[];
|
||||
blockParentUntilDone?: boolean;
|
||||
}
|
||||
|
||||
export interface AcceptedPlanDecomposition {
|
||||
id: string;
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
acceptedPlanRevisionId: string;
|
||||
acceptedInteractionId: string | null;
|
||||
status: AcceptedPlanDecompositionStatus;
|
||||
requestFingerprint: string;
|
||||
requestedChildCount: number;
|
||||
childIssueIds: string[];
|
||||
ownerAgentId: string | null;
|
||||
ownerUserId: string | null;
|
||||
ownerRunId: string | null;
|
||||
completedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface AcceptedPlanDecompositionResult {
|
||||
decomposition: AcceptedPlanDecomposition;
|
||||
childIssueIds: string[];
|
||||
newlyCreatedChildIssueIds: string[];
|
||||
}
|
||||
|
||||
export interface IssueRelationIssueSummary {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
|
||||
@@ -151,6 +151,7 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueInputSchema,
|
||||
createChildIssueSchema,
|
||||
createAcceptedPlanDecompositionSchema,
|
||||
resolveCreateIssueStatusDefault,
|
||||
createIssueLabelSchema,
|
||||
issueBlockedInboxAttentionSchema,
|
||||
@@ -202,6 +203,7 @@ export {
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
type CreateIssue,
|
||||
type CreateChildIssue,
|
||||
type CreateAcceptedPlanDecomposition,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
|
||||
@@ -412,6 +412,13 @@ export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBa
|
||||
|
||||
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
|
||||
|
||||
export const createAcceptedPlanDecompositionSchema = z.object({
|
||||
acceptedPlanRevisionId: z.string().uuid(),
|
||||
children: z.array(createChildIssueSchema).min(1).max(25),
|
||||
});
|
||||
|
||||
export type CreateAcceptedPlanDecomposition = z.infer<typeof createAcceptedPlanDecompositionSchema>;
|
||||
|
||||
export const createIssueLabelSchema = z.object({
|
||||
name: z.string().trim().min(1).max(48),
|
||||
color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"),
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
@@ -14,7 +16,10 @@ import {
|
||||
instanceSettings,
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issueDocuments,
|
||||
issuePlanDecompositions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
@@ -3236,3 +3241,238 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
|
||||
expect(row).toEqual({ executionRunId: null, executionLockedAt: null });
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("accepted plan decomposition", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-decomposition-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedAcceptedPlanIssue() {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const sourceIssueId = randomUUID();
|
||||
const planDocumentId = randomUUID();
|
||||
const acceptedPlanRevisionId = randomUUID();
|
||||
const acceptedInteractionId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Accepted plan decomposition",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Planning issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
workMode: "planning",
|
||||
assigneeAgentId: assigneeAgentId,
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: planDocumentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "Plan body",
|
||||
latestRevisionId: acceptedPlanRevisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: assigneeAgentId,
|
||||
updatedByAgentId: assigneeAgentId,
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: acceptedPlanRevisionId,
|
||||
companyId,
|
||||
documentId: planDocumentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "Plan body",
|
||||
createdByAgentId: assigneeAgentId,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
documentId: planDocumentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: acceptedInteractionId,
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: sourceIssueId,
|
||||
documentId: planDocumentId,
|
||||
key: "plan",
|
||||
revisionId: acceptedPlanRevisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
},
|
||||
resolvedAt: new Date(),
|
||||
createdByUserId: "local-board",
|
||||
resolvedByUserId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId };
|
||||
}
|
||||
|
||||
it("reuses the same child issue set on repeat decomposition attempts for an accepted plan revision", async () => {
|
||||
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
|
||||
const children = [
|
||||
{
|
||||
title: "Implement the claim table",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
title: "Add decomposition route tests",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const first = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
expect(first.childIssueIds).toHaveLength(2);
|
||||
expect(first.newlyCreatedIssues).toHaveLength(2);
|
||||
expect(first.decomposition.status).toBe("completed");
|
||||
|
||||
const second = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
expect(second.childIssueIds).toEqual(first.childIssueIds);
|
||||
expect(second.newlyCreatedIssues).toHaveLength(0);
|
||||
expect(second.decomposition.status).toBe("completed");
|
||||
|
||||
const persistedClaims = await db
|
||||
.select()
|
||||
.from(issuePlanDecompositions)
|
||||
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId));
|
||||
expect(persistedClaims).toHaveLength(1);
|
||||
expect(persistedClaims[0]?.requestedChildCount).toBe(2);
|
||||
expect(persistedClaims[0]?.childIssueIds).toEqual(first.childIssueIds);
|
||||
|
||||
const childrenRows = await db
|
||||
.select({ id: issues.id, title: issues.title })
|
||||
.from(issues)
|
||||
.where(eq(issues.parentId, sourceIssueId));
|
||||
expect(childrenRows).toHaveLength(2);
|
||||
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
||||
|
||||
const companyIssues = await svc.list(companyId, { parentId: sourceIssueId });
|
||||
expect(companyIssues).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects a different child set for the same accepted plan fingerprint", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
|
||||
await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Implement the claim table",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
await expect(svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Implement the claim table",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
title: "This duplicate should be rejected",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createIssueThreadInteractionSchema,
|
||||
createIssueWorkProductSchema,
|
||||
createIssueLabelSchema,
|
||||
createAcceptedPlanDecompositionSchema,
|
||||
checkoutIssueSchema,
|
||||
createChildIssueSchema,
|
||||
createIssueSchema,
|
||||
@@ -3332,6 +3333,139 @@ export function issueRoutes(
|
||||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/accepted-plan-decompositions", validate(createAcceptedPlanDecompositionSchema), async (req, res) => {
|
||||
const sourceIssueId = req.params.id as string;
|
||||
const sourceIssue = await svc.getById(sourceIssueId);
|
||||
if (!sourceIssue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, sourceIssue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return;
|
||||
|
||||
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(child));
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, child))) return;
|
||||
if (child.assigneeAgentId || child.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, sourceIssue.companyId, {
|
||||
projectId: child.projectId ?? sourceIssue.projectId ?? null,
|
||||
parentIssueId: sourceIssue.id,
|
||||
assigneeAgentId: child.assigneeAgentId ?? null,
|
||||
assigneeUserId: child.assigneeUserId ?? null,
|
||||
});
|
||||
}
|
||||
await assertIssueEnvironmentSelection(sourceIssue.companyId, child.executionWorkspaceSettings?.environmentId);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const normalizedChildren = req.body.children.map((child: typeof req.body.children[number]) => {
|
||||
const executionPolicy = applyActorMonitorScheduledBy(
|
||||
normalizeIssueExecutionPolicy(child.executionPolicy),
|
||||
actor.actorType,
|
||||
);
|
||||
assertCanManageIssueMonitor(req, child.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
||||
return {
|
||||
...child,
|
||||
executionPolicy,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await svc.decomposeAcceptedPlan(sourceIssue.id, {
|
||||
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
|
||||
children: normalizedChildren,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId ?? null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: sourceIssue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.accepted_plan_decomposition_updated",
|
||||
entityType: "issue",
|
||||
entityId: sourceIssue.id,
|
||||
details: {
|
||||
identifier: sourceIssue.identifier,
|
||||
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
|
||||
decompositionId: result.decomposition.id,
|
||||
status: result.decomposition.status,
|
||||
requestedChildCount: req.body.children.length,
|
||||
childIssueIds: result.childIssueIds,
|
||||
newlyCreatedChildIssueIds: result.newlyCreatedIssues.map((issue) => issue.id),
|
||||
},
|
||||
});
|
||||
|
||||
for (const issue of result.newlyCreatedIssues) {
|
||||
await logActivity(db, {
|
||||
companyId: sourceIssue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.child_created",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
parentId: sourceIssue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
inheritedExecutionWorkspaceFromIssueId: sourceIssue.id,
|
||||
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
|
||||
...buildCreateIssueActivityStatusDetails(issue, res),
|
||||
},
|
||||
});
|
||||
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy);
|
||||
if (executionPolicy?.monitor) {
|
||||
await logActivity(db, {
|
||||
companyId: sourceIssue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.monitor_scheduled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
parentId: sourceIssue.id,
|
||||
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
|
||||
nextCheckAt: executionPolicy.monitor.nextCheckAt,
|
||||
notes: executionPolicy.monitor.notes,
|
||||
scheduledBy: executionPolicy.monitor.scheduledBy,
|
||||
serviceName: executionPolicy.monitor.serviceName ?? null,
|
||||
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
|
||||
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void queueIssueAssignmentWakeup({
|
||||
heartbeat,
|
||||
issue,
|
||||
reason: "issue_assigned",
|
||||
mutation: "accepted_plan_decomposition",
|
||||
contextSource: "issue.accepted_plan_decomposition",
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
decomposition: result.decomposition,
|
||||
childIssueIds: result.childIssueIds,
|
||||
newlyCreatedChildIssueIds: result.newlyCreatedIssues.map((issue) => issue.id),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/issues/:id/monitor/check-now", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
assets,
|
||||
companies,
|
||||
companyMemberships,
|
||||
documentRevisions,
|
||||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
issueAttachments,
|
||||
issueInboxArchives,
|
||||
issueLabels,
|
||||
issuePlanDecompositions,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueComments,
|
||||
@@ -245,6 +248,7 @@ export interface IssueFilters {
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
type IssueLabelRow = typeof labels.$inferSelect;
|
||||
type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect;
|
||||
type IssueActiveRunRow = {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -303,6 +307,16 @@ type IssueChildCreateInput = IssueCreateInput & {
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
};
|
||||
type AcceptedPlanDecompositionInput = {
|
||||
acceptedPlanRevisionId: string;
|
||||
children: IssueChildCreateInput[];
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
type AcceptedPlanDocumentInteraction = {
|
||||
id: string;
|
||||
};
|
||||
type IssueRelationSummaryMap = {
|
||||
blockedBy: IssueRelationIssueSummary[];
|
||||
blocks: IssueRelationIssueSummary[];
|
||||
@@ -376,6 +390,96 @@ function appendAcceptanceCriteriaToDescription(description: string | null | unde
|
||||
return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown;
|
||||
}
|
||||
|
||||
function normalizeAcceptedPlanDecompositionFingerprintValue(value: unknown): unknown {
|
||||
if (value === undefined) return null;
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeAcceptedPlanDecompositionFingerprintValue(item));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => [key, normalizeAcceptedPlanDecompositionFingerprintValue(record[key])]),
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function createAcceptedPlanDecompositionRequestFingerprint(input: {
|
||||
acceptedPlanRevisionId: string;
|
||||
children: IssueChildCreateInput[];
|
||||
}) {
|
||||
const canonical = JSON.stringify(normalizeAcceptedPlanDecompositionFingerprintValue(input));
|
||||
return createHash("sha256").update(canonical).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeIssuePlanDecompositionChildIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
||||
}
|
||||
|
||||
function readAcceptedPlanConfirmationTarget(payload: unknown): {
|
||||
revisionId: string;
|
||||
key: string;
|
||||
issueId: string;
|
||||
} | null {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
||||
const target = (payload as Record<string, unknown>).target;
|
||||
if (!target || typeof target !== "object" || Array.isArray(target)) return null;
|
||||
const record = target as Record<string, unknown>;
|
||||
if (record.type !== "issue_document") return null;
|
||||
const revisionId = readStringFromRecord(record, "revisionId");
|
||||
const key = readStringFromRecord(record, "key");
|
||||
const issueId = readStringFromRecord(record, "issueId");
|
||||
if (!revisionId || !key || !issueId) return null;
|
||||
return { revisionId, key, issueId };
|
||||
}
|
||||
|
||||
async function findAcceptedPlanDocumentInteraction(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
input: {
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
acceptedPlanRevisionId: string;
|
||||
},
|
||||
): Promise<AcceptedPlanDocumentInteraction | null> {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
id: issueThreadInteractions.id,
|
||||
payload: issueThreadInteractions.payload,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.issueId, input.sourceIssueId),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "accepted"),
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt));
|
||||
|
||||
for (const row of rows) {
|
||||
const target = readAcceptedPlanConfirmationTarget(row.payload);
|
||||
if (
|
||||
target?.issueId === input.sourceIssueId &&
|
||||
target.key === "plan" &&
|
||||
target.revisionId === input.acceptedPlanRevisionId
|
||||
) {
|
||||
return { id: row.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createIssueDependencyReadiness(issueId: string): IssueDependencyReadiness {
|
||||
return {
|
||||
issueId,
|
||||
@@ -4058,6 +4162,220 @@ export function issueService(db: Db) {
|
||||
};
|
||||
},
|
||||
|
||||
decomposeAcceptedPlan: async (
|
||||
sourceIssueId: string,
|
||||
data: AcceptedPlanDecompositionInput,
|
||||
) => {
|
||||
const sourceIssue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
workMode: issues.workMode,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, sourceIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceIssue) throw notFound("Source issue not found");
|
||||
if (sourceIssue.workMode !== "planning") {
|
||||
throw unprocessable("Accepted-plan decomposition is only available for planning issues");
|
||||
}
|
||||
|
||||
const requestFingerprint = createAcceptedPlanDecompositionRequestFingerprint({
|
||||
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
|
||||
children: data.children,
|
||||
});
|
||||
|
||||
const initialClaim = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${issues.id} from ${issues} where ${issues.id} = ${sourceIssue.id} for update`);
|
||||
|
||||
const belongsToPlanDocument = await tx
|
||||
.select({ revisionId: documentRevisions.id })
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documentRevisions, eq(issueDocuments.documentId, documentRevisions.documentId))
|
||||
.where(and(
|
||||
eq(issueDocuments.companyId, sourceIssue.companyId),
|
||||
eq(issueDocuments.issueId, sourceIssue.id),
|
||||
eq(issueDocuments.key, "plan"),
|
||||
eq(documentRevisions.id, data.acceptedPlanRevisionId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!belongsToPlanDocument) {
|
||||
throw unprocessable("acceptedPlanRevisionId must belong to the source issue's plan document");
|
||||
}
|
||||
|
||||
const acceptedInteraction = await findAcceptedPlanDocumentInteraction(tx, {
|
||||
companyId: sourceIssue.companyId,
|
||||
sourceIssueId: sourceIssue.id,
|
||||
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
|
||||
});
|
||||
if (!acceptedInteraction) {
|
||||
throw unprocessable("acceptedPlanRevisionId must have an accepted plan confirmation");
|
||||
}
|
||||
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(issuePlanDecompositions)
|
||||
.where(and(
|
||||
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
|
||||
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
|
||||
eq(issuePlanDecompositions.acceptedPlanRevisionId, data.acceptedPlanRevisionId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
const now = new Date();
|
||||
if (!existing) {
|
||||
const [created] = await tx
|
||||
.insert(issuePlanDecompositions)
|
||||
.values({
|
||||
companyId: sourceIssue.companyId,
|
||||
sourceIssueId: sourceIssue.id,
|
||||
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
|
||||
acceptedInteractionId: acceptedInteraction.id,
|
||||
status: "in_flight",
|
||||
requestFingerprint,
|
||||
requestedChildCount: data.children.length,
|
||||
requestedChildren: data.children as unknown as Record<string, unknown>[],
|
||||
childIssueIds: [],
|
||||
ownerAgentId: data.actorAgentId ?? null,
|
||||
ownerUserId: data.actorUserId ?? null,
|
||||
ownerRunId: data.actorRunId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error("Failed to create accepted-plan decomposition claim");
|
||||
return created;
|
||||
}
|
||||
|
||||
if (existing.requestFingerprint !== requestFingerprint) {
|
||||
throw conflict("Accepted-plan decomposition already exists for this revision with a different child set");
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
acceptedInteractionId: existing.acceptedInteractionId ?? acceptedInteraction.id,
|
||||
ownerAgentId: data.actorAgentId ?? null,
|
||||
ownerUserId: data.actorUserId ?? null,
|
||||
ownerRunId: data.actorRunId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(issuePlanDecompositions.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error("Failed to refresh accepted-plan decomposition claim");
|
||||
return updated;
|
||||
});
|
||||
|
||||
let currentClaim = initialClaim;
|
||||
const newlyCreatedIssues: Array<typeof issues.$inferSelect> = [];
|
||||
|
||||
while (true) {
|
||||
const step = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select ${issuePlanDecompositions.id}
|
||||
from ${issuePlanDecompositions}
|
||||
where ${issuePlanDecompositions.id} = ${currentClaim.id}
|
||||
for update`,
|
||||
);
|
||||
|
||||
const claim = await tx
|
||||
.select()
|
||||
.from(issuePlanDecompositions)
|
||||
.where(eq(issuePlanDecompositions.id, currentClaim.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!claim) throw notFound("Accepted-plan decomposition claim not found");
|
||||
if (claim.requestFingerprint !== requestFingerprint) {
|
||||
throw conflict("Accepted-plan decomposition already exists for this revision with a different child set");
|
||||
}
|
||||
|
||||
const existingChildIssueIds = normalizeIssuePlanDecompositionChildIds(claim.childIssueIds);
|
||||
if (claim.status === "completed" || existingChildIssueIds.length >= data.children.length) {
|
||||
const nextIds = existingChildIssueIds.slice(0, data.children.length);
|
||||
if (claim.status === "completed" && nextIds.length === data.children.length) {
|
||||
return {
|
||||
claim,
|
||||
createdIssue: null,
|
||||
};
|
||||
}
|
||||
|
||||
const completedAt = claim.completedAt ?? new Date();
|
||||
const [completed] = await tx
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: "completed",
|
||||
childIssueIds: nextIds,
|
||||
completedAt,
|
||||
ownerAgentId: data.actorAgentId ?? null,
|
||||
ownerUserId: data.actorUserId ?? null,
|
||||
ownerRunId: data.actorRunId ?? null,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(eq(issuePlanDecompositions.id, claim.id))
|
||||
.returning();
|
||||
if (!completed) throw new Error("Failed to complete accepted-plan decomposition claim");
|
||||
return {
|
||||
claim: completed,
|
||||
createdIssue: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextChildInput = data.children[existingChildIssueIds.length];
|
||||
if (!nextChildInput) {
|
||||
throw new Error("Accepted-plan decomposition child cursor moved past the requested children");
|
||||
}
|
||||
|
||||
const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, nextChildInput);
|
||||
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
|
||||
const now = new Date();
|
||||
const nextStatus = nextIds.length === data.children.length ? "completed" : "in_flight";
|
||||
const [updatedClaim] = await tx
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: nextStatus,
|
||||
childIssueIds: nextIds,
|
||||
completedAt: nextStatus === "completed" ? now : null,
|
||||
ownerAgentId: data.actorAgentId ?? null,
|
||||
ownerUserId: data.actorUserId ?? null,
|
||||
ownerRunId: data.actorRunId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(issuePlanDecompositions.id, claim.id))
|
||||
.returning();
|
||||
if (!updatedClaim) throw new Error("Failed to persist accepted-plan decomposition progress");
|
||||
return {
|
||||
claim: updatedClaim,
|
||||
createdIssue: createdChild.issue,
|
||||
};
|
||||
});
|
||||
|
||||
currentClaim = step.claim;
|
||||
if (step.createdIssue) {
|
||||
newlyCreatedIssues.push(step.createdIssue);
|
||||
}
|
||||
if (step.claim.status === "completed") break;
|
||||
}
|
||||
|
||||
const childIssueIds = normalizeIssuePlanDecompositionChildIds(currentClaim.childIssueIds);
|
||||
const childIssueRows = childIssueIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, childIssueIds)))
|
||||
: [];
|
||||
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
|
||||
const orderedChildIssues = childIssueIds
|
||||
.map((childIssueId) => childIssueMap.get(childIssueId))
|
||||
.filter((row): row is typeof issues.$inferSelect => Boolean(row));
|
||||
|
||||
return {
|
||||
decomposition: currentClaim,
|
||||
childIssueIds,
|
||||
childIssues: orderedChildIssues,
|
||||
newlyCreatedIssues,
|
||||
};
|
||||
},
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
data: IssueCreateInput,
|
||||
|
||||
Reference in new issue
Block a user