mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add request confirmation interactions
This commit is contained in:
1 parent
b51a0273b6
commit
4acaebddc0
11 files changed
+1038
-17
No files matched your search
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -124,9 +124,14 @@ export type {
|
||||
AskUserQuestionsPayload,
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsResult,
|
||||
RequestConfirmationIssueDocumentTarget,
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
IssueThreadInteractionBase,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionPayload,
|
||||
IssueThreadInteractionResult,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -154,6 +154,10 @@ export {
|
||||
askUserQuestionsPayloadSchema,
|
||||
askUserQuestionsAnswerSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
requestConfirmationIssueDocumentTargetSchema,
|
||||
requestConfirmationTargetSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
|
||||
@@ -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<typeof createIssueThreadInteractionSchema>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+131
-4
@@ -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<typeof getActorInfo>;
|
||||
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<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
||||
|
||||
@@ -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<IssueThreadInteraction | null> {
|
||||
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<IssueThreadInteraction> {
|
||||
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<IssueThreadInteraction> {
|
||||
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<ResolvedInteractionResult> => {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user