diff --git a/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql b/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql new file mode 100644 index 0000000000..00d65045eb --- /dev/null +++ b/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql @@ -0,0 +1,15 @@ +ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "kind" text;--> statement-breakpoint +UPDATE "inbox_dismissals" SET "kind" = 'dismiss' WHERE "kind" IS NULL;--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET DEFAULT 'dismiss';--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "snoozed_until" timestamp with time zone;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_check" CHECK ("kind" IN ('dismiss', 'snooze')); +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_snooze_until_check" CHECK (("kind" = 'dismiss' AND "snoozed_until" IS NULL) OR ("kind" = 'snooze' AND "snoozed_until" IS NOT NULL)); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fe570bd9dc..a0331a5af0 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1002,6 +1002,13 @@ "when": 1783520000000, "tag": "0144_case_document_annotations", "breakpoints": true + }, + { + "idx": 145, + "version": "7", + "when": 1783641600000, + "tag": "0145_inbox_dismissal_snooze_kind", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/inbox_dismissals.ts b/packages/db/src/schema/inbox_dismissals.ts index 22996a476f..ff77b45043 100644 --- a/packages/db/src/schema/inbox_dismissals.ts +++ b/packages/db/src/schema/inbox_dismissals.ts @@ -8,7 +8,9 @@ export const inboxDismissals = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), userId: text("user_id").notNull(), itemKey: text("item_key").notNull(), + kind: text("kind").$type<"dismiss" | "snooze">().notNull().default("dismiss"), dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(), + snoozedUntil: timestamp("snoozed_until", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 587d78d3cb..801019a47a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -248,10 +248,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [ "ask_user_questions", "request_confirmation", "request_checkbox_confirmation", + "request_item_verdicts", ] as const; export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200; +export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT; export const ISSUE_THREAD_INTERACTION_STATUSES = [ "pending", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e78fccfcbf..9b90f1bb8a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -49,6 +49,21 @@ export { type ResponsibleUserDenialCopy, type ResponsibleUserDenialTone, } from "./responsible-user-denial.js"; +export type { + AttentionDecisionVerb, + AttentionDetailImage, + AttentionFeed, + AttentionItem, + AttentionItemDetail, + AttentionItemDismissal, + AttentionProjectRef, + AttentionSeverity, + AttentionSourceKind, + AttentionSubject, + AttentionSubjectKind, + AttentionWorkspaceRef, +} from "./types/attention.js"; + export type { PipelineAutomationRetryBlocker, PipelineAutomationRetryCleanupOptions, @@ -296,6 +311,7 @@ export { type IssueThreadInteractionStatus, type IssueThreadInteractionContinuationPolicy, REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, + REQUEST_ITEM_VERDICTS_ITEM_LIMIT, type BuiltInIssueOriginKind, type PluginIssueOriginKind, type IssueOriginKind, @@ -770,6 +786,11 @@ export type { RequestCheckboxConfirmationOption, RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationResult, + RequestItemVerdictValue, + RequestItemVerdictsItem, + RequestItemVerdictsPayload, + RequestItemVerdictsResultItem, + RequestItemVerdictsResult, AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, @@ -781,6 +802,7 @@ export type { AskUserQuestionsInteraction, RequestConfirmationInteraction, RequestCheckboxConfirmationInteraction, + RequestItemVerdictsInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, @@ -857,6 +879,7 @@ export type { SidebarBadges, SidebarOrderPreference, InboxDismissal, + InboxDismissalKind, AccessUserProfile, CompanyMemberRecord, CompanyMembersResponse, @@ -1284,11 +1307,17 @@ export { requestCheckboxConfirmationOptionSchema, requestCheckboxConfirmationPayloadSchema, requestCheckboxConfirmationResultSchema, + requestItemVerdictValueSchema, + requestItemVerdictsItemSchema, + requestItemVerdictsPayloadSchema, + requestItemVerdictsResultItemSchema, + requestItemVerdictsResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, @@ -1350,6 +1379,7 @@ export { type RejectIssueThreadInteraction, type CancelIssueThreadInteraction, type RespondIssueThreadInteraction, + type SubmitIssueThreadInteractionVerdicts, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type CreateIssueWorkProduct, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index fea5c7e592..5251dc0360 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -3,6 +3,7 @@ import { acceptIssueThreadInteractionSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, } from "./validators/issue.js"; describe("issue thread interaction schemas", () => { @@ -306,4 +307,111 @@ describe("issue thread interaction schemas", () => { selectedOptionIds: ["item-1", "item-1"], })).toThrow("selectedOptionIds must be unique"); }); + + it("parses request_item_verdicts payloads with defaults", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review these generated items.", + items: [ + { id: "api", label: "API route", description: "Server submit endpoint" }, + { id: "docs", label: "Docs", previewMarkdown: "Document the route." }, + ], + }, + }); + + expect(parsed).toMatchObject({ + kind: "request_item_verdicts", + continuationPolicy: "wake_assignee", + payload: { + verdicts: ["approve", "reject"], + requireReasonOn: ["reject"], + allowBulkApprove: true, + }, + }); + }); + + it("accepts request_item_verdicts defer when enabled explicitly", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review these generated items.", + items: [{ id: "api", label: "API route" }], + verdicts: ["approve", "reject", "defer"], + requireReasonOn: ["reject", "defer"], + }, + }); + + expect(parsed).toMatchObject({ + kind: "request_item_verdicts", + payload: { + verdicts: ["approve", "reject", "defer"], + requireReasonOn: ["reject", "defer"], + }, + }); + }); + + it("rejects invalid request_item_verdicts item and reason references", () => { + const base = { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review these generated items.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + }, + } as const; + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + items: [], + }, + })).toThrow(); + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + items: [ + { id: "api", label: "API route" }, + { id: "api", label: "Duplicate" }, + ], + }, + })).toThrow("Item ids must be unique within one item verdict request"); + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + items: Array.from({ length: 201 }, (_value, index) => ({ + id: `item-${index}`, + label: `Item ${index}`, + })), + }, + })).toThrow(); + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + verdicts: ["approve", "reject"], + requireReasonOn: ["defer"], + }, + })).toThrow("requireReasonOn must reference enabled verdicts"); + }); + + it("rejects duplicate request_item_verdicts submit ids", () => { + expect(() => submitIssueThreadInteractionVerdictsSchema.parse({ + verdicts: [ + { id: "api", verdict: "approve" }, + { id: "api", verdict: "reject", reason: "Needs revision" }, + ], + })).toThrow("verdict item ids must be unique"); + }); }); diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts new file mode 100644 index 0000000000..6e01956439 --- /dev/null +++ b/packages/shared/src/types/attention.ts @@ -0,0 +1,177 @@ +import type { InboxDismissalKind } from "./inbox-dismissal.js"; + +export type AttentionSourceKind = + | "approval" + | "issue_thread_interaction" + | "join_request" + | "recovery_action" + | "productivity_review" + | "blocker_attention" + | "review" + | "failed_run" + | "budget_alert" + | "agent_error_alert"; + +export type AttentionSubjectKind = + | "approval" + | "issue" + | "interaction" + | "join_request" + | "recovery_action" + | "run" + | "budget_incident" + | "agent"; + +export type AttentionSeverity = "critical" | "high" | "medium" | "low"; + +export interface AttentionSubject { + kind: AttentionSubjectKind; + id: string; + companyId: string; + title: string | null; + identifier: string | null; + status: string | null; + href: string | null; + metadata?: Record; +} + +export interface AttentionDecisionVerb { + id: string; + label: string; + description: string | null; +} + +export interface AttentionProjectRef { + id: string; + name: string; + urlKey: string; + color: string | null; + icon: string | null; +} + +export interface AttentionWorkspaceRef { + id: string; + name: string; +} + +export interface AttentionDetailImage { + assetId: string; + alt?: string | null; +} + +export interface AttentionItemDismissal { + kind: InboxDismissalKind; + dismissedAt: string; + snoozedUntil: string | null; + isActive: boolean; +} + +export type AttentionItemDetail = + | { + kind: "approval"; + approvalType: string; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "plan_approval"; + issueTitle: string | null; + planTitle: string | null; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "confirmation"; + promptExcerpt: string | null; + isPlanTarget: false; + images: AttentionDetailImage[]; + } + | { + kind: "questions"; + questionCount: number; + firstQuestionText: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "suggested_tasks"; + taskCount: number; + firstTaskTitle: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "checkbox_confirmation"; + optionCount: number; + promptExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "item_verdicts"; + itemCount: number; + promptExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "failed_run"; + agentName: string | null; + failureReasonExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "blocker"; + blockingIssue: { + id: string | null; + identifier: string | null; + title: string | null; + } | null; + images: AttentionDetailImage[]; + } + | { + kind: "budget"; + observedPercent: number; + amountObserved: number; + amountLimit: number; + images: AttentionDetailImage[]; + } + | { + kind: "agent_error"; + agentName: string | null; + failureReasonExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "generic"; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + }; + +export interface AttentionItem { + id: string; + companyId: string; + sourceKind: AttentionSourceKind; + subject: AttentionSubject; + whyNow: string; + decisionVerbs: AttentionDecisionVerb[]; + inlineResolvable: boolean; + entryRule: string; + exitRule: string; + dedupKey: string; + dismissalKey: string; + dismissal: AttentionItemDismissal | null; + severity: AttentionSeverity; + rank: number; + activityAt: string; + createdAt: string; + updatedAt: string; + relatedIssue: AttentionSubject | null; + project: AttentionProjectRef | null; + workspace: AttentionWorkspaceRef | null; + detail: AttentionItemDetail | null; +} + +export interface AttentionFeed { + companyId: string; + generatedAt: string; + totalCount: number; + countsBySourceKind: Record; + items: AttentionItem[]; +} diff --git a/packages/shared/src/types/inbox-dismissal.ts b/packages/shared/src/types/inbox-dismissal.ts index 0c76ecc824..5d2c71ce44 100644 --- a/packages/shared/src/types/inbox-dismissal.ts +++ b/packages/shared/src/types/inbox-dismissal.ts @@ -1,9 +1,13 @@ +export type InboxDismissalKind = "dismiss" | "snooze"; + export interface InboxDismissal { id: string; companyId: string; userId: string; itemKey: string; + kind: InboxDismissalKind; dismissedAt: Date; + snoozedUntil: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c336dd262a..e117d04b5b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,18 @@ export type { Company } from "./company.js"; +export type { + AttentionDecisionVerb, + AttentionDetailImage, + AttentionFeed, + AttentionItem, + AttentionItemDetail, + AttentionItemDismissal, + AttentionProjectRef, + AttentionSeverity, + AttentionSourceKind, + AttentionSubject, + AttentionSubjectKind, + AttentionWorkspaceRef, +} from "./attention.js"; export type { Environment, EnvironmentDeleteBlastRadius, @@ -400,6 +414,11 @@ export type { RequestCheckboxConfirmationOption, RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationResult, + RequestItemVerdictValue, + RequestItemVerdictsItem, + RequestItemVerdictsPayload, + RequestItemVerdictsResultItem, + RequestItemVerdictsResult, AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, @@ -411,6 +430,7 @@ export type { AskUserQuestionsInteraction, RequestConfirmationInteraction, RequestCheckboxConfirmationInteraction, + RequestItemVerdictsInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, @@ -558,7 +578,7 @@ export type { UpdateResourceMembership, } from "./resource-memberships.js"; export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js"; -export type { InboxDismissal } from "./inbox-dismissal.js"; +export type { InboxDismissal, InboxDismissalKind } from "./inbox-dismissal.js"; export type { AccessUserProfile, CompanyMemberRecord, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 3f426ce68f..655626cfd9 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -57,6 +57,7 @@ export interface InstanceExperimentalSettings { enableCloudSync: boolean; enableExternalObjects: boolean; enableBuiltInAgents: boolean; + enableDecisions: boolean; enableGoalsSidebarLink: boolean; enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index f651e482df..d9c3324a47 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1082,6 +1082,30 @@ export interface RequestCheckboxConfirmationPayload { target?: RequestConfirmationTarget | null; } +export type RequestItemVerdictValue = "approve" | "reject" | "defer"; + +export interface RequestItemVerdictsItem { + id: string; + label: string; + description?: string | null; + previewMarkdown?: string | null; + href?: string | null; + attachmentId?: string | null; +} + +export interface RequestItemVerdictsPayload { + version: 1; + prompt: string; + detailsMarkdown?: string | null; + items: RequestItemVerdictsItem[]; + verdicts?: RequestItemVerdictValue[]; + requireReasonOn?: RequestItemVerdictValue[]; + reasonLabel?: string | null; + allowBulkApprove?: boolean; + supersedeOnUserComment?: boolean; + target?: RequestConfirmationTarget | null; +} + export interface RequestConfirmationResult { version: 1; outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; @@ -1104,6 +1128,24 @@ export interface RequestCheckboxConfirmationResult extends RequestConfirmationRe selectedOptionIds?: string[]; } +export interface RequestItemVerdictsResultItem { + id: string; + verdict: RequestItemVerdictValue; + reason?: string | null; + resolvedByUserId: string; + resolvedAt: Date | string; + commentId?: string | null; +} + +export interface RequestItemVerdictsResult { + version: 1; + outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled"; + complete: boolean; + items: RequestItemVerdictsResultItem[]; + commentId?: string | null; + staleTarget?: RequestConfirmationTarget | null; +} + export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { id: string; companyId: string; @@ -1145,23 +1187,32 @@ export interface RequestCheckboxConfirmationInteraction extends IssueThreadInter result?: RequestCheckboxConfirmationResult | null; } +export interface RequestItemVerdictsInteraction extends IssueThreadInteractionBase { + kind: "request_item_verdicts"; + payload: RequestItemVerdictsPayload; + result?: RequestItemVerdictsResult | null; +} + export type IssueThreadInteraction = | SuggestTasksInteraction | AskUserQuestionsInteraction | RequestConfirmationInteraction - | RequestCheckboxConfirmationInteraction; + | RequestCheckboxConfirmationInteraction + | RequestItemVerdictsInteraction; export type IssueThreadInteractionPayload = | SuggestTasksPayload | AskUserQuestionsPayload | RequestConfirmationPayload - | RequestCheckboxConfirmationPayload; + | RequestCheckboxConfirmationPayload + | RequestItemVerdictsPayload; export type IssueThreadInteractionResult = | SuggestTasksResult | AskUserQuestionsResult | RequestConfirmationResult - | RequestCheckboxConfirmationResult; + | RequestCheckboxConfirmationResult + | RequestItemVerdictsResult; export interface IssueAttachment { id: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 8b14b7e62c..29a12a77db 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -364,11 +364,17 @@ export { requestCheckboxConfirmationOptionSchema, requestCheckboxConfirmationPayloadSchema, requestCheckboxConfirmationResultSchema, + requestItemVerdictValueSchema, + requestItemVerdictsItemSchema, + requestItemVerdictsPayloadSchema, + requestItemVerdictsResultItemSchema, + requestItemVerdictsResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, issueDocumentFormatSchema, @@ -391,6 +397,7 @@ export { type RejectIssueThreadInteraction, type CancelIssueThreadInteraction, type RespondIssueThreadInteraction, + type SubmitIssueThreadInteractionVerdicts, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type IssueDocumentFormat, diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 9324f96248..6f41db4834 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -60,6 +60,22 @@ describe("instance experimental settings validators", () => { }); }); + it("defaults the decisions sidebar link off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableDecisions).toBe(false); + }); + + it("accepts decisions patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableDecisions: true, + }), + ).toEqual({ + enableDecisions: true, + }); + }); + it("accepts server info debug view patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index e4a074b89b..aef68079d3 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -51,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), enableBuiltInAgents: z.boolean().default(false), + enableDecisions: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false), enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a89116a38b..0726a0cf75 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -28,6 +28,7 @@ import { ISSUE_WATCHDOG_DISCOVERY_KINDS, MODEL_PROFILE_KEYS, REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, + REQUEST_ITEM_VERDICTS_ITEM_LIMIT, } from "../constants.js"; import { multilineTextSchema } from "./text.js"; import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js"; @@ -894,6 +895,121 @@ export const requestCheckboxConfirmationResultSchema = requestConfirmationResult } }); +export const requestItemVerdictValueSchema = z.enum(["approve", "reject", "defer"]); + +export const requestItemVerdictsItemSchema = z.object({ + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable().optional(), + previewMarkdown: z.string().max(20000).nullable().optional(), + href: requestConfirmationHrefSchema.nullable().optional(), + attachmentId: z.string().uuid().nullable().optional(), +}); + +export const requestItemVerdictsPayloadSchema = z.object({ + version: z.literal(1), + prompt: z.string().trim().min(1).max(1000), + detailsMarkdown: z.string().max(20000).nullable().optional(), + items: z.array(requestItemVerdictsItemSchema) + .min(1) + .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT), + verdicts: z.array(requestItemVerdictValueSchema) + .min(2) + .max(3) + .optional() + .default(["approve", "reject"]), + requireReasonOn: z.array(requestItemVerdictValueSchema) + .max(3) + .optional() + .default(["reject"]), + reasonLabel: z.string().trim().min(1).max(160).nullable().optional(), + allowBulkApprove: z.boolean().optional().default(true), + supersedeOnUserComment: z.boolean().optional(), + target: requestConfirmationTargetSchema.nullable().optional(), +}).superRefine((value, ctx) => { + const itemIds = new Set(); + for (const [index, item] of value.items.entries()) { + if (itemIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Item ids must be unique within one item verdict request", + path: ["items", index, "id"], + }); + } + itemIds.add(item.id); + } + + const verdicts = new Set(); + for (const [index, verdict] of value.verdicts.entries()) { + if (verdicts.has(verdict)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "verdicts must be unique", + path: ["verdicts", index], + }); + } + verdicts.add(verdict); + } + if (!verdicts.has("approve") || !verdicts.has("reject")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "verdicts must include approve and reject; defer is optional", + path: ["verdicts"], + }); + } + + const reasonVerdicts = new Set(); + for (const [index, verdict] of value.requireReasonOn.entries()) { + if (reasonVerdicts.has(verdict)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "requireReasonOn must be unique", + path: ["requireReasonOn", index], + }); + continue; + } + reasonVerdicts.add(verdict); + if (!verdicts.has(verdict)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "requireReasonOn must reference enabled verdicts", + path: ["requireReasonOn", index], + }); + } + } +}); + +export const requestItemVerdictsResultItemSchema = z.object({ + id: z.string().trim().min(1).max(120), + verdict: requestItemVerdictValueSchema, + reason: z.string().trim().max(4000).nullable().optional(), + resolvedByUserId: z.string().trim().min(1).max(255), + resolvedAt: z.union([z.string().datetime(), z.date()]), + commentId: z.string().uuid().nullable().optional(), +}); + +export const requestItemVerdictsResultSchema = z.object({ + version: z.literal(1), + outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled"]), + complete: z.boolean(), + items: z.array(requestItemVerdictsResultItemSchema) + .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT), + commentId: z.string().uuid().nullable().optional(), + staleTarget: requestConfirmationTargetSchema.nullable().optional(), +}).superRefine((value, ctx) => { + const itemIds = new Set(); + for (const [index, item] of value.items.entries()) { + if (itemIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "result item ids must be unique", + path: ["items", index, "id"], + }); + } + itemIds.add(item.id); + } +}); + export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("suggest_tasks"), @@ -935,6 +1051,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), payload: requestCheckboxConfirmationPayloadSchema, }), + z.object({ + kind: z.literal("request_item_verdicts"), + 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("wake_assignee"), + payload: requestItemVerdictsPayloadSchema, + }), ]); export type CreateIssueThreadInteraction = z.infer; @@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({ }); export type RespondIssueThreadInteraction = z.infer; +export const submitIssueThreadInteractionVerdictsSchema = z.object({ + verdicts: z.array(z.object({ + id: z.string().trim().min(1).max(120), + verdict: requestItemVerdictValueSchema, + reason: z.string().trim().max(4000).nullable().optional(), + })) + .min(1) + .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT), +}).superRefine((value, ctx) => { + const itemIds = new Set(); + for (const [index, verdict] of value.verdicts.entries()) { + if (itemIds.has(verdict.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "verdict item ids must be unique", + path: ["verdicts", index, "id"], + }); + } + itemIds.add(verdict.id); + } +}); +export type SubmitIssueThreadInteractionVerdicts = z.infer; + export const linkIssueApprovalSchema = z.object({ approvalId: z.string().uuid(), }); diff --git a/scripts/screenshot-verdicts.mjs b/scripts/screenshot-verdicts.mjs new file mode 100644 index 0000000000..85c295b1cc --- /dev/null +++ b/scripts/screenshot-verdicts.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Screenshot the C3 per-item verdict card stories (PAP-13249) at both +// viewports and both themes against a served storybook-static (see PAP-13249). +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; + +const BASE = "http://localhost:6102/iframe.html"; +const PREFIX = "chat-comments-issue-thread-interactions"; +const OUT_DIR = process.argv[2] || "screenshots/pap-13249"; + +const stories = [ + { id: "item-verdicts-pending", label: "s1-s2-pending" }, + { id: "item-verdicts-partial", label: "s4-partial" }, + { id: "item-verdicts-complete", label: "s5-complete" }, + { id: "item-verdicts-superseded", label: "s6-superseded" }, + { id: "item-verdicts-many-items", label: "s7-many" }, +]; +const viewports = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]; +const themes = ["light", "dark"]; + +await fs.mkdir(path.resolve(OUT_DIR), { recursive: true }); +const executablePath = process.env.CHROME_BIN || undefined; +const browser = await chromium.launch({ + headless: true, + executablePath, + args: ["--no-sandbox", "--headless=new"], +}); +try { + for (const vp of viewports) { + for (const theme of themes) { + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + for (const story of stories) { + const url = `${BASE}?id=${PREFIX}--${story.id}&viewMode=story&globals=theme:${theme}`; + await page.goto(url, { waitUntil: "networkidle" }); + await page.waitForTimeout(800); + const out = path.join(OUT_DIR, `${story.label}_${vp.name}_${theme}.png`); + await page.screenshot({ path: out, fullPage: true }); + console.log(`Wrote ${out}`); + } + await ctx.close(); + } + } +} finally { + await browser.close(); +} diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts new file mode 100644 index 0000000000..694929f745 --- /dev/null +++ b/server/src/__tests__/attention-service.test.ts @@ -0,0 +1,961 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + approvals, + assets, + budgetIncidents, + budgetPolicies, + companies, + createDb, + documents, + heartbeatRunEvents, + heartbeatRuns, + inboxDismissals, + invites, + issueApprovals, + issueAttachments, + issueDocuments, + issueRecoveryActions, + issueRelations, + issueThreadInteractions, + issues, + joinRequests, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { attentionRoutes } from "../routes/attention.js"; +import { attentionService } from "../services/attention.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres attention service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("attention service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(inboxDismissals); + await db.delete(issueThreadInteractions); + await db.delete(issueApprovals); + await db.delete(issueAttachments); + await db.delete(issueDocuments); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(budgetIncidents); + await db.delete(budgetPolicies); + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(issueRecoveryActions); + await db.delete(issueRelations); + await db.delete(activityLog); + await db.delete(approvals); + await db.delete(issues); + await db.delete(assets); + await db.delete(documents); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany(prefix = "ATN") { + const companyId = randomUUID(); + const workerId = randomUUID(); + const reviewerId = randomUUID(); + const errorAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: `${prefix} Co`, + issuePrefix: prefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: workerId, + companyId, + name: "Worker", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: reviewerId, + companyId, + name: "Reviewer", + role: "qa", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: errorAgentId, + companyId, + name: "Broken Agent", + role: "engineer", + status: "error", + errorReason: "adapter config missing", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + return { companyId, workerId, reviewerId, errorAgentId, prefix }; + } + + async function insertIssue(input: { + companyId: string; + id?: string; + identifier: string; + title: string; + status: string; + priority?: string; + parentId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + originKind?: string; + originId?: string | null; + originFingerprint?: string; + projectId?: string | null; + projectWorkspaceId?: string | null; + executionState?: Record | null; + updatedAt?: Date; + createdAt?: Date; + }) { + const id = input.id ?? randomUUID(); + await db.insert(issues).values({ + id, + companyId: input.companyId, + identifier: input.identifier, + title: input.title, + status: input.status, + priority: input.priority ?? "medium", + parentId: input.parentId ?? null, + projectId: input.projectId ?? null, + projectWorkspaceId: input.projectWorkspaceId ?? null, + assigneeAgentId: input.assigneeAgentId ?? null, + assigneeUserId: input.assigneeUserId ?? null, + originKind: input.originKind ?? "manual", + originId: input.originId ?? null, + originFingerprint: input.originFingerprint ?? "default", + executionState: input.executionState ?? null, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + return id; + } + + function pendingUserExecutionState(userId = "board-user") { + return { + status: "pending", + currentStageId: null, + currentStageIndex: null, + currentStageType: "review", + currentParticipant: { type: "user", userId }, + returnAssignee: null, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + monitor: null, + }; + } + + function pendingAgentExecutionState(agentId: string) { + return { + ...pendingUserExecutionState(), + currentParticipant: { type: "agent", agentId }, + }; + } + + it("returns ranked decision-only items for every active source and excludes non-human or transient rows", async () => { + const { companyId, workerId, reviewerId } = await seedCompany("ATN"); + const baseTime = new Date("2026-07-09T12:00:00.000Z"); + const interactionIssueId = await insertIssue({ + companyId, + identifier: "ATN-1", + title: "Needs interaction", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const recoverySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-2", + title: "Needs recovery", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const agentRecoverySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-21", + title: "Agent-owned recovery source", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const productivitySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-3", + title: "Needs productivity review source", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const agentProductivitySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-31", + title: "Agent productivity review source", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const blockerParentId = await insertIssue({ + companyId, + identifier: "ATN-4", + title: "Blocked parent", + status: "blocked", + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }); + const blockerLeafId = await insertIssue({ + companyId, + identifier: "ATN-5", + title: "Stalled review blocker", + status: "in_review", + assigneeAgentId: reviewerId, + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerLeafId, + relatedIssueId: blockerParentId, + type: "blocks", + }); + const reviewUserIssueId = await insertIssue({ + companyId, + identifier: "ATN-6", + title: "Human review", + status: "in_review", + executionState: pendingUserExecutionState(), + updatedAt: new Date("2026-07-09T12:06:00.000Z"), + }); + await insertIssue({ + companyId, + identifier: "ATN-7", + title: "Agent review excluded", + status: "in_review", + executionState: pendingAgentExecutionState(reviewerId), + updatedAt: new Date("2026-07-09T12:07:00.000Z"), + }); + + const pendingApprovalId = randomUUID(); + await db.insert(approvals).values([ + { + id: pendingApprovalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Designer" }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + { + id: randomUUID(), + companyId, + type: "hire_agent", + status: "revision_requested", + payload: { title: "Revision requested" }, + createdAt: new Date("2026-07-09T12:02:00.000Z"), + updatedAt: new Date("2026-07-09T12:02:00.000Z"), + }, + ]); + + await db.insert(issueThreadInteractions).values([ + { + id: randomUUID(), + companyId, + issueId: interactionIssueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Pick a launch date", + payload: { version: 1, questions: [] }, + createdAt: new Date("2026-07-09T12:03:00.000Z"), + updatedAt: new Date("2026-07-09T12:03:00.000Z"), + }, + { + id: randomUUID(), + companyId, + issueId: interactionIssueId, + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee", + title: "Already accepted", + payload: { version: 1, prompt: "Already done" }, + createdAt: new Date("2026-07-09T12:03:30.000Z"), + updatedAt: new Date("2026-07-09T12:03:30.000Z"), + }, + ]); + + const inviteId = randomUUID(); + await db.insert(invites).values({ + id: inviteId, + companyId, + tokenHash: `hash-${inviteId}`, + allowedJoinTypes: "both", + expiresAt: new Date("2026-07-10T00:00:00.000Z"), + }); + await db.insert(joinRequests).values({ + id: randomUUID(), + inviteId, + companyId, + requestType: "human", + status: "pending_approval", + requestIp: "127.0.0.1", + requestEmailSnapshot: "new@paperclip.test", + createdAt: new Date("2026-07-09T12:04:00.000Z"), + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }); + + await db.insert(issueRecoveryActions).values([ + { + id: randomUUID(), + companyId, + sourceIssueId: recoverySourceIssueId, + kind: "missing_disposition", + status: "escalated", + ownerType: "board", + ownerAgentId: null, + ownerUserId: null, + cause: "missing_disposition", + fingerprint: "human-recovery", + evidence: {}, + nextAction: "Choose the final disposition.", + createdAt: new Date("2026-07-09T12:05:00.000Z"), + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }, + { + id: randomUUID(), + companyId, + sourceIssueId: agentRecoverySourceIssueId, + kind: "stranded_assigned_issue", + status: "active", + ownerType: "agent", + ownerAgentId: workerId, + ownerUserId: null, + cause: "stranded", + fingerprint: "agent-recovery", + evidence: {}, + nextAction: "Agent should self-heal.", + createdAt: new Date("2026-07-09T12:05:30.000Z"), + updatedAt: new Date("2026-07-09T12:05:30.000Z"), + }, + ]); + + await insertIssue({ + companyId, + identifier: "ATN-8", + title: "Human productivity review", + status: "todo", + priority: "high", + parentId: productivitySourceIssueId, + assigneeUserId: "board-user", + originKind: "issue_productivity_review", + originId: productivitySourceIssueId, + originFingerprint: `productivity-review:${productivitySourceIssueId}`, + updatedAt: new Date("2026-07-09T12:08:00.000Z"), + }); + await insertIssue({ + companyId, + identifier: "ATN-9", + title: "Agent productivity review excluded", + status: "todo", + priority: "high", + parentId: agentProductivitySourceIssueId, + assigneeAgentId: workerId, + originKind: "issue_productivity_review", + originId: agentProductivitySourceIssueId, + originFingerprint: `productivity-review-agent:${agentProductivitySourceIssueId}`, + updatedAt: new Date("2026-07-09T12:08:30.000Z"), + }); + + const exhaustedRunId = randomUUID(); + const transientRunId = randomUUID(); + await db.insert(heartbeatRuns).values([ + { + id: exhaustedRunId, + companyId, + agentId: workerId, + invocationSource: "automation", + status: "failed", + error: "adapter failed", + errorCode: "adapter_failed", + contextSnapshot: { issueId: reviewUserIssueId }, + scheduledRetryAttempt: 4, + scheduledRetryReason: "transient_failure", + createdAt: new Date("2026-07-09T12:09:00.000Z"), + updatedAt: new Date("2026-07-09T12:09:00.000Z"), + finishedAt: new Date("2026-07-09T12:09:00.000Z"), + }, + { + id: transientRunId, + companyId, + agentId: reviewerId, + invocationSource: "automation", + status: "failed", + error: "will retry", + errorCode: "provider_quota", + contextSnapshot: { issueId: interactionIssueId }, + createdAt: new Date("2026-07-09T12:09:30.000Z"), + updatedAt: new Date("2026-07-09T12:09:30.000Z"), + finishedAt: new Date("2026-07-09T12:09:30.000Z"), + }, + ]); + await db.insert(heartbeatRunEvents).values({ + companyId, + runId: exhaustedRunId, + agentId: workerId, + seq: 1, + eventType: "lifecycle", + message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued", + payload: { retryReason: "transient_failure", maxAttempts: 4 }, + createdAt: new Date("2026-07-09T12:09:01.000Z"), + }); + + const softPolicy85Id = randomUUID(); + const softPolicy84Id = randomUUID(); + const hardPolicyId = randomUUID(); + await db.insert(budgetPolicies).values([ + { + id: softPolicy85Id, + companyId, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: 100, + }, + { + id: softPolicy84Id, + companyId, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "lifetime", + amount: 100, + }, + { + id: hardPolicyId, + companyId, + scopeType: "agent", + scopeId: workerId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: 100, + }, + ]); + await db.insert(budgetIncidents).values([ + { + companyId, + policyId: softPolicy85Id, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart: new Date("2026-07-01T00:00:00.000Z"), + windowEnd: new Date("2026-08-01T00:00:00.000Z"), + thresholdType: "soft", + amountLimit: 100, + amountObserved: 85, + status: "open", + createdAt: new Date("2026-07-09T12:10:00.000Z"), + updatedAt: new Date("2026-07-09T12:10:00.000Z"), + }, + { + companyId, + policyId: softPolicy84Id, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "lifetime", + windowStart: new Date("1970-01-01T00:00:00.000Z"), + windowEnd: new Date("9999-01-01T00:00:00.000Z"), + thresholdType: "soft", + amountLimit: 100, + amountObserved: 84, + status: "open", + createdAt: new Date("2026-07-09T12:10:30.000Z"), + updatedAt: new Date("2026-07-09T12:10:30.000Z"), + }, + { + companyId, + policyId: hardPolicyId, + scopeType: "agent", + scopeId: workerId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart: new Date("2026-07-01T00:00:00.000Z"), + windowEnd: new Date("2026-08-01T00:00:00.000Z"), + thresholdType: "hard", + amountLimit: 100, + amountObserved: 100, + status: "open", + createdAt: new Date("2026-07-09T12:11:00.000Z"), + updatedAt: new Date("2026-07-09T12:11:00.000Z"), + }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.totalCount).toBe(11); + expect(feed.countsBySourceKind).toMatchObject({ + approval: 1, + issue_thread_interaction: 1, + join_request: 1, + recovery_action: 1, + productivity_review: 1, + blocker_attention: 1, + review: 1, + failed_run: 1, + budget_alert: 2, + agent_error_alert: 1, + }); + expect(feed.items.map((item) => item.sourceKind)).toEqual(expect.arrayContaining([ + "approval", + "issue_thread_interaction", + "join_request", + "recovery_action", + "productivity_review", + "blocker_attention", + "review", + "failed_run", + "budget_alert", + "agent_error_alert", + ])); + for (const item of feed.items) { + expect(item.dedupKey).toBeTruthy(); + expect(item.dismissalKey).toBe(`attention:${item.dedupKey}`); + expect(item.whyNow).toBeTruthy(); + expect(item.entryRule).toBeTruthy(); + expect(item.exitRule).toBeTruthy(); + expect(item.decisionVerbs.length).toBeGreaterThan(0); + expect(item.rank).toBeGreaterThan(0); + } + expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false); + expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false); + expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false); + expect(feed.items.some((item) => + item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota" + )).toBe(false); + expect(feed.items.find((item) => item.sourceKind === "approval")?.detail).toMatchObject({ + kind: "approval", + approvalType: "hire_agent", + summaryExcerpt: "Hire Designer", + }); + expect(feed.items.find((item) => item.sourceKind === "issue_thread_interaction")?.detail).toMatchObject({ + kind: "questions", + questionCount: 0, + }); + expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.detail).toMatchObject({ + kind: "blocker", + blockingIssue: { identifier: "ATN-5", title: "Stalled review blocker" }, + }); + expect(feed.items.find((item) => item.sourceKind === "failed_run")?.detail).toMatchObject({ + kind: "failed_run", + agentName: "Worker", + failureReasonExcerpt: "adapter failed", + }); + expect(feed.items.find((item) => + item.sourceKind === "budget_alert" && item.detail?.kind === "budget" && item.detail.observedPercent === 100 + )).toBeTruthy(); + expect(feed.items.find((item) => item.sourceKind === "agent_error_alert")?.detail).toMatchObject({ + kind: "agent_error", + agentName: "Broken Agent", + failureReasonExcerpt: "adapter config missing", + }); + }); + + it("suppresses failed-run attention after a newer run for the same issue", async () => { + const { companyId, workerId } = await seedCompany("ATN"); + const issueId = await insertIssue({ + companyId, + identifier: "ATN-1", + title: "Recoverable task", + status: "in_progress", + }); + const failedRunId = randomUUID(); + const failedAt = new Date("2026-07-09T12:00:00.000Z"); + + await db.insert(heartbeatRuns).values([ + { + id: failedRunId, + companyId, + agentId: workerId, + invocationSource: "automation", + status: "failed", + error: "adapter failed", + contextSnapshot: { issueId }, + createdAt: failedAt, + updatedAt: failedAt, + finishedAt: failedAt, + }, + { + id: randomUUID(), + companyId, + agentId: workerId, + invocationSource: "automation", + status: "succeeded", + contextSnapshot: { issueId }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + finishedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + ]); + await db.insert(heartbeatRunEvents).values({ + companyId, + runId: failedRunId, + agentId: workerId, + seq: 1, + eventType: "lifecycle", + message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued", + createdAt: new Date("2026-07-09T12:00:01.000Z"), + }); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.items.filter((item) => item.sourceKind === "failed_run")).toEqual([]); + }); + + it("enriches interaction details with project, workspace, plan metadata, and images", async () => { + const { companyId, workerId } = await seedCompany("ATE"); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const issueId = randomUUID(); + const planDocumentId = randomUUID(); + const planRevisionId = randomUUID(); + const imageAssetIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()]; + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Attention Project", + status: "in_progress", + color: "#0f766e", + icon: "rocket", + }); + await db.insert(projectWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + name: "Preview workspace", + sourceType: "local_path", + isPrimary: true, + }); + await insertIssue({ + id: issueId, + companyId, + identifier: "ATE-1", + title: "Approve launch plan", + status: "in_progress", + assigneeAgentId: workerId, + projectId, + projectWorkspaceId: workspaceId, + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(documents).values({ + id: planDocumentId, + companyId, + title: "Launch Plan", + format: "markdown", + latestBody: "# Summary\n\nThis plan explains the launch checklist, rollout owner, QA gates, and risk controls for the homepage release.", + latestRevisionId: planRevisionId, + latestRevisionNumber: 2, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId: planDocumentId, + key: "plan", + }); + await db.insert(assets).values([ + { id: imageAssetIds[0], companyId, provider: "local_disk", objectKey: "img-1", contentType: "image/png", byteSize: 10, sha256: "a".repeat(64), originalFilename: "one.png" }, + { id: imageAssetIds[1], companyId, provider: "local_disk", objectKey: "img-2", contentType: "image/jpeg", byteSize: 10, sha256: "b".repeat(64), originalFilename: "two.jpg" }, + { id: imageAssetIds[2], companyId, provider: "local_disk", objectKey: "img-3", contentType: "image/gif", byteSize: 10, sha256: "c".repeat(64), originalFilename: "three.gif" }, + { id: imageAssetIds[3], companyId, provider: "local_disk", objectKey: "img-4", contentType: "image/png", byteSize: 10, sha256: "d".repeat(64), originalFilename: "four.png" }, + ]); + await db.insert(issueAttachments).values(imageAssetIds.map((assetId, index) => ({ + companyId, + issueId, + assetId, + createdAt: new Date(`2026-07-09T12:0${index}:30.000Z`), + updatedAt: new Date(`2026-07-09T12:0${index}:30.000Z`), + }))); + + const planInteractionId = randomUUID(); + const questionsInteractionId = randomUUID(); + const tasksInteractionId = randomUUID(); + const checkboxInteractionId = randomUUID(); + const verdictInteractionId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: planInteractionId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Approve the plan", + payload: { + version: 1, + prompt: "Approve plan?", + acceptLabel: "Approve plan", + rejectLabel: "Request changes", + target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId }, + }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + { + id: questionsInteractionId, + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Questions", + payload: { + version: 1, + questions: [ + { id: "q1", prompt: "Which auth provider should we use?", selectionMode: "single", options: [] }, + { id: "q2", prompt: "Should we add a fallback?", selectionMode: "single", options: [] }, + ], + }, + createdAt: new Date("2026-07-09T12:02:00.000Z"), + updatedAt: new Date("2026-07-09T12:02:00.000Z"), + }, + { + id: tasksInteractionId, + companyId, + issueId, + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Tasks", + payload: { version: 1, tasks: [{ clientKey: "t1", title: "Build API" }, { clientKey: "t2", title: "Wire UI" }] }, + createdAt: new Date("2026-07-09T12:03:00.000Z"), + updatedAt: new Date("2026-07-09T12:03:00.000Z"), + }, + { + id: checkboxInteractionId, + companyId, + issueId, + kind: "request_checkbox_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Checkbox", + payload: { version: 1, prompt: "Select rollout regions", options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }] }, + createdAt: new Date("2026-07-09T12:04:00.000Z"), + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }, + { + id: verdictInteractionId, + companyId, + issueId, + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Verdicts", + payload: { version: 1, prompt: "Approve these screenshots", items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }] }, + createdAt: new Date("2026-07-09T12:05:00.000Z"), + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const interactionItems = feed.items.filter((item) => item.sourceKind === "issue_thread_interaction"); + const detailsByKind = new Map(interactionItems.map((item) => [item.detail?.kind, item])); + + const planItem = detailsByKind.get("plan_approval"); + expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan"); + expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" }); + expect(planItem?.decisionVerbs).toEqual([ + expect.objectContaining({ id: "accept", label: "Approve plan" }), + expect.objectContaining({ id: "reject", label: "Request changes" }), + ]); + expect(planItem?.project).toMatchObject({ + id: projectId, + name: "Attention Project", + color: "#0f766e", + icon: "rocket", + }); + expect(planItem?.project?.urlKey).toEqual(expect.any(String)); + expect(planItem?.workspace).toEqual({ id: workspaceId, name: "Preview workspace" }); + expect(planItem?.detail).toMatchObject({ + kind: "plan_approval", + issueTitle: "Approve launch plan", + planTitle: "Launch Plan", + summaryExcerpt: expect.stringContaining("launch checklist"), + images: imageAssetIds.slice(0, 3).map((assetId) => ({ assetId, alt: expect.any(String) })), + }); + expect(detailsByKind.get("questions")?.detail).toMatchObject({ + kind: "questions", + questionCount: 2, + firstQuestionText: "Which auth provider should we use?", + }); + expect(detailsByKind.get("suggested_tasks")?.detail).toMatchObject({ + kind: "suggested_tasks", + taskCount: 2, + firstTaskTitle: "Build API", + }); + expect(detailsByKind.get("checkbox_confirmation")?.detail).toMatchObject({ + kind: "checkbox_confirmation", + optionCount: 2, + promptExcerpt: "Select rollout regions", + }); + expect(detailsByKind.get("item_verdicts")?.detail).toMatchObject({ + kind: "item_verdicts", + itemCount: 2, + promptExcerpt: "Approve these screenshots", + }); + }); + + it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => { + const { companyId } = await seedCompany("ATD"); + const approvalId = randomUUID(); + await db.insert(approvals).values({ + id: approvalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Writer" }, + createdAt: new Date("2026-07-09T12:00:00.000Z"), + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(inboxDismissals).values({ + companyId, + userId: "board-user", + itemKey: `attention:approval:${approvalId}`, + dismissedAt: new Date("2026-07-09T13:00:00.000Z"), + }); + + await expect(attentionService(db).list(companyId, { userId: "board-user" })) + .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed + const includeDismissedFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }); + expect(includeDismissedFeed.totalCount).toBe(2); + expect(includeDismissedFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal) + .toMatchObject({ kind: "dismiss", isActive: true, snoozedUntil: null }); + + await db + .update(approvals) + .set({ updatedAt: new Date("2026-07-09T14:00:00.000Z") }) + .where(eq(approvals.id, approvalId)); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true); + }); + + it("hides snoozed attention rows until snoozedUntil passes, then returns them unconditionally", async () => { + const { companyId } = await seedCompany("ATS"); + const approvalId = randomUUID(); + await db.insert(approvals).values({ + id: approvalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Researcher" }, + createdAt: new Date("2026-07-09T12:00:00.000Z"), + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(inboxDismissals).values({ + companyId, + userId: "board-user", + itemKey: `attention:approval:${approvalId}`, + kind: "snooze", + dismissedAt: new Date("2099-01-01T00:00:00.000Z"), + snoozedUntil: new Date("2099-01-02T00:00:00.000Z"), + }); + + await expect(attentionService(db).list(companyId, { userId: "board-user" })) + .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed + const hiddenFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }); + expect(hiddenFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal) + .toMatchObject({ kind: "snooze", isActive: true, snoozedUntil: "2099-01-02T00:00:00.000Z" }); + + await db + .update(inboxDismissals) + .set({ snoozedUntil: new Date("2020-01-01T00:00:00.000Z") }) + .where(eq(inboxDismissals.itemKey, `attention:approval:${approvalId}`)); + + const visibleFeed = await attentionService(db).list(companyId, { userId: "board-user" }); + const visibleApproval = visibleFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`); + expect(visibleApproval?.dismissal).toMatchObject({ kind: "snooze", isActive: false }); + expect(visibleApproval).toBeTruthy(); + }); + + it("serves the route for board users and rejects agent callers", async () => { + const { companyId } = await seedCompany("ATR"); + + function app(actor: Record) { + const testApp = express(); + testApp.use(express.json()); + testApp.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + testApp.use("/api", attentionRoutes(db)); + testApp.use(errorHandler); + return testApp; + } + + const board = { + type: "board", + source: "local_implicit", + userId: "board-user", + companyIds: [companyId], + isInstanceAdmin: false, + }; + const agent = { + type: "agent", + source: "agent_key", + companyId, + agentId: randomUUID(), + runId: null, + }; + + await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200); + await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403); + }); +}); diff --git a/server/src/__tests__/inbox-dismissals.test.ts b/server/src/__tests__/inbox-dismissals.test.ts index c6360a219f..193c6f11a3 100644 --- a/server/src/__tests__/inbox-dismissals.test.ts +++ b/server/src/__tests__/inbox-dismissals.test.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + activityLog, agents, approvals, companies, @@ -14,6 +17,8 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js"; import { inboxDismissalService } from "../services/inbox-dismissals.ts"; import { sidebarBadgeService } from "../services/sidebar-badges.ts"; @@ -43,6 +48,7 @@ describeEmbeddedPostgres("inbox dismissals", () => { await db.delete(inboxDismissals); await db.delete(joinRequests); await db.delete(invites); + await db.delete(activityLog); await db.delete(heartbeatRuns); await db.delete(approvals); await db.delete(agents); @@ -73,9 +79,63 @@ describeEmbeddedPostgres("inbox dismissals", () => { expect(dismissals).toHaveLength(1); expect(dismissals[0]?.itemKey).toBe("approval:approval-1"); + expect(dismissals[0]?.kind).toBe("dismiss"); + expect(dismissals[0]?.snoozedUntil).toBeNull(); expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString()); }); + it("snoozes and restores dismissal records through the route", async () => { + const companyId = randomUUID(); + const userId = "board-user"; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId, + companyIds: [companyId], + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", inboxDismissalRoutes(db)); + app.use(errorHandler); + + await request(app) + .post(`/api/companies/${companyId}/inbox-dismissals`) + .send({ itemKey: "attention:approval:old", kind: "snooze", snoozedUntil: "2020-01-01T00:00:00.000Z" }) + .expect(400); + + const snoozedUntil = "2099-01-01T00:00:00.000Z"; + const createRes = await request(app) + .post(`/api/companies/${companyId}/inbox-dismissals`) + .send({ itemKey: "attention:approval:approval-1", kind: "snooze", snoozedUntil }) + .expect(201); + + expect(createRes.body).toMatchObject({ + companyId, + userId, + itemKey: "attention:approval:approval-1", + kind: "snooze", + snoozedUntil, + }); + + await request(app) + .delete(`/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent("attention:approval:approval-1")}`) + .expect(204); + + await expect(dismissalsSvc.list(companyId, userId)).resolves.toEqual([]); + }); + it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => { const companyId = randomUUID(); const userId = "board-user"; diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 802259c192..dbca0fa120 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -37,6 +37,7 @@ describe("instance settings service", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableBuiltInAgents: true, + enableDecisions: false, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, @@ -83,6 +84,14 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableDecisions to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableDecisions).toBe(false); + expect(normalizeExperimentalSettings({}).enableDecisions).toBe(false); + expect( + normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: true }).enableDecisions, + ).toBe(false); + }); + it("defaults workspace branch repair settings to true for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true); expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index c0ab044906..fb08b98f1c 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -18,6 +18,7 @@ const mockInteractionService = vi.hoisted(() => ({ rejectSuggestedTasks: vi.fn(), expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(), answerQuestions: vi.fn(), + submitItemVerdicts: vi.fn(), cancelQuestions: vi.fn(), })); @@ -284,6 +285,48 @@ describe.sequential("issue thread interaction routes", () => { updatedAt: "2026-04-20T12:06:00.000Z", resolvedAt: "2026-04-20T12:06:00.000Z", }); + mockInteractionService.submitItemVerdicts.mockResolvedValue({ + interaction: { + id: "interaction-verdicts", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-verdicts", + sourceRunId: "run-verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + verdicts: ["approve", "reject"], + requireReasonOn: ["reject"], + allowBulkApprove: true, + }, + result: { + version: 1, + outcome: "resolved", + complete: false, + items: [ + { + id: "docs", + verdict: "reject", + reason: "Missing examples", + resolvedByUserId: "local-board", + resolvedAt: "2026-04-20T12:06:00.000Z", + }, + ], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:06:00.000Z", + resolvedAt: null, + }, + newlyResolvedItemIds: ["docs"], + }); mockInteractionService.cancelQuestions.mockResolvedValue({ id: "interaction-2", companyId: "company-1", @@ -469,6 +512,68 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("submits item verdicts and emits one continuation wake with resolved item ids", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-verdicts/verdicts") + .send({ + verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }], + }); + + expect(res.status).toBe(200); + expect(mockInteractionService.submitItemVerdicts).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-verdicts", + { verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }] }, + expect.objectContaining({ userId: "local-board" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + idempotencyKey: expect.stringMatching( + /^request_item_verdicts:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:interaction-verdicts:/, + ), + payload: expect.objectContaining({ + interactionId: "interaction-verdicts", + interactionKind: "request_item_verdicts", + interactionStatus: "pending", + sourceCommentId: "comment-verdicts", + sourceRunId: "run-verdicts", + newlyResolvedItemIds: ["docs"], + itemVerdicts: { + newlyResolvedItemIds: ["docs"], + coalesceWindowMs: 2000, + }, + }), + contextSnapshot: expect.objectContaining({ + interactionId: "interaction-verdicts", + interactionKind: "request_item_verdicts", + interactionStatus: "pending", + newlyResolvedItemIds: ["docs"], + itemVerdicts: { + newlyResolvedItemIds: ["docs"], + coalesceWindowMs: 2000, + }, + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_item_verdicts_submitted", + details: expect.objectContaining({ + interactionKind: "request_item_verdicts", + newlyResolvedItemCount: 1, + newlyResolvedItemIds: ["docs"], + complete: false, + }), + }), + ); + }); + it("cancels question interactions and emits a continuation wake", async () => { const app = await createApp(); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 9463c734e3..8a957a6b4e 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -1140,6 +1140,243 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("submits request_item_verdicts partially and completes when all items are resolved", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + { id: "tests", label: "Tests" }, + ], + }, + }, { + userId: "local-board", + }); + + expect(created).toMatchObject({ + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + payload: { + verdicts: ["approve", "reject"], + requireReasonOn: ["reject"], + allowBulkApprove: true, + supersedeOnUserComment: true, + }, + }); + + const first = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }], + }, { + userId: "local-board", + }); + + expect(first.newlyResolvedItemIds).toEqual(["docs"]); + expect(first.interaction).toMatchObject({ + kind: "request_item_verdicts", + status: "pending", + result: { + version: 1, + outcome: "resolved", + complete: false, + items: [ + { + id: "docs", + verdict: "reject", + reason: "Missing examples", + resolvedByUserId: "local-board", + }, + ], + }, + resolvedAt: null, + }); + + const duplicate = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "reject" }], + }, { + userId: "local-board", + }); + + expect(duplicate.newlyResolvedItemIds).toEqual([]); + expect(duplicate.interaction).toMatchObject({ + status: "pending", + result: { + complete: false, + items: [ + { + id: "docs", + verdict: "reject", + reason: "Missing examples", + }, + ], + }, + }); + + const completed = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [ + { id: "api", verdict: "approve" }, + { id: "tests", verdict: "reject", reason: "No route coverage" }, + ], + }, { + userId: "local-board", + }); + + expect(completed.newlyResolvedItemIds).toEqual(["api", "tests"]); + expect(completed.interaction).toMatchObject({ + kind: "request_item_verdicts", + status: "answered", + result: { + version: 1, + outcome: "resolved", + complete: true, + items: [ + { id: "api", verdict: "approve" }, + { id: "docs", verdict: "reject", reason: "Missing examples" }, + { id: "tests", verdict: "reject", reason: "No route coverage" }, + ], + }, + resolvedByUserId: "local-board", + }); + + const duplicateAfterComplete = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "approve" }], + }, { + userId: "local-board", + }); + expect(duplicateAfterComplete.newlyResolvedItemIds).toEqual([]); + expect(duplicateAfterComplete.interaction.status).toBe("answered"); + }); + + it("enforces request_item_verdicts ids, enabled verdicts, and required reasons", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Item verdict validation"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + }, + }, { + userId: "local-board", + }); + + await expect(interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "missing", verdict: "approve" }], + }, { + userId: "local-board", + })).rejects.toThrow("Unknown item verdict id: missing"); + + await expect(interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "defer" }], + }, { + userId: "local-board", + })).rejects.toThrow("Verdict defer is not enabled"); + + await expect(interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "reject" }], + }, { + userId: "local-board", + })).rejects.toThrow("A reason is required when verdict is reject"); + }); + + it("preserves resolved request_item_verdicts items when a later user comment supersedes the pending remainder", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Item verdict supersede"); + const commentId = randomUUID(); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + }, + }, { + userId: "local-board", + }); + + await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "approve" }], + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + kind: "request_item_verdicts", + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + complete: false, + commentId, + items: [ + { + id: "api", + verdict: "approve", + resolvedByUserId: "local-board", + }, + ], + }, + }); + }); + it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { const companyId = randomUUID(); const goalId = randomUUID(); @@ -1580,6 +1817,152 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("preserves resolved request_item_verdicts items 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 verdicts", + 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_item_verdicts", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + target: { + type: "issue_document", + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }, + }, + }, { + userId: "local-board", + }); + + await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "approve" }], + }, { + 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 stale = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "approve" }], + }, { + userId: "local-board", + }); + + expect(stale.newlyResolvedItemIds).toEqual([]); + expect(stale.interaction).toMatchObject({ + id: created.id, + status: "expired", + payload: { + target: { + type: "issue_document", + key: "plan", + revisionId: nextRevisionId, + revisionNumber: 2, + }, + }, + result: { + version: 1, + outcome: "stale_target", + complete: false, + staleTarget: { + type: "issue_document", + key: "plan", + revisionId, + }, + items: [ + { + id: "api", + verdict: "approve", + resolvedByUserId: "local-board", + }, + ], + }, + }); + }); + describe("workspace_finalize accept gate", () => { type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation"; diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 5eb8c8e87e..d743302307 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -15,6 +15,7 @@ const apiPrefixes: Record = { "activity.ts": "/api", "adapters.ts": "/api", "agents.ts": "/api", + "attention.ts": "/api", "approvals.ts": "/api", "assets.ts": "/api", "auth.ts": "/api/auth", diff --git a/server/src/app.ts b/server/src/app.ts index 75e0f1fe24..9cb4ad9f9d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -33,6 +33,7 @@ import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; +import { attentionRoutes } from "./routes/attention.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; @@ -253,6 +254,7 @@ export async function createApp( api.use(costRoutes(db, { pluginWorkerManager: workerManager })); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); + api.use(attentionRoutes(db)); api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts new file mode 100644 index 0000000000..a8ef20f0e0 --- /dev/null +++ b/server/src/routes/attention.ts @@ -0,0 +1,28 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { attentionService } from "../services/attention.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; + +export function attentionRoutes(db: Db) { + const router = Router(); + const svc = attentionService(db); + + router.get("/companies/:companyId/attention", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + assertBoard(req); + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + + const includeDismissed = req.query.includeDismissed === "true"; + const feed = await svc.list(companyId, { + userId: req.actor.userId, + includeDismissed, + }); + res.json(feed); + }); + + return router; +} diff --git a/server/src/routes/inbox-dismissals.ts b/server/src/routes/inbox-dismissals.ts index 1b51633e56..ceae79c19c 100644 --- a/server/src/routes/inbox-dismissals.ts +++ b/server/src/routes/inbox-dismissals.ts @@ -1,14 +1,50 @@ -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { inboxDismissalService, logActivity } from "../services/index.js"; +const ITEM_KEY_RE = /^(approval|join|run|attention):.+$/; + const inboxDismissalSchema = z.object({ - itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"), + itemKey: z.string().trim().min(1).regex(ITEM_KEY_RE, "Unsupported inbox item key"), + kind: z.enum(["dismiss", "snooze"]).default("dismiss"), + snoozedUntil: z.string().trim().optional(), +}).superRefine((value, ctx) => { + if (value.kind === "dismiss") { + if (value.snoozedUntil != null) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Dismissals must not include snoozedUntil" }); + } + return; + } + + if (!value.snoozedUntil) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Snooze requires snoozedUntil" }); + return; + } + const timestamp = new Date(value.snoozedUntil).getTime(); + if (!Number.isFinite(timestamp)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be an ISO timestamp" }); + return; + } + if (timestamp <= Date.now()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be in the future" }); + } }); +function requireBoardUser(req: Request, res: Response) { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return null; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + export function inboxDismissalRoutes(db: Db) { const router = Router(); const svc = inboxDismissalService(db); @@ -16,15 +52,10 @@ export function inboxDismissalRoutes(db: Db) { router.get("/companies/:companyId/inbox-dismissals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board authentication required" }); - return; - } - if (!req.actor.userId) { - res.status(403).json({ error: "Board user context required" }); - return; - } - const dismissals = await svc.list(companyId, req.actor.userId); + const userId = requireBoardUser(req, res); + if (!userId) return; + + const dismissals = await svc.list(companyId, userId); res.json(dismissals); }); @@ -34,16 +65,12 @@ export function inboxDismissalRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board authentication required" }); - return; - } - if (!req.actor.userId) { - res.status(403).json({ error: "Board user context required" }); - return; - } + const userId = requireBoardUser(req, res); + if (!userId) return; - const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date()); + const dismissal = req.body.kind === "snooze" + ? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil)) + : await svc.dismiss(companyId, userId, req.body.itemKey, new Date()); const actor = getActorInfo(req); await logActivity(db, { companyId, @@ -51,13 +78,15 @@ export function inboxDismissalRoutes(db: Db) { actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, - action: "inbox.dismissed", + action: dismissal.kind === "snooze" ? "inbox.snoozed" : "inbox.dismissed", entityType: "company", entityId: companyId, details: { - userId: req.actor.userId, + userId, itemKey: dismissal.itemKey, + kind: dismissal.kind, dismissedAt: dismissal.dismissedAt, + snoozedUntil: dismissal.snoozedUntil, }, }); @@ -65,5 +94,42 @@ export function inboxDismissalRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/inbox-dismissals/:itemKey", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUser(req, res); + if (!userId) return; + + const itemKey = req.params.itemKey as string; + if (!ITEM_KEY_RE.test(itemKey)) { + res.status(400).json({ error: "Unsupported inbox item key" }); + return; + } + + const restored = await svc.restore(companyId, userId, itemKey); + if (restored) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "inbox.restored", + entityType: "company", + entityId: companyId, + details: { + userId, + itemKey: restored.itemKey, + kind: restored.kind, + dismissedAt: restored.dismissedAt, + snoozedUntil: restored.snoozedUntil, + }, + }); + } + + res.status(204).send(); + }); + return router; } diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index c637677c9f..aef7f58901 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -15,6 +15,7 @@ export { secretRoutes } from "./secrets.js"; export { costRoutes } from "./costs.js"; export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; +export { attentionRoutes } from "./attention.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { sidebarPreferenceRoutes } from "./sidebar-preferences.js"; export { resourceMembershipRoutes } from "./resource-memberships.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6dc7ab4e3f..391c5a25b3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -53,6 +53,7 @@ import { rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, updateIssueWorkProductSchema, updateDocumentAnnotationThreadSchema, upsertIssueDocumentSchema, @@ -1766,6 +1767,18 @@ function isAssigneeSelfCommentOnTerminalIssue(input: { return input.actorId === input.assigneeAgentId; } +const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000; + +function buildRequestItemVerdictsWakeIdempotencyKey(args: { + issueId: string; + interactionId: string; + at?: Date; +}) { + const now = args.at ?? new Date(); + const bucket = Math.floor(now.getTime() / REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS); + return `request_item_verdicts:${args.issueId}:${args.interactionId}:${bucket}`; +} + function queueResolvedInteractionContinuationWakeup(input: { heartbeat: ReturnType; issue: { id: string; assigneeAgentId: string | null; status: string }; @@ -1783,6 +1796,8 @@ function queueResolvedInteractionContinuationWakeup(input: { source: string; forceFreshSession?: boolean; workspaceRefreshReason?: string | null; + newlyResolvedItemIds?: string[]; + idempotencyKey?: string | null; }) { if ( input.interaction.continuationPolicy !== "wake_assignee" @@ -1800,6 +1815,13 @@ function queueResolvedInteractionContinuationWakeup(input: { const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id); const interactionResult = readConfirmationResultForWake(input.interaction.result); const checkboxSelection = readCheckboxSelectionForWake(input.interaction); + const newlyResolvedItemIds = input.newlyResolvedItemIds?.filter((value) => value.length > 0) ?? []; + const itemVerdicts = newlyResolvedItemIds.length > 0 + ? { + newlyResolvedItemIds, + coalesceWindowMs: REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS, + } + : null; const planReviewInteraction = planTarget && input.interaction.kind === "request_confirmation" ? { @@ -1824,8 +1846,10 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), ...(checkboxSelection ? { checkboxSelection } : {}), + ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}), mutation: "interaction", }, + idempotencyKey: input.idempotencyKey ?? null, requestedByActorType: input.actor.actorType, requestedByActorId: input.actor.actorId, contextSnapshot: { @@ -1838,6 +1862,7 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), ...(checkboxSelection ? { checkboxSelection } : {}), + ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}), wakeReason: "issue_commented", source: input.source, ...(forceFreshSession ? { forceFreshSession: true } : {}), @@ -9181,6 +9206,76 @@ export function issueRoutes( }, ); + router.post( + "/issues/:id/interactions/:interactionId/verdicts", + validate(submitIssueThreadInteractionVerdictsSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; + assertBoard(req); + + const actor = getActorInfo(req); + const { interaction, newlyResolvedItemIds } = await issueThreadInteractionService(db).submitItemVerdicts( + issue, + interactionId, + req.body, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: interaction.status === "expired" + ? "issue.thread_interaction_expired" + : "issue.thread_interaction_item_verdicts_submitted", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0, + newlyResolvedItemCount: newlyResolvedItemIds.length, + newlyResolvedItemIds, + complete: + interaction.kind === "request_item_verdicts" + ? (interaction.result?.complete ?? false) + : false, + }, + }); + + if (newlyResolvedItemIds.length > 0) { + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.verdicts", + newlyResolvedItemIds, + idempotencyKey: buildRequestItemVerdictsWakeIdempotencyKey({ + issueId: issue.id, + interactionId: interaction.id, + }), + }); + } + + res.json(interaction); + }, + ); + router.post( "/issues/:id/interactions/:interactionId/cancel", validate(cancelIssueThreadInteractionSchema), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5f897a14f8..1c485cda35 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -111,6 +111,7 @@ import { acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, // Auth / profile updateCurrentUserProfileSchema, // Company portability (legacy routes) @@ -2813,6 +2814,15 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/attention", + tags: ["inbox"], + summary: "List decision-only attention feed items", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + registry.registerPath({ method: "get", path: "/api/sidebar-preferences/me", @@ -2866,14 +2876,25 @@ registry.registerPath({ method: "post", path: "/api/companies/{companyId}/inbox-dismissals", tags: ["inbox"], - summary: "Create an inbox dismissal", + summary: "Create an inbox dismissal or snooze", request: { params: z.object({ companyId: z.string() }), body: jsonBody(z.object({ - itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"), + itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"), + kind: z.enum(["dismiss", "snooze"]).optional(), + snoozedUntil: z.string().datetime().optional(), })), }, - responses: { 200: r.ok(), 401: r.unauthorized }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/inbox-dismissals/{itemKey}", + tags: ["inbox"], + summary: "Restore an inbox dismissal or snooze", + request: { params: z.object({ companyId: z.string(), itemKey: z.string() }) }, + responses: { 204: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); // ─── Instance settings ──────────────────────────────────────────────────────── @@ -3433,6 +3454,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/verdicts", + tags: ["issues"], + summary: "Submit item verdicts on an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(submitIssueThreadInteractionVerdictsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + registry.registerPath({ method: "post", path: "/api/issues/{id}/children", diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 8b4d1f2285..770bbc2847 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -9,11 +9,19 @@ import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-d import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( - dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>, + dismissals: Array<{ itemKey: string; kind: string; dismissedAt: Date | string; snoozedUntil: Date | string | null }>, ): Map { - return new Map( - dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]), - ); + const now = Date.now(); + const entries: Array<[string, number]> = []; + for (const dismissal of dismissals) { + if (dismissal.kind === "snooze") { + const snoozedUntil = dismissal.snoozedUntil ? new Date(dismissal.snoozedUntil).getTime() : 0; + if (Number.isFinite(snoozedUntil) && snoozedUntil > now) entries.push([dismissal.itemKey, Number.MAX_SAFE_INTEGER]); + continue; + } + entries.push([dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]); + } + return new Map(entries); } export function sidebarBadgeRoutes(db: Db) { @@ -59,7 +67,12 @@ export function sidebarBadgeRoutes(db: Db) { const dismissedAtByKey = req.actor.type === "board" && req.actor.userId ? await db - .select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt }) + .select({ + itemKey: inboxDismissals.itemKey, + kind: inboxDismissals.kind, + dismissedAt: inboxDismissals.dismissedAt, + snoozedUntil: inboxDismissals.snoozedUntil, + }) .from(inboxDismissals) .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId))) .then(buildDismissedAtByKey) diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts new file mode 100644 index 0000000000..203a263cf8 --- /dev/null +++ b/server/src/services/attention.ts @@ -0,0 +1,1253 @@ +import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + approvals, + assets, + companies, + heartbeatRunEvents, + heartbeatRuns, + inboxDismissals, + invites, + issueApprovals, + issueAttachments, + issueDocuments, + issueRecoveryActions, + issueRelations, + issueThreadInteractions, + issues, + joinRequests, + documents, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { deriveProjectUrlKey } from "@paperclipai/shared"; +import type { + AttentionDecisionVerb, + AttentionFeed, + AttentionDetailImage, + AttentionItem, + AttentionItemDetail, + AttentionProjectRef, + AttentionSeverity, + AttentionSourceKind, + AttentionSubject, + AttentionWorkspaceRef, +} from "@paperclipai/shared"; +import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js"; +import { budgetService } from "./budgets.js"; +import { issueService } from "./issues.js"; +import { parseIssueExecutionState } from "./issue-execution-policy.js"; + +const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [ + "approval", + "issue_thread_interaction", + "join_request", + "recovery_action", + "productivity_review", + "blocker_attention", + "review", + "failed_run", + "budget_alert", + "agent_error_alert", +]; + +const SEVERITY_RANK: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + +const SOURCE_RANK: Record = { + failed_run: 0, + recovery_action: 1, + blocker_attention: 2, + budget_alert: 3, + agent_error_alert: 4, + approval: 5, + issue_thread_interaction: 6, + review: 7, + productivity_review: 8, + join_request: 9, +}; + +const PENDING_INTERACTION_STATUSES = ["pending"] as const; +const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const; +const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; +const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const; +const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const; +const DETAIL_EXCERPT_LENGTH = 160; +const DETAIL_IMAGE_LIMIT = 3; + +type IssueSummaryRow = { + id: string; + companyId: string; + identifier: string | null; + title: string; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + createdAt: Date; + updatedAt: Date; + project: AttentionProjectRef | null; + workspace: AttentionWorkspaceRef | null; +}; + +type IssueSubjectRow = Omit; + +type DismissalState = { + kind: "dismiss" | "snooze"; + dismissedAt: Date; + snoozedUntil: Date | null; +}; + +type PlanDocumentSummary = { + title: string | null; + body: string; +}; + +type BlockingIssueSummary = { + id: string | null; + identifier: string | null; + title: string | null; +}; + +type AttentionListOptions = { + userId?: string | null; + includeDismissed?: boolean; +}; + +function emptyCounts(): Record { + return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record; +} + +function toIso(value: Date | string | null | undefined): string { + if (!value) return new Date(0).toISOString(); + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function timestamp(value: Date | string | null | undefined): number { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function activeDismissalState( + dismissalByKey: ReadonlyMap, + dismissalKey: string, + activityAt: string, + now: number, +) { + const dismissal = dismissalByKey.get(dismissalKey); + if (!dismissal) return null; + + const dismissedAt = toIso(dismissal.dismissedAt); + const snoozedUntil = dismissal.snoozedUntil ? toIso(dismissal.snoozedUntil) : null; + const isActive = dismissal.kind === "snooze" + ? dismissal.snoozedUntil != null && timestamp(dismissal.snoozedUntil) > now + : timestamp(dismissal.dismissedAt) >= timestamp(activityAt); + + return { + kind: dismissal.kind, + dismissedAt, + snoozedUntil, + isActive, + }; +} + +function stripMarkdown(value: string) { + return value + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/[>*_~#-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function excerpt(value: unknown, maxLength = DETAIL_EXCERPT_LENGTH) { + if (typeof value !== "string") return null; + const cleaned = stripMarkdown(value); + if (!cleaned) return null; + if (cleaned.length <= maxLength) return cleaned; + return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; +} + +function readRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isPlanDocumentTarget(payload: Record) { + const target = readRecord(payload.target); + return target.type === "issue_document" && target.key === "plan"; +} + +function issueContext(issue: IssueSummaryRow | null | undefined) { + return { + project: issue?.project ?? null, + workspace: issue?.workspace ?? null, + }; +} + +function issueImages(imageMap: ReadonlyMap, issueId: string | null | undefined) { + return issueId ? imageMap.get(issueId) ?? [] : []; +} + +function genericDetail(summary: unknown, images: AttentionDetailImage[]): AttentionItemDetail { + return { kind: "generic", summaryExcerpt: excerpt(summary), images }; +} + +function approvalDetail(type: string, payload: Record): AttentionItemDetail { + return { + kind: "approval", + approvalType: type, + summaryExcerpt: excerpt(payload.summary ?? payload.title ?? payload.recommendedAction), + images: [], + }; +} + +function interactionDetail(input: { + kind: string; + payload: Record; + issue: IssueSummaryRow | null; + planDocument: PlanDocumentSummary | null; + images: AttentionDetailImage[]; +}): AttentionItemDetail { + if (input.kind === "request_confirmation" && isPlanDocumentTarget(input.payload)) { + return { + kind: "plan_approval", + issueTitle: input.issue?.title ?? null, + planTitle: input.planDocument?.title ?? "Plan", + summaryExcerpt: excerpt(input.planDocument?.body ?? input.payload.detailsMarkdown ?? input.payload.prompt), + images: input.images, + }; + } + + if (input.kind === "ask_user_questions") { + const questions = readArray(input.payload.questions).map(readRecord); + return { + kind: "questions", + questionCount: questions.length, + firstQuestionText: readString(questions[0]?.prompt), + images: input.images, + }; + } + + if (input.kind === "suggest_tasks") { + const tasks = readArray(input.payload.tasks).map(readRecord); + return { + kind: "suggested_tasks", + taskCount: tasks.length, + firstTaskTitle: readString(tasks[0]?.title), + images: input.images, + }; + } + + if (input.kind === "request_checkbox_confirmation") { + return { + kind: "checkbox_confirmation", + optionCount: readArray(input.payload.options).length, + promptExcerpt: excerpt(input.payload.prompt), + images: input.images, + }; + } + + if (input.kind === "request_item_verdicts") { + return { + kind: "item_verdicts", + itemCount: readArray(input.payload.items).length, + promptExcerpt: excerpt(input.payload.prompt), + images: input.images, + }; + } + + return { + kind: "confirmation", + promptExcerpt: excerpt(input.payload.prompt ?? input.payload.detailsMarkdown), + isPlanTarget: false, + images: input.images, + }; +} + +function issueHref(prefix: string, issue: Pick) { + return `/${prefix}/issues/${issue.identifier ?? issue.id}`; +} + +function issueSubject(prefix: string, issue: IssueSubjectRow): AttentionSubject { + return { + kind: "issue", + id: issue.id, + companyId: issue.companyId, + title: issue.title, + identifier: issue.identifier, + status: issue.status, + href: issueHref(prefix, issue), + metadata: { + priority: issue.priority, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + }, + }; +} + +function itemId(sourceKind: AttentionSourceKind, dedupKey: string) { + return `${sourceKind}:${dedupKey}`; +} + +function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb[] { + return verbs; +} + +type CreateAttentionItemInput = Omit & { + project?: AttentionProjectRef | null; + workspace?: AttentionWorkspaceRef | null; + detail?: AttentionItemDetail | null; +}; + +function createItem(input: CreateAttentionItemInput): AttentionItem { + return { + ...input, + id: itemId(input.sourceKind, input.dedupKey), + dismissalKey: `attention:${input.dedupKey}`, + dismissal: null, + project: input.project ?? null, + workspace: input.workspace ?? null, + detail: input.detail ?? null, + rank: 0, + }; +} + +function compareAttentionItems(left: AttentionItem, right: AttentionItem) { + const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt); + if (timeDiff !== 0) return timeDiff; + const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]; + if (severityDiff !== 0) return severityDiff; + const sourceDiff = SOURCE_RANK[left.sourceKind] - SOURCE_RANK[right.sourceKind]; + if (sourceDiff !== 0) return sourceDiff; + return left.dedupKey.localeCompare(right.dedupKey); +} + +function betterDuplicate(left: AttentionItem, right: AttentionItem) { + return compareAttentionItems(left, right) <= 0 ? left : right; +} + +function approvalTitle(type: string, payload: Record) { + const title = typeof payload.title === "string" ? payload.title.trim() : ""; + if (title) return title; + const summary = typeof payload.summary === "string" ? payload.summary.trim() : ""; + if (summary) return summary; + return type.replaceAll("_", " "); +} + +function interactionLabel(kind: string) { + switch (kind) { + case "request_confirmation": + return "Confirmation requested"; + case "request_checkbox_confirmation": + return "Selection confirmation requested"; + case "ask_user_questions": + return "Questions need answers"; + case "suggest_tasks": + return "Suggested tasks need a decision"; + case "request_item_verdicts": + return "Item verdicts need a decision"; + default: + return "Interaction needs a decision"; + } +} + +function interactionVerbs(kind: string, payload: Record) { + if (kind === "ask_user_questions") { + return decisionVerbs({ + id: "respond", + label: "Respond", + description: "Submit answers to the pending questions.", + }); + } + if (kind === "request_confirmation") { + const acceptLabel = typeof payload.acceptLabel === "string" && payload.acceptLabel.trim() + ? payload.acceptLabel.trim() + : "Confirm"; + const rejectLabel = typeof payload.rejectLabel === "string" && payload.rejectLabel.trim() + ? payload.rejectLabel.trim() + : "Decline"; + return decisionVerbs( + { + id: "accept", + label: acceptLabel, + description: "Accept the pending confirmation.", + }, + { + id: "reject", + label: rejectLabel, + description: "Decline the pending confirmation.", + }, + ); + } + return decisionVerbs( + { + id: "accept", + label: "Accept", + description: "Accept the pending interaction.", + }, + { + id: "reject", + label: "Reject", + description: "Reject the pending interaction and provide a reason when required.", + }, + ); +} + +function budgetObservedPercent(amountObserved: number, amountLimit: number) { + return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0; +} + +async function companyPrefix(db: Db, companyId: string) { + const row = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + return row?.issuePrefix ?? "PAP"; +} + +async function dismissalByKey(db: Db, companyId: string, userId: string | null | undefined) { + if (!userId) return new Map(); + const rows = await db + .select({ + itemKey: inboxDismissals.itemKey, + kind: inboxDismissals.kind, + dismissedAt: inboxDismissals.dismissedAt, + snoozedUntil: inboxDismissals.snoozedUntil, + }) + .from(inboxDismissals) + .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId))); + return new Map(rows.map((row) => [row.itemKey, { + kind: row.kind, + dismissedAt: row.dismissedAt, + snoozedUntil: row.snoozedUntil, + }])); +} + +async function issueSummaryMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + projectId: projects.id, + projectName: projects.name, + projectColor: projects.color, + projectIcon: projects.icon, + workspaceId: projectWorkspaces.id, + workspaceName: projectWorkspaces.name, + }) + .from(issues) + .leftJoin(projects, and(eq(issues.projectId, projects.id), eq(projects.companyId, companyId))) + .leftJoin(projectWorkspaces, and( + eq(issues.projectWorkspaceId, projectWorkspaces.id), + eq(projectWorkspaces.companyId, companyId), + )) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt))); + return new Map(rows.map((row) => [row.id, { + id: row.id, + companyId: row.companyId, + identifier: row.identifier, + title: row.title, + status: row.status, + priority: row.priority, + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + project: row.projectId && row.projectName ? { + id: row.projectId, + name: row.projectName, + urlKey: deriveProjectUrlKey(row.projectName, row.projectId), + color: row.projectColor, + icon: row.projectIcon, + } : null, + workspace: row.workspaceId && row.workspaceName ? { + id: row.workspaceId, + name: row.workspaceName, + } : null, + }])); +} + +async function issueImageMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + issueId: issueAttachments.issueId, + assetId: issueAttachments.assetId, + originalFilename: assets.originalFilename, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where(and( + eq(issueAttachments.companyId, companyId), + eq(assets.companyId, companyId), + inArray(issueAttachments.issueId, ids), + sql`${assets.contentType} like 'image/%'`, + )) + .orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id)); + + const map = new Map(); + for (const row of rows) { + const images = map.get(row.issueId) ?? []; + if (images.length >= DETAIL_IMAGE_LIMIT) continue; + images.push({ assetId: row.assetId, alt: row.originalFilename ?? null }); + map.set(row.issueId, images); + } + return map; +} + +async function planDocumentMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + issueId: issueDocuments.issueId, + title: documents.title, + body: documents.latestBody, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, companyId), + eq(documents.companyId, companyId), + eq(issueDocuments.key, "plan"), + inArray(issueDocuments.issueId, ids), + )); + return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }])); +} + +async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array) { + const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + blockedIssueId: issueRelations.relatedIssueId, + id: issues.id, + identifier: issues.identifier, + title: issues.title, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where(and( + eq(issueRelations.companyId, companyId), + eq(issues.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.relatedIssueId, ids), + isNull(issues.hiddenAt), + )) + .orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id)); + const map = new Map(); + for (const row of rows) { + if (!map.has(row.blockedIssueId)) { + map.set(row.blockedIssueId, { id: row.id, identifier: row.identifier, title: row.title }); + } + } + return map; +} + +function readRunIssueId(contextSnapshot: Record | null) { + const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId; + return typeof issueId === "string" && issueId.length > 0 ? issueId : null; +} + +export function attentionService(db: Db) { + return { + list: async (companyId: string, options: AttentionListOptions = {}): Promise => { + const prefix = await companyPrefix(db, companyId); + const dismissals = await dismissalByKey(db, companyId, options.userId); + const includeDismissed = options.includeDismissed === true; + const now = Date.now(); + const collected: AttentionItem[] = []; + + const add = (item: AttentionItem) => { + const dismissal = activeDismissalState(dismissals, item.dismissalKey, item.activityAt, now); + if (!includeDismissed && dismissal?.isActive) return; + collected.push({ ...item, dismissal }); + }; + + const pendingApprovals = await db + .select({ + id: approvals.id, + type: approvals.type, + status: approvals.status, + requestedByAgentId: approvals.requestedByAgentId, + requestedByUserId: approvals.requestedByUserId, + payload: approvals.payload, + createdAt: approvals.createdAt, + updatedAt: approvals.updatedAt, + }) + .from(approvals) + .where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending"))) + .orderBy(desc(approvals.updatedAt), desc(approvals.id)); + + for (const approval of pendingApprovals) { + const dedupKey = `approval:${approval.id}`; + const title = approvalTitle(approval.type, approval.payload); + add(createItem({ + companyId, + sourceKind: "approval", + subject: { + kind: "approval", + id: approval.id, + companyId, + title, + identifier: null, + status: approval.status, + href: `/${prefix}/approvals/${approval.id}`, + metadata: { + type: approval.type, + requestedByAgentId: approval.requestedByAgentId, + requestedByUserId: approval.requestedByUserId, + }, + }, + whyNow: "Approval is pending a board decision.", + decisionVerbs: decisionVerbs( + { id: "approve", label: "Approve", description: "Approve the request." }, + { id: "reject", label: "Reject", description: "Reject the request." }, + { id: "request_revision", label: "Request revision", description: "Send the request back for changes." }, + ), + inlineResolvable: approval.type !== "request_board_approval", + entryRule: "approvals.status = 'pending'", + exitRule: "Approval leaves pending status.", + dedupKey, + severity: "medium", + activityAt: toIso(approval.updatedAt), + createdAt: toIso(approval.createdAt), + updatedAt: toIso(approval.updatedAt), + relatedIssue: null, + detail: approvalDetail(approval.type, approval.payload), + })); + } + + const interactionRows = await db + .select({ + id: issueThreadInteractions.id, + issueId: issueThreadInteractions.issueId, + kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, + title: issueThreadInteractions.title, + summary: issueThreadInteractions.summary, + payload: issueThreadInteractions.payload, + createdAt: issueThreadInteractions.createdAt, + updatedAt: issueThreadInteractions.updatedAt, + }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, companyId), + inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), + )) + .orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id)); + const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId)); + const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId)); + const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId)); + + for (const interaction of interactionRows) { + const issue = interactionIssueMap.get(interaction.issueId) ?? null; + const payload = readRecord(interaction.payload); + const detail = interactionDetail({ + kind: interaction.kind, + payload, + issue, + planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null, + images: issueImages(interactionImageMap, interaction.issueId), + }); + const isPlanTarget = detail.kind === "plan_approval"; + const dedupKey = `interaction:${interaction.id}`; + add(createItem({ + companyId, + sourceKind: "issue_thread_interaction", + subject: { + kind: "interaction", + id: interaction.id, + companyId, + title: isPlanTarget && issue ? `Plan approval - ${issue.title}` : interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind), + identifier: null, + status: interaction.status, + href: issue ? `${issueHref(prefix, issue)}#interaction-${interaction.id}` : null, + metadata: { + kind: interaction.kind, + issueId: interaction.issueId, + isPlanTarget, + targetDocumentKey: isPlanTarget ? "plan" : null, + }, + }, + whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`, + decisionVerbs: interactionVerbs(interaction.kind, payload), + inlineResolvable: true, + entryRule: "issue_thread_interactions.status = 'pending'", + exitRule: "Interaction resolves, expires, fails, or is cancelled.", + dedupKey, + severity: "medium", + activityAt: toIso(interaction.updatedAt), + createdAt: toIso(interaction.createdAt), + updatedAt: toIso(interaction.updatedAt), + relatedIssue: issue ? issueSubject(prefix, issue) : null, + ...issueContext(issue), + detail, + })); + } + + const pendingJoins = await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + agentName: joinRequests.agentName, + adapterType: joinRequests.adapterType, + createdAt: joinRequests.createdAt, + updatedAt: joinRequests.updatedAt, + }) + .from(joinRequests) + .innerJoin(invites, eq(joinRequests.inviteId, invites.id)) + .where(and( + eq(joinRequests.companyId, companyId), + eq(invites.companyId, companyId), + eq(joinRequests.status, "pending_approval"), + )) + .orderBy(desc(joinRequests.updatedAt), desc(joinRequests.id)); + + for (const join of pendingJoins) { + const label = join.requestType === "agent" + ? join.agentName ?? "Agent join request" + : join.requestEmailSnapshot ?? join.requestingUserId ?? "Human join request"; + const dedupKey = `join:${join.id}`; + add(createItem({ + companyId, + sourceKind: "join_request", + subject: { + kind: "join_request", + id: join.id, + companyId, + title: label, + identifier: null, + status: join.status, + href: `/${prefix}/settings/access`, + metadata: { + requestType: join.requestType, + requestingUserId: join.requestingUserId, + adapterType: join.adapterType, + }, + }, + whyNow: "Join request is pending approval.", + decisionVerbs: decisionVerbs( + { id: "approve", label: "Approve", description: "Approve this join request." }, + { id: "reject", label: "Reject", description: "Reject this join request." }, + ), + inlineResolvable: true, + entryRule: "join_requests.status = 'pending_approval'", + exitRule: "Join request is approved or rejected.", + dedupKey, + severity: "medium", + activityAt: toIso(join.updatedAt), + createdAt: toIso(join.createdAt), + updatedAt: toIso(join.updatedAt), + relatedIssue: null, + detail: genericDetail(label, []), + })); + } + + const recoveryRows = await db + .select() + .from(issueRecoveryActions) + .where(and( + eq(issueRecoveryActions.companyId, companyId), + inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]), + inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]), + )) + .orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id)); + const recoveryIssueMap = await issueSummaryMap( + db, + companyId, + recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]), + ); + const recoveryImageMap = await issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId)); + + for (const recovery of recoveryRows) { + const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null; + const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null; + const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`; + add(createItem({ + companyId, + sourceKind: "recovery_action", + subject: { + kind: "recovery_action", + id: recovery.id, + companyId, + title: recovery.nextAction, + identifier: null, + status: recovery.status, + href: recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null, + metadata: { + kind: recovery.kind, + cause: recovery.cause, + ownerType: recovery.ownerType, + ownerUserId: recovery.ownerUserId, + sourceIssueId: recovery.sourceIssueId, + recoveryIssueId: recovery.recoveryIssueId, + }, + }, + whyNow: recovery.status === "escalated" + ? "Recovery action escalated to a human owner." + : "Recovery action is assigned to a human owner.", + decisionVerbs: decisionVerbs( + { id: "resolve", label: "Resolve", description: "Record the recovery outcome." }, + { id: "reassign", label: "Reassign", description: "Move the recovery to another owner." }, + { id: "cancel", label: "Cancel", description: "Cancel the recovery action." }, + ), + inlineResolvable: false, + entryRule: "issue_recovery_actions.status in ('active','escalated') and owner_type in ('user','board')", + exitRule: "Recovery action resolves, is cancelled, or moves back to an agent/system owner.", + dedupKey, + severity: recovery.status === "escalated" ? "high" : "medium", + activityAt: toIso(recovery.updatedAt), + createdAt: toIso(recovery.createdAt), + updatedAt: toIso(recovery.updatedAt), + relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, + ...issueContext(sourceIssue), + detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)), + })); + } + + const productivityRows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + originId: issues.originId, + originFingerprint: issues.originFingerprint, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), + isNull(issues.hiddenAt), + isNotNull(issues.assigneeUserId), + notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]), + )) + .orderBy(desc(issues.updatedAt), desc(issues.id)); + const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId)); + const productivityReviewMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.id)); + const productivityImageMap = await issueImageMap(db, companyId, productivityRows.map((row) => row.id)); + + for (const review of productivityRows) { + const reviewIssue = productivityReviewMap.get(review.id); + if (!reviewIssue) continue; + const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null; + const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`; + add(createItem({ + companyId, + sourceKind: "productivity_review", + subject: issueSubject(prefix, reviewIssue), + whyNow: "Productivity review is awaiting a human decision.", + decisionVerbs: decisionVerbs( + { id: "resolve", label: "Resolve", description: "Record a productivity review outcome." }, + { id: "dismiss", label: "Dismiss", description: "Dismiss this review for now." }, + { id: "reassign", label: "Reassign", description: "Move the review to another owner." }, + ), + inlineResolvable: false, + entryRule: "Open issue_productivity_review issue assigned to a user.", + exitRule: "Review issue is done/cancelled or no longer assigned to a user.", + dedupKey, + severity: review.priority === "critical" ? "critical" : review.priority === "high" ? "high" : "medium", + activityAt: toIso(review.updatedAt), + createdAt: toIso(review.createdAt), + updatedAt: toIso(review.updatedAt), + relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, + ...issueContext(reviewIssue), + detail: genericDetail(sourceIssue?.title ?? review.title, issueImages(productivityImageMap, review.id)), + })); + } + + const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true }); + const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)); + for (const issue of blockedIssues as Array) { + const blockerAttention = issue.blockerAttention; + if (blockerAttention?.state !== "stalled") continue; + const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; + const summarizedIssue = issueSummary ?? issue; + const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id; + const blockingIssue = blockingIssues.get(issue.id) ?? { id: null, identifier: sample, title: null }; + const dedupKey = `blocker:${issue.id}:${sample}`; + add(createItem({ + companyId, + sourceKind: "blocker_attention", + subject: issueSubject(prefix, summarizedIssue), + whyNow: "Blocked dependency chain is stalled and needs a human to choose the next owner or action.", + decisionVerbs: decisionVerbs( + { id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." }, + { id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." }, + { id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." }, + ), + inlineResolvable: false, + entryRule: "blocked issue has blockerAttention.state = 'stalled'", + exitRule: "Blocker chain is no longer stalled or the issue leaves blocked status.", + dedupKey, + severity: "high", + activityAt: toIso(issue.updatedAt), + createdAt: toIso(issue.createdAt), + updatedAt: toIso(issue.updatedAt), + relatedIssue: null, + ...issueContext(issueSummary), + detail: { kind: "blocker", blockingIssue, images: issueImages(blockedImageMap, issue.id) }, + })); + } + + const reviewRows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + executionState: issues.executionState, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), isNull(issues.hiddenAt))) + .orderBy(desc(issues.updatedAt), desc(issues.id)); + const reviewIssueIds = reviewRows.map((row) => row.id); + const pendingReviewApprovalRows = reviewIssueIds.length === 0 + ? [] + : await db + .select({ issueId: issueApprovals.issueId, approvalId: approvals.id }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where(and( + eq(issueApprovals.companyId, companyId), + eq(approvals.companyId, companyId), + inArray(issueApprovals.issueId, reviewIssueIds), + eq(approvals.status, "pending"), + )); + const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId])); + const reviewIssueMap = await issueSummaryMap(db, companyId, reviewIssueIds); + const reviewImageMap = await issueImageMap(db, companyId, reviewIssueIds); + + for (const review of reviewRows) { + const state = parseIssueExecutionState(review.executionState); + const currentParticipant = state?.status === "pending" ? state.currentParticipant : null; + const hasHumanParticipant = currentParticipant?.type === "user"; + const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null; + if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId) continue; + const issue = reviewIssueMap.get(review.id); + if (!issue) continue; + const dedupKey = `review:${review.id}`; + add(createItem({ + companyId, + sourceKind: "review", + subject: issueSubject(prefix, issue), + whyNow: pendingApprovalId + ? "Issue is in review with a linked pending approval." + : hasHumanParticipant + ? "Issue is in review and the current execution participant is a user." + : "Issue is in review and assigned to a user.", + decisionVerbs: decisionVerbs( + { id: "approve", label: "Approve", description: "Approve the review and advance the issue." }, + { id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." }, + ), + inlineResolvable: false, + entryRule: "issues.status = 'in_review' and human reviewer, user assignee, or linked pending approval exists.", + exitRule: "Issue leaves in_review or the human review path resolves.", + dedupKey, + severity: "medium", + activityAt: toIso(review.updatedAt), + createdAt: toIso(review.createdAt), + updatedAt: toIso(review.updatedAt), + relatedIssue: null, + ...issueContext(issue), + detail: genericDetail(review.title, issueImages(reviewImageMap, review.id)), + })); + } + + const exhaustedRunRows = await db + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + agentName: agents.name, + status: heartbeatRuns.status, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + contextSnapshot: heartbeatRuns.contextSnapshot, + createdAt: heartbeatRuns.createdAt, + updatedAt: heartbeatRuns.updatedAt, + finishedAt: heartbeatRuns.finishedAt, + exhaustionMessage: heartbeatRunEvents.message, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .innerJoin(heartbeatRunEvents, eq(heartbeatRunEvents.runId, heartbeatRuns.id)) + .where(and( + eq(heartbeatRuns.companyId, companyId), + eq(agents.companyId, companyId), + notInArray(agents.status, ["terminated"]), + inArray(heartbeatRuns.status, [...FAILED_RUN_STATUSES]), + eq(heartbeatRunEvents.companyId, companyId), + eq(heartbeatRunEvents.eventType, "lifecycle"), + sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`, + )) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRunEvents.id)); + + const latestExhaustedByRunId = new Map(); + for (const row of exhaustedRunRows) { + if (!latestExhaustedByRunId.has(row.id)) latestExhaustedByRunId.set(row.id, row); + } + const failedRows = [...latestExhaustedByRunId.values()]; + const failedIssueIds = failedRows.map((row) => readRunIssueId(row.contextSnapshot)); + const failedIssueMap = await issueSummaryMap( + db, + companyId, + failedIssueIds, + ); + const failedImageMap = await issueImageMap(db, companyId, failedIssueIds); + const failedAgentIds = [...new Set(failedRows.map((row) => row.agentId))]; + const oldestFailedRunCreatedAt = failedRows.reduce((oldest, row) => { + if (!oldest || row.createdAt < oldest) return row.createdAt; + return oldest; + }, null); + const latestRunCreatedAtByKey = new Map(); + if (oldestFailedRunCreatedAt && failedAgentIds.length > 0) { + const newerRuns = await db + .select({ + agentId: heartbeatRuns.agentId, + createdAt: heartbeatRuns.createdAt, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.agentId, failedAgentIds), + gt(heartbeatRuns.createdAt, oldestFailedRunCreatedAt), + )); + for (const newerRun of newerRuns) { + const newerRunKey = `${newerRun.agentId}:${readRunIssueId(newerRun.contextSnapshot) ?? ""}`; + const latestCreatedAt = latestRunCreatedAtByKey.get(newerRunKey); + if (!latestCreatedAt || newerRun.createdAt > latestCreatedAt) { + latestRunCreatedAtByKey.set(newerRunKey, newerRun.createdAt); + } + } + } + for (const run of failedRows) { + const issueId = readRunIssueId(run.contextSnapshot); + const runKey = `${run.agentId}:${issueId ?? ""}`; + const hasNewerRun = (latestRunCreatedAtByKey.get(runKey)?.getTime() ?? 0) > run.createdAt.getTime(); + if (hasNewerRun) continue; + + const issue = issueId ? failedIssueMap.get(issueId) ?? null : null; + const dedupKey = `run:${run.id}`; + add(createItem({ + companyId, + sourceKind: "failed_run", + subject: { + kind: "run", + id: run.id, + companyId, + title: `${run.agentName} run ${run.status}`, + identifier: null, + status: run.status, + href: `/${prefix}/agents/${run.agentId}/runs/${run.id}`, + metadata: { + agentId: run.agentId, + agentName: run.agentName, + issueId, + errorCode: run.errorCode, + error: run.error, + retryExhaustedReason: run.exhaustionMessage, + }, + }, + whyNow: "Run failed after automatic retries were exhausted.", + decisionVerbs: decisionVerbs( + { id: "retry", label: "Retry", description: "Retry the failed run or issue." }, + { id: "reassign", label: "Reassign", description: "Move the work to another owner." }, + { id: "dismiss", label: "Dismiss", description: "Dismiss this failed-run attention row." }, + ), + inlineResolvable: true, + entryRule: "latest failed/timed_out run has a Bounded retry exhausted lifecycle event.", + exitRule: "A newer run exists for the same issue/agent pair or the row is dismissed.", + dedupKey, + severity: "high", + activityAt: toIso(run.finishedAt ?? run.updatedAt ?? run.createdAt), + createdAt: toIso(run.createdAt), + updatedAt: toIso(run.updatedAt), + relatedIssue: issue ? issueSubject(prefix, issue) : null, + ...issueContext(issue), + detail: { + kind: "failed_run", + agentName: run.agentName, + failureReasonExcerpt: excerpt(run.error ?? run.exhaustionMessage ?? run.errorCode), + images: issueImages(failedImageMap, issueId), + }, + })); + } + + const budgetOverview = await budgetService(db).overview(companyId); + for (const incident of budgetOverview.activeIncidents) { + const observedPercent = budgetObservedPercent(incident.amountObserved, incident.amountLimit); + if (incident.thresholdType !== "hard" && observedPercent < 85) continue; + const dedupKey = `budget:${incident.policyId}:${toIso(incident.windowStart)}:${incident.thresholdType}`; + add(createItem({ + companyId, + sourceKind: "budget_alert", + subject: { + kind: "budget_incident", + id: incident.id, + companyId, + title: `${incident.scopeName} budget ${incident.thresholdType === "hard" ? "hard stop" : "warning"}`, + identifier: null, + status: incident.status, + href: `/${prefix}/costs`, + metadata: { + policyId: incident.policyId, + scopeType: incident.scopeType, + scopeId: incident.scopeId, + thresholdType: incident.thresholdType, + amountObserved: incident.amountObserved, + amountLimit: incident.amountLimit, + observedPercent, + approvalId: incident.approvalId, + approvalStatus: incident.approvalStatus, + }, + }, + whyNow: incident.thresholdType === "hard" + ? "Budget hard stop was reached." + : "Budget crossed the 85% warning threshold.", + decisionVerbs: decisionVerbs( + { id: "raise_budget_and_resume", label: "Raise budget", description: "Raise the budget and resume paused work." }, + { id: "keep_paused", label: "Keep paused", description: "Dismiss or keep the budget stop in place." }, + ), + inlineResolvable: true, + entryRule: "open budget incident is hard, or soft with observed spend >= 85% of limit.", + exitRule: "Budget incident is resolved or dismissed.", + dedupKey, + severity: incident.thresholdType === "hard" ? "high" : "medium", + activityAt: toIso(incident.updatedAt), + createdAt: toIso(incident.createdAt), + updatedAt: toIso(incident.updatedAt), + relatedIssue: null, + detail: { + kind: "budget", + observedPercent, + amountObserved: incident.amountObserved, + amountLimit: incident.amountLimit, + images: [], + }, + })); + } + + const erroredAgents = await db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + role: agents.role, + status: agents.status, + errorReason: agents.errorReason, + createdAt: agents.createdAt, + updatedAt: agents.updatedAt, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.status, "error"))) + .orderBy(desc(agents.updatedAt), desc(agents.id)); + + for (const agent of erroredAgents) { + const dedupKey = `agent_error:${agent.id}`; + add(createItem({ + companyId, + sourceKind: "agent_error_alert", + subject: { + kind: "agent", + id: agent.id, + companyId, + title: agent.name, + identifier: null, + status: agent.status, + href: `/${prefix}/agents/${agent.id}`, + metadata: { role: agent.role, errorReason: agent.errorReason }, + }, + whyNow: "Agent is in error status and needs operator action or dismissal.", + decisionVerbs: decisionVerbs( + { id: "inspect", label: "Inspect", description: "Inspect the agent error." }, + { id: "dismiss", label: "Dismiss", description: "Dismiss this alert." }, + ), + inlineResolvable: true, + entryRule: "agents.status = 'error'", + exitRule: "Agent leaves error status or the row is dismissed.", + dedupKey, + severity: "high", + activityAt: toIso(agent.updatedAt), + createdAt: toIso(agent.createdAt), + updatedAt: toIso(agent.updatedAt), + relatedIssue: null, + detail: { + kind: "agent_error", + agentName: agent.name, + failureReasonExcerpt: excerpt(agent.errorReason), + images: [], + }, + })); + } + + const deduped = new Map(); + for (const item of collected) { + const current = deduped.get(item.dedupKey); + deduped.set(item.dedupKey, current ? betterDuplicate(current, item) : item); + } + + const items = [...deduped.values()] + .sort(compareAttentionItems) + .map((item, index) => ({ ...item, rank: index + 1 })); + const countsBySourceKind = emptyCounts(); + for (const item of items) countsBySourceKind[item.sourceKind] += 1; + + return { + companyId, + generatedAt: new Date().toISOString(), + totalCount: items.length, + countsBySourceKind, + items, + }; + }, + }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 32432a830d..7d6992f853 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3951,6 +3951,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ "interactionStatus", "continuationPolicy", "checkboxSelection", + "itemVerdicts", + "newlyResolvedItemIds", ] as const; function isInteractionResolutionWakePayload(payload: Record | null | undefined) { diff --git a/server/src/services/inbox-dismissals.ts b/server/src/services/inbox-dismissals.ts index 68032c69dc..7b940f95e7 100644 --- a/server/src/services/inbox-dismissals.ts +++ b/server/src/services/inbox-dismissals.ts @@ -1,8 +1,42 @@ import { and, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { inboxDismissals } from "@paperclipai/db"; +import type { InboxDismissalKind } from "@paperclipai/shared"; export function inboxDismissalService(db: Db) { + async function upsert( + companyId: string, + userId: string, + itemKey: string, + input: { kind: InboxDismissalKind; dismissedAt?: Date; snoozedUntil?: Date | null }, + ) { + const now = new Date(); + const dismissedAt = input.dismissedAt ?? now; + const snoozedUntil = input.kind === "snooze" ? input.snoozedUntil ?? null : null; + const [row] = await db + .insert(inboxDismissals) + .values({ + companyId, + userId, + itemKey, + kind: input.kind, + dismissedAt, + snoozedUntil, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey], + set: { + kind: input.kind, + dismissedAt, + snoozedUntil, + updatedAt: now, + }, + }) + .returning(); + return row; + } + return { list: async (companyId: string, userId: string) => db @@ -16,26 +50,26 @@ export function inboxDismissalService(db: Db) { userId: string, itemKey: string, dismissedAt: Date = new Date(), - ) => { - const now = new Date(); + ) => upsert(companyId, userId, itemKey, { kind: "dismiss", dismissedAt }), + + snooze: async ( + companyId: string, + userId: string, + itemKey: string, + snoozedUntil: Date, + dismissedAt: Date = new Date(), + ) => upsert(companyId, userId, itemKey, { kind: "snooze", dismissedAt, snoozedUntil }), + + restore: async (companyId: string, userId: string, itemKey: string) => { const [row] = await db - .insert(inboxDismissals) - .values({ - companyId, - userId, - itemKey, - dismissedAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey], - set: { - dismissedAt, - updatedAt: now, - }, - }) + .delete(inboxDismissals) + .where(and( + eq(inboxDismissals.companyId, companyId), + eq(inboxDismissals.userId, userId), + eq(inboxDismissals.itemKey, itemKey), + )) .returning(); - return row; + return row ?? null; }, }; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 43524a5642..a1e15e48e4 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -58,6 +58,7 @@ export { export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js"; +export { attentionService } from "./attention.js"; export type { WorkTimelineActor, WorkTimelineEdge, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 5b8498bdf0..4f0cdb3419 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -214,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, + enableDecisions: parsed.data.enableDecisions ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, @@ -242,6 +243,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCloudSync: false, enableExternalObjects: false, enableBuiltInAgents: false, + enableDecisions: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 62550db0dd..5a2a3ae19d 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -21,10 +21,14 @@ import type { RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, RequestConfirmationTarget, + RequestItemVerdictsInteraction, + RequestItemVerdictsResult, + RequestItemVerdictsResultItem, RejectIssueThreadInteraction, RespondIssueThreadInteraction, SuggestTasksInteraction, SuggestTasksResultCreatedTask, + SubmitIssueThreadInteractionVerdicts, } from "@paperclipai/shared"; import { acceptIssueThreadInteractionSchema, @@ -37,8 +41,11 @@ import { requestCheckboxConfirmationResultSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, + requestItemVerdictsPayloadSchema, + requestItemVerdictsResultSchema, suggestTasksPayloadSchema, suggestTasksResultSchema, + submitIssueThreadInteractionVerdictsSchema, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { getTelemetryClient } from "../telemetry.js"; @@ -85,19 +92,32 @@ type RequestConfirmationLikeInteraction = | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; -const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [ +const TARGET_BOUND_INTERACTION_KINDS = [ ...REQUEST_CONFIRMATION_INTERACTION_KINDS, + "request_item_verdicts", +] as const; +type TargetBoundInteractionKind = (typeof TARGET_BOUND_INTERACTION_KINDS)[number]; +type TargetBoundInteraction = + | RequestConfirmationLikeInteraction + | RequestItemVerdictsInteraction; + +const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [ + ...TARGET_BOUND_INTERACTION_KINDS, "ask_user_questions", ] as const; type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number]; type UserCommentSupersedableInteraction = - | RequestConfirmationLikeInteraction + | TargetBoundInteraction | AskUserQuestionsInteraction; function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind { return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind); } +function isTargetBoundInteractionKind(kind: string): kind is TargetBoundInteractionKind { + return (TARGET_BOUND_INTERACTION_KINDS as readonly string[]).includes(kind); +} + function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind { return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind); } @@ -167,6 +187,13 @@ function hydrateInteraction( payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload), result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null, } satisfies RequestCheckboxConfirmationInteraction; + case "request_item_verdicts": + return { + ...base, + kind: "request_item_verdicts", + payload: requestItemVerdictsPayloadSchema.parse(row.payload), + result: row.result ? requestItemVerdictsResultSchema.parse(row.result) : null, + } satisfies RequestItemVerdictsInteraction; default: throw unprocessable(`Unknown interaction kind: ${row.kind}`); } @@ -227,6 +254,14 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, }, }; + case "request_item_verdicts": + return { + ...input, + payload: { + ...input.payload, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + }, + }; default: return input; } @@ -243,6 +278,17 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI } as const; } + if (row.kind === "request_item_verdicts") { + const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction; + return { + version: 1, + outcome: "superseded_by_comment", + complete: false, + items: interaction.result?.items ?? [], + commentId, + } satisfies RequestItemVerdictsResult; + } + return { version: 1, outcome: "superseded_by_comment", @@ -250,6 +296,28 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI } as const; } +function buildStaleTargetResult( + row: IssueThreadInteractionRow, + staleTarget: RequestConfirmationTarget | null, +) { + if (row.kind === "request_item_verdicts") { + const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction; + return { + version: 1, + outcome: "stale_target", + complete: false, + items: interaction.result?.items ?? [], + staleTarget, + } satisfies RequestItemVerdictsResult; + } + + return { + version: 1, + outcome: "stale_target", + staleTarget, + } as const; +} + function resolveActorKind(interaction: Pick) { if (interaction.resolvedByAgentId) return "agent"; if (interaction.resolvedByUserId) return "user"; @@ -263,10 +331,14 @@ function resolveCreatorKind(interaction: Pick option.id); } +function resolveRequestItemVerdictSubmissions(args: { + interaction: RequestItemVerdictsInteraction; + input: SubmitIssueThreadInteractionVerdicts; + actor: InteractionActor; + now: Date; +}) { + if (!args.actor.userId) { + throw unprocessable("request_item_verdicts submissions require a user actor"); + } + + const existingItems = args.interaction.result?.items ?? []; + const existingById = new Map(existingItems.map((item) => [item.id, item] as const)); + const payloadItemIds = new Set(args.interaction.payload.items.map((item) => item.id)); + const enabledVerdicts = new Set(args.interaction.payload.verdicts ?? ["approve", "reject"]); + const requireReasonOn = new Set(args.interaction.payload.requireReasonOn ?? ["reject"]); + const newlyResolvedById = new Map(); + const newlyResolvedItemIds: string[] = []; + + for (const submitted of args.input.verdicts) { + if (!payloadItemIds.has(submitted.id)) { + throw unprocessable(`Unknown item verdict id: ${submitted.id}`); + } + if (existingById.has(submitted.id)) { + continue; + } + if (!enabledVerdicts.has(submitted.verdict)) { + throw unprocessable(`Verdict ${submitted.verdict} is not enabled for this item verdict request`); + } + + const reason = submitted.reason?.trim() ?? ""; + if (requireReasonOn.has(submitted.verdict) && reason.length === 0) { + throw unprocessable(`A reason is required when verdict is ${submitted.verdict}`); + } + + if (newlyResolvedById.has(submitted.id)) { + continue; + } + newlyResolvedById.set(submitted.id, { + id: submitted.id, + verdict: submitted.verdict, + ...(reason ? { reason } : {}), + resolvedByUserId: args.actor.userId, + resolvedAt: args.now, + }); + newlyResolvedItemIds.push(submitted.id); + } + + const nextById = new Map(existingItems.map((item) => [item.id, item])); + for (const [id, item] of newlyResolvedById) { + nextById.set(id, item); + } + const items = args.interaction.payload.items + .map((item) => nextById.get(item.id)) + .filter((item): item is RequestItemVerdictsResultItem => Boolean(item)); + + return { + items, + complete: items.length === args.interaction.payload.items.length, + newlyResolvedItemIds, + }; +} + function normalizeQuestionAnswers(args: { questions: AskUserQuestionsInteraction["payload"]["questions"]; answers: RespondIssueThreadInteraction["answers"]; @@ -653,8 +801,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { row: IssueThreadInteractionRow; actor: InteractionActor; }): Promise { - if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null; - const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction; + if (!isTargetBoundInteractionKind(args.row.kind) || args.row.status !== "pending") return null; + const interaction = hydrateInteraction(args.row) as TargetBoundInteraction; const target = interaction.payload.target ?? null; if (!target) return null; if (target.type !== "issue_document") return null; @@ -686,9 +834,7 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { } : interaction.payload, result: { - version: 1, - outcome: "stale_target", - staleTarget: target, + ...buildStaleTargetResult(args.row, target), }, resolvedByAgentId: args.actor.agentId ?? null, resolvedByUserId: args.actor.userId ?? null, @@ -1002,7 +1148,11 @@ export function issueThreadInteractionService(db: Db) { } } - if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") { + if ( + data.kind === "request_confirmation" + || data.kind === "request_checkbox_confirmation" + || data.kind === "request_item_verdicts" + ) { await assertRequestConfirmationTargetIsCurrent(db, { companyId: issue.companyId, issueId: issue.id, @@ -1274,6 +1424,106 @@ export function issueThreadInteractionService(db: Db) { } }, + submitItemVerdicts: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: SubmitIssueThreadInteractionVerdicts, + actor: InteractionActor, + ): Promise<{ interaction: IssueThreadInteraction; newlyResolvedItemIds: string[] }> => { + const data = submitIssueThreadInteractionVerdictsSchema.parse(input); + const submission = await db.transaction(async (tx) => { + const current = await tx + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .for("update") + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "request_item_verdicts") { + throw unprocessable("Only request_item_verdicts interactions can receive item verdicts"); + } + + const interaction = hydrateInteraction(current) as RequestItemVerdictsInteraction; + if (current.status !== "pending") { + if (current.status === "answered") { + const resolvedIds = new Set(interaction.result?.items.map((item) => item.id) ?? []); + const payloadIds = new Set(interaction.payload.items.map((item) => item.id)); + for (const submitted of data.verdicts) { + if (!payloadIds.has(submitted.id)) { + throw unprocessable(`Unknown item verdict id: ${submitted.id}`); + } + if (!resolvedIds.has(submitted.id)) { + throw conflict("Interaction has already been resolved"); + } + } + return { interaction, newlyResolvedItemIds: [], resolved: false }; + } + throw conflict("Interaction has already been resolved"); + } + + const expired = await expireStaleRequestConfirmationTarget(tx, { + row: current, + actor, + }); + if (expired) { + return { interaction: expired, newlyResolvedItemIds: [], resolved: false }; + } + + const now = new Date(); + const { items, complete, newlyResolvedItemIds } = resolveRequestItemVerdictSubmissions({ + interaction, + input: data, + actor, + now, + }); + if (newlyResolvedItemIds.length === 0) { + return { interaction, newlyResolvedItemIds: [], resolved: false }; + } + + const result = { + version: 1, + outcome: "resolved", + complete, + items, + } satisfies RequestItemVerdictsResult; + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + status: complete ? "answered" : "pending", + result, + resolvedByAgentId: complete ? actor.agentId ?? null : null, + resolvedByUserId: complete ? actor.userId ?? null : null, + resolvedAt: complete ? now : null, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + await touchIssue(tx, issue.id); + return { + interaction: hydrateInteraction(updated), + newlyResolvedItemIds, + resolved: complete, + }; + }); + + if (submission.resolved) { + await emitInteractionResolvedTelemetry(db, submission.interaction); + } + return submission; + }, + rejectSuggestedTasks: async ( issue: { id: string; companyId: string }, interactionId: string, @@ -1447,6 +1697,8 @@ export function issueThreadInteractionService(db: Db) { const confirmationRowIds = commentRows .filter((row) => isRequestConfirmationLikeKind(row.kind)) .map((row) => row.id); + const itemVerdictRows = commentRows + .filter((row) => row.kind === "request_item_verdicts"); if (questionRowIds.length > 0) { const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions"); @@ -1489,6 +1741,25 @@ export function issueThreadInteractionService(db: Db) { .returning(); expired.push(...updatedRows.map(hydrateInteraction)); } + + for (const row of itemVerdictRows) { + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByCommentResult(row, comment.id), + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + 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) { @@ -1509,12 +1780,12 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), + inArray(issueThreadInteractions.kind, [...TARGET_BOUND_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )); const staleRows = rows.filter((row) => { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; + const interaction = hydrateInteraction(row) as TargetBoundInteraction; const target = interaction.payload.target; if (!target || target.type !== "issue_document") return false; const targetIssueId = target.issueId ?? issue.id; @@ -1533,7 +1804,7 @@ export function issueThreadInteractionService(db: Db) { const now = new Date(); const expired: IssueThreadInteraction[] = []; for (const row of staleRows) { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; + const interaction = hydrateInteraction(row) as TargetBoundInteraction; const target = interaction.payload.target ?? null; const currentTarget = buildIssueDocumentTargetFromDocument({ issueId: issue.id, @@ -1549,11 +1820,7 @@ export function issueThreadInteractionService(db: Db) { target: currentTarget, } : interaction.payload, - result: { - version: 1, - outcome: "stale_target", - staleTarget: target, - }, + result: buildStaleTargetResult(row, target), resolvedByAgentId: actor.agentId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: now, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index bb7f746341..bc26b123e9 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -200,20 +200,21 @@ POST /api/companies/{companyId}/approvals Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path. -Four kinds are supported. Pick the smallest kind that fits the decision shape: +Five kinds are supported. Pick the smallest kind that fits the decision shape: | Kind | When to use | When **not** to use | | ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. | | `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). | +| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. | | `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. | | `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. | Key shared semantics: -- **Continuation policy.** `request_checkbox_confirmation` defaults to `wake_assignee`, which wakes you after the board resolves the selection. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume. -- **Target binding and staleness.** `request_confirmation` and `request_checkbox_confirmation` both accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction. -- **Supersede on user comment.** Both confirmation kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required. +- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the board resolves the selection or submits newly resolved item verdicts. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume. +- **Target binding and staleness.** `request_confirmation`, `request_checkbox_confirmation`, and `request_item_verdicts` accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction. +- **Supersede on user comment.** Target-bound request kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required. - **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards. - **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path. @@ -257,6 +258,35 @@ When the board accepts, your wake delivers `result.selectedOptionIds` — the op For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**. +Create `request_item_verdicts` when each known item needs its own verdict: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_item_verdicts", + "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Review each generated artifact.", + "items": [ + { "id": "api", "label": "API route", "description": "Partial submit endpoint." }, + { "id": "docs", "label": "Docs update" } + ], + "verdicts": ["approve", "reject", "defer"], + "requireReasonOn": ["reject"], + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +The board submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`. + ## Niche Workflow Pointers Load `references/workflows.md` when the task matches one of these: diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 57ed100dea..1b26b59aef 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -960,6 +960,116 @@ Best practice: - After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`. - When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id. +### Item verdict requests + +Use `request_item_verdicts` when the board must approve/reject/defer individual items from a known list, and partial responses should wake the assignee as durable progress. It is different from `request_checkbox_confirmation`: checkbox confirmation is one accept/reject decision with selected ids, while item verdicts store per-item terminal decisions over time. + +Create an item-verdict request: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_item_verdicts", + "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}", + "title": "Review generated artifacts", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Review each generated artifact.", + "detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.", + "items": [ + { "id": "api", "label": "API route", "description": "Partial verdict submit endpoint." }, + { "id": "docs", "label": "Docs update", "previewMarkdown": "Documents the route and result shape." } + ], + "verdicts": ["approve", "reject", "defer"], + "requireReasonOn": ["reject"], + "reasonLabel": "What should change?", + "allowBulkApprove": true, + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +Payload field reference (`RequestItemVerdictsPayload`): + +| Field | Type | Default | Notes | +| ------------------------ | -------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | required | Versioned for forward compatibility. | +| `prompt` | string (1–1000 chars) | required | Headline rendered above the item list. | +| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. | +| `items` | `[{ id, label, description?, previewMarkdown?, href?, attachmentId? }]` | required, 1–200 entries | Item `id` and `label` are 1–120 chars. Item ids must be unique. `href` must be safe: root-relative, fragment, or http(s). | +| `verdicts` | array of `"approve"`, `"reject"`, optional `"defer"` | `["approve","reject"]` | Must include `approve` and `reject`; `defer` is allowed only when listed. | +| `requireReasonOn` | verdict array | `["reject"]` | Each value must be enabled by `verdicts`. Pending submissions with those verdicts require a non-empty `reason`. | +| `reasonLabel` | string (1–160) \| `null` | `null` | Field label for the verdict reason. | +| `allowBulkApprove` | boolean | `true` | UI hint for bulk-approve affordances. Server still validates each submitted item id. | +| `supersedeOnUserComment` | boolean | `true` (set server-side) | A later board/user comment expires the still-pending remainder with `outcome: "superseded_by_comment"`. | +| `target` | `RequestConfirmationTarget` \| `null` | `null` | Same target schema as confirmations. Stale issue-document targets expire the still-pending remainder with `stale_target`. | + +Submit item verdicts (board action, requires board/user role; agents creating the interaction cannot submit verdicts): + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/verdicts +{ + "verdicts": [ + { "id": "api", "verdict": "approve" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions." } + ] +} +``` + +Server behavior: + +- Unknown item ids return 422. +- A verdict not listed in `payload.verdicts` returns 422. +- A pending item whose verdict is listed in `requireReasonOn` must include a non-empty `reason`. +- Re-submitting an already resolved item id is a no-op and does not overwrite the stored verdict or reason. +- Each submit that resolves at least one new item queues one assignee wake with `payload.newlyResolvedItemIds` and `payload.itemVerdicts.newlyResolvedItemIds`. Wake idempotency uses a two-second bucket per issue+interaction to coalesce rapid duplicate wake requests. + +Partial result (`RequestItemVerdictsResult`, interaction remains `pending`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": false, + "items": [ + { + "id": "docs", + "verdict": "reject", + "reason": "Needs install instructions.", + "resolvedByUserId": "local-board", + "resolvedAt": "2026-07-09T12:00:00.000Z" + } + ] +} +``` + +Complete result (interaction becomes `answered`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": true, + "items": [ + { "id": "api", "verdict": "approve", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions.", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" } + ] +} +``` + +Expiration results preserve already resolved items and omit undecided items: + +- `superseded_by_comment` — `{ outcome: "superseded_by_comment", complete: false, items, commentId }`. +- `stale_target` — `{ outcome: "stale_target", complete: false, items, staleTarget }`. +- `cancelled` is reserved for future explicit cancellation flows. + ### Checking approval status ``` @@ -1069,10 +1179,11 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | | GET | `/api/issues/:issueId/interactions` | List issue-thread interactions | -| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`) | +| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) | | POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) | | POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation | | POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions | +| POST | `/api/issues/:issueId/interactions/:interactionId/verdicts` | Submit partial item verdicts for `request_item_verdicts` | | GET | `/api/issues/:issueId/documents` | List issue documents | | GET | `/api/issues/:issueId/documents/:key` | Get issue document by key | | PUT | `/api/issues/:issueId/documents/:key` | Create or update issue document (send `baseRevisionId` when updating) | diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 076484b302..46dd51a26c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -37,6 +37,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; +import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { BoardChat } from "./pages/BoardChat"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; @@ -214,6 +215,7 @@ function boardRoutes() { } /> } /> + } /> } /> } /> } /> @@ -464,6 +466,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/attention.ts b/ui/src/api/attention.ts new file mode 100644 index 0000000000..b76f25baa6 --- /dev/null +++ b/ui/src/api/attention.ts @@ -0,0 +1,14 @@ +import type { AttentionFeed } from "@paperclipai/shared"; +import { api } from "./client"; + +export const attentionApi = { + /** + * Fetch the ranked Decisions attention feed for a company. The server + * unions every attention source (approvals, interactions, recovery, reviews, + * failures, budget…) into one ranked queue with the §0 contract. + */ + list: (companyId: string, options: { includeDismissed?: boolean } = {}) => + api.get( + `/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`, + ), +}; diff --git a/ui/src/api/inboxDismissals.ts b/ui/src/api/inboxDismissals.ts index f80d3aef96..e8de87dfd1 100644 --- a/ui/src/api/inboxDismissals.ts +++ b/ui/src/api/inboxDismissals.ts @@ -5,4 +5,12 @@ export const inboxDismissalsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/inbox-dismissals`), dismiss: (companyId: string, itemKey: string) => api.post(`/companies/${companyId}/inbox-dismissals`, { itemKey }), + snooze: (companyId: string, itemKey: string, snoozedUntil: string) => + api.post(`/companies/${companyId}/inbox-dismissals`, { + itemKey, + kind: "snooze", + snoozedUntil, + }), + restore: (companyId: string, itemKey: string) => + api.delete(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`), }; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 2fd1d1cebb..73e6798b89 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -252,6 +252,12 @@ export const issuesApi = { data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null }, ) => api.post(`/issues/${id}/interactions/${interactionId}/respond`, data), + submitInteractionVerdicts: ( + id: string, + interactionId: string, + verdicts: { id: string; verdict: "approve" | "reject" | "defer"; reason?: string | null }[], + ) => + api.post(`/issues/${id}/interactions/${interactionId}/verdicts`, { verdicts }), getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), diff --git a/ui/src/components/AttentionInteractionResolver.tsx b/ui/src/components/AttentionInteractionResolver.tsx new file mode 100644 index 0000000000..8176f534d8 --- /dev/null +++ b/ui/src/components/AttentionInteractionResolver.tsx @@ -0,0 +1,143 @@ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { + isIssueThreadInteraction, + type AskUserQuestionsAnswer, + type AskUserQuestionsInteraction, + type IssueThreadInteraction, + type RequestCheckboxConfirmationInteraction, + type RequestConfirmationInteraction, + type RequestItemVerdictsInteraction, + type RequestItemVerdictValue, + type SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; +import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; + +interface AttentionInteractionResolverProps { + companyId: string; + issueId: string; + interactionId: string; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + /** Called after a resolution so the parent can refresh the feed. */ + onResolved?: () => void; +} + +/** + * Lazily fetches the full issue-thread interaction referenced by an attention + * row and renders the existing {@link IssueThreadInteractionCard} inline, so + * confirmations and questions are answerable in-row without leaving the queue + * (converged PAP-12628). Reviews never reach here — they deep-link. + */ +export function AttentionInteractionResolver({ + companyId, + issueId, + interactionId, + agentMap, + currentUserId, + userLabelMap, + onResolved, +}: AttentionInteractionResolverProps) { + const queryClient = useQueryClient(); + + const { data: interactions, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.interactions(issueId), + queryFn: () => issuesApi.listInteractions(issueId), + enabled: !!issueId, + }); + + const interaction = useMemo(() => { + const match = (interactions ?? []).find((entry) => entry.id === interactionId); + return match && isIssueThreadInteraction(match) ? match : null; + }, [interactions, interactionId]); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + onResolved?.(); + }; + + const acceptMutation = useMutation({ + mutationFn: (input: { + interaction: SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; + selectedClientKeys?: string[]; + selectedOptionIds?: string[]; + }) => + issuesApi.acceptInteraction(issueId, input.interaction.id, { + selectedClientKeys: input.selectedClientKeys, + selectedOptionIds: input.selectedOptionIds, + }), + onSuccess: invalidate, + }); + + const rejectMutation = useMutation({ + mutationFn: (input: { interactionId: string; reason?: string }) => + issuesApi.rejectInteraction(issueId, input.interactionId, input.reason), + onSuccess: invalidate, + }); + + const respondMutation = useMutation({ + mutationFn: (input: { interactionId: string; answers: AskUserQuestionsAnswer[] }) => + issuesApi.respondToInteraction(issueId, input.interactionId, { answers: input.answers }), + onSuccess: invalidate, + }); + + const cancelMutation = useMutation({ + mutationFn: (input: { interactionId: string }) => + issuesApi.cancelInteraction(issueId, input.interactionId), + onSuccess: invalidate, + }); + + const verdictsMutation = useMutation({ + mutationFn: (input: { + interactionId: string; + verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[]; + }) => issuesApi.submitInteractionVerdicts(issueId, input.interactionId, input.verdicts), + onSuccess: invalidate, + }); + + if (isLoading) { + return ( +
+ Loading decision… +
+ ); + } + + if (error || !interaction) { + return ( +

+ This decision is no longer available — it may have been resolved elsewhere. +

+ ); + } + + return ( + + acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds }).then(() => undefined) + } + onRejectInteraction={(target, reason) => + rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined) + } + onSubmitInteractionAnswers={(target: AskUserQuestionsInteraction, answers) => + respondMutation.mutateAsync({ interactionId: target.id, answers }).then(() => undefined) + } + onCancelInteraction={(target: AskUserQuestionsInteraction) => + cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined) + } + onSubmitInteractionVerdicts={(target: RequestItemVerdictsInteraction, verdicts) => + verdictsMutation.mutateAsync({ interactionId: target.id, verdicts }).then(() => undefined) + } + /> + ); +} diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx new file mode 100644 index 0000000000..09dfdf0ca0 --- /dev/null +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -0,0 +1,456 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { AnchorHTMLAttributes, ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared"; +import { approvalsApi } from "../api/approvals"; +import { issuesApi } from "../api/issues"; +import { ToastViewport } from "./ToastViewport"; +import { ToastProvider } from "../context/ToastContext"; +import { AttentionQueueRow } from "./AttentionQueueRow"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +vi.mock("../api/approvals", () => ({ + approvalsApi: { + approve: vi.fn(), + reject: vi.fn(), + requestRevision: vi.fn(), + }, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + acceptInteraction: vi.fn(), + rejectInteraction: vi.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(cb: () => T): T { + let result: T | undefined; + flushSync(() => { + result = cb(); + }); + return result as T; +} + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.clearAllMocks(); +}); + +function render(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => + root?.render( + + + {element} + + + , + ), + ); + return container; +} + +function buildItem(overrides: Partial = {}): AttentionItem { + return { + id: "a1", + companyId: "c1", + sourceKind: "approval", + subject: { + kind: "approval", + id: "approval-1", + companyId: "c1", + title: "Hire agent: Research Analyst", + identifier: null, + status: "pending", + href: "/PAP/approvals/approval-1", + metadata: {}, + }, + whyNow: "Approval is pending a board decision.", + decisionVerbs: [], + inlineResolvable: true, + entryRule: "", + exitRule: "", + dedupKey: "approval:approval-1", + dismissalKey: "attention:approval:approval-1", + severity: "high", + rank: 0, + activityAt: "2026-07-09T12:00:00Z", + createdAt: "2026-07-09T12:00:00Z", + updatedAt: "2026-07-09T12:00:00Z", + relatedIssue: null, + project: null, + workspace: null, + detail: null, + dismissal: null, + ...overrides, + }; +} + +const noop = () => {}; + +describe("AttentionQueueRow", () => { + it("renders an inline approval resolver when expanded", () => { + const el = render( + , + ); + expect(el.textContent).toContain("Approve"); + expect(el.textContent).toContain("Request revision"); + expect(el.textContent).toContain("Reject"); + // Inline rows show an expand chevron, not an "Open" deep-link. + expect(el.textContent).not.toContain("Open"); + }); + + it("does not inline a review — it deep-links instead", () => { + const el = render( + , + ); + expect(el.textContent).toContain("Open"); + // No approval buttons should render for a review row. + expect(el.textContent).not.toContain("Request revision"); + }); + + it("fires onDismiss from the row menu action", () => { + const onDismiss = vi.fn(); + const item = buildItem(); + render( + , + ); + // The dropdown trigger + item live in a portal; invoke the handler contract + // directly via the rendered menu after opening is environment-flaky in + // jsdom, so assert the wiring by locating the trigger exists. + const trigger = container?.querySelector('[aria-label="Row actions"]'); + expect(trigger).toBeTruthy(); + }); + + it("toggles expand when the collapsed header of an inline row is clicked", () => { + const onToggleExpand = vi.fn(); + render( + , + ); + const header = container?.querySelector('[role="button"][aria-expanded]'); + expect(header).toBeTruthy(); + expect(header?.getAttribute("aria-expanded")).toBe("false"); + act(() => { + header?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onToggleExpand).toHaveBeenCalledTimes(1); + }); + + it("does not navigate on title click — the title is plain text, not a link", () => { + render( + , + ); + const links = Array.from(container?.querySelectorAll("a") ?? []); + // No anchor should carry the subject title (only the identifier link, absent here). + expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false); + }); + + it("renders project identity once without a filter button", () => { + render( + , + ); + + const projectMeta = container?.querySelector('[data-testid="attention-project-meta"]'); + expect(projectMeta?.textContent).toBe("Alpha"); + expect(projectMeta?.querySelector("button")).toBeNull(); + expect(projectMeta?.getAttribute("class")).not.toContain("border"); + expect(projectMeta?.getAttribute("class")).not.toContain("bg-"); + expect(container?.querySelector('button[title="Filter by Alpha"]')).toBeNull(); + expect(container?.textContent?.match(/Alpha/g)).toHaveLength(1); + }); + + it("places the timestamp beside the row menu without a clock icon", () => { + render( + , + ); + + const menu = container?.querySelector('[aria-label="Row actions"]'); + const menuArea = menu?.closest('[data-attention-menu="true"]'); + expect(menuArea?.textContent).not.toBe(""); + expect(container?.querySelector("svg.lucide-clock")).toBeNull(); + }); + + it("uses square row edges and can show a keyboard selection ring", () => { + render( + , + ); + + const row = container?.querySelector("[data-attention-row]"); + expect(row?.getAttribute("class")).not.toContain("rounded"); + expect(row?.getAttribute("class")).toContain("ring-ring"); + }); + + it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => { + render( + , + ); + + const header = container?.querySelector('[role="button"][aria-expanded]'); + expect(header?.textContent).not.toContain("Approve"); + expect(header?.textContent).not.toContain("Reject"); + + const decisionActions = container?.querySelector('[aria-label="Decision actions"]'); + expect(decisionActions?.textContent).toContain("Approve"); + expect(decisionActions?.textContent).toContain("Reject"); + + const actionArea = decisionActions?.closest('[data-attention-actions="true"]'); + expect(actionArea?.getAttribute("class")).toContain("mt-auto"); + + const controls = decisionActions?.closest('[data-attention-controls="true"]'); + expect(controls?.getAttribute("class")).toContain("self-stretch"); + expect(controls?.getAttribute("class")).toContain("justify-between"); + + const rowMenu = container?.querySelector('[aria-label="Row actions"]'); + expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy(); + expect(rowMenu?.closest('[data-attention-actions="true"]')).toBeNull(); + + const buttons = Array.from(decisionActions?.querySelectorAll("button") ?? []); + expect(buttons.find((button) => button.textContent === "Approve")?.getAttribute("data-variant")).toBe( + "default", + ); + expect(buttons.find((button) => button.textContent === "Reject")?.getAttribute("data-variant")).toBe( + "destructive", + ); + }); + + it("submits a compact approval without expanding the card and confirms it", async () => { + const onToggleExpand = vi.fn(); + vi.mocked(approvalsApi.approve).mockResolvedValue({} as never); + render( + , + ); + + const approve = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Approve", + ); + act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(approvalsApi.approve).toHaveBeenCalledWith("approval-1"); + expect(onToggleExpand).not.toHaveBeenCalled(); + expect(container?.textContent).toContain("Approval approved"); + }); + + it("renders configured confirmation labels and accepts from the compact action area", async () => { + const onToggleExpand = vi.fn(); + vi.mocked(issuesApi.acceptInteraction).mockResolvedValue({} as never); + render( + , + ); + + const decisionActions = container?.querySelector('[aria-label="Decision actions"]'); + expect(decisionActions?.textContent).toContain("Approve plan"); + expect(decisionActions?.textContent).toContain("Request changes"); + expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Approve plan")?.getAttribute("data-variant")).toBe("default"); + expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Request changes")?.getAttribute("data-variant")).toBe("outline"); + + const approve = Array.from(decisionActions?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Approve plan", + ); + act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(issuesApi.acceptInteraction).toHaveBeenCalledWith("issue-1", "interaction-1"); + expect(onToggleExpand).not.toHaveBeenCalled(); + }); + + it("opens the matching confirmation form when requesting changes from a compact action", () => { + const onToggleExpand = vi.fn(); + render( + , + ); + + const requestChanges = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Request changes", + ); + act(() => requestChanges?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + expect(onToggleExpand).toHaveBeenCalledOnce(); + expect(issuesApi.rejectInteraction).not.toHaveBeenCalled(); + }); + + it("centers thumbnails beside the full card text stack", () => { + render( + , + ); + + const image = container?.querySelector('img[alt="Screenshot"]'); + expect(image?.getAttribute("src")).toBe("/api/assets/asset-1/content"); + + const thumbnailStack = image?.parentElement?.parentElement; + expect(thumbnailStack?.getAttribute("class")).toContain("items-center"); + expect(thumbnailStack?.parentElement?.getAttribute("class")).toContain("items-center"); + }); + + it("does not expose a toggle button for non-inline rows", () => { + render( + , + ); + expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull(); + }); +}); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx new file mode 100644 index 0000000000..1ead76c45d --- /dev/null +++ b/ui/src/components/AttentionQueueRow.tsx @@ -0,0 +1,638 @@ +import { useState, type KeyboardEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AlarmClock, + ChevronDown, + ChevronRight, + ExternalLink, + Loader2, + MoreHorizontal, + RotateCcw, + X, +} from "lucide-react"; +import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { accessApi } from "../api/access"; +import { approvalsApi } from "../api/approvals"; +import { issuesApi } from "../api/issues"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { + attentionDetailImages, + attentionDetailLine, + attentionImageUrl, + attentionToneStyle, + isInlineResolvable, + severityBadge, + sourceMeta, +} from "../lib/attention"; +import { cn, relativeTime } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { AttentionInteractionResolver } from "./AttentionInteractionResolver"; +import { ProjectTile } from "./ProjectTile"; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** Tomorrow at 9am local time. */ +function tomorrowMorningIso(): string { + const d = new Date(); + d.setDate(d.getDate() + 1); + d.setHours(9, 0, 0, 0); + return d.toISOString(); +} + +/** Snooze presets, resolved to a future ISO timestamp at click time. */ +const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [ + { label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() }, + { label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() }, + { label: "Tomorrow morning", resolve: tomorrowMorningIso }, + { label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() }, +]; + +interface AttentionQueueRowProps { + item: AttentionItem; + companyId: string; + expanded: boolean; + onToggleExpand: () => void; + onDismiss: (item: AttentionItem) => void; + onSnooze?: (item: AttentionItem, snoozedUntil: string) => void; + /** Restore a snoozed/dismissed row (curtain variant only). */ + onRestore?: (item: AttentionItem) => void; + /** "active" renders the live queue row; "hidden" renders a curtain row. */ + variant?: "active" | "hidden"; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + selected?: boolean; +} + +export function AttentionQueueRow({ + item, + companyId, + expanded, + onToggleExpand, + onDismiss, + onSnooze, + onRestore, + variant = "active", + agentMap, + currentUserId, + userLabelMap, + selected = false, +}: AttentionQueueRowProps) { + const meta = sourceMeta(item.sourceKind); + const tone = attentionToneStyle(item); + const sevBadge = severityBadge(item.severity); + const Icon = meta.icon; + const isHidden = variant === "hidden"; + const inline = !isHidden && isInlineResolvable(item); + const href = item.subject.href; + const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null; + const detailLine = attentionDetailLine(item) ?? item.whyNow; + const images = attentionDetailImages(item); + // Only inline-resolvable active rows can expand; that's the only case where a + // whole-header click has somewhere to go (plan §5). Non-inline rows keep the + // explicit Open button and never toggle on a stray click. + const expandable = inline; + + const activate = () => { + if (expandable) onToggleExpand(); + }; + const onHeaderKeyDown = (e: KeyboardEvent) => { + if (!expandable) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggleExpand(); + } + }; + + return ( +
+ {/* Type accent bar (canonical color map — never severity). */} + + +
+ {/* Clickable header region: toggles expand for inline rows (plan §2/§5). */} +
+ {/* Expand affordance / source icon */} + {expandable ? ( + + {expanded ? : } + + ) : ( + + + + )} + +
+
+
+ + + {meta.label} + + {sevBadge && ( + + {sevBadge.label} + + )} + {item.relatedIssue?.identifier && ( + e.stopPropagation()} + > + {item.relatedIssue.identifier} + + )} +
+ +
+ + {item.subject.title ?? meta.label} + +

{detailLine}

+ + {item.project && ( +
+ +
+ )} +
+
+ + {images.length > 0 && } +
+
+ + {/* Controls: kept as siblings (not inside the clickable header) so they + never toggle expand and stay valid interactive targets. */} +
+
+ {isHidden && snoozedUntil ? ( + + Reappears {reappearLabel(snoozedUntil)} + + ) : ( + {relativeTime(item.activityAt)} + )} + {!isHidden && ( + + + + + + {onSnooze && onSnooze(item, iso)} />} + onDismiss(item)}> + + Dismiss + + {href && ( + <> + + + Open source + + + )} + + + )} +
+ +
+ {!expanded && } + +
+ {!inline && href && ( + + )} + + {isHidden && onRestore && ( + + )} +
+
+
+
+ + {inline && expanded && ( +
+ +
+ )} +
+ ); +} + +type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision"; + +function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null { + if (item.sourceKind === "approval" && (verbId === "approve" || verbId === "reject" || verbId === "request_revision")) { + return verbId; + } + if (item.sourceKind === "join_request" && (verbId === "approve" || verbId === "reject")) { + return verbId; + } + if ( + item.sourceKind === "issue_thread_interaction" + && item.subject.metadata?.kind === "request_confirmation" + && (verbId === "accept" || verbId === "reject") + ) { + return verbId; + } + return null; +} + +function CompactDecisionActions({ + item, + companyId, + onOpen, +}: { + item: AttentionItem; + companyId: string; + onOpen: () => void; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const actions = item.decisionVerbs + .slice(0, 3) + .flatMap((verb) => { + const action = compactDecisionAction(item, verb.id); + return action ? [{ action, label: verb.label, id: verb.id }] : []; + }); + + const decision = useMutation({ + mutationFn: (action: CompactDecisionAction) => { + if (item.sourceKind === "approval") { + if (action === "approve") return approvalsApi.approve(item.subject.id); + if (action === "reject") return approvalsApi.reject(item.subject.id); + return approvalsApi.requestRevision(item.subject.id); + } + if (item.sourceKind === "join_request") { + return action === "approve" + ? accessApi.approveJoinRequest(companyId, item.subject.id) + : accessApi.rejectJoinRequest(companyId, item.subject.id); + } + if (item.sourceKind === "issue_thread_interaction") { + const issueId = item.subject.metadata?.issueId; + if (typeof issueId !== "string") throw new Error("Missing issue reference for this decision."); + if (action === "accept") return issuesApi.acceptInteraction(issueId, item.subject.id); + return issuesApi.rejectInteraction(issueId, item.subject.id); + } + throw new Error("This decision must be completed from its detail view."); + }, + onSuccess: (_result, action) => { + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + if (item.sourceKind === "approval") { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); + } else { + queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) }); + } + pushToast({ + title: compactDecisionSuccessLabel(item.sourceKind, action), + tone: "success", + }); + }, + onError: (error, action) => { + pushToast({ + title: `Could not ${decisionLabel(action)}`, + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); + }, + }); + + if (actions.length === 0) return null; + + return ( +
+ {actions.map(({ action, id, label }) => ( + + ))} +
+ ); +} + +function decisionLabel(action: CompactDecisionAction): string { + if (action === "request_revision") return "sent for revision"; + if (action === "accept" || action === "approve") return "approved"; + return "rejected"; +} + +function compactDecisionSuccessLabel(sourceKind: AttentionItem["sourceKind"], action: CompactDecisionAction): string { + if (sourceKind === "approval") return `Approval ${decisionLabel(action)}`; + if (sourceKind === "join_request") return `Join request ${decisionLabel(action)}`; + return action === "accept" ? "Confirmation accepted" : "Confirmation declined"; +} + +function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" { + const text = `${verb.label} ${verb.description ?? ""}`.toLowerCase(); + if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive"; + if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default"; + return "outline"; +} + +/** Inline project identity keeps useful context without a competing badge. */ +function ProjectMeta({ project }: { project: NonNullable }) { + return ( + + + {project.name} + + ); +} + +/** Square screenshot thumbnails at the right of the description (plan §10). */ +function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) { + const visible = images.slice(0, 3); + const extra = images.length - visible.length; + return ( +
+
+ {visible.map((img, i) => ( + {img.alt + ))} +
+ {extra > 0 && ( + + +{extra} + + )} +
+ ); +} + +/** Snooze submenu: presets + a custom date-time (plan §6). */ +function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) { + const [customValue, setCustomValue] = useState(""); + const applyCustom = () => { + if (!customValue) return; + const ts = new Date(customValue); + if (Number.isNaN(ts.getTime())) return; + onSnooze(ts.toISOString()); + }; + return ( + + + + Snooze + + + {SNOOZE_PRESETS.map((preset) => ( + onSnooze(preset.resolve())}> + {preset.label} + + ))} + + {/* Custom picker: a non-menu-item region so interacting with the input + doesn't close the menu (guard keydown/select against Radix typeahead). */} +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + Custom + + setCustomValue(e.target.value)} + className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs" + /> + +
+
+
+ ); +} + +/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */ +function reappearLabel(snoozedUntil: string): string { + const diffMs = new Date(snoozedUntil).getTime() - Date.now(); + if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon"; + const diffMin = Math.round(diffMs / 60000); + if (diffMin < 60) return `in ${diffMin}m`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `in ${diffHr}h`; + const diffDay = Math.round(diffHr / 24); + return `in ${diffDay}d`; +} + +function InlineResolver({ + item, + companyId, + agentMap, + currentUserId, + userLabelMap, +}: { + item: AttentionItem; + companyId: string; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; +}) { + if (item.sourceKind === "issue_thread_interaction") { + const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id; + if (!issueId) { + return

Missing issue reference for this decision.

; + } + return ( + + ); + } + + if (item.sourceKind === "approval") { + return ; + } + + if (item.sourceKind === "join_request") { + return ; + } + + return null; +} + +function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) { + const queryClient = useQueryClient(); + const [note, setNote] = useState(""); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); + }; + const approve = useMutation({ + mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const reject = useMutation({ + mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const revise = useMutation({ + mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const pending = approve.isPending || reject.isPending || revise.isPending; + + return ( +
+