3709 lines
136 KiB
TypeScript
3709 lines
136 KiB
TypeScript
import { isDeepStrictEqual } from "node:util";
|
|
import { and, asc, desc, eq, inArray, isNotNull, isNull, ne } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
companySecretProposals,
|
|
companies,
|
|
documents,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueQuestionResponseDeliveries,
|
|
issueThreadInteractions,
|
|
issues,
|
|
toolActionRequests,
|
|
toolOauthStates,
|
|
} from "@paperclipai/db";
|
|
import { trackInteractionCreated, trackInteractionResolved } from "@paperclipai/shared/telemetry";
|
|
import type {
|
|
AcceptIssueThreadInteraction,
|
|
AskUserQuestionsAnswer,
|
|
AskUserQuestionsInteraction,
|
|
CancelIssueThreadInteraction,
|
|
ConnectionIntentInteraction,
|
|
CreateIssueThreadInteraction,
|
|
InteractionResolverGovernance,
|
|
IssueReviewPolicy,
|
|
IssueThreadInteraction,
|
|
IssueThreadInteractionCanonicalResolverPolicy,
|
|
IssueThreadInteractionEffectiveResolverPolicySource,
|
|
IssueThreadInteractionKind,
|
|
IssueThreadInteractionResolverPolicy,
|
|
IssueThreadInteractionResolverPolicyProvenance,
|
|
RequestCheckboxConfirmationInteraction,
|
|
RequestConfirmationInteraction,
|
|
RequestConfirmationTarget,
|
|
RequestItemVerdictsInteraction,
|
|
RequestItemVerdictsResult,
|
|
RequestItemVerdictsResultItem,
|
|
RejectIssueThreadInteraction,
|
|
RespondIssueThreadInteraction,
|
|
SuggestTasksInteraction,
|
|
SuggestTasksResultCreatedTask,
|
|
SubmitIssueThreadInteractionVerdicts,
|
|
WithdrawIssueThreadInteraction,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
acceptIssueThreadInteractionSchema,
|
|
askUserQuestionsPayloadSchema,
|
|
askUserQuestionsResultSchema,
|
|
cancelIssueThreadInteractionSchema,
|
|
connectionIntentPayloadSchema,
|
|
connectionIntentResultSchema,
|
|
createIssueThreadInteractionSchema,
|
|
legacyIssueThreadInteractionResolverPolicyAlias,
|
|
normalizeIssueThreadInteractionResolverPolicy,
|
|
rejectIssueThreadInteractionSchema,
|
|
requestCheckboxConfirmationPayloadSchema,
|
|
requestCheckboxConfirmationResultSchema,
|
|
requestConfirmationPayloadSchema,
|
|
requestConfirmationResultSchema,
|
|
requestItemVerdictsPayloadSchema,
|
|
requestItemVerdictsResultSchema,
|
|
suggestTasksPayloadSchema,
|
|
suggestTasksResultSchema,
|
|
submitIssueThreadInteractionVerdictsSchema,
|
|
withdrawIssueThreadInteractionSchema,
|
|
} from "@paperclipai/shared";
|
|
import { z } from "zod";
|
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|
import { getTelemetryClient } from "../telemetry.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { evaluateAgentInvokabilityFromDb } from "./agent-invokability.js";
|
|
import {
|
|
assertIssueReviewVerdictActorAllowed,
|
|
isIssueReviewVerdictInteraction,
|
|
} from "./issue-review-policy.js";
|
|
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
|
|
import { questionResponseDeliveryValues } from "./question-response-delivery.js";
|
|
import {
|
|
assertIssueThreadInteractionResolverAudience,
|
|
canonicalizeStoredResolverPolicy,
|
|
issueThreadInteractionResolutionError,
|
|
type IssueThreadInteractionResolverRestriction,
|
|
} from "./issue-thread-interaction-resolution.js";
|
|
import {
|
|
createPullRequestMergeStateResolver,
|
|
extractGitHubPullRequestReferences,
|
|
setBoundedPullRequestCacheEntry,
|
|
type GitHubPullRequestReference,
|
|
type PullRequestMergeState,
|
|
} from "./github-pull-request-merge.js";
|
|
|
|
export { extractGitHubPullRequestReferences } from "./github-pull-request-merge.js";
|
|
export type { GitHubPullRequestReference } from "./github-pull-request-merge.js";
|
|
|
|
type InteractionActor = {
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
userId?: string | null;
|
|
systemId?: string | null;
|
|
resolverPolicyRestriction?:
|
|
| IssueThreadInteractionCanonicalResolverPolicy
|
|
| IssueThreadInteractionResolverRestriction
|
|
| null;
|
|
suggestedTaskEffectsAuthorized?: boolean;
|
|
resolutionDetails?: Record<string, unknown>;
|
|
};
|
|
|
|
type CreateInteractionOptions = {
|
|
/** Keep independently owned pending cards actionable. Internal runtime bridges use this. */
|
|
supersedePendingSiblingInteractions?: boolean;
|
|
};
|
|
|
|
type InteractionWakeup = (agentId: string, options: {
|
|
source: "automation";
|
|
triggerDetail: "system";
|
|
reason: "issue_commented";
|
|
payload: Record<string, unknown>;
|
|
idempotencyKey: string;
|
|
requestedByActorType: "system";
|
|
requestedByActorId: string;
|
|
contextSnapshot: Record<string, unknown>;
|
|
}) => Promise<unknown>;
|
|
|
|
export type IssueThreadInteractionServiceOptions = {
|
|
resolvePullRequestState?: (
|
|
companyId: string,
|
|
reference: GitHubPullRequestReference,
|
|
) => Promise<PullRequestMergeState>;
|
|
wakeup?: InteractionWakeup;
|
|
pullRequestCacheTtlMs?: number;
|
|
now?: () => Date;
|
|
};
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type InteractionResolutionMutationOptions = {
|
|
afterResolveInTransaction?: (
|
|
tx: DbTransaction,
|
|
interaction: IssueThreadInteraction,
|
|
) => Promise<void>;
|
|
};
|
|
|
|
const GITHUB_PULL_REQUEST_URL_PATTERN = /https:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)/gi;
|
|
const GITHUB_PULL_REQUEST_SHORTHAND_PATTERN = /(^|[^A-Za-z0-9_.-])([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#([1-9][0-9]*)\b/g;
|
|
const MERGE_CONFIRMATION_INTENT_PATTERN = /^(?:please\s+)?(?:confirm(?:\s+that)?\s+.{0,80}\s+)?(?:merge|merged)\b|\bready\s+to\s+merge\b/i;
|
|
const MERGE_CONFIRMATION_ALLOWED_WORDS = new Set([
|
|
"all",
|
|
"and",
|
|
"approved",
|
|
"are",
|
|
"both",
|
|
"checks",
|
|
"ci",
|
|
"confirm",
|
|
"github",
|
|
"green",
|
|
"is",
|
|
"it",
|
|
"link",
|
|
"linked",
|
|
"links",
|
|
"merge",
|
|
"merged",
|
|
"merging",
|
|
"passed",
|
|
"passing",
|
|
"please",
|
|
"pr",
|
|
"primary",
|
|
"prs",
|
|
"pull",
|
|
"ready",
|
|
"reference",
|
|
"references",
|
|
"request",
|
|
"requests",
|
|
"review",
|
|
"secondary",
|
|
"tests",
|
|
"that",
|
|
"the",
|
|
"these",
|
|
"this",
|
|
"to",
|
|
"url",
|
|
]);
|
|
|
|
function isMergeConfirmationOnlyText(value: string) {
|
|
GITHUB_PULL_REQUEST_URL_PATTERN.lastIndex = 0;
|
|
const withoutUrls = value.replace(GITHUB_PULL_REQUEST_URL_PATTERN, " pr_reference ");
|
|
GITHUB_PULL_REQUEST_SHORTHAND_PATTERN.lastIndex = 0;
|
|
const withoutReferences = withoutUrls.replace(
|
|
GITHUB_PULL_REQUEST_SHORTHAND_PATTERN,
|
|
(_match, prefix: string) => `${prefix} pr_reference `,
|
|
);
|
|
const normalized = withoutReferences
|
|
.replace(/\b(?:github-)?pr-[1-9][0-9]*\b/gi, " pr_reference ")
|
|
.replace(/[`*_\[\]{}()<>:;,.!?"'=+&|\\/-]+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!normalized) return true;
|
|
return normalized
|
|
.split(/\s+/)
|
|
.every((word) => word === "pr_reference" || MERGE_CONFIRMATION_ALLOWED_WORDS.has(word));
|
|
}
|
|
|
|
export function getMergeConfirmationPullRequestReferences(
|
|
row: Pick<IssueThreadInteractionRow, "kind" | "title" | "summary" | "payload">,
|
|
) {
|
|
if (row.kind !== "request_confirmation") return [];
|
|
const payload = row.payload && typeof row.payload === "object" && !Array.isArray(row.payload)
|
|
? row.payload as unknown as Record<string, unknown>
|
|
: null;
|
|
if (!payload || payload.toolAction !== undefined || payload.secretProposal !== undefined) return [];
|
|
|
|
const target = payload.target && typeof payload.target === "object" && !Array.isArray(payload.target)
|
|
? payload.target as Record<string, unknown>
|
|
: null;
|
|
// Plan/document confirmations and governed action cards must never inherit
|
|
// merge-confirmation authority merely because their prose links to a PR.
|
|
if (target?.type === "issue_document") return [];
|
|
|
|
const intentValues = [row.title, row.summary, payload.prompt, payload.acceptLabel];
|
|
const intentText = intentValues.filter((value): value is string => typeof value === "string").join("\n");
|
|
if (!MERGE_CONFIRMATION_INTENT_PATTERN.test(intentText)) return [];
|
|
|
|
const trustedTextValues = [
|
|
...intentValues,
|
|
payload.detailsMarkdown,
|
|
target?.key,
|
|
target?.label,
|
|
target?.href,
|
|
];
|
|
// System acceptance is intentionally fail-closed: after replacing recognized
|
|
// PR references, every trusted field must contain merge-only vocabulary. This
|
|
// prevents an otherwise valid merge prompt from smuggling an additional action
|
|
// that a governed-action denylist did not anticipate.
|
|
if (!trustedTextValues.every((value) => typeof value !== "string" || isMergeConfirmationOnlyText(value))) {
|
|
return [];
|
|
}
|
|
|
|
return extractGitHubPullRequestReferences(trustedTextValues);
|
|
}
|
|
|
|
const ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT =
|
|
"issue_thread_interactions_company_issue_idempotency_uq";
|
|
|
|
type IssueWakeTarget = {
|
|
id: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId?: string | null;
|
|
status: string;
|
|
};
|
|
|
|
type ResolvedInteractionResult = {
|
|
interaction: IssueThreadInteraction;
|
|
createdIssues: IssueWakeTarget[];
|
|
continuationIssue?: IssueWakeTarget | null;
|
|
};
|
|
|
|
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
|
|
type IssueTouchDb = Pick<Db, "update">;
|
|
|
|
export const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<
|
|
IssueThreadInteractionKind,
|
|
IssueThreadInteractionCanonicalResolverPolicy
|
|
> = {
|
|
suggest_tasks: "anyone",
|
|
ask_user_questions: "anyone",
|
|
request_confirmation: "anyone",
|
|
request_checkbox_confirmation: "anyone",
|
|
request_item_verdicts: "anyone",
|
|
connection_intent: "human_only",
|
|
};
|
|
|
|
const RESOLVER_POLICY_RESTRICTION_RANK: Record<IssueThreadInteractionCanonicalResolverPolicy, number> = {
|
|
anyone: 0,
|
|
not_creator: 1,
|
|
human_only: 2,
|
|
};
|
|
|
|
export function resolveInteractionPolicy(args: {
|
|
kind: IssueThreadInteractionKind;
|
|
requested?: IssueThreadInteractionResolverPolicy;
|
|
governance: InteractionResolverGovernance;
|
|
hasToolAction: boolean;
|
|
hasSecretProposal?: boolean;
|
|
}) {
|
|
const kindGovernance = args.governance[args.kind];
|
|
const requestedPolicyInput = args.requested
|
|
?? kindGovernance?.defaultPolicy
|
|
?? DEFAULT_RESOLVER_POLICY_BY_KIND[args.kind];
|
|
const requestedResolverPolicy = normalizeIssueThreadInteractionResolverPolicy(requestedPolicyInput);
|
|
const resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance =
|
|
args.requested === undefined ? "inherited" : "explicit";
|
|
|
|
let effectiveResolverPolicy = requestedResolverPolicy;
|
|
let effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource = "requested";
|
|
if (args.hasToolAction || args.hasSecretProposal) {
|
|
effectiveResolverPolicy = "human_only";
|
|
effectiveResolverPolicySource = "governed_action";
|
|
} else if (kindGovernance?.cap) {
|
|
const cap = normalizeIssueThreadInteractionResolverPolicy(kindGovernance.cap);
|
|
if (RESOLVER_POLICY_RESTRICTION_RANK[cap] > RESOLVER_POLICY_RESTRICTION_RANK[effectiveResolverPolicy]) {
|
|
effectiveResolverPolicy = cap;
|
|
effectiveResolverPolicySource = "company_cap";
|
|
}
|
|
}
|
|
return {
|
|
requestedResolverPolicy,
|
|
effectiveResolverPolicy,
|
|
resolverPolicyProvenance,
|
|
effectiveResolverPolicySource,
|
|
} as const;
|
|
}
|
|
|
|
function resolverActor(actor: InteractionActor) {
|
|
if (actor.systemId) return { type: "system" as const, systemId: actor.systemId };
|
|
if (actor.agentId) {
|
|
return { type: "agent" as const, agentId: actor.agentId, runId: actor.runId };
|
|
}
|
|
if (actor.userId) return { type: "user" as const, userId: actor.userId };
|
|
// Missing principals must fail closed. Internal maintenance paths that are
|
|
// intentionally system-owned provide an explicit systemId.
|
|
return { type: "agent" as const, agentId: null, runId: null };
|
|
}
|
|
|
|
function assertInteractionResolutionAllowed(current: IssueThreadInteractionRow, actor: InteractionActor) {
|
|
return assertIssueThreadInteractionResolverAudience({
|
|
actor: resolverActor(actor),
|
|
interaction: current,
|
|
additionalRestriction: actor.resolverPolicyRestriction,
|
|
governedAction:
|
|
current.kind === "request_confirmation"
|
|
&& current.payload !== null
|
|
&& typeof current.payload === "object"
|
|
&& (
|
|
("toolAction" in current.payload && current.payload.toolAction !== undefined)
|
|
|| ("secretProposal" in current.payload && current.payload.secretProposal !== undefined)
|
|
),
|
|
});
|
|
}
|
|
|
|
type IssueResolutionContext = {
|
|
id: string;
|
|
companyId: string;
|
|
status: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
reviewPolicy: IssueReviewPolicy | null;
|
|
createdByAgentId: string | null;
|
|
createdByUserId: string | null;
|
|
};
|
|
|
|
async function assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx: Db,
|
|
issue: IssueResolutionContext,
|
|
interaction: IssueThreadInteractionRow,
|
|
actor: InteractionActor,
|
|
) {
|
|
if (isTerminalIssueStatus(issue.status)) {
|
|
throw conflict("Interaction is no longer actionable because the issue is closed");
|
|
}
|
|
|
|
const isReviewVerdict = issue.status === "in_review"
|
|
&& isRequestConfirmationLikeKind(interaction.kind)
|
|
&& await isIssueReviewVerdictInteraction(tx, { issue, interaction });
|
|
|
|
assertInteractionResolutionAllowed(interaction, actor);
|
|
if (!isReviewVerdict) return;
|
|
|
|
const verdictActor = actor.agentId
|
|
? { type: "agent" as const, id: actor.agentId }
|
|
: actor.userId
|
|
? { type: "user" as const, id: actor.userId }
|
|
: null;
|
|
if (!verdictActor) {
|
|
throw forbidden("A review verdict requires an authenticated agent or user");
|
|
}
|
|
await assertIssueReviewVerdictActorAllowed(tx, {
|
|
issue,
|
|
actor: verdictActor,
|
|
});
|
|
}
|
|
|
|
const REQUEST_CONFIRMATION_INTERACTION_KINDS = [
|
|
"request_confirmation",
|
|
"request_checkbox_confirmation",
|
|
] as const;
|
|
type RequestConfirmationLikeKind = (typeof REQUEST_CONFIRMATION_INTERACTION_KINDS)[number];
|
|
type RequestConfirmationLikeInteraction =
|
|
| RequestConfirmationInteraction
|
|
| RequestCheckboxConfirmationInteraction;
|
|
|
|
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",
|
|
"connection_intent",
|
|
] as const;
|
|
type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
|
|
type UserCommentSupersedableInteraction =
|
|
| TargetBoundInteraction
|
|
| AskUserQuestionsInteraction
|
|
| ConnectionIntentInteraction;
|
|
|
|
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);
|
|
}
|
|
|
|
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
|
|
if (typeof error !== "object" || error === null) return false;
|
|
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
|
const constraint = err.constraint ?? err.constraint_name;
|
|
return err.code === "23505" && constraint === ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT;
|
|
}
|
|
|
|
function isEquivalentCreateRequest(
|
|
row: IssueThreadInteractionRow,
|
|
input: CreateIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
) {
|
|
return (
|
|
row.kind === input.kind
|
|
&& row.requestedResolverPolicy === input.resolverPolicy
|
|
&& (row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null)
|
|
&& (row.addresseeUserId ?? null) === (input.addresseeUserId ?? null)
|
|
&& row.continuationPolicy === input.continuationPolicy
|
|
&& (row.idempotencyKey ?? null) === (input.idempotencyKey ?? null)
|
|
&& (row.sourceCommentId ?? null) === (input.sourceCommentId ?? null)
|
|
&& (row.sourceRunId ?? null) === (input.sourceRunId ?? null)
|
|
&& (row.title ?? null) === (input.title ?? null)
|
|
&& (row.summary ?? null) === (input.summary ?? null)
|
|
&& (row.createdByAgentId ?? null) === (actor.agentId ?? null)
|
|
&& (row.createdByUserId ?? null) === (actor.userId ?? null)
|
|
&& isDeepStrictEqual(row.payload, input.payload)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse a stored interaction `result` blob tolerantly. Rows persisted by older
|
|
* builds can carry a `result` shape that predates the current schema — e.g. a
|
|
* legacy `outcome` value ("withdrawn_by_creator") no longer in the enum.
|
|
* `hydrateInteraction` runs over every row in `listForIssue`, so a hard
|
|
* `.parse()` on one stale row throws and 500s the *entire* issue's interaction
|
|
* list — which bricks both the web thread and plugin consumers such as the
|
|
* Slack gateway's notifier/digest/aging loops (LOOA-629). Degrade an
|
|
* unparseable `result` to `null` (the interaction still lists; a
|
|
* resolved-but-unparseable result is treated as absent) instead of throwing.
|
|
*/
|
|
function parseStoredInteractionResult<S extends z.ZodTypeAny>(
|
|
schema: S,
|
|
raw: unknown,
|
|
row: Pick<IssueThreadInteractionRow, "id" | "kind">,
|
|
): z.infer<S> | null {
|
|
if (raw == null) return null;
|
|
const parsed = schema.safeParse(raw);
|
|
if (parsed.success) return parsed.data;
|
|
console.warn(
|
|
`[paperclip] Dropping unparseable ${row.kind} interaction result for interaction ${row.id}`,
|
|
parsed.error.issues,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function hydrateInteraction(
|
|
row: IssueThreadInteractionRow,
|
|
): IssueThreadInteraction {
|
|
const storedRequestedResolverPolicy = row.requestedResolverPolicy as IssueThreadInteractionResolverPolicy;
|
|
const storedEffectiveResolverPolicy = row.effectiveResolverPolicy as IssueThreadInteractionResolverPolicy;
|
|
const resolverPolicyProvenance = row.resolverPolicyProvenance
|
|
?? (storedRequestedResolverPolicy === "board_only" || storedRequestedResolverPolicy === "board_or_agents"
|
|
? "legacy_inherited_restriction"
|
|
: "inherited");
|
|
const canonicalizeStoredPolicy = (
|
|
policy: IssueThreadInteractionResolverPolicy,
|
|
): IssueThreadInteractionCanonicalResolverPolicy =>
|
|
canonicalizeStoredResolverPolicy(policy, resolverPolicyProvenance);
|
|
const requestedResolverPolicy = canonicalizeStoredPolicy(storedRequestedResolverPolicy);
|
|
const effectiveResolverPolicy = canonicalizeStoredPolicy(storedEffectiveResolverPolicy);
|
|
const base = {
|
|
...row,
|
|
idempotencyKey: row.idempotencyKey ?? null,
|
|
addresseeAgentId: row.addresseeAgentId ?? null,
|
|
addresseeUserId: row.addresseeUserId ?? null,
|
|
status: row.status as IssueThreadInteraction["status"],
|
|
continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"],
|
|
resolverPolicy: requestedResolverPolicy,
|
|
requestedResolverPolicy,
|
|
effectiveResolverPolicy,
|
|
resolverPolicyProvenance,
|
|
effectiveResolverPolicySource: row.effectiveResolverPolicySource ?? "requested",
|
|
legacyResolverPolicyAliases: {
|
|
requested: legacyIssueThreadInteractionResolverPolicyAlias(requestedResolverPolicy),
|
|
effective: legacyIssueThreadInteractionResolverPolicyAlias(effectiveResolverPolicy),
|
|
},
|
|
};
|
|
|
|
switch (row.kind) {
|
|
case "suggest_tasks":
|
|
return {
|
|
...base,
|
|
kind: "suggest_tasks",
|
|
payload: suggestTasksPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(suggestTasksResultSchema, row.result, row),
|
|
} satisfies SuggestTasksInteraction;
|
|
case "ask_user_questions":
|
|
return {
|
|
...base,
|
|
kind: "ask_user_questions",
|
|
payload: askUserQuestionsPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(askUserQuestionsResultSchema, row.result, row),
|
|
} satisfies AskUserQuestionsInteraction;
|
|
case "request_confirmation":
|
|
return {
|
|
...base,
|
|
kind: "request_confirmation",
|
|
payload: requestConfirmationPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(requestConfirmationResultSchema, row.result, row),
|
|
} satisfies RequestConfirmationInteraction;
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
...base,
|
|
kind: "request_checkbox_confirmation",
|
|
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(requestCheckboxConfirmationResultSchema, row.result, row),
|
|
} satisfies RequestCheckboxConfirmationInteraction;
|
|
case "request_item_verdicts":
|
|
return {
|
|
...base,
|
|
kind: "request_item_verdicts",
|
|
payload: requestItemVerdictsPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(requestItemVerdictsResultSchema, row.result, row),
|
|
} satisfies RequestItemVerdictsInteraction;
|
|
case "connection_intent":
|
|
return {
|
|
...base,
|
|
kind: "connection_intent",
|
|
payload: connectionIntentPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(connectionIntentResultSchema, row.result, row),
|
|
} satisfies ConnectionIntentInteraction;
|
|
default:
|
|
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
|
|
}
|
|
}
|
|
|
|
async function touchIssue(db: IssueTouchDb, issueId: string) {
|
|
await db
|
|
.update(issues)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(issues.id, issueId));
|
|
}
|
|
|
|
function isTerminalIssueStatus(status: string) {
|
|
return status === "done" || status === "cancelled";
|
|
}
|
|
|
|
function interactionNotFoundError() {
|
|
return notFound("Interaction not found", { code: "interaction_not_found" });
|
|
}
|
|
|
|
function interactionIssueClosedError() {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_issue_closed",
|
|
"Interaction is no longer actionable because the issue is closed",
|
|
);
|
|
}
|
|
|
|
function interactionAlreadyResolvedError() {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
|
|
function interactionTerminalError(row: { status: string; result?: unknown }) {
|
|
const result = row.result && typeof row.result === "object" && !Array.isArray(row.result)
|
|
? row.result as unknown as Record<string, unknown>
|
|
: null;
|
|
if (result?.outcome === "stale_target") {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_stale_target",
|
|
"Interaction target is stale",
|
|
);
|
|
}
|
|
if (result?.outcome === "superseded_by_comment" || result?.outcome === "superseded_by_newer_request") {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_superseded",
|
|
"Interaction has been superseded",
|
|
);
|
|
}
|
|
if (result?.outcome === "issue_closed") return interactionIssueClosedError();
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
|
|
function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
|
issue: IssueResolutionContext;
|
|
current: IssueThreadInteractionRow;
|
|
actor: InteractionActor;
|
|
}) {
|
|
if (!isRequestConfirmationLikeKind(args.current.kind)) return false;
|
|
if (!args.current.createdByAgentId) return false;
|
|
if (!args.actor.userId) return false;
|
|
if (isTerminalIssueStatus(args.issue.status)) return false;
|
|
if (args.issue.assigneeAgentId) {
|
|
return args.issue.status === "in_review"
|
|
&& args.issue.assigneeAgentId === args.current.createdByAgentId;
|
|
}
|
|
return Boolean(args.issue.assigneeUserId);
|
|
}
|
|
|
|
function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) {
|
|
if (interaction.kind === "connection_intent") return true;
|
|
return interaction.payload.supersedeOnUserComment === true;
|
|
}
|
|
|
|
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
|
|
switch (input.kind) {
|
|
case "ask_user_questions":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
|
},
|
|
};
|
|
case "request_confirmation":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
|
},
|
|
};
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
|
},
|
|
};
|
|
case "request_item_verdicts":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
|
},
|
|
};
|
|
default:
|
|
return input;
|
|
}
|
|
}
|
|
|
|
function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentId: string) {
|
|
if (row.kind === "connection_intent") {
|
|
return {
|
|
version: 1,
|
|
outcome: "expired",
|
|
reason: "Superseded by a newer user comment",
|
|
} as const;
|
|
}
|
|
if (row.kind === "ask_user_questions") {
|
|
return {
|
|
version: 1,
|
|
answers: [],
|
|
expirationReason: "superseded_by_comment",
|
|
commentId,
|
|
summaryMarkdown: null,
|
|
} 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",
|
|
commentId,
|
|
} 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 buildSupersededByNewerRequestResult(replacementInteractionId: string) {
|
|
return {
|
|
version: 1,
|
|
outcome: "superseded_by_newer_request",
|
|
supersededByInteractionId: replacementInteractionId,
|
|
} as const;
|
|
}
|
|
|
|
// An agent that posts a fresh ask_user_questions while its own earlier ones on
|
|
// the same issue are still pending has replaced them — the newer card carries
|
|
// the real ask, so the stale siblings auto-expire (PAP-437). Mirrors the
|
|
// `superseded_by_comment` shape (ask_user_questions results key expiry off
|
|
// `expirationReason`, not `outcome`) so the UI can hide them cleanly.
|
|
function buildSupersededByNewerInteractionResult(replacementInteractionId: string) {
|
|
return {
|
|
version: 1,
|
|
answers: [],
|
|
expirationReason: "superseded_by_newer_interaction",
|
|
supersededByInteractionId: replacementInteractionId,
|
|
summaryMarkdown: null,
|
|
} as const;
|
|
}
|
|
|
|
function buildAdministrativeOutcomeResult(
|
|
row: IssueThreadInteractionRow,
|
|
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
|
|
reason: string | null = null,
|
|
) {
|
|
if (row.kind === "connection_intent") {
|
|
return { version: 1, outcome: "expired", reason } as const;
|
|
}
|
|
if (row.kind === "ask_user_questions") {
|
|
return { version: 1, outcome, reason, answers: [], summaryMarkdown: null } as const;
|
|
}
|
|
if (row.kind === "request_item_verdicts") {
|
|
const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction;
|
|
return {
|
|
version: 1,
|
|
outcome,
|
|
reason,
|
|
complete: false,
|
|
items: interaction.result?.items ?? [],
|
|
} satisfies RequestItemVerdictsResult;
|
|
}
|
|
return {
|
|
version: 1,
|
|
outcome,
|
|
reason,
|
|
...(linkedSecretProposalId(row)
|
|
? {
|
|
secretProposal: {
|
|
version: 1,
|
|
status: outcome === "withdrawn" ? "withdrawn" : "expired",
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
: {}),
|
|
} as const;
|
|
}
|
|
|
|
// Rollback sentinel: the interaction was resolved by another actor between the
|
|
// pending-rows read and the conditional update, so the enclosing transaction's
|
|
// tool-action revocation must be undone.
|
|
class InteractionResolvedConcurrentlyError extends Error {
|
|
constructor() {
|
|
super("Interaction was resolved concurrently");
|
|
}
|
|
}
|
|
|
|
// A request_confirmation card can govern a parked tool call via a linked
|
|
// tool_action_requests row. Administrative resolutions (withdraw, terminal-issue
|
|
// expiry) must settle that row too, or the parked call stays approvable under
|
|
// its own one-hour lifecycle after its card is gone.
|
|
async function resolveLinkedToolActionRequests(
|
|
db: Pick<Db, "update">,
|
|
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind">,
|
|
outcome: {
|
|
status: "expired" | "cancelled";
|
|
fromStatuses: Array<"pending" | "approved">;
|
|
actor: InteractionActor;
|
|
now: Date;
|
|
},
|
|
) {
|
|
if (interaction.kind !== "request_confirmation") return;
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: outcome.status,
|
|
resolvedByAgentId: outcome.actor.agentId ?? null,
|
|
resolvedByUserId: outcome.actor.userId ?? null,
|
|
resolvedAt: outcome.now,
|
|
updatedAt: outcome.now,
|
|
})
|
|
.where(and(
|
|
eq(toolActionRequests.companyId, interaction.companyId),
|
|
eq(toolActionRequests.interactionId, interaction.id),
|
|
inArray(toolActionRequests.status, outcome.fromStatuses),
|
|
));
|
|
}
|
|
|
|
function linkedSecretProposalId(interaction: Pick<IssueThreadInteractionRow, "kind" | "payload">) {
|
|
if (interaction.kind !== "request_confirmation") return null;
|
|
const payload = interaction.payload && typeof interaction.payload === "object" && !Array.isArray(interaction.payload)
|
|
? interaction.payload as unknown as Record<string, unknown>
|
|
: null;
|
|
const secretProposal = payload?.secretProposal && typeof payload.secretProposal === "object" && !Array.isArray(payload.secretProposal)
|
|
? payload.secretProposal as Record<string, unknown>
|
|
: null;
|
|
return typeof secretProposal?.proposalId === "string" ? secretProposal.proposalId : null;
|
|
}
|
|
|
|
async function lockLinkedSecretProposal(
|
|
db: Db,
|
|
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind" | "payload">,
|
|
) {
|
|
const proposalId = linkedSecretProposalId(interaction);
|
|
if (!proposalId) return;
|
|
await db
|
|
.select({ id: companySecretProposals.id })
|
|
.from(companySecretProposals)
|
|
.where(and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, interaction.companyId),
|
|
eq(companySecretProposals.interactionId, interaction.id),
|
|
))
|
|
.for("update");
|
|
}
|
|
|
|
async function resolveLinkedSecretProposal(
|
|
db: Db,
|
|
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind" | "payload">,
|
|
outcome: {
|
|
status: "rejected" | "withdrawn" | "expired";
|
|
actor: InteractionActor;
|
|
reason?: string | null;
|
|
now: Date;
|
|
},
|
|
) {
|
|
const proposalId = linkedSecretProposalId(interaction);
|
|
if (!proposalId) return;
|
|
const [proposal] = await db
|
|
.update(companySecretProposals)
|
|
.set({
|
|
status: outcome.status,
|
|
resolvedByUserId: outcome.actor.userId ?? null,
|
|
resolvedAt: outcome.now,
|
|
resolutionReason: outcome.reason ?? null,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: outcome.now,
|
|
updatedAt: outcome.now,
|
|
})
|
|
.where(and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, interaction.companyId),
|
|
eq(companySecretProposals.interactionId, interaction.id),
|
|
eq(companySecretProposals.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (!proposal) throw conflict("Linked secret proposal is no longer pending");
|
|
const actorType = outcome.actor.userId ? "user" as const : outcome.actor.agentId ? "agent" as const : "system" as const;
|
|
const actorId = outcome.actor.userId ?? outcome.actor.agentId ?? outcome.actor.systemId ?? "system";
|
|
await logActivity(db, {
|
|
companyId: interaction.companyId,
|
|
actorType,
|
|
actorId,
|
|
action: `secret.proposal.${outcome.status}`,
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
ciphertextScrubbed: true,
|
|
issueId: proposal.originIssueId,
|
|
interactionId: interaction.id,
|
|
reason: outcome.reason ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function resolveActorKind(interaction: Pick<IssueThreadInteraction, "resolvedByAgentId" | "resolvedByUserId">) {
|
|
if (interaction.resolvedByAgentId) return "agent";
|
|
if (interaction.resolvedByUserId) return "user";
|
|
return "system";
|
|
}
|
|
|
|
function resolveCreatorKind(interaction: Pick<IssueThreadInteraction, "createdByAgentId" | "createdByUserId">) {
|
|
if (interaction.createdByAgentId) return "agent";
|
|
if (interaction.createdByUserId) return "user";
|
|
return undefined;
|
|
}
|
|
|
|
function deriveTargetType(interaction: IssueThreadInteraction) {
|
|
switch (interaction.kind) {
|
|
case "request_confirmation":
|
|
case "request_checkbox_confirmation":
|
|
case "request_item_verdicts":
|
|
return interaction.payload.target?.type ?? "none";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
|
switch (interaction.status) {
|
|
case "accepted":
|
|
return "accepted";
|
|
case "rejected":
|
|
return "rejected";
|
|
case "cancelled":
|
|
return "cancelled";
|
|
case "expired": {
|
|
if (interaction.kind === "connection_intent") {
|
|
return interaction.result?.outcome ?? "expired";
|
|
}
|
|
if (interaction.kind === "ask_user_questions") {
|
|
return interaction.result?.expirationReason ?? "expired";
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
function nonNegativeInteger(value: number) {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.trunc(value));
|
|
}
|
|
|
|
function buildInteractionResolvedCounts(interaction: IssueThreadInteraction, args?: {
|
|
createdTaskCount?: number;
|
|
}) {
|
|
switch (interaction.kind) {
|
|
case "suggest_tasks":
|
|
return {
|
|
createdTaskCount: nonNegativeInteger(args?.createdTaskCount ?? 0),
|
|
skippedTaskCount: nonNegativeInteger(interaction.result?.skippedClientKeys?.length ?? 0),
|
|
};
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
optionCount: nonNegativeInteger(interaction.payload.options.length),
|
|
selectedOptionCount: nonNegativeInteger(interaction.result?.selectedOptionIds?.length ?? 0),
|
|
};
|
|
case "ask_user_questions":
|
|
return {
|
|
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 {};
|
|
}
|
|
}
|
|
|
|
async function fetchCreatorAgentRoleById(
|
|
db: Pick<Db, "select">,
|
|
interactions: readonly IssueThreadInteraction[],
|
|
) {
|
|
const creatorAgentIds = [...new Set(interactions
|
|
.map((interaction) => interaction.createdByAgentId)
|
|
.filter((value): value is string => Boolean(value)))];
|
|
if (creatorAgentIds.length === 0) return new Map<string, string | null>();
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: agents.id,
|
|
role: agents.role,
|
|
})
|
|
.from(agents)
|
|
.where(inArray(agents.id, creatorAgentIds));
|
|
|
|
return new Map(rows.map((row) => [row.id, row.role] as const));
|
|
}
|
|
|
|
async function emitInteractionResolvedTelemetry(
|
|
db: Pick<Db, "select">,
|
|
interaction: IssueThreadInteraction,
|
|
args?: { createdTaskCount?: number; creatorRoleByAgentId?: ReadonlyMap<string, string | null> },
|
|
) {
|
|
const telemetryClient = getTelemetryClient();
|
|
if (!telemetryClient) return;
|
|
|
|
try {
|
|
let roleByAgentId = args?.creatorRoleByAgentId ?? new Map<string, string | null>();
|
|
if (!args?.creatorRoleByAgentId) {
|
|
try {
|
|
roleByAgentId = await fetchCreatorAgentRoleById(db, [interaction]);
|
|
} catch (error) {
|
|
console.error("[paperclip] Failed to load interaction.resolved creator role", error);
|
|
}
|
|
}
|
|
const creatorAgentRole = interaction.createdByAgentId
|
|
? roleByAgentId.get(interaction.createdByAgentId) ?? undefined
|
|
: undefined;
|
|
|
|
trackInteractionResolved(telemetryClient, {
|
|
interactionKind: interaction.kind === "connection_intent" ? "other" : interaction.kind,
|
|
status: interaction.status,
|
|
resolvedByKind: resolveActorKind(interaction),
|
|
resolutionReason: deriveResolutionReason(interaction),
|
|
createdByKind: resolveCreatorKind(interaction),
|
|
creatorAgentRole,
|
|
continuationPolicy: interaction.continuationPolicy,
|
|
targetType: deriveTargetType(interaction),
|
|
...buildInteractionResolvedCounts(interaction, {
|
|
createdTaskCount: args?.createdTaskCount,
|
|
}),
|
|
legacyInheritedRestriction:
|
|
interaction.resolverPolicyProvenance === "legacy_inherited_restriction",
|
|
});
|
|
} catch (error) {
|
|
console.error("[paperclip] Failed to emit interaction.resolved telemetry", error);
|
|
}
|
|
}
|
|
|
|
function emitInteractionCreatedTelemetry(args: {
|
|
interactionKind: IssueThreadInteractionKind;
|
|
usedDeprecatedResolverPolicyAlias: boolean;
|
|
}) {
|
|
const telemetryClient = getTelemetryClient();
|
|
if (!telemetryClient) return;
|
|
|
|
try {
|
|
trackInteractionCreated(telemetryClient, {
|
|
...args,
|
|
interactionKind: args.interactionKind === "connection_intent" ? "other" : args.interactionKind,
|
|
});
|
|
} catch (error) {
|
|
console.error("[paperclip] Failed to emit interaction.created telemetry", error);
|
|
}
|
|
}
|
|
|
|
async function emitResolvedInteractionsTelemetry(
|
|
db: Pick<Db, "select">,
|
|
interactions: readonly IssueThreadInteraction[],
|
|
) {
|
|
if (interactions.length === 0 || !getTelemetryClient()) return;
|
|
let roleByAgentId = new Map<string, string | null>();
|
|
try {
|
|
roleByAgentId = await fetchCreatorAgentRoleById(db, interactions);
|
|
} catch (error) {
|
|
console.error("[paperclip] Failed to load interaction.resolved creator roles", error);
|
|
}
|
|
await Promise.all(interactions.map((interaction) =>
|
|
emitInteractionResolvedTelemetry(db, interaction, { creatorRoleByAgentId: roleByAgentId })
|
|
));
|
|
}
|
|
|
|
function isCommentAtOrAfterInteraction(args: {
|
|
commentCreatedAt: Date | string;
|
|
interactionCreatedAt: Date | string;
|
|
}) {
|
|
const commentCreatedAtMs = new Date(args.commentCreatedAt).getTime();
|
|
const interactionCreatedAtMs = new Date(args.interactionCreatedAt).getTime();
|
|
if (!Number.isFinite(commentCreatedAtMs) || !Number.isFinite(interactionCreatedAtMs)) return false;
|
|
return commentCreatedAtMs >= interactionCreatedAtMs;
|
|
}
|
|
|
|
function buildTaskCreationOrder(tasks: ReadonlyArray<SuggestTasksInteraction["payload"]["tasks"][number]>) {
|
|
const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const));
|
|
const ordered: Array<SuggestTasksInteraction["payload"]["tasks"][number]> = [];
|
|
const state = new Map<string, "visiting" | "done">();
|
|
|
|
const visit = (clientKey: string) => {
|
|
const currentState = state.get(clientKey);
|
|
if (currentState === "done") return;
|
|
if (currentState === "visiting") {
|
|
throw unprocessable("Suggested tasks contain a parentClientKey cycle");
|
|
}
|
|
|
|
const task = taskByClientKey.get(clientKey);
|
|
if (!task) {
|
|
throw unprocessable(`Unknown parentClientKey: ${clientKey}`);
|
|
}
|
|
|
|
state.set(clientKey, "visiting");
|
|
if (task.parentClientKey) {
|
|
visit(task.parentClientKey);
|
|
}
|
|
state.set(clientKey, "done");
|
|
ordered.push(task);
|
|
};
|
|
|
|
for (const task of tasks) {
|
|
visit(task.clientKey);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
export function resolveSelectedSuggestedTasks(args: {
|
|
interaction: SuggestTasksInteraction;
|
|
selectedClientKeys?: AcceptIssueThreadInteraction["selectedClientKeys"];
|
|
}) {
|
|
const taskByClientKey = new Map(
|
|
args.interaction.payload.tasks.map((task) => [task.clientKey, task] as const),
|
|
);
|
|
const selectedClientKeys = args.selectedClientKeys ?? args.interaction.payload.tasks.map((task) => task.clientKey);
|
|
const selectedClientKeySet = new Set<string>();
|
|
|
|
for (const clientKey of selectedClientKeys) {
|
|
const task = taskByClientKey.get(clientKey);
|
|
if (!task) {
|
|
throw unprocessable(`Unknown suggested task clientKey: ${clientKey}`);
|
|
}
|
|
selectedClientKeySet.add(clientKey);
|
|
}
|
|
|
|
if (selectedClientKeySet.size === 0) {
|
|
throw unprocessable("Select at least one suggested task to accept");
|
|
}
|
|
|
|
for (const clientKey of selectedClientKeySet) {
|
|
let parentClientKey = taskByClientKey.get(clientKey)?.parentClientKey ?? null;
|
|
while (parentClientKey) {
|
|
if (!selectedClientKeySet.has(parentClientKey)) {
|
|
throw unprocessable(`Suggested task ${clientKey} requires its parent ${parentClientKey} to also be selected`);
|
|
}
|
|
parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
selectedTasks: args.interaction.payload.tasks.filter((task) => selectedClientKeySet.has(task.clientKey)),
|
|
skippedClientKeys: args.interaction.payload.tasks
|
|
.filter((task) => !selectedClientKeySet.has(task.clientKey))
|
|
.map((task) => task.clientKey),
|
|
};
|
|
}
|
|
|
|
function resolveSelectedCheckboxConfirmationOptions(args: {
|
|
interaction: RequestCheckboxConfirmationInteraction;
|
|
selectedOptionIds?: AcceptIssueThreadInteraction["selectedOptionIds"];
|
|
}) {
|
|
const optionIds = new Set(args.interaction.payload.options.map((option) => option.id));
|
|
const selectedOptionIds = args.selectedOptionIds ?? args.interaction.payload.defaultSelectedOptionIds ?? [];
|
|
const selectedOptionIdSet = new Set<string>();
|
|
|
|
for (const optionId of selectedOptionIds) {
|
|
if (!optionIds.has(optionId)) {
|
|
throw unprocessable(`Unknown checkbox confirmation optionId: ${optionId}`);
|
|
}
|
|
selectedOptionIdSet.add(optionId);
|
|
}
|
|
|
|
const selectedCount = selectedOptionIdSet.size;
|
|
const minSelected = args.interaction.payload.minSelected ?? 0;
|
|
const maxSelected = args.interaction.payload.maxSelected ?? null;
|
|
if (selectedCount < minSelected) {
|
|
throw unprocessable(`Select at least ${minSelected} checkbox confirmation option(s)`);
|
|
}
|
|
if (maxSelected != null && selectedCount > maxSelected) {
|
|
throw unprocessable(`Select no more than ${maxSelected} checkbox confirmation option(s)`);
|
|
}
|
|
|
|
return args.interaction.payload.options
|
|
.filter((option) => selectedOptionIdSet.has(option.id))
|
|
.map((option) => option.id);
|
|
}
|
|
|
|
function resolveRequestItemVerdictSubmissions(args: {
|
|
interaction: RequestItemVerdictsInteraction;
|
|
input: SubmitIssueThreadInteractionVerdicts;
|
|
actor: InteractionActor;
|
|
now: Date;
|
|
}) {
|
|
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 } : {}),
|
|
...(args.actor.userId ? { resolvedByUserId: args.actor.userId } : {}),
|
|
...(args.actor.agentId ? { resolvedByAgentId: args.actor.agentId } : {}),
|
|
...(args.actor.runId ? { resolvedByRunId: args.actor.runId } : {}),
|
|
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"];
|
|
}) {
|
|
const questionById = new Map(args.questions.map((question) => [question.id, question] as const));
|
|
const answerByQuestionId = new Map<string, AskUserQuestionsAnswer>();
|
|
|
|
for (const answer of args.answers) {
|
|
const question = questionById.get(answer.questionId);
|
|
if (!question) {
|
|
throw unprocessable(`Unknown questionId: ${answer.questionId}`);
|
|
}
|
|
if (answerByQuestionId.has(answer.questionId)) {
|
|
throw unprocessable(`Duplicate answer for questionId: ${answer.questionId}`);
|
|
}
|
|
|
|
const uniqueOptionIds = [...new Set(answer.optionIds)];
|
|
const validOptionIds = new Set(question.options.map((option) => option.id));
|
|
for (const optionId of uniqueOptionIds) {
|
|
if (!validOptionIds.has(optionId)) {
|
|
throw unprocessable(`Unknown optionId for question ${answer.questionId}: ${optionId}`);
|
|
}
|
|
}
|
|
|
|
if (question.selectionMode === "single" && uniqueOptionIds.length > 1) {
|
|
throw unprocessable(`Question ${answer.questionId} only allows one answer`);
|
|
}
|
|
|
|
const otherText = answer.otherText?.trim() ?? "";
|
|
answerByQuestionId.set(answer.questionId, {
|
|
questionId: answer.questionId,
|
|
optionIds: uniqueOptionIds,
|
|
...(otherText ? { otherText } : {}),
|
|
});
|
|
}
|
|
|
|
for (const question of args.questions) {
|
|
const answer = answerByQuestionId.get(question.id);
|
|
if (
|
|
question.required
|
|
&& (!answer || (answer.optionIds.length === 0 && !answer.otherText))
|
|
) {
|
|
throw unprocessable(`Question ${question.id} requires an answer`);
|
|
}
|
|
}
|
|
|
|
return args.questions
|
|
.map((question) => answerByQuestionId.get(question.id))
|
|
.filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer));
|
|
}
|
|
|
|
async function getIssueDocumentTargetSnapshot(db: Db | any, args: {
|
|
companyId: string;
|
|
issueId: string;
|
|
target: RequestConfirmationTarget;
|
|
// When true, take a FOR UPDATE row lock on the joined document so a concurrent
|
|
// revision publish (which updates documents.latestRevisionId) must serialize
|
|
// behind the caller's transaction. Only meaningful inside a transaction.
|
|
lockForUpdate?: boolean;
|
|
}) {
|
|
if (args.target.type !== "issue_document") return null;
|
|
const targetIssueId = args.target.issueId ?? args.issueId;
|
|
const query = db
|
|
.select({
|
|
issueId: issueDocuments.issueId,
|
|
documentId: issueDocuments.documentId,
|
|
key: issueDocuments.key,
|
|
latestRevisionId: documents.latestRevisionId,
|
|
latestRevisionNumber: documents.latestRevisionNumber,
|
|
})
|
|
.from(issueDocuments)
|
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
|
.where(and(
|
|
eq(issueDocuments.companyId, args.companyId),
|
|
eq(issueDocuments.issueId, targetIssueId),
|
|
eq(issueDocuments.key, args.target.key),
|
|
));
|
|
const row = await (args.lockForUpdate ? query.for("update", { of: documents }) : query)
|
|
.then((rows: Array<{
|
|
issueId: string;
|
|
documentId: string;
|
|
key: string;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
}>) => rows[0] ?? null);
|
|
|
|
if (!row) return null;
|
|
if (args.target.documentId && args.target.documentId !== row.documentId) return null;
|
|
return row;
|
|
}
|
|
|
|
function buildIssueDocumentTargetFromSnapshot(args: {
|
|
issueId: string;
|
|
snapshot: {
|
|
issueId: string;
|
|
documentId: string;
|
|
key: string;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
} | null;
|
|
}): RequestConfirmationTarget | null {
|
|
if (!args.snapshot?.latestRevisionId) return null;
|
|
return {
|
|
type: "issue_document",
|
|
issueId: args.snapshot.issueId ?? args.issueId,
|
|
documentId: args.snapshot.documentId,
|
|
key: args.snapshot.key,
|
|
revisionId: args.snapshot.latestRevisionId,
|
|
revisionNumber: args.snapshot.latestRevisionNumber,
|
|
};
|
|
}
|
|
|
|
function buildIssueDocumentTargetFromDocument(args: {
|
|
issueId: string;
|
|
document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null;
|
|
}): RequestConfirmationTarget | null {
|
|
if (!args.document?.latestRevisionId) return null;
|
|
return {
|
|
type: "issue_document",
|
|
issueId: args.issueId,
|
|
documentId: args.document.id,
|
|
key: args.document.key,
|
|
revisionId: args.document.latestRevisionId,
|
|
revisionNumber: args.document.latestRevisionNumber ?? null,
|
|
};
|
|
}
|
|
|
|
async function assertRequestConfirmationTargetIsCurrent(db: Db | any, args: {
|
|
companyId: string;
|
|
issueId: string;
|
|
target?: RequestConfirmationTarget | null;
|
|
// Forwarded to getIssueDocumentTargetSnapshot; pass true when validating
|
|
// inside the create transaction so the revision read locks the document row
|
|
// and stays atomic with the interaction insert.
|
|
lockForUpdate?: boolean;
|
|
}) {
|
|
if (!args.target) return;
|
|
if (args.target.type !== "issue_document") return;
|
|
const snapshot = await getIssueDocumentTargetSnapshot(db, {
|
|
companyId: args.companyId,
|
|
issueId: args.issueId,
|
|
target: args.target,
|
|
lockForUpdate: args.lockForUpdate,
|
|
});
|
|
if (!snapshot || snapshot.latestRevisionId !== args.target.revisionId) {
|
|
throw unprocessable("request_confirmation target must reference the current issue document revision");
|
|
}
|
|
if (args.target.revisionNumber && snapshot.latestRevisionNumber !== args.target.revisionNumber) {
|
|
throw unprocessable("request_confirmation target revisionNumber must match the current issue document revision");
|
|
}
|
|
}
|
|
|
|
async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
|
row: IssueThreadInteractionRow;
|
|
actor: InteractionActor;
|
|
}): Promise<IssueThreadInteraction | null> {
|
|
if (!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;
|
|
|
|
const snapshot = await getIssueDocumentTargetSnapshot(db, {
|
|
companyId: args.row.companyId,
|
|
issueId: args.row.issueId,
|
|
target,
|
|
});
|
|
const isCurrent =
|
|
snapshot
|
|
&& snapshot.latestRevisionId === target.revisionId
|
|
&& (!target.revisionNumber || snapshot.latestRevisionNumber === target.revisionNumber);
|
|
if (isCurrent) return null;
|
|
|
|
const now = new Date();
|
|
const currentTarget = buildIssueDocumentTargetFromSnapshot({
|
|
issueId: args.row.issueId,
|
|
snapshot,
|
|
});
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
payload: currentTarget
|
|
? {
|
|
...interaction.payload,
|
|
target: currentTarget,
|
|
}
|
|
: interaction.payload,
|
|
result: {
|
|
...buildStaleTargetResult(args.row, target),
|
|
},
|
|
resolvedByAgentId: args.actor.agentId ?? null,
|
|
resolvedByRunId: args.actor.runId ?? null,
|
|
resolvedByUserId: args.actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, args.row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await touchIssue(db, args.row.issueId);
|
|
const expired = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, expired);
|
|
return expired;
|
|
}
|
|
|
|
export function issueThreadInteractionService(db: Db, opts: IssueThreadInteractionServiceOptions = {}) {
|
|
const pullRequestStateCache = new Map<string, { state: PullRequestMergeState; checkedAt: number }>();
|
|
const now = opts.now ?? (() => new Date());
|
|
const defaultPullRequestStateResolver = opts.resolvePullRequestState
|
|
? null
|
|
: createPullRequestMergeStateResolver(db);
|
|
|
|
async function resolvePullRequestState(
|
|
companyId: string,
|
|
reference: GitHubPullRequestReference,
|
|
): Promise<PullRequestMergeState> {
|
|
if (opts.resolvePullRequestState) return opts.resolvePullRequestState(companyId, reference);
|
|
return defaultPullRequestStateResolver?.(companyId, reference) ?? "unknown";
|
|
}
|
|
|
|
async function resolvePullRequestStates(
|
|
entries: Array<{ key: string; companyId: string; reference: GitHubPullRequestReference }>,
|
|
) {
|
|
const states = new Map<string, PullRequestMergeState>();
|
|
const pending = entries.slice();
|
|
const workerCount = Math.min(8, pending.length);
|
|
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
while (pending.length > 0) {
|
|
const entry = pending.shift();
|
|
if (!entry) return;
|
|
const state = await resolvePullRequestState(entry.companyId, entry.reference);
|
|
states.set(entry.key, state);
|
|
}
|
|
}));
|
|
return states;
|
|
}
|
|
async function getIdempotentInteraction(args: {
|
|
issueId: string;
|
|
companyId: string;
|
|
idempotencyKey: string;
|
|
}) {
|
|
return db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, args.companyId),
|
|
eq(issueThreadInteractions.issueId, args.issueId),
|
|
eq(issueThreadInteractions.idempotencyKey, args.idempotencyKey),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getForIssue(issue: { id: string; companyId: string }, interactionId: string) {
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current || current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
return hydrateInteraction(current);
|
|
}
|
|
|
|
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
|
db: Pick<Db, "select">;
|
|
issue: { id: string; companyId: string };
|
|
sourceRunId: string | null;
|
|
}) {
|
|
if (!args.sourceRunId) return;
|
|
|
|
const executionWorkspaceId = await args.db
|
|
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
|
.from(issues)
|
|
.where(eq(issues.id, args.issue.id))
|
|
.then((rows: Array<{ executionWorkspaceId: string | null }>) => rows[0]?.executionWorkspaceId ?? null);
|
|
|
|
if (!executionWorkspaceId) return;
|
|
|
|
// Block only while the source run's worktree sync-back is genuinely still
|
|
// pending or in flight. A finalize that reached a terminal outcome — including
|
|
// a `failed` sync-back or a stale `running` record left by an ended run — is
|
|
// treated as settled by `runWorkspaceIsFinalized`, so a dead run can no longer
|
|
// wedge this confirmation forever.
|
|
const isFinalized = await runWorkspaceIsFinalized(
|
|
args.db,
|
|
args.issue.companyId,
|
|
executionWorkspaceId,
|
|
args.sourceRunId,
|
|
);
|
|
if (isFinalized) return;
|
|
|
|
throw conflict(
|
|
"Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. "
|
|
+ "Retry once the local worktree has finished syncing.",
|
|
{ executionWorkspaceId, sourceRunId: args.sourceRunId },
|
|
);
|
|
}
|
|
|
|
async function getPendingInteractionForResolution(args: {
|
|
issue: { id: string; companyId: string; status?: string };
|
|
interactionId: string;
|
|
}) {
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, args.interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (current.companyId !== args.issue.companyId || current.issueId !== args.issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (args.issue.status && isTerminalIssueStatus(args.issue.status)) {
|
|
throw interactionIssueClosedError();
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function assertIssueOpenForInteractionResolution(issue: { id: string; companyId: string; status?: string }) {
|
|
if (issue.status && isTerminalIssueStatus(issue.status)) {
|
|
throw interactionIssueClosedError();
|
|
}
|
|
}
|
|
|
|
async function acceptRequestConfirmation(args: {
|
|
issue: { id: string; companyId: string };
|
|
current: IssueThreadInteractionRow;
|
|
input: AcceptIssueThreadInteraction;
|
|
actor: InteractionActor;
|
|
}): Promise<{
|
|
interaction: IssueThreadInteraction;
|
|
continuationIssue: IssueWakeTarget | null;
|
|
}> {
|
|
const expired = await expireStaleRequestConfirmationTarget(db, {
|
|
row: args.current,
|
|
actor: args.actor,
|
|
});
|
|
if (expired) throw interactionTerminalError({ status: expired.status, result: expired.result });
|
|
|
|
const now = new Date();
|
|
const result = await db.transaction(async (tx) => {
|
|
// Policy mutations and review transitions use the same issue-row lock,
|
|
// so the authoritative review policy and requester are stable through
|
|
// the verdict write. Terminal issue transitions also lock the issue
|
|
// before expiring linked proposals, so keep issue -> proposal ->
|
|
// interaction as the shared lifecycle order.
|
|
const issueContext = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, args.issue.id))
|
|
.for("update")
|
|
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
|
|
|
if (!issueContext || issueContext.companyId !== args.issue.companyId) {
|
|
throw notFound("Issue not found");
|
|
}
|
|
|
|
await lockLinkedSecretProposal(tx as unknown as Db, args.current);
|
|
|
|
const lockedCurrent = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, args.current.id))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!lockedCurrent
|
|
|| lockedCurrent.companyId !== args.issue.companyId
|
|
|| lockedCurrent.issueId !== args.issue.id
|
|
) {
|
|
throw notFound("Interaction not found");
|
|
}
|
|
if (lockedCurrent.status !== "pending") {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx as unknown as Db,
|
|
issueContext,
|
|
lockedCurrent,
|
|
args.actor,
|
|
);
|
|
|
|
const interaction = hydrateInteraction(lockedCurrent);
|
|
const selectedOptionIds = interaction.kind === "request_checkbox_confirmation"
|
|
? resolveSelectedCheckboxConfirmationOptions({
|
|
interaction,
|
|
selectedOptionIds: args.input.selectedOptionIds,
|
|
})
|
|
: undefined;
|
|
|
|
const [updated] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
...(selectedOptionIds ? { selectedOptionIds } : {}),
|
|
},
|
|
resolvedByAgentId: args.actor.agentId ?? null,
|
|
resolvedByRunId: args.actor.runId ?? null,
|
|
resolvedByUserId: args.actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, lockedCurrent.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
|
|
let continuationIssue: IssueWakeTarget | null = null;
|
|
if (shouldReturnAcceptedConfirmationToCreatorAgent({
|
|
issue: issueContext,
|
|
current: lockedCurrent,
|
|
actor: args.actor,
|
|
})) {
|
|
const returnStatus = issueContext.status === "blocked" ? "blocked" : "todo";
|
|
const returnedIssue = await issueService(db).update(args.issue.id, {
|
|
status: returnStatus,
|
|
assigneeAgentId: lockedCurrent.createdByAgentId,
|
|
assigneeUserId: null,
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
}, tx);
|
|
|
|
if (returnedIssue) {
|
|
continuationIssue = {
|
|
id: returnedIssue.id,
|
|
assigneeAgentId: returnedIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: returnedIssue.assigneeUserId ?? null,
|
|
status: returnedIssue.status,
|
|
};
|
|
}
|
|
} else {
|
|
await touchIssue(tx, args.issue.id);
|
|
}
|
|
|
|
if (args.actor.systemId) {
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: args.issue.companyId,
|
|
actorType: "system",
|
|
actorId: args.actor.systemId,
|
|
agentId: null,
|
|
runId: null,
|
|
action: "issue.thread_interaction_accepted",
|
|
entityType: "issue",
|
|
entityId: args.issue.id,
|
|
details: {
|
|
interactionId: lockedCurrent.id,
|
|
interactionKind: lockedCurrent.kind,
|
|
interactionStatus: "accepted",
|
|
resolutionActorKind: "system",
|
|
requestedResolverPolicy: lockedCurrent.requestedResolverPolicy,
|
|
effectiveResolverPolicy: lockedCurrent.effectiveResolverPolicy,
|
|
...(args.actor.resolutionDetails ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
interaction: hydrateInteraction(updated),
|
|
continuationIssue,
|
|
};
|
|
});
|
|
await emitInteractionResolvedTelemetry(db, result.interaction);
|
|
return result;
|
|
}
|
|
|
|
async function rejectRequestConfirmation(args: {
|
|
issue: { id: string; companyId: string };
|
|
current: IssueThreadInteractionRow;
|
|
input: RejectIssueThreadInteraction;
|
|
actor: InteractionActor;
|
|
}): Promise<IssueThreadInteraction> {
|
|
const expired = await expireStaleRequestConfirmationTarget(db, {
|
|
row: args.current,
|
|
actor: args.actor,
|
|
});
|
|
if (expired) throw interactionTerminalError({ status: expired.status, result: expired.result });
|
|
|
|
const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction;
|
|
const reason = args.input.reason?.trim() ?? "";
|
|
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
|
|
throw unprocessable("A decline reason is required for this confirmation");
|
|
}
|
|
|
|
const now = new Date();
|
|
const updated = await db.transaction(async (tx) => {
|
|
const issueContext = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, args.issue.id))
|
|
.for("update")
|
|
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
|
if (!issueContext || issueContext.companyId !== args.issue.companyId) {
|
|
throw notFound("Issue not found");
|
|
}
|
|
|
|
// Terminal issue transitions expire linked proposals while holding this
|
|
// issue row. Match their issue -> proposal -> interaction order so a
|
|
// close/cancel race cannot invert the first two locks.
|
|
await lockLinkedSecretProposal(tx as unknown as Db, args.current);
|
|
|
|
const lockedCurrent = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, args.current.id))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!lockedCurrent
|
|
|| lockedCurrent.companyId !== args.issue.companyId
|
|
|| lockedCurrent.issueId !== args.issue.id
|
|
) {
|
|
throw notFound("Interaction not found");
|
|
}
|
|
if (lockedCurrent.status !== "pending") {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx as unknown as Db,
|
|
issueContext,
|
|
lockedCurrent,
|
|
args.actor,
|
|
);
|
|
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, lockedCurrent, {
|
|
status: "rejected",
|
|
actor: args.actor,
|
|
reason: reason || null,
|
|
now,
|
|
});
|
|
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "rejected",
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
reason: reason || null,
|
|
...(linkedSecretProposalId(lockedCurrent)
|
|
? { secretProposal: { version: 1, status: "rejected", updatedAt: now.toISOString() } }
|
|
: {}),
|
|
},
|
|
resolvedByAgentId: args.actor.agentId ?? null,
|
|
resolvedByRunId: args.actor.runId ?? null,
|
|
resolvedByUserId: args.actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, lockedCurrent.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!resolved) {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await touchIssue(tx, args.issue.id);
|
|
return resolved;
|
|
});
|
|
|
|
const rejected = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, rejected);
|
|
return rejected;
|
|
}
|
|
|
|
return {
|
|
getForIssue,
|
|
createConnectionIntent: async (
|
|
issue: { id: string; companyId: string },
|
|
input: {
|
|
payload: ConnectionIntentInteraction["payload"];
|
|
sourceRunId: string;
|
|
addresseeUserId: string;
|
|
idempotencyKey: string;
|
|
},
|
|
) => {
|
|
const payload = connectionIntentPayloadSchema.parse(input.payload);
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: input.idempotencyKey,
|
|
});
|
|
if (existing) {
|
|
if (
|
|
existing.kind !== "connection_intent"
|
|
|| existing.sourceRunId !== input.sourceRunId
|
|
|| existing.addresseeUserId !== input.addresseeUserId
|
|
|| !isDeepStrictEqual(existing.payload, payload)
|
|
) {
|
|
throw conflict("Interaction idempotency key already exists for a different request", {
|
|
idempotencyKey: input.idempotencyKey,
|
|
});
|
|
}
|
|
return hydrateInteraction(existing) as ConnectionIntentInteraction;
|
|
}
|
|
|
|
const created = await db.transaction(async (tx) => {
|
|
const issueRow = await tx
|
|
.select({ status: issues.status })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
|
|
throw conflict("Cannot create an interaction on a closed issue");
|
|
}
|
|
|
|
const [row] = await tx
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
kind: "connection_intent",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "governed_action",
|
|
idempotencyKey: input.idempotencyKey,
|
|
sourceRunId: input.sourceRunId,
|
|
title: `Connect ${payload.serviceName}`,
|
|
summary: `${payload.requestingAgentName} needs this connection to continue.`,
|
|
createdByAgentId: payload.requestingAgentId,
|
|
addresseeUserId: input.addresseeUserId,
|
|
payload,
|
|
})
|
|
.returning();
|
|
|
|
const olderPending = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, "connection_intent"),
|
|
eq(issueThreadInteractions.createdByAgentId, payload.requestingAgentId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
ne(issueThreadInteractions.id, row.id),
|
|
));
|
|
const supersededIds = olderPending
|
|
.filter((candidate) => {
|
|
const candidatePayload = connectionIntentPayloadSchema.safeParse(candidate.payload);
|
|
return candidatePayload.success && candidatePayload.data.serviceSlug === payload.serviceSlug;
|
|
})
|
|
.map((candidate) => candidate.id);
|
|
if (supersededIds.length > 0) {
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(inArray(toolOauthStates.interactionId, supersededIds));
|
|
await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded",
|
|
supersededByInteractionId: row.id,
|
|
},
|
|
resolvedAt: now(),
|
|
updatedAt: now(),
|
|
})
|
|
.where(inArray(issueThreadInteractions.id, supersededIds));
|
|
}
|
|
await touchIssue(tx, issue.id);
|
|
return row;
|
|
});
|
|
|
|
const interaction = hydrateInteraction(created) as ConnectionIntentInteraction;
|
|
emitInteractionCreatedTelemetry({
|
|
interactionKind: "connection_intent",
|
|
usedDeprecatedResolverPolicyAlias: false,
|
|
});
|
|
return interaction;
|
|
},
|
|
updateConnectionIntentPhase: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
phase: ConnectionIntentInteraction["payload"]["phase"],
|
|
actor: InteractionActor,
|
|
) => {
|
|
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
|
if (current.kind !== "connection_intent") {
|
|
throw unprocessable("Only connection_intent interactions have a connection phase");
|
|
}
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
const payload = connectionIntentPayloadSchema.parse(current.payload);
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({ payload: { ...payload, phase }, updatedAt: now() })
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (!updated) throw interactionAlreadyResolvedError();
|
|
await touchIssue(db, issue.id);
|
|
return hydrateInteraction(updated) as ConnectionIntentInteraction;
|
|
},
|
|
resolveConnectionIntent: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
resultInput: ConnectionIntentInteraction["result"] extends infer T ? NonNullable<T> : never,
|
|
actor: InteractionActor,
|
|
) => {
|
|
const result = connectionIntentResultSchema.parse(resultInput);
|
|
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
|
if (current.kind !== "connection_intent") {
|
|
throw unprocessable("Only connection_intent interactions can be resolved by this operation");
|
|
}
|
|
if (!actor.userId) throw forbidden("Connection intents require a human resolver");
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
const status = result.outcome === "connected"
|
|
? "accepted"
|
|
: result.outcome === "declined"
|
|
? "rejected"
|
|
: "expired";
|
|
const resolvedAt = now();
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status,
|
|
result,
|
|
resolvedByUserId: actor.userId,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (!updated) throw interactionAlreadyResolvedError();
|
|
await touchIssue(db, issue.id);
|
|
const interaction = hydrateInteraction(updated) as ConnectionIntentInteraction;
|
|
await emitInteractionResolvedTelemetry(db, interaction);
|
|
return interaction;
|
|
},
|
|
sweepMergedPullRequestConfirmations: async () => {
|
|
const rows = await db
|
|
.select({
|
|
interaction: issueThreadInteractions,
|
|
issue: {
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
goalId: issues.goalId,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
},
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
|
|
.where(and(
|
|
eq(issueThreadInteractions.kind, "request_confirmation"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
|
|
const candidates = rows.flatMap((row) => {
|
|
const references = getMergeConfirmationPullRequestReferences(row.interaction);
|
|
return references.length > 0 ? [{ ...row, references }] : [];
|
|
});
|
|
if (candidates.length === 0) {
|
|
return { checked: rows.length, candidates: 0, accepted: 0, woken: 0 };
|
|
}
|
|
|
|
const candidateIds = candidates.map(({ interaction }) => interaction.id);
|
|
const linkedToolActions = await db
|
|
.select({ interactionId: toolActionRequests.interactionId })
|
|
.from(toolActionRequests)
|
|
.where(inArray(toolActionRequests.interactionId, candidateIds));
|
|
const toolActionInteractionIds = new Set(linkedToolActions
|
|
.map((row) => row.interactionId)
|
|
.filter((value): value is string => Boolean(value)));
|
|
const eligible = candidates.filter(({ interaction }) => !toolActionInteractionIds.has(interaction.id));
|
|
|
|
const checkedAt = now().getTime();
|
|
const cacheTtlMs = opts.pullRequestCacheTtlMs ?? 5 * 60 * 1000;
|
|
const uniqueReferences = new Map<string, {
|
|
key: string;
|
|
companyId: string;
|
|
reference: GitHubPullRequestReference;
|
|
}>();
|
|
for (const candidate of eligible) {
|
|
for (const reference of candidate.references) {
|
|
const key = `${candidate.issue.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
|
|
const cached = pullRequestStateCache.get(key);
|
|
if (cached && checkedAt - cached.checkedAt < cacheTtlMs) continue;
|
|
uniqueReferences.set(key, { key, companyId: candidate.issue.companyId, reference });
|
|
}
|
|
}
|
|
|
|
const refreshedStates = await resolvePullRequestStates([...uniqueReferences.values()]);
|
|
for (const [key, state] of refreshedStates) {
|
|
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, { state, checkedAt });
|
|
}
|
|
|
|
let accepted = 0;
|
|
let woken = 0;
|
|
for (const candidate of eligible) {
|
|
const allMerged = candidate.references.every((reference) => {
|
|
const key = `${candidate.issue.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
|
|
return pullRequestStateCache.get(key)?.state === "merged";
|
|
});
|
|
if (!allMerged) continue;
|
|
|
|
let resolved: Awaited<ReturnType<typeof acceptRequestConfirmation>>;
|
|
try {
|
|
resolved = await acceptRequestConfirmation({
|
|
issue: candidate.issue,
|
|
current: candidate.interaction,
|
|
input: {},
|
|
actor: {
|
|
systemId: "system:pr-merged",
|
|
resolutionDetails: {
|
|
source: "merged_pull_request_sweep",
|
|
pullRequests: candidate.references.map((reference) =>
|
|
`${reference.owner}/${reference.repo}#${reference.number}`
|
|
),
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && "status" in error && error.status === 409) continue;
|
|
throw error;
|
|
}
|
|
if (resolved.interaction.status !== "accepted") continue;
|
|
accepted += 1;
|
|
|
|
const wakeIssue = resolved.continuationIssue ?? candidate.issue;
|
|
const shouldWake = resolved.interaction.continuationPolicy === "wake_assignee"
|
|
|| resolved.interaction.continuationPolicy === "wake_assignee_on_accept";
|
|
if (!opts.wakeup || !shouldWake || !wakeIssue.assigneeAgentId || isTerminalIssueStatus(wakeIssue.status)) {
|
|
continue;
|
|
}
|
|
await opts.wakeup(wakeIssue.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: wakeIssue.id,
|
|
interactionId: resolved.interaction.id,
|
|
interactionKind: resolved.interaction.kind,
|
|
interactionStatus: resolved.interaction.status,
|
|
sourceCommentId: resolved.interaction.sourceCommentId ?? null,
|
|
sourceRunId: resolved.interaction.sourceRunId ?? null,
|
|
mutation: "interaction",
|
|
resolutionSource: "merged_pull_request_sweep",
|
|
},
|
|
idempotencyKey: `interaction:${resolved.interaction.id}:accepted`,
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "system:pr-merged",
|
|
contextSnapshot: {
|
|
issueId: wakeIssue.id,
|
|
taskId: wakeIssue.id,
|
|
interactionId: resolved.interaction.id,
|
|
interactionKind: resolved.interaction.kind,
|
|
interactionStatus: resolved.interaction.status,
|
|
wakeReason: "issue_commented",
|
|
source: "merged_pull_request_sweep",
|
|
},
|
|
});
|
|
woken += 1;
|
|
}
|
|
|
|
return { checked: rows.length, candidates: eligible.length, accepted, woken };
|
|
},
|
|
listForIssue: async (issueId: string) => {
|
|
const [rows, issueStatus] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.issueId, issueId))
|
|
.orderBy(asc(issueThreadInteractions.createdAt), asc(issueThreadInteractions.id)),
|
|
db
|
|
.select({ status: issues.status })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((issueRows) => issueRows[0]?.status ?? null),
|
|
]);
|
|
|
|
return rows.map((row) => hydrateInteraction(
|
|
issueStatus && isTerminalIssueStatus(issueStatus) && row.status === "pending"
|
|
? {
|
|
...row,
|
|
status: "expired",
|
|
result: buildAdministrativeOutcomeResult(row, "issue_closed"),
|
|
resolvedAt: row.updatedAt,
|
|
}
|
|
: row,
|
|
));
|
|
},
|
|
|
|
getById: async (interactionId: string) => {
|
|
const row = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return row ? hydrateInteraction(row) : null;
|
|
},
|
|
|
|
recordSecretProposalExecutionResult: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
proposalId: string,
|
|
execution: { status: "executed" | "failed"; errorCode?: string | null },
|
|
) => {
|
|
const updated = await db.transaction(async (tx) => {
|
|
// Verdict and terminal-transition paths lock issue -> proposal ->
|
|
// interaction. Take the same order before recording the receipt so a
|
|
// concurrent rejection or issue close cannot deadlock here.
|
|
const lockedIssue = await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(and(
|
|
eq(issues.id, issue.id),
|
|
eq(issues.companyId, issue.companyId),
|
|
))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!lockedIssue) throw notFound("Issue not found");
|
|
|
|
const proposal = await tx
|
|
.select()
|
|
.from(companySecretProposals)
|
|
.where(and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, issue.companyId),
|
|
))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
const current = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current || current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw notFound("Interaction not found");
|
|
}
|
|
if (
|
|
!proposal
|
|
|| proposal.interactionId !== interactionId
|
|
|| current.status !== "accepted"
|
|
|| linkedSecretProposalId(current) !== proposalId
|
|
) {
|
|
throw conflict("Secret proposal interaction is not awaiting an execution result");
|
|
}
|
|
const now = new Date();
|
|
const payload = current.payload && typeof current.payload === "object" && !Array.isArray(current.payload)
|
|
? current.payload as unknown as Record<string, unknown>
|
|
: {};
|
|
const secretProposalPayload = payload.secretProposal && typeof payload.secretProposal === "object"
|
|
&& !Array.isArray(payload.secretProposal)
|
|
? payload.secretProposal as Record<string, unknown>
|
|
: {};
|
|
const proposalAlreadyExecuted = proposal.status === "approved"
|
|
&& proposal.appliedBindingConfigPath === secretProposalPayload.configPath;
|
|
const executionStatus = proposalAlreadyExecuted ? "executed" : execution.status;
|
|
if (executionStatus === "failed" && proposal.status === "pending") {
|
|
const resolutionReason = `Interaction acceptance failed: ${execution.errorCode ?? "secret_proposal_execution_failed"}`;
|
|
await tx
|
|
.update(companySecretProposals)
|
|
.set({
|
|
status: "rejected",
|
|
resolvedByUserId: current.resolvedByUserId ?? null,
|
|
resolvedAt: now,
|
|
resolutionReason,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(companySecretProposals.id, proposal.id),
|
|
eq(companySecretProposals.status, "pending"),
|
|
));
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: issue.companyId,
|
|
actorType: current.resolvedByUserId ? "user" : "system",
|
|
actorId: current.resolvedByUserId ?? "system",
|
|
action: "secret.proposal.rejected",
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
ciphertextScrubbed: true,
|
|
issueId: proposal.originIssueId,
|
|
interactionId: current.id,
|
|
reason: resolutionReason,
|
|
executionFailed: true,
|
|
},
|
|
});
|
|
}
|
|
const result = current.result && typeof current.result === "object" && !Array.isArray(current.result)
|
|
? current.result as unknown as Record<string, unknown>
|
|
: {};
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
result: {
|
|
...result,
|
|
version: 1,
|
|
outcome: "accepted",
|
|
secretProposal: {
|
|
version: 1,
|
|
status: executionStatus,
|
|
errorCode: executionStatus === "failed" ? execution.errorCode ?? null : null,
|
|
updatedAt: now.toISOString(),
|
|
},
|
|
},
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(issueThreadInteractions.id, current.id))
|
|
.returning();
|
|
await touchIssue(tx, issue.id);
|
|
return row;
|
|
});
|
|
return hydrateInteraction(updated);
|
|
},
|
|
|
|
cancelPendingForDeletedAddressee: async (companyId: string, addresseeAgentId: string) => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
eq(issueThreadInteractions.addresseeAgentId, addresseeAgentId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
if (rows.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const cancelled: IssueThreadInteraction[] = [];
|
|
for (const row of rows) {
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: buildAdministrativeOutcomeResult(
|
|
row,
|
|
"addressee_deleted",
|
|
"Cancelled because the addressed agent was deleted",
|
|
),
|
|
resolvedByAgentId: null,
|
|
resolvedByRunId: null,
|
|
resolvedByUserId: null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (updated) cancelled.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
for (const issueId of new Set(cancelled.map((interaction) => interaction.issueId))) {
|
|
await touchIssue(db, issueId);
|
|
}
|
|
await emitResolvedInteractionsTelemetry(db, cancelled);
|
|
return cancelled;
|
|
},
|
|
|
|
sweepSupersededPendingRequestConfirmations: async () => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.kind, "request_confirmation"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
isNotNull(issueThreadInteractions.createdByAgentId),
|
|
))
|
|
.orderBy(
|
|
asc(issueThreadInteractions.companyId),
|
|
asc(issueThreadInteractions.issueId),
|
|
asc(issueThreadInteractions.kind),
|
|
asc(issueThreadInteractions.createdByAgentId),
|
|
desc(issueThreadInteractions.createdAt),
|
|
desc(issueThreadInteractions.id),
|
|
);
|
|
|
|
const newestByGroup = new Map<string, IssueThreadInteractionRow>();
|
|
const supersededRows: Array<{
|
|
row: IssueThreadInteractionRow;
|
|
replacementInteractionId: string;
|
|
}> = [];
|
|
for (const row of rows) {
|
|
if (!row.createdByAgentId) continue;
|
|
const groupKey = `${row.companyId}:${row.issueId}:${row.kind}:${row.createdByAgentId}`;
|
|
const newest = newestByGroup.get(groupKey);
|
|
if (!newest) {
|
|
newestByGroup.set(groupKey, row);
|
|
continue;
|
|
}
|
|
supersededRows.push({ row, replacementInteractionId: newest.id });
|
|
}
|
|
|
|
if (supersededRows.length === 0) return { expired: 0 };
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const { row, replacementInteractionId } of supersededRows) {
|
|
const updated = await db.transaction(async (tx) => {
|
|
const [updatedRow] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByNewerRequestResult(replacementInteractionId),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (!updatedRow) return null;
|
|
await resolveLinkedToolActionRequests(tx, updatedRow, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor: {},
|
|
now,
|
|
});
|
|
return updatedRow;
|
|
});
|
|
if (!updated) continue;
|
|
expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
for (const issueId of new Set(expired.map((interaction) => interaction.issueId))) {
|
|
await touchIssue(db, issueId);
|
|
}
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return { expired: expired.length };
|
|
},
|
|
|
|
create: async (
|
|
issue: { id: string; companyId: string },
|
|
input: CreateIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
options: CreateInteractionOptions = {},
|
|
) => {
|
|
const data = normalizeCreateInteractionInput(createIssueThreadInteractionSchema.parse(input));
|
|
const usedDeprecatedResolverPolicyAlias =
|
|
data.resolverPolicy === "board_or_agents" || data.resolverPolicy === "board_only";
|
|
const governance = await db
|
|
.select({ interactionResolverGovernance: companies.interactionResolverGovernance })
|
|
.from(companies)
|
|
.where(eq(companies.id, issue.companyId))
|
|
.then((rows) => rows[0]?.interactionResolverGovernance ?? {});
|
|
const policy = resolveInteractionPolicy({
|
|
kind: data.kind,
|
|
requested: data.resolverPolicy,
|
|
governance,
|
|
hasToolAction: data.kind === "request_confirmation" && data.payload.toolAction !== undefined,
|
|
hasSecretProposal: data.kind === "request_confirmation" && data.payload.secretProposal !== undefined,
|
|
});
|
|
const normalizedData = { ...data, resolverPolicy: policy.requestedResolverPolicy };
|
|
|
|
if (normalizedData.addresseeAgentId && normalizedData.addresseeUserId) {
|
|
throw unprocessable("An issue-thread interaction cannot address both an agent and a user");
|
|
}
|
|
|
|
if (normalizedData.addresseeAgentId) {
|
|
if (normalizedData.addresseeAgentId === actor.agentId) {
|
|
throw unprocessable("Agents cannot address issue-thread interactions to themselves");
|
|
}
|
|
if (normalizedData.kind === "request_confirmation" && normalizedData.payload.toolAction !== undefined) {
|
|
throw unprocessable("Tool-action confirmations cannot be addressed to agents");
|
|
}
|
|
if (normalizedData.kind === "request_confirmation" && normalizedData.payload.secretProposal !== undefined) {
|
|
throw unprocessable("Secret-proposal confirmations cannot be addressed to agents");
|
|
}
|
|
const addressee = await db
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
name: agents.name,
|
|
reportsTo: agents.reportsTo,
|
|
status: agents.status,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.id, normalizedData.addresseeAgentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!addressee || addressee.companyId !== issue.companyId) {
|
|
throw unprocessable("addresseeAgentId must belong to the same company");
|
|
}
|
|
const invokability = await evaluateAgentInvokabilityFromDb(db, addressee);
|
|
if (!invokability.invokable) {
|
|
throw unprocessable("addresseeAgentId must reference an invokable agent", {
|
|
reason: invokability.reason,
|
|
...invokability.details,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (normalizedData.idempotencyKey) {
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
if (existing) {
|
|
if (!isEquivalentCreateRequest(existing, normalizedData, actor)) {
|
|
throw conflict("Interaction idempotency key already exists for a different request", {
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
}
|
|
return hydrateInteraction(existing);
|
|
}
|
|
}
|
|
|
|
if (data.sourceCommentId) {
|
|
const sourceComment = await db
|
|
.select({
|
|
companyId: issueComments.companyId,
|
|
issueId: issueComments.issueId,
|
|
})
|
|
.from(issueComments)
|
|
.where(eq(issueComments.id, data.sourceCommentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!sourceComment || sourceComment.companyId !== issue.companyId || sourceComment.issueId !== issue.id) {
|
|
throw unprocessable("sourceCommentId must belong to the same issue and company");
|
|
}
|
|
}
|
|
|
|
if (data.sourceRunId) {
|
|
const sourceRun = await db
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, data.sourceRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!sourceRun || sourceRun.companyId !== issue.companyId) {
|
|
throw unprocessable("sourceRunId must belong to the same company");
|
|
}
|
|
}
|
|
|
|
const requiresCurrentTarget =
|
|
data.kind === "request_confirmation"
|
|
|| data.kind === "request_checkbox_confirmation"
|
|
|| data.kind === "request_item_verdicts";
|
|
|
|
let created: IssueThreadInteractionRow;
|
|
let superseded: IssueThreadInteractionRow[] = [];
|
|
try {
|
|
// A terminal issue must not regain pending actionable cards. FOR UPDATE
|
|
// on the issue row serializes this insert both against terminal status
|
|
// transitions and against concurrent confirmations on the same issue.
|
|
// Idempotent reuse above stays allowed so retries of a pre-close
|
|
// create keep returning the (by now expired) original.
|
|
const result = await db.transaction(async (tx) => {
|
|
const [issueRow] = await tx
|
|
.select({ status: issues.status })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)))
|
|
.for("update");
|
|
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
|
|
throw conflict("Cannot create an interaction on a closed issue");
|
|
}
|
|
// Validate the plan/document confirmation target inside the same
|
|
// transaction (locking the document row) so the latest-revision check
|
|
// is atomic with the insert below. A concurrent revision publish can no
|
|
// longer slip between the check and the insert to leave a confirmation
|
|
// pointing at a stale revision.
|
|
if (requiresCurrentTarget) {
|
|
await assertRequestConfirmationTargetIsCurrent(tx, {
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
target: data.payload.target ?? null,
|
|
lockForUpdate: true,
|
|
});
|
|
}
|
|
const [row] = await tx
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
kind: data.kind,
|
|
status: "pending",
|
|
continuationPolicy: data.continuationPolicy,
|
|
requestedResolverPolicy: policy.requestedResolverPolicy,
|
|
effectiveResolverPolicy: policy.effectiveResolverPolicy,
|
|
resolverPolicyProvenance: policy.resolverPolicyProvenance,
|
|
effectiveResolverPolicySource: policy.effectiveResolverPolicySource,
|
|
idempotencyKey: data.idempotencyKey ?? null,
|
|
sourceCommentId: data.sourceCommentId ?? null,
|
|
sourceRunId: data.sourceRunId ?? null,
|
|
title: data.title ?? null,
|
|
summary: data.summary ?? null,
|
|
createdByAgentId: actor.agentId ?? null,
|
|
addresseeAgentId: data.addresseeAgentId ?? null,
|
|
addresseeUserId: data.addresseeUserId ?? null,
|
|
createdByUserId: actor.userId ?? null,
|
|
payload: data.payload,
|
|
})
|
|
.returning();
|
|
|
|
// An agent replacing its own still-pending card supersedes the older
|
|
// one so the thread never accumulates stale sibling cards. This covers
|
|
// request_confirmation drafts and ask_user_questions (PAP-437: probe
|
|
// question cards that agents never withdrew). Each kind keeps its own
|
|
// result shape. Scoped strictly to the same agent + issue + kind, so
|
|
// other agents' or other kinds' pending cards are untouched.
|
|
const canSupersedeSiblingCards =
|
|
options.supersedePendingSiblingInteractions !== false
|
|
&& (
|
|
(data.kind === "request_confirmation"
|
|
&& data.payload.toolAction === undefined
|
|
&& data.payload.secretProposal === undefined)
|
|
|| data.kind === "ask_user_questions"
|
|
);
|
|
if (!actor.agentId || !canSupersedeSiblingCards) {
|
|
return { row, supersededRows: [] };
|
|
}
|
|
|
|
const now = new Date();
|
|
const supersededResult = data.kind === "ask_user_questions"
|
|
? buildSupersededByNewerInteractionResult(row.id)
|
|
: buildSupersededByNewerRequestResult(row.id);
|
|
const supersededRows = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: supersededResult,
|
|
resolvedByAgentId: actor.agentId,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, data.kind),
|
|
eq(issueThreadInteractions.createdByAgentId, actor.agentId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
ne(issueThreadInteractions.id, row.id),
|
|
))
|
|
.returning();
|
|
for (const supersededRow of supersededRows) {
|
|
await resolveLinkedToolActionRequests(tx, supersededRow, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
}
|
|
return { row, supersededRows };
|
|
});
|
|
created = result.row;
|
|
superseded = result.supersededRows;
|
|
} catch (error) {
|
|
if (!normalizedData.idempotencyKey || !isIssueThreadInteractionIdempotencyConflict(error)) {
|
|
throw error;
|
|
}
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
if (!existing) throw error;
|
|
if (!isEquivalentCreateRequest(existing, normalizedData, actor)) {
|
|
throw conflict("Interaction idempotency key already exists for a different request", {
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
}
|
|
return hydrateInteraction(existing);
|
|
}
|
|
|
|
await touchIssue(db, issue.id);
|
|
if (superseded.length > 0) {
|
|
await emitResolvedInteractionsTelemetry(db, superseded.map(hydrateInteraction));
|
|
}
|
|
const interaction = hydrateInteraction(created);
|
|
emitInteractionCreatedTelemetry({
|
|
interactionKind: interaction.kind,
|
|
usedDeprecatedResolverPolicyAlias,
|
|
});
|
|
return interaction;
|
|
},
|
|
|
|
acceptInteraction: async (
|
|
issue: { id: string; companyId: string; projectId: string | null; goalId: string | null; status?: string },
|
|
interactionId: string,
|
|
input: AcceptIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
): Promise<ResolvedInteractionResult> => {
|
|
const data = acceptIssueThreadInteractionSchema.parse(input);
|
|
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
switch (current.kind) {
|
|
case "suggest_tasks":
|
|
// Accepting suggest_tasks only creates follow-up issues; it does not
|
|
// approve code state or move the source workspace forward, so the
|
|
// workspace_finalize gate (PAPA-440) does not apply here.
|
|
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
|
case "request_confirmation": {
|
|
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
|
const accepted = await acceptRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
});
|
|
return {
|
|
interaction: accepted.interaction,
|
|
continuationIssue: accepted.continuationIssue,
|
|
createdIssues: [],
|
|
};
|
|
}
|
|
case "request_checkbox_confirmation": {
|
|
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
|
const accepted = await acceptRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
});
|
|
return {
|
|
interaction: accepted.interaction,
|
|
continuationIssue: accepted.continuationIssue,
|
|
createdIssues: [],
|
|
};
|
|
}
|
|
default:
|
|
throw unprocessable(`Interactions of kind ${current.kind} cannot be accepted`);
|
|
}
|
|
},
|
|
|
|
acceptSuggestedTasks: async (
|
|
issue: { id: string; companyId: string; projectId: string | null; goalId: string | null; status?: string },
|
|
interactionId: string,
|
|
input: AcceptIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
if (current.kind !== "suggest_tasks") {
|
|
throw unprocessable("Only suggest_tasks interactions can be accepted");
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
if (actor.agentId && actor.suggestedTaskEffectsAuthorized !== true) {
|
|
throw issueThreadInteractionResolutionError(
|
|
403,
|
|
"interaction_governed_action_denied",
|
|
"Suggested-task creation requires independent task-creation authorization",
|
|
);
|
|
}
|
|
|
|
const interaction = hydrateInteraction(current) as SuggestTasksInteraction;
|
|
const { selectedTasks, skippedClientKeys } = resolveSelectedSuggestedTasks({
|
|
interaction,
|
|
selectedClientKeys: input.selectedClientKeys,
|
|
});
|
|
const orderedTasks = buildTaskCreationOrder(selectedTasks);
|
|
const explicitParentIds = [...new Set([
|
|
issue.id,
|
|
...(interaction.payload.defaultParentId ? [interaction.payload.defaultParentId] : []),
|
|
...selectedTasks
|
|
.map((task) => task.parentId ?? null)
|
|
.filter((value): value is string => Boolean(value)),
|
|
])];
|
|
|
|
const parentRows = explicitParentIds.length === 0
|
|
? []
|
|
: await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
companyId: issues.companyId,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, issue.companyId), inArray(issues.id, explicitParentIds)));
|
|
if (parentRows.length !== explicitParentIds.length) {
|
|
throw unprocessable("Suggested tasks reference parent issues outside this company or issue tree");
|
|
}
|
|
|
|
const parentById = new Map(parentRows.map((row) => [row.id, row] as const));
|
|
const createdByClientKey = new Map<string, SuggestTasksResultCreatedTask>();
|
|
const createdWakeTargets: IssueWakeTarget[] = [];
|
|
|
|
await db.transaction(async (tx) => {
|
|
const resolvedAt = new Date();
|
|
const [claimed] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!claimed) {
|
|
throw interactionAlreadyResolvedError();
|
|
}
|
|
|
|
for (const task of orderedTasks) {
|
|
const parentIssueId = task.parentClientKey
|
|
? createdByClientKey.get(task.parentClientKey)?.issueId ?? null
|
|
: task.parentId ?? interaction.payload.defaultParentId ?? issue.id;
|
|
if (!parentIssueId) {
|
|
throw unprocessable(`Unable to resolve parent for suggested task ${task.clientKey}`);
|
|
}
|
|
|
|
const { issue: createdIssue } = await issueService(tx as unknown as Db).createChild(parentIssueId, {
|
|
title: task.title,
|
|
description: task.description ?? null,
|
|
status: "todo",
|
|
workMode: task.workMode ?? "standard",
|
|
priority: task.priority ?? "medium",
|
|
assigneeAgentId: task.assigneeAgentId ?? null,
|
|
assigneeUserId: task.assigneeUserId ?? null,
|
|
projectId: task.projectId ?? issue.projectId,
|
|
goalId: task.goalId ?? issue.goalId,
|
|
billingCode: task.billingCode ?? null,
|
|
createdByAgentId: actor.agentId ?? null,
|
|
createdByUserId: actor.userId ?? null,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.userId ?? null,
|
|
} as Parameters<ReturnType<typeof issueService>["createChild"]>[1]);
|
|
|
|
const parentIdentifier = createdByClientKey.get(task.parentClientKey ?? "")?.identifier
|
|
?? parentById.get(parentIssueId)?.identifier
|
|
?? null;
|
|
createdByClientKey.set(task.clientKey, {
|
|
clientKey: task.clientKey,
|
|
issueId: createdIssue.id,
|
|
identifier: createdIssue.identifier ?? null,
|
|
title: createdIssue.title,
|
|
parentIssueId,
|
|
parentIdentifier,
|
|
});
|
|
createdWakeTargets.push({
|
|
id: createdIssue.id,
|
|
assigneeAgentId: createdIssue.assigneeAgentId ?? null,
|
|
status: createdIssue.status,
|
|
});
|
|
}
|
|
|
|
const [updated] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
result: {
|
|
version: 1,
|
|
createdTasks: [...createdByClientKey.values()],
|
|
...(skippedClientKeys.length > 0 ? { skippedClientKeys } : {}),
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.returning();
|
|
|
|
await touchIssue(tx, issue.id);
|
|
current.status = updated.status;
|
|
current.result = updated.result;
|
|
current.resolvedByAgentId = updated.resolvedByAgentId;
|
|
current.resolvedByUserId = updated.resolvedByUserId;
|
|
current.resolvedAt = updated.resolvedAt;
|
|
current.updatedAt = updated.updatedAt;
|
|
});
|
|
|
|
const accepted = hydrateInteraction(current);
|
|
await emitInteractionResolvedTelemetry(db, accepted, {
|
|
createdTaskCount: createdWakeTargets.length,
|
|
});
|
|
return {
|
|
interaction: accepted,
|
|
createdIssues: createdWakeTargets,
|
|
};
|
|
},
|
|
|
|
rejectInteraction: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: RejectIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
) => {
|
|
const data = rejectIssueThreadInteractionSchema.parse(input);
|
|
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
switch (current.kind) {
|
|
case "suggest_tasks":
|
|
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
|
|
case "request_confirmation":
|
|
case "request_checkbox_confirmation":
|
|
return rejectRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
});
|
|
default:
|
|
throw unprocessable(`Interactions of kind ${current.kind} cannot be rejected`);
|
|
}
|
|
},
|
|
|
|
submitItemVerdicts: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: SubmitIssueThreadInteractionVerdicts,
|
|
actor: InteractionActor,
|
|
): Promise<{ interaction: IssueThreadInteraction; newlyResolvedItemIds: string[] }> => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
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 interactionNotFoundError();
|
|
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.kind !== "request_item_verdicts") {
|
|
throw unprocessable("Only request_item_verdicts interactions can receive item verdicts");
|
|
}
|
|
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
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 interactionTerminalError(current);
|
|
}
|
|
}
|
|
return { interaction, newlyResolvedItemIds: [], resolved: false };
|
|
}
|
|
throw interactionTerminalError(current);
|
|
}
|
|
const expired = await expireStaleRequestConfirmationTarget(tx, {
|
|
row: current,
|
|
actor,
|
|
});
|
|
if (expired) {
|
|
return {
|
|
interaction: expired,
|
|
newlyResolvedItemIds: [],
|
|
resolved: false,
|
|
terminalError: interactionTerminalError({ status: expired.status, result: expired.result }),
|
|
};
|
|
}
|
|
|
|
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,
|
|
resolvedByRunId: complete ? actor.runId ?? 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 interactionAlreadyResolvedError();
|
|
}
|
|
|
|
await touchIssue(tx, issue.id);
|
|
return {
|
|
interaction: hydrateInteraction(updated),
|
|
newlyResolvedItemIds,
|
|
resolved: complete,
|
|
};
|
|
});
|
|
|
|
if ("terminalError" in submission) throw submission.terminalError;
|
|
if (submission.resolved) {
|
|
await emitInteractionResolvedTelemetry(db, submission.interaction);
|
|
}
|
|
return submission;
|
|
},
|
|
|
|
rejectSuggestedTasks: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: RejectIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
current: IssueThreadInteractionRow,
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.kind !== "suggest_tasks") {
|
|
throw unprocessable("Only suggest_tasks interactions can be rejected");
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "rejected",
|
|
result: {
|
|
version: 1,
|
|
rejectionReason: input.reason?.trim() || null,
|
|
},
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw interactionAlreadyResolvedError();
|
|
}
|
|
|
|
await touchIssue(db, issue.id);
|
|
const rejected = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, rejected);
|
|
return rejected;
|
|
},
|
|
|
|
expireRequestConfirmationsSupersededByComment: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
comment: { id: string; createdAt: Date | string; authorUserId?: string | null; createdByRunId?: string | null },
|
|
actor: InteractionActor,
|
|
) => {
|
|
if (!comment.authorUserId) return [];
|
|
// Local-CLI adapters post under user auth, so authorUserId can't tell a human from a
|
|
// machine; createdByRunId can. Only genuine human comments (no run context) supersede.
|
|
if (comment.createdByRunId) return [];
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
|
|
const superseded = rows.filter((row) => {
|
|
if (!isUserCommentSupersedableKind(row.kind)) return false;
|
|
const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction;
|
|
return (
|
|
shouldSupersedeInteractionOnUserComment(interaction)
|
|
&& isCommentAtOrAfterInteraction({
|
|
commentCreatedAt: comment.createdAt,
|
|
interactionCreatedAt: row.createdAt,
|
|
})
|
|
);
|
|
});
|
|
|
|
if (superseded.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const row of superseded) {
|
|
const [updated] = await db.transaction(async (tx) => {
|
|
if (row.kind === "connection_intent") {
|
|
await tx.delete(toolOauthStates).where(eq(toolOauthStates.interactionId, row.id));
|
|
}
|
|
return tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(row, comment.id),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
});
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expireRequestConfirmationsSupersededByHistoricalComments: async (
|
|
issue: { id: string; companyId: string },
|
|
) => {
|
|
const [rows, comments] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
)),
|
|
db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(and(
|
|
eq(issueComments.companyId, issue.companyId),
|
|
eq(issueComments.issueId, issue.id),
|
|
isNotNull(issueComments.authorUserId),
|
|
// Only genuine human comments supersede; machine-originated ones carry createdByRunId.
|
|
isNull(issueComments.createdByRunId),
|
|
))
|
|
.orderBy(asc(issueComments.createdAt)),
|
|
]);
|
|
|
|
if (rows.length === 0 || comments.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
const supersededByComment = new Map<
|
|
string,
|
|
{
|
|
comment: (typeof comments)[number];
|
|
rowIds: string[];
|
|
}
|
|
>();
|
|
for (const row of rows) {
|
|
if (!isUserCommentSupersedableKind(row.kind)) continue;
|
|
const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction;
|
|
if (!shouldSupersedeInteractionOnUserComment(interaction)) continue;
|
|
|
|
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
|
|
commentCreatedAt: comment.createdAt,
|
|
interactionCreatedAt: row.createdAt,
|
|
}));
|
|
if (!supersedingComment) continue;
|
|
|
|
const group = supersededByComment.get(supersedingComment.id);
|
|
if (group) {
|
|
group.rowIds.push(row.id);
|
|
} else {
|
|
supersededByComment.set(supersedingComment.id, {
|
|
comment: supersedingComment,
|
|
rowIds: [row.id],
|
|
});
|
|
}
|
|
}
|
|
|
|
const rowById = new Map(rows.map((row) => [row.id, row] as const));
|
|
for (const { comment, rowIds } of supersededByComment.values()) {
|
|
const commentRows = rowIds
|
|
.map((rowId) => rowById.get(rowId))
|
|
.filter((row): row is IssueThreadInteractionRow => Boolean(row));
|
|
const questionRowIds = commentRows
|
|
.filter((row) => row.kind === "ask_user_questions")
|
|
.map((row) => row.id);
|
|
const confirmationRowIds = commentRows
|
|
.filter((row) => isRequestConfirmationLikeKind(row.kind))
|
|
.map((row) => row.id);
|
|
const itemVerdictRows = commentRows
|
|
.filter((row) => row.kind === "request_item_verdicts");
|
|
const connectionIntentRows = commentRows
|
|
.filter((row) => row.kind === "connection_intent");
|
|
|
|
if (questionRowIds.length > 0) {
|
|
const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions");
|
|
if (!sampleQuestionRow) continue;
|
|
const updatedRows = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(sampleQuestionRow, comment.id),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: comment.authorUserId,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
inArray(issueThreadInteractions.id, questionRowIds),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
expired.push(...updatedRows.map(hydrateInteraction));
|
|
}
|
|
|
|
if (confirmationRowIds.length > 0) {
|
|
const sampleConfirmationRow = commentRows.find((row) => isRequestConfirmationLikeKind(row.kind));
|
|
if (!sampleConfirmationRow) continue;
|
|
const updatedRows = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(sampleConfirmationRow, comment.id),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: comment.authorUserId,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
inArray(issueThreadInteractions.id, confirmationRowIds),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.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));
|
|
}
|
|
|
|
for (const row of connectionIntentRows) {
|
|
const [updated] = await db.transaction(async (tx) => {
|
|
await tx.delete(toolOauthStates).where(eq(toolOauthStates.interactionId, row.id));
|
|
return tx
|
|
.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) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expireStaleRequestConfirmationsForIssueDocument: async (
|
|
issue: { id: string; companyId: string },
|
|
document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null,
|
|
actor: InteractionActor,
|
|
) => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
inArray(issueThreadInteractions.kind, [...TARGET_BOUND_INTERACTION_KINDS]),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
|
|
const staleRows = rows.filter((row) => {
|
|
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;
|
|
if (targetIssueId !== issue.id) return false;
|
|
if (document && target.documentId && target.documentId !== document.id) return false;
|
|
if (document && target.key !== document.key) return false;
|
|
if (!document) return true;
|
|
return (
|
|
target.revisionId !== document.latestRevisionId
|
|
|| (target.revisionNumber != null && target.revisionNumber !== document.latestRevisionNumber)
|
|
);
|
|
});
|
|
|
|
if (staleRows.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const row of staleRows) {
|
|
const interaction = hydrateInteraction(row) as TargetBoundInteraction;
|
|
const target = interaction.payload.target ?? null;
|
|
const currentTarget = buildIssueDocumentTargetFromDocument({
|
|
issueId: issue.id,
|
|
document,
|
|
});
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
payload: currentTarget
|
|
? {
|
|
...interaction.payload,
|
|
target: currentTarget,
|
|
}
|
|
: interaction.payload,
|
|
result: buildStaleTargetResult(row, target),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expirePendingInteractionsForTerminalIssue: async (
|
|
issue: { id: string; companyId: string; status: string },
|
|
actor: InteractionActor = {},
|
|
) => {
|
|
if (!isTerminalIssueStatus(issue.status)) return [];
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
if (rows.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const row of rows) {
|
|
// Same ordering as withdrawal: revoke the linked tool action before
|
|
// resolving the card, inside one transaction. A concurrent gateway
|
|
// claim (approved -> executing) blocks on the revocation's row lock
|
|
// and then aborts; if the card was concurrently resolved instead, the
|
|
// no-row update below rolls the revocation back. A claim that already
|
|
// committed is in flight and cannot be recalled — the card still
|
|
// expires and the execution result lands on it via the gateway's
|
|
// lifecycle reflection.
|
|
const updated = await db.transaction(async (tx) => {
|
|
if (row.kind === "connection_intent") {
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(eq(toolOauthStates.interactionId, row.id));
|
|
}
|
|
await resolveLinkedToolActionRequests(tx, row, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, row, {
|
|
status: "expired",
|
|
actor,
|
|
reason: "Issue closed before the secret proposal was resolved",
|
|
now,
|
|
});
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildAdministrativeOutcomeResult(row, "issue_closed"),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (!resolved) throw new InteractionResolvedConcurrentlyError();
|
|
return resolved;
|
|
}).catch((err: unknown) => {
|
|
if (err instanceof InteractionResolvedConcurrentlyError) return null;
|
|
throw err;
|
|
});
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
withdrawInteraction: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
input: WithdrawIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = withdrawIssueThreadInteractionSchema.parse(input);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current || current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.status !== "pending") throw interactionTerminalError(current);
|
|
|
|
const reason = data.reason?.trim() || null;
|
|
const now = new Date();
|
|
// One transaction, linked tool action first: revoking pending/approved
|
|
// requests before resolving the card means a concurrent gateway claim
|
|
// (approved -> executing) either loses to the revocation's row lock or
|
|
// is detected below and aborts the withdrawal; a concurrent card
|
|
// resolution rolls the revocation back via the status="pending" guard.
|
|
// "approved" is revoked too — the request can be approved from the tool
|
|
// review queue while the card is still pending, and an executable
|
|
// request must not outlive a withdrawn card.
|
|
const updated = await db.transaction(async (tx) => {
|
|
await resolveLinkedToolActionRequests(tx, current, {
|
|
status: "cancelled",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, current, {
|
|
status: "withdrawn",
|
|
actor,
|
|
reason,
|
|
now,
|
|
});
|
|
if (current.kind === "request_confirmation") {
|
|
const active = await tx
|
|
.select({ id: toolActionRequests.id })
|
|
.from(toolActionRequests)
|
|
.where(and(
|
|
eq(toolActionRequests.companyId, current.companyId),
|
|
eq(toolActionRequests.interactionId, current.id),
|
|
inArray(toolActionRequests.status, ["executing", "executed"]),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (active) throw conflict("The linked tool action is already executing and can no longer be withdrawn");
|
|
}
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: buildAdministrativeOutcomeResult(current, "withdrawn", reason),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(row));
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const withdrawn = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, withdrawn);
|
|
return withdrawn;
|
|
},
|
|
|
|
answerQuestions: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: RespondIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
if (current.kind !== "ask_user_questions") {
|
|
throw unprocessable("Only ask_user_questions interactions can be answered");
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
|
|
const interaction = hydrateInteraction(current) as AskUserQuestionsInteraction;
|
|
const normalizedAnswers = normalizeQuestionAnswers({
|
|
questions: interaction.payload.questions,
|
|
answers: input.answers,
|
|
});
|
|
|
|
const updated = await db.transaction(async (tx) => {
|
|
const resolvedAt = new Date();
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "answered",
|
|
result: {
|
|
version: 1,
|
|
answers: normalizedAnswers,
|
|
summaryMarkdown: input.summaryMarkdown ?? null,
|
|
},
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
const answered = hydrateInteraction(row) as AskUserQuestionsInteraction;
|
|
await tx.insert(issueQuestionResponseDeliveries).values(
|
|
questionResponseDeliveryValues(answered),
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const answered = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, answered);
|
|
return answered;
|
|
},
|
|
|
|
cancelQuestions: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: CancelIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = cancelIssueThreadInteractionSchema.parse(input);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.kind !== "ask_user_questions") {
|
|
throw unprocessable("Only ask_user_questions interactions can be cancelled");
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
|
|
const reason = data.reason?.trim() || null;
|
|
const updated = await db.transaction(async (tx) => {
|
|
const resolvedAt = new Date();
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: {
|
|
version: 1,
|
|
answers: [],
|
|
cancelled: true,
|
|
cancellationReason: reason,
|
|
summaryMarkdown: null,
|
|
},
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(row));
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const cancelled = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, cancelled);
|
|
return cancelled;
|
|
},
|
|
};
|
|
}
|