diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 40d9647879f..04ec0b35e54 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -140,6 +140,7 @@ export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; export const ISSUE_THREAD_INTERACTION_KINDS = [ "suggest_tasks", "ask_user_questions", + "request_confirmation", ] as const; export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ecabee86702..0514d182738 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -316,9 +316,14 @@ export type { AskUserQuestionsPayload, AskUserQuestionsAnswer, AskUserQuestionsResult, + RequestConfirmationIssueDocumentTarget, + RequestConfirmationTarget, + RequestConfirmationPayload, + RequestConfirmationResult, IssueThreadInteractionBase, SuggestTasksInteraction, AskUserQuestionsInteraction, + RequestConfirmationInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, @@ -589,6 +594,10 @@ export { askUserQuestionsPayloadSchema, askUserQuestionsAnswerSchema, askUserQuestionsResultSchema, + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationTargetSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts new file mode 100644 index 00000000000..2fb153f3ce3 --- /dev/null +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { createIssueThreadInteractionSchema } from "./validators/issue.js"; + +describe("issue thread interaction schemas", () => { + it("parses request_confirmation payloads with default no-wake continuation", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Revise", + detailsMarkdown: "The current plan document will be accepted as-is.", + supersedeOnUserComment: true, + }, + }); + + expect(parsed).toMatchObject({ + kind: "request_confirmation", + continuationPolicy: "none", + payload: { + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Revise", + supersedeOnUserComment: true, + }, + }); + }); + + it("accepts issue document targets for request_confirmation interactions", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Accept the latest plan revision?", + target: { + type: "issue_document", + issueId: "11111111-1111-4111-8111-111111111111", + documentId: "22222222-2222-4222-8222-222222222222", + key: "plan", + revisionId: "33333333-3333-4333-8333-333333333333", + revisionNumber: 2, + }, + }, + }); + + expect(parsed.kind).toBe("request_confirmation"); + if (parsed.kind !== "request_confirmation") return; + expect(parsed.payload.target).toMatchObject({ + type: "issue_document", + key: "plan", + revisionNumber: 2, + }); + }); +}); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a9d674a17d6..187ecf5d472 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -124,9 +124,14 @@ export type { AskUserQuestionsPayload, AskUserQuestionsAnswer, AskUserQuestionsResult, + RequestConfirmationIssueDocumentTarget, + RequestConfirmationTarget, + RequestConfirmationPayload, + RequestConfirmationResult, IssueThreadInteractionBase, SuggestTasksInteraction, AskUserQuestionsInteraction, + RequestConfirmationInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index a1a0d59dad0..88ecebeb191 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -344,6 +344,35 @@ export interface AskUserQuestionsResult { summaryMarkdown?: string | null; } +export interface RequestConfirmationIssueDocumentTarget { + type: "issue_document"; + issueId?: string | null; + documentId?: string | null; + key: string; + revisionId: string; + revisionNumber?: number | null; +} + +export type RequestConfirmationTarget = RequestConfirmationIssueDocumentTarget; + +export interface RequestConfirmationPayload { + version: 1; + prompt: string; + acceptLabel?: string | null; + rejectLabel?: string | null; + detailsMarkdown?: string | null; + supersedeOnUserComment?: boolean; + target?: RequestConfirmationTarget | null; +} + +export interface RequestConfirmationResult { + version: 1; + outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; + reason?: string | null; + commentId?: string | null; + staleTarget?: RequestConfirmationTarget | null; +} + export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { id: string; companyId: string; @@ -373,17 +402,26 @@ export interface AskUserQuestionsInteraction extends IssueThreadInteractionBase result?: AskUserQuestionsResult | null; } +export interface RequestConfirmationInteraction extends IssueThreadInteractionBase { + kind: "request_confirmation"; + payload: RequestConfirmationPayload; + result?: RequestConfirmationResult | null; +} + export type IssueThreadInteraction = | SuggestTasksInteraction - | AskUserQuestionsInteraction; + | AskUserQuestionsInteraction + | RequestConfirmationInteraction; export type IssueThreadInteractionPayload = | SuggestTasksPayload - | AskUserQuestionsPayload; + | AskUserQuestionsPayload + | RequestConfirmationPayload; export type IssueThreadInteractionResult = | SuggestTasksResult - | AskUserQuestionsResult; + | AskUserQuestionsResult + | RequestConfirmationResult; export interface IssueAttachment { id: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 62a34b3402b..f2af85ea983 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -154,6 +154,10 @@ export { askUserQuestionsPayloadSchema, askUserQuestionsAnswerSchema, askUserQuestionsResultSchema, + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationTargetSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 7760b6c60fa..9f594654ca2 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -192,6 +192,13 @@ export const issueThreadInteractionContinuationPolicySchema = z.enum( ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ); +export const issueDocumentKeySchema = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -"); + export const suggestedTaskDraftSchema = z.object({ clientKey: z.string().trim().min(1).max(120), parentClientKey: z.string().trim().min(1).max(120).nullable().optional(), @@ -308,6 +315,37 @@ export const askUserQuestionsResultSchema = z.object({ summaryMarkdown: z.string().max(20000).nullable().optional(), }); +export const requestConfirmationIssueDocumentTargetSchema = z.object({ + type: z.literal("issue_document"), + issueId: z.string().uuid().nullable().optional(), + documentId: z.string().uuid().nullable().optional(), + key: issueDocumentKeySchema, + revisionId: z.string().uuid(), + revisionNumber: z.number().int().positive().nullable().optional(), +}); + +export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [ + requestConfirmationIssueDocumentTargetSchema, +]); + +export const requestConfirmationPayloadSchema = z.object({ + version: z.literal(1), + prompt: z.string().trim().min(1).max(1000), + acceptLabel: z.string().trim().min(1).max(80).nullable().optional(), + rejectLabel: z.string().trim().min(1).max(80).nullable().optional(), + detailsMarkdown: z.string().max(20000).nullable().optional(), + supersedeOnUserComment: z.boolean().optional(), + target: requestConfirmationTargetSchema.nullable().optional(), +}); + +export const requestConfirmationResultSchema = z.object({ + version: z.literal(1), + outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), + reason: z.string().trim().max(4000).nullable().optional(), + commentId: z.string().uuid().nullable().optional(), + staleTarget: requestConfirmationTargetSchema.nullable().optional(), +}); + export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("suggest_tasks"), @@ -329,6 +367,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), payload: askUserQuestionsPayloadSchema, }), + z.object({ + kind: z.literal("request_confirmation"), + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"), + payload: requestConfirmationPayloadSchema, + }), ]); export type CreateIssueThreadInteraction = z.infer; @@ -378,13 +426,6 @@ export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const; export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS); -export const issueDocumentKeySchema = z - .string() - .trim() - .min(1) - .max(64) - .regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -"); - export const upsertIssueDocumentSchema = z.object({ title: z.string().trim().max(200).nullable().optional(), format: issueDocumentFormatSchema, diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index a3879052ca0..075e89b8560 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -12,7 +12,9 @@ const mockIssueService = vi.hoisted(() => ({ const mockInteractionService = vi.hoisted(() => ({ listForIssue: vi.fn(), create: vi.fn(), + acceptInteraction: vi.fn(), acceptSuggestedTasks: vi.fn(), + rejectInteraction: vi.fn(), rejectSuggestedTasks: vi.fn(), answerQuestions: vi.fn(), })); @@ -163,7 +165,7 @@ describe("issue thread interaction routes", () => { createdAt: "2026-04-20T12:00:00.000Z", updatedAt: "2026-04-20T12:00:00.000Z", }); - mockInteractionService.acceptSuggestedTasks.mockResolvedValue({ + mockInteractionService.acceptInteraction.mockResolvedValue({ interaction: { id: "interaction-1", companyId: "company-1", @@ -195,7 +197,7 @@ describe("issue thread interaction routes", () => { }, ], }); - mockInteractionService.rejectSuggestedTasks.mockResolvedValue({ + mockInteractionService.rejectInteraction.mockResolvedValue({ id: "interaction-1", companyId: "company-1", issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", @@ -290,7 +292,7 @@ describe("issue thread interaction routes", () => { .send({ selectedClientKeys: ["task-1"] }); expect(res.status).toBe(200); - expect(mockInteractionService.acceptSuggestedTasks).toHaveBeenCalledWith( + expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith( expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), "interaction-1", { selectedClientKeys: ["task-1"] }, @@ -358,6 +360,87 @@ describe("issue thread interaction routes", () => { ); }); + it("accepts request confirmations and wakes the current assignee when configured", async () => { + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-3", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-3", + payload: { + version: 1, + prompt: "Apply this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + interactionId: "interaction-3", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }), + }), + ); + }); + + it("does not emit a continuation wake when request confirmations are rejected", async () => { + mockInteractionService.rejectInteraction.mockResolvedValueOnce({ + id: "interaction-3", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "rejected", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-3", + payload: { + version: 1, + prompt: "Apply this plan?", + }, + result: { + version: 1, + outcome: "rejected", + reason: "Needs changes", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/reject") + .send({ reason: "Needs changes" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("allows agent-authored interaction creation and stamps the active run id", async () => { const app = await createApp({ type: "agent", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 56c898dcea7..48368e62911 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -4,8 +4,11 @@ import { agents, companies, createDb, + documentRevisions, + documents, goals, heartbeatRuns, + issueDocuments, instanceSettings, issueRelations, issueThreadInteractions, @@ -37,6 +40,9 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { afterEach(async () => { await db.delete(issueThreadInteractions); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); await db.delete(issueRelations); await db.delete(heartbeatRuns); await db.delete(issues); @@ -487,4 +493,255 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(rows).toHaveLength(1); expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire"); }); + + it("accepts request_confirmation interactions without creating child issues", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = 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(goals).values({ + id: goalId, + companyId, + title: "Confirm a request", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Keep editing", + detailsMarkdown: "Creates follow-up work after acceptance.", + }, + }, { + userId: "local-board", + }); + + expect(created.kind).toBe("request_confirmation"); + expect(created.status).toBe("pending"); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.createdIssues).toEqual([]); + expect(accepted.interaction).toMatchObject({ + kind: "request_confirmation", + status: "accepted", + result: { + version: 1, + outcome: "accepted", + }, + resolvedByUserId: "local-board", + }); + }); + + it("expires supersedable request confirmations when a user comments", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const commentId = 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(goals).values({ + id: goalId, + companyId, + title: "Comment supersede", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId, + }, + resolvedByUserId: "local-board", + }); + }); + + it("expires request confirmations when the watched issue document revision changes", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const nextRevisionId = 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(goals).values({ + id: goalId, + companyId, + title: "Document target confirmation", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "v1", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "v1", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Apply the plan document?", + target: { + type: "issue_document", + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }, + }, + }, { + userId: "local-board", + }); + + await db.insert(documentRevisions).values({ + id: nextRevisionId, + companyId, + documentId, + revisionNumber: 2, + title: "Plan", + format: "markdown", + body: "v2", + }); + await db.update(documents).set({ + latestBody: "v2", + latestRevisionId: nextRevisionId, + latestRevisionNumber: 2, + }); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.interaction).toMatchObject({ + id: created.id, + status: "expired", + result: { + version: 1, + outcome: "stale_target", + staleTarget: { + type: "issue_document", + key: "plan", + revisionId, + }, + }, + }); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 377d13a589f..65fc74d8c57 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -205,6 +205,8 @@ function queueResolvedInteractionContinuationWakeup(input: { source: string; }) { if (input.interaction.continuationPolicy !== "wake_assignee") return; + if (input.interaction.kind === "request_confirmation" && input.interaction.status !== "accepted") return; + if (input.interaction.status === "expired") return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; void input.heartbeat.wakeup(input.issue.assigneeAgentId, { @@ -407,6 +409,34 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + async function logExpiredRequestConfirmations(input: { + issue: { id: string; companyId: string; identifier?: string | null }; + interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>; + actor: ReturnType; + source: string; + }) { + for (const interaction of input.interactions) { + await logActivity(db, { + companyId: input.issue.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "issue.thread_interaction_expired", + entityType: "issue", + entityId: input.issue.id, + details: { + identifier: input.issue.identifier ?? null, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + source: input.source, + result: interaction.result ?? null, + }, + }); + } + } + function parseDateQuery(value: unknown, field: string) { if (typeof value !== "string" || value.trim().length === 0) return undefined; const parsed = new Date(value); @@ -1097,6 +1127,28 @@ export function issueRoutes( }, }); + if (!result.created) { + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: doc.id, + key: doc.key, + latestRevisionId: doc.latestRevisionId, + latestRevisionNumber: doc.latestRevisionNumber, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_updated", + }); + } + res.status(result.created ? 201 : 200).json(doc); }); @@ -1174,6 +1226,26 @@ export function issueRoutes( }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: result.document.id, + key: result.document.key, + latestRevisionId: result.document.latestRevisionId, + latestRevisionNumber: result.document.latestRevisionNumber, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_restored", + }); + res.json(result.document); }, ); @@ -1225,6 +1297,25 @@ export function issueRoutes( }), }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: removed.id, + key: removed.key, + latestRevisionId: null, + latestRevisionNumber: null, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_deleted", + }); res.json({ ok: true }); }); @@ -2088,6 +2179,21 @@ export function issueRoutes( }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( + issue, + comment, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.comment", + }); + } else if (updateReferenceSummaryAfter) { issueResponse = { ...issueResponse, @@ -2569,7 +2675,7 @@ export function issueRoutes( assertBoard(req); const actor = getActorInfo(req); - const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, req.body, { + const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); @@ -2580,7 +2686,9 @@ export function issueRoutes( actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, - action: "issue.thread_interaction_accepted", + action: interaction.status === "expired" + ? "issue.thread_interaction_expired" + : "issue.thread_interaction_accepted", entityType: "issue", entityId: issue.id, details: { @@ -2637,7 +2745,7 @@ export function issueRoutes( assertBoard(req); const actor = getActorInfo(req); - const interaction = await issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, req.body, { + const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); @@ -2648,7 +2756,9 @@ export function issueRoutes( actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, - action: "issue.thread_interaction_rejected", + action: interaction.status === "expired" + ? "issue.thread_interaction_expired" + : "issue.thread_interaction_rejected", entityType: "issue", entityId: issue.id, details: { @@ -2658,6 +2768,8 @@ export function issueRoutes( rejectionReason: interaction.kind === "suggest_tasks" ? (interaction.result?.rejectionReason ?? null) + : interaction.kind === "request_confirmation" + ? (interaction.result?.reason ?? null) : null, }, }); @@ -3023,6 +3135,21 @@ export function issueRoutes( }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( + currentIssue, + comment, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue: currentIssue, + interactions: expiredInteractions, + actor, + source: "issue.comment", + }); + // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. void (async () => { const wakeups = new Map[1]>(); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index c9279339027..acf6267d791 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2,8 +2,10 @@ import { isDeepStrictEqual } from "node:util"; import { and, asc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + documents, heartbeatRuns, issueComments, + issueDocuments, issueThreadInteractions, issues, } from "@paperclipai/db"; @@ -13,6 +15,8 @@ import type { AskUserQuestionsInteraction, CreateIssueThreadInteraction, IssueThreadInteraction, + RequestConfirmationInteraction, + RequestConfirmationTarget, RejectIssueThreadInteraction, RespondIssueThreadInteraction, SuggestTasksInteraction, @@ -23,6 +27,9 @@ import { askUserQuestionsPayloadSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, suggestTasksPayloadSchema, suggestTasksResultSchema, } from "@paperclipai/shared"; @@ -43,6 +50,11 @@ type CreatedIssueWakeTarget = { status: string; }; +type ResolvedInteractionResult = { + interaction: IssueThreadInteraction; + createdIssues: CreatedIssueWakeTarget[]; +}; + type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect; function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { @@ -96,6 +108,13 @@ function hydrateInteraction( payload: askUserQuestionsPayloadSchema.parse(row.payload), result: row.result ? askUserQuestionsResultSchema.parse(row.result) : null, } satisfies AskUserQuestionsInteraction; + case "request_confirmation": + return { + ...base, + kind: "request_confirmation", + payload: requestConfirmationPayloadSchema.parse(row.payload), + result: row.result ? requestConfirmationResultSchema.parse(row.result) : null, + } satisfies RequestConfirmationInteraction; default: throw unprocessable(`Unknown interaction kind: ${row.kind}`); } @@ -226,6 +245,110 @@ function normalizeQuestionAnswers(args: { .filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer)); } +async function getIssueDocumentTargetSnapshot(db: Db | any, args: { + companyId: string; + issueId: string; + target: RequestConfirmationTarget; +}) { + if (args.target.type !== "issue_document") { + throw unprocessable(`Unsupported request confirmation target: ${(args.target as { type: string }).type}`); + } + const targetIssueId = args.target.issueId ?? args.issueId; + const row = await db + .select({ + issueId: issueDocuments.issueId, + documentId: issueDocuments.documentId, + key: issueDocuments.key, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, args.companyId), + eq(issueDocuments.issueId, targetIssueId), + eq(issueDocuments.key, args.target.key), + )) + .then((rows: Array<{ + issueId: string; + documentId: string; + key: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + }>) => rows[0] ?? null); + + if (!row) return null; + if (args.target.documentId && args.target.documentId !== row.documentId) return null; + return row; +} + +async function assertRequestConfirmationTargetIsCurrent(db: Db | any, args: { + companyId: string; + issueId: string; + target?: RequestConfirmationTarget | null; +}) { + if (!args.target) return; + const snapshot = await getIssueDocumentTargetSnapshot(db, { + companyId: args.companyId, + issueId: args.issueId, + target: args.target, + }); + if (!snapshot || snapshot.latestRevisionId !== args.target.revisionId) { + throw unprocessable("request_confirmation target must reference the current issue document revision"); + } + if (args.target.revisionNumber && snapshot.latestRevisionNumber !== args.target.revisionNumber) { + throw unprocessable("request_confirmation target revisionNumber must match the current issue document revision"); + } +} + +async function expireStaleRequestConfirmationTarget(db: Db | any, args: { + row: IssueThreadInteractionRow; + actor: InteractionActor; +}): Promise { + if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null; + const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction; + const target = interaction.payload.target ?? null; + if (!target) return null; + + const snapshot = await getIssueDocumentTargetSnapshot(db, { + companyId: args.row.companyId, + issueId: args.row.issueId, + target, + }); + const isCurrent = + snapshot + && snapshot.latestRevisionId === target.revisionId + && (!target.revisionNumber || snapshot.latestRevisionNumber === target.revisionNumber); + if (isCurrent) return null; + + const now = new Date(); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "stale_target", + staleTarget: target, + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.row.issueId); + return hydrateInteraction(updated); +} + export function issueThreadInteractionService(db: Db) { async function getIdempotentInteraction(args: { issueId: string; @@ -243,6 +366,108 @@ export function issueThreadInteractionService(db: Db) { .then((rows) => rows[0] ?? null); } + async function getPendingInteractionForResolution(args: { + issue: { id: string; companyId: string }; + interactionId: string; + }) { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, args.interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== args.issue.companyId || current.issueId !== args.issue.id) { + throw notFound("Interaction not found"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + return current; + } + + async function acceptRequestConfirmation(args: { + issue: { id: string; companyId: string }; + current: IssueThreadInteractionRow; + actor: InteractionActor; + }): Promise { + const expired = await expireStaleRequestConfirmationTarget(db, { + row: args.current, + actor: args.actor, + }); + if (expired) { + return expired; + } + + const now = new Date(); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "accepted", + result: { + version: 1, + outcome: "accepted", + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.current.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.issue.id); + return hydrateInteraction(updated); + } + + async function rejectRequestConfirmation(args: { + issue: { id: string; companyId: string }; + current: IssueThreadInteractionRow; + input: RejectIssueThreadInteraction; + actor: InteractionActor; + }): Promise { + const expired = await expireStaleRequestConfirmationTarget(db, { + row: args.current, + actor: args.actor, + }); + if (expired) { + return expired; + } + + const now = new Date(); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + outcome: "rejected", + reason: args.input.reason?.trim() || null, + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.current.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.issue.id); + return hydrateInteraction(updated); + } + return { listForIssue: async (issueId: string) => { const rows = await db @@ -314,6 +539,14 @@ export function issueThreadInteractionService(db: Db) { } } + if (data.kind === "request_confirmation") { + await assertRequestConfirmationTargetIsCurrent(db, { + companyId: issue.companyId, + issueId: issue.id, + target: data.payload.target ?? null, + }); + } + let created: IssueThreadInteractionRow; try { [created] = await db @@ -356,6 +589,31 @@ export function issueThreadInteractionService(db: Db) { return hydrateInteraction(created); }, + acceptInteraction: async ( + issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, + interactionId: string, + input: AcceptIssueThreadInteraction, + actor: InteractionActor, + ): Promise => { + const data = acceptIssueThreadInteractionSchema.parse(input); + const current = await getPendingInteractionForResolution({ issue, interactionId }); + switch (current.kind) { + case "suggest_tasks": + return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor); + case "request_confirmation": + return { + interaction: await acceptRequestConfirmation({ + issue, + current, + actor, + }), + createdIssues: [], + }; + default: + throw unprocessable(`Interactions of kind ${current.kind} cannot be accepted`); + } + }, + acceptSuggestedTasks: async ( issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, interactionId: string, @@ -487,6 +745,29 @@ export function issueThreadInteractionService(db: Db) { }; }, + rejectInteraction: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = rejectIssueThreadInteractionSchema.parse(input); + const current = await getPendingInteractionForResolution({ issue, interactionId }); + switch (current.kind) { + case "suggest_tasks": + return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor); + case "request_confirmation": + return rejectRequestConfirmation({ + issue, + current, + input: data, + actor, + }); + default: + throw unprocessable(`Interactions of kind ${current.kind} cannot be rejected`); + } + }, + rejectSuggestedTasks: async ( issue: { id: string; companyId: string }, interactionId: string, @@ -530,6 +811,125 @@ export function issueThreadInteractionService(db: Db) { return hydrateInteraction(updated); }, + expireRequestConfirmationsSupersededByComment: async ( + issue: { id: string; companyId: string }, + comment: { id: string; authorUserId?: string | null }, + actor: InteractionActor, + ) => { + if (!comment.authorUserId) return []; + + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )); + + const superseded = rows.filter((row) => { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + return interaction.payload.supersedeOnUserComment === true; + }); + + if (superseded.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const row of superseded) { + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: comment.id, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) expired.push(hydrateInteraction(updated)); + } + + if (expired.length > 0) { + await touchIssue(db, issue.id); + } + return expired; + }, + + expireStaleRequestConfirmationsForIssueDocument: async ( + issue: { id: string; companyId: string }, + document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null, + actor: InteractionActor, + ) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )); + + const staleRows = rows.filter((row) => { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const target = interaction.payload.target; + if (!target || target.type !== "issue_document") return false; + const targetIssueId = target.issueId ?? issue.id; + if (targetIssueId !== issue.id) return false; + if (document && target.documentId && target.documentId !== document.id) return false; + if (document && target.key !== document.key) return false; + if (!document) return true; + return ( + target.revisionId !== document.latestRevisionId + || (target.revisionNumber != null && target.revisionNumber !== document.latestRevisionNumber) + ); + }); + + if (staleRows.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const row of staleRows) { + const target = (hydrateInteraction(row) as RequestConfirmationInteraction).payload.target ?? null; + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "stale_target", + staleTarget: target, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) expired.push(hydrateInteraction(updated)); + } + + if (expired.length > 0) { + await touchIssue(db, issue.id); + } + return expired; + }, + answerQuestions: async ( issue: { id: string; companyId: string }, interactionId: string,