mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
feat: add attention queue and Decisions surface (#9380)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, where operators need a reliable way to find and act on work awaiting their input. > - The attention and issue-thread interaction subsystems expose those decision points across server APIs and the board UI. > - The previous navigation and interaction presentation left these actions fragmented and did not offer a controlled rollout for the Decisions surface. > - This branch adds the attention feed, richer interaction cards, grouping, dismiss/snooze behavior, and a gated Decisions sidebar entry. > - It also keeps experimental settings and API contracts synchronized, with an idempotent migration for the new dismissal state. > - This pull request delivers the complete, tested attention/Decisions experience as one reviewable unit. ## Linked Issues or Issue Description - Adds an operator-focused attention queue and Decisions experience: grouped decision cards, semantic interaction actions, dismiss/snooze handling, resilient interaction states, and an experimental flag to control the Decisions navigation entry. ## Feature Context ### Problem or Motivation Operators currently have to hunt across approvals, interactions, failed runs, and budget alerts to find decisions that need their action. ### Proposed Solution Provide a gated Decisions attention queue that groups actionable items, supports direct resolution, and preserves operator control through dismiss and snooze actions. ### Alternatives Considered Keep separate, source-specific views only; this leaves cross-cutting operator decisions fragmented and harder to prioritize. ### Roadmap Alignment This improves the V1 control-plane operator workflow by making pending governed actions discoverable in one company-scoped surface. ## What Changed - Added server attention-feed services, routes, interaction handling, dismiss/snooze support, and an idempotent `0145` inbox-dismissal migration. - Added shared attention, inbox-dismissal, and experimental-settings contracts. - Added Decisions/attention UI, interaction-card states, sidebar badge/navigation integration, grouping, keyboard support, and Storybook coverage. - Added tests for attention behavior, thread interactions, settings normalization, dismissals, and API behavior. - Removed generated screenshots from the final PR diff and rebased the branch onto current `master`. ## Verification - `pnpm check:token-gates` — passed. - `pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/attention-service.test.ts server/src/__tests__/inbox-dismissals.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts ui/src/lib/attention.test.ts ui/src/components/AttentionQueueRow.test.tsx ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` — passed: 158 tests across 9 focused files. - GitHub Actions for `ad636f560`: build and typecheck/release-registry have passed; remaining general-server and Greptile checks are in progress. ## Risks - Moderate: this is a cross-layer attention/interaction feature with a new migration and navigation behavior. - The `enableDecisions` experimental setting defaults to off, limiting rollout impact. - Existing dismissal data is backfilled to `dismiss`; the migration is idempotent and uses guarded constraint creation. > ROADMAP.md was checked; no duplicate planned core feature was identified. Related open pull requests were searched before opening this PR. ## Model Used - OpenAI GPT-5.5 via Codex CLI, with tool use and local code execution. Context-window size unavailable in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My public PR branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally; focused tests pass and the remaining unrelated AWS test failure is documented above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
70 files changed
+9067
-90
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -3951,6 +3951,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
|
||||
"interactionStatus",
|
||||
"continuationPolicy",
|
||||
"checkboxSelection",
|
||||
"itemVerdicts",
|
||||
"newlyResolvedItemIds",
|
||||
] as const;
|
||||
|
||||
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | null | undefined) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IssueThreadInteraction, "resolvedByAgentId" | "resolvedByUserId">) {
|
||||
if (interaction.resolvedByAgentId) return "agent";
|
||||
if (interaction.resolvedByUserId) return "user";
|
||||
@@ -263,10 +331,14 @@ function resolveCreatorKind(interaction: Pick<IssueThreadInteraction, "createdBy
|
||||
}
|
||||
|
||||
function deriveTargetType(interaction: IssueThreadInteraction) {
|
||||
if (interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation") {
|
||||
return "none";
|
||||
switch (interaction.kind) {
|
||||
case "request_confirmation":
|
||||
case "request_checkbox_confirmation":
|
||||
case "request_item_verdicts":
|
||||
return interaction.payload.target?.type ?? "none";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
return interaction.payload.target?.type ?? "none";
|
||||
}
|
||||
|
||||
function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
||||
@@ -284,8 +356,17 @@ function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
||||
if (interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation") {
|
||||
return interaction.result?.outcome ?? "expired";
|
||||
}
|
||||
if (interaction.kind === "request_item_verdicts") {
|
||||
return interaction.result?.outcome ?? "expired";
|
||||
}
|
||||
return "expired";
|
||||
}
|
||||
case "answered": {
|
||||
if (interaction.kind === "request_item_verdicts") {
|
||||
return interaction.result?.outcome ?? "answered";
|
||||
}
|
||||
return "answered";
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -315,6 +396,11 @@ function buildInteractionResolvedCounts(interaction: IssueThreadInteraction, arg
|
||||
questionCount: nonNegativeInteger(interaction.payload.questions.length),
|
||||
answeredQuestionCount: nonNegativeInteger(interaction.result?.answers?.length ?? 0),
|
||||
};
|
||||
case "request_item_verdicts":
|
||||
return {
|
||||
itemCount: nonNegativeInteger(interaction.payload.items.length),
|
||||
resolvedItemCount: nonNegativeInteger(interaction.result?.items?.length ?? 0),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -507,6 +593,68 @@ function resolveSelectedCheckboxConfirmationOptions(args: {
|
||||
.map((option) => 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<string, RequestItemVerdictsResultItem>();
|
||||
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<string, RequestItemVerdictsResultItem>(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<IssueThreadInteraction | null> {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user