mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:35:36 +01:00
## Thinking Path > - Paperclip manages work done by AI agents. > - A completed delegated task must report its result to the originating chat. > - That report uses a real provider turn and must stay in cost accounting. > - Ordinary first-task proposals must also support the promised chat acceptance path. > - A revision response can reject an older proposal before it creates the replacement. > - The old rejection transition clears that active response's ownership and blocks its next tool. > - This change proves required completion work, guides ordinary proposal policy and preserves the revision turn under the existing locks. ## Linked Issues or Issue Description No separate issue exists. The original failures remain in [the complete Product run](https://github.com/paperclipai/paperclip/actions/runs/41915965118). The independent continuation repair is #19076. Related #12209 concerns prior-run confirmation binding. This change fixes the ownership reset during rejection, rather than changing binding authorization. **What happened?** The complete 20-case run finished with 13 passes and 7 failures. Three delegated-chat cases counted one required `chat_task_completed` provider turn as unexpected work. Two first-task agents chose optional human-only cards, then correctly received 403 when they tried conversational acceptance. A [reviewed two-case candidate run](https://github.com/paperclipai/paperclip/actions/runs/41931445311) then used the changed first-task skill. Both models inherited the ordinary resolver policy. Clarify/propose/accept passed all 12 predicates. Revise/accept failed after the new response rejected its predecessor's proposal. The saved rejection was followed by `paperclip_runner_tool_binding_not_authorized` on its next tools. The final `native_session_lease_lost` label followed those binding failures; this PR does not assume lease expiry caused them. Both cells cleaned up and published their original grades. **Expected behavior** Keep the existing ordinary-turn bounds and account for one independently proved completion notification. Inherit the normal resolver policy for ordinary proposals that accept chat replies. When an active response rejects an older proposal, save the rejection and retain that response's current ownership so it can create the revised proposal. Keep human, current-source and native completion-review rejection behavior and all real approval restrictions. **Steps to reproduce** The original full run used `4740d251`. The two-case candidate used trusted workflow `7d1e9b34` and target `74da56c4`. Run the focused provider-free controls below. The dual-run API regression first fails because rejection changes the issue to `todo` and clears the newer run's execution and checkout fields. ## What Changed - Prove one completion notification only for native Codex/local `multi-repository`, `clarify-reuse` and `plan-handoff`. Keep the ordinary bounds of 2, 3 and 4. Preserve actual totals of 3, 4 and 5, all run IDs and all usage. - Read those declared totals from the current catalog, which now includes the completion reply. Check the total once and prove that exactly one turn is the required notification. Do not add another turn while waiting. The required-server controls use the actual catalog tasks. - Bind the notification to the same company, agent, conversation generation, completed child, single delivery, accounted native run, saved reply and rendered result link. Reject extra work, duplicate deliveries, unknown events and mutations, including native work without a Paperclip tool call. - Collect workspace operations through the real run API for these three proofs. Keep other collector calls unchanged. Keep the proof controls in the existing required server test lane. - Stop idle polling on a permanent completion-proof error. Continue to retry transient observations within the original 240-second window. - Guide ordinary chat-accepting first-task proposals to omit optional `resolverPolicy` and inherit server/company defaults. Preserve explicit and governed human-only restrictions. Do not lower or recreate saved restricted cards. - Under the existing locked issue and interaction rows, preserve the active responding run when it rejects a different source run's proposal. Change only the ordinary requeue predicate. Keep the saved rejection, notifications, activity and existing Telemetry calls. Keep global status, lease and tool-authority guards unchanged. - Extend the existing native API controls with the actual dual-run rejection/revision sequence and policy, provenance, human, current-source and native completion-review countercontrols. No new workflow or test framework is needed. ## Verification - Current combined head is `a8567ded67e5ab811675390e9a5c5ed2194aee2f`, integrated cleanly on `fb96537e28c4c7bfced4939909cb334392320868`. The upstream completion-reply waiter is preserved. [Current required CI](https://github.com/paperclipai/paperclip/actions/runs/41940780852) passes. Actual test checkout `ff93c034d7bb40f3df7bc75cf51828fd07bb0d29` merges this head into `9c6993f0aab7ebb22a26d78f5d227accb35a6ad1`. - Fresh Greptile is 5/5 on exact head `a8567ded`, with no blocking finding. All addressed review threads are resolved. The separate automated reviewer emitted a paused-work response; that response is not a review approval. - Current required server CI actually passed all 11 native confirmation API cases, 46 completion-proof controls and 13 onboarding asset checks. Canonical typecheck, Build, the native package/server suites, all browser and serialized shards, `ci / e2e` and `ci / verify` pass. No current-head CI retry was needed. - Local dual-run RED: the unmodified transition returned HTTP 200 and saved rejection, then changed the issue to `todo` and cleared the newer run's execution/checkout fields. Whole setup and check took 28.95 seconds; the case took 596 ms. - Local treatment: all 11 real native/API cases pass. The same-session response retains execution/checkout ownership, reads task context and persists the recorded-shape revised confirmation. Its stored lease receipt stays intact and one rejection activity is saved. Human-only rejection, current-source rejection, human rejection with/without a newer owner, Plan/Ask, foreign provenance, authentication and formal approval controls pass. - All 59 existing confirmation resolver controls pass in 16.62 seconds. These cover cancellation, lost ownership, company/user scope, policy changes, transactional rollback, concurrent retries and stale revisions. - All 46 completion controls pass in 4.07 seconds. The two added clock controls preserve the original idle deadline and prove immediate permanent-proof failure plus transient observation retry. The existing live collector-to-proof and native mutation negatives remain in this required server file. - The first current-catalog integration check failed 5 controls in 2.70 seconds. The old consumer added the already-declared reply again and could accept an extra ordinary turn. The corrected consumer passes all 46 controls using the actual catalog. The existing full 20-case catalog/reporting compatibility check also passes in 4.93 seconds. Case identities, configured models and reporting match the declared 20. - The API controls used one owned loopback PostgreSQL instance through existing supported test hooks. It was stopped and removed. Earlier missing-library/setup failures and one corrected current-source test expectation remain retained; they are not counted as product failures or passes. - The unchanged first-task skill has SHA-256 `b789090996e7925676590c006418eb8b57f4514e646f6bca18ffd7b2b8e864de`. Both candidate runtime copies matched it. The inherited policy was observed in both models; one complete acceptance journey passed. This does not qualify the new rejection repair. - The three retained real completion streams pass the stronger local proof, including native items, workspace observations and recorded DOM links. Their original failed grades stay unchanged. - Earlier related checks passed 153 hiring/non-execution controls and 13 asset checks. Their source is unchanged. Required discovery includes the completion, API and asset files exactly once. - Prior candidate `74da56c4` passed all required CI, including canonical typecheck and build, in [CI run 41929148958](https://github.com/paperclipai/paperclip/actions/runs/41929148958). Its fresh Greptile was 5/5. This is prior-head evidence. - The intermediate `053150d9` head also received Greptile 5/5. Its [CI run](https://github.com/paperclipai/paperclip/actions/runs/41940014165) and review stay retained as superseded evidence. They do not qualify the final catalog correction. - A clean full local typecheck/build is not claimed because the reused dependency cache has stale adapter-utils/Runner declarations. No source or lock workaround was made. Fresh canonical CI must pass before merge. - The original full-plan result remains 13/20. The separate two-case probe remains 1/2. A fresh complete saved-plan run against delivered source is required for acceptance. No case, assertion, model, budget, timeout or failed grade is changed. Focused commands: ```sh # From server/; database URLs point only to an owned disposable local instance. node ../node_modules/vitest/vitest.mjs run src/__tests__/chat-completion-proof.test.ts PAPERCLIP_TEST_DATABASE_URL="$OWNED_TEST_DATABASE_URL" node ../node_modules/vitest/vitest.mjs run src/services/native-runtime/confirmation-comment-api.integration.test.ts PAPERCLIP_TEST_POSTGRES_ADMIN_URL="$OWNED_TEST_DATABASE_URL" node ../node_modules/vitest/vitest.mjs run src/__tests__/confirmation-comment-resolution.test.ts ``` ## Risks - The ownership exception applies only to an agent acting as the current execution run against a different non-null source run. Human, unowned and current-source requeue behavior must stay intact. Native completion-review handling remains separate. - The completion proof fails closed on new native tools or event shapes. A new shape needs review. - A stored unchanged lease receipt is supporting local evidence. The final real journey must prove full revision, execution and delivery. - The new head still needs normal protected merge approval. The optional queued Paperclip review is not a passing review. No causal repair of unrelated CI intermittency is claimed. ## Model Used OpenAI `gpt-6.1-sol` in Codex, with `max` reasoning effort. The runtime reports a 475,000-token context window. Used reasoning, code editing, shell tools and provider-free controls. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
5682 lines
211 KiB
TypeScript
5682 lines
211 KiB
TypeScript
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
|
import { notifyMergeConfirmationWork, type MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js";
|
|
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
|
import { notifyIssueReviewState } from "./issue-review-state-notifications.js";
|
|
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
|
|
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
|
import { notifyToolReviewChange } from "./tool-review-work-notifications.js";
|
|
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
|
|
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
|
import { historicalQuestionCondition } from "./issue-question-context.js";
|
|
import {
|
|
currentContinuationOrigins,
|
|
deliveredContinuationCommentIds,
|
|
} from "./execution-continuation.js";
|
|
import { parseQuestionInteractionAnswers } from "./question-interaction-answers.js";
|
|
import { bindQualityReview, requiredQualityReview } from "./required-quality-review.js";
|
|
import { resolveChatTaskAssignment } from "./chat-task-assignment.js";
|
|
import { isUniqueViolation } from "../db-errors.js";
|
|
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
|
|
import { connectionIntentDeliveries } from "@paperclipai/db";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
gte,
|
|
inArray,
|
|
isNotNull,
|
|
isNull,
|
|
ne,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
assets,
|
|
chatVoiceSessions,
|
|
authUsers,
|
|
companySecretProposals,
|
|
companies,
|
|
documents,
|
|
heartbeatRuns,
|
|
runIdentityContexts,
|
|
issueComments,
|
|
issueAttachments,
|
|
issueDocuments,
|
|
issueQuestionResponseDeliveries,
|
|
issueThreadInteractions,
|
|
issueThreadInteractionIdempotencyKeys,
|
|
issues,
|
|
toolActionRequests,
|
|
toolOauthStates,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
trackInteractionCreated,
|
|
trackInteractionResolved,
|
|
} from "@paperclipai/shared/telemetry";
|
|
import type {
|
|
AcceptIssueThreadInteraction,
|
|
AskUserQuestionsAnswer,
|
|
AskUserQuestionsInteraction,
|
|
CancelIssueThreadInteraction,
|
|
ConnectionIntentInteraction,
|
|
CreateIssueThreadInteraction,
|
|
CreateIssueThreadInteractionInput,
|
|
InteractionResolverGovernance,
|
|
IssueReviewPolicy,
|
|
IssueThreadInteraction,
|
|
IssueThreadInteractionCanonicalResolverPolicy,
|
|
IssueThreadInteractionEffectiveResolverPolicySource,
|
|
IssueThreadInteractionKind,
|
|
IssueThreadInteractionResolverPolicy,
|
|
IssueThreadInteractionResolverPolicyProvenance,
|
|
RequestCheckboxConfirmationInteraction,
|
|
RequestConfirmationInteraction,
|
|
RequestConfirmationTarget,
|
|
RequestItemVerdictsInteraction,
|
|
RequestItemVerdictsResult,
|
|
RequestItemVerdictsResultItem,
|
|
RejectIssueThreadInteraction,
|
|
SkipIssueThreadInteraction,
|
|
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,
|
|
skipIssueThreadInteractionSchema,
|
|
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 { authorizationService } from "./authorization.js";
|
|
import { isCloudManagedInstance } from "./cloud-instance.js";
|
|
import { resolveDeploymentMode } from "../config-file.js";
|
|
import {
|
|
logActivity,
|
|
publishActivity,
|
|
type ActivityPublication,
|
|
} from "./activity-log.js";
|
|
import { evaluateAgentInvokabilityFromDb } from "./agent-invokability.js";
|
|
import { getNativeReviewAssignment } from "./native-runtime/native-review-participant.js";
|
|
import {
|
|
assertIssueReviewVerdictActorAllowed,
|
|
isIssueReviewVerdictInteraction,
|
|
} from "./issue-review-policy.js";
|
|
import {
|
|
issueService,
|
|
ensureAssignmentIssueAccessGrant,
|
|
readAcceptedPlanConfirmationTarget,
|
|
runWorkspaceIsFinalized,
|
|
} from "./issues.js";
|
|
import { questionResponseDeliveryValues } from "./question-response-delivery.js";
|
|
import { lockAcceptedChatHandoffSource } from "./chat-completion-delivery.js";
|
|
import {
|
|
cancelPendingIssueInteractionChatPublications,
|
|
enqueueIssueInteractionChatPublications,
|
|
enqueueTerminalIssueInteractionChatPublications,
|
|
} from "./chat-interaction-publications.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 = {
|
|
identityContextId?: string | null;
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
userId?: string | null;
|
|
systemId?: string | null;
|
|
resolverPolicyRestriction?:
|
|
| IssueThreadInteractionCanonicalResolverPolicy
|
|
| IssueThreadInteractionResolverRestriction
|
|
| null;
|
|
suggestedTaskEffectsAuthorized?: boolean;
|
|
resolutionDetails?: Record<string, unknown>;
|
|
};
|
|
|
|
async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor) {
|
|
if (!actor.agentId || !actor.runId) return;
|
|
// Keep the same issue -> run lock order as task mutation and checkout.
|
|
await tx.select({ id: issues.id }).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))).for("update");
|
|
await assertAgentRunWriteAllowed(tx, issue.companyId, actor);
|
|
}
|
|
|
|
type CreateInteractionOptions = {
|
|
/** Keep independently owned pending cards actionable. Internal runtime bridges use this. */
|
|
supersedePendingSiblingInteractions?: boolean;
|
|
replayAcceptedInteractionIds?: readonly string[];
|
|
requireAcceptedReplay?: boolean;
|
|
};
|
|
|
|
type InteractionWakeup = (
|
|
agentId: string,
|
|
options: {
|
|
source: "automation";
|
|
triggerDetail: "system";
|
|
reason: "issue_commented";
|
|
payload: Record<string, unknown>;
|
|
idempotencyKey: string;
|
|
allowRunCoalescing?: boolean;
|
|
requestedByActorType: "system";
|
|
requestedByActorId: string;
|
|
contextSnapshot: Record<string, unknown>;
|
|
},
|
|
) => Promise<unknown>;
|
|
|
|
export type IssueThreadInteractionServiceOptions = {
|
|
resolvePullRequestState?: (
|
|
companyId: string,
|
|
reference: GitHubPullRequestReference,
|
|
signal?: AbortSignal,
|
|
) => Promise<PullRequestMergeState>;
|
|
wakeup?: InteractionWakeup;
|
|
pullRequestCacheTtlMs?: number;
|
|
now?: () => Date;
|
|
};
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
export type InteractionResolutionMutationOptions = {
|
|
/** Confirmation accept/reject nested in an outer transaction must defer these
|
|
* effects and flush them with the root database only after its commit. */
|
|
deferConfirmationCommitEffects?: (effect: (committedDb: Db) => Promise<void>) => void;
|
|
beforeResolveInTransaction?: (tx: DbTransaction) => Promise<void>;
|
|
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" || target?.type === "issue_attachment") 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;
|
|
workMode?: string;
|
|
};
|
|
|
|
type ResolvedInteractionResult = {
|
|
interaction: IssueThreadInteraction;
|
|
createdIssues: IssueWakeTarget[];
|
|
continuationIssue?: IssueWakeTarget | null;
|
|
};
|
|
|
|
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
|
|
type IssueTouchDb = Pick<Db, "update" | "transaction">;
|
|
|
|
function isNativeCompletionReview(
|
|
row: Pick<IssueThreadInteractionRow, "kind" | "payload">,
|
|
) {
|
|
if (row.kind !== "request_confirmation") return false;
|
|
const payload =
|
|
row.payload &&
|
|
typeof row.payload === "object" &&
|
|
!Array.isArray(row.payload)
|
|
? (row.payload as unknown as Record<string, unknown>)
|
|
: {};
|
|
const target =
|
|
payload.target &&
|
|
typeof payload.target === "object" &&
|
|
!Array.isArray(payload.target)
|
|
? (payload.target as Record<string, unknown>)
|
|
: {};
|
|
return target.type === "custom" && target.key === "native_completion_review";
|
|
}
|
|
|
|
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;
|
|
executionRunId: string | null;
|
|
hiddenAt?: Date | null;
|
|
workMode: 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 (actor.agentId && isNativeCompletionReview(interaction)) {
|
|
const target = (interaction.payload as { target?: { revisionId?: string } }).target;
|
|
if (!actor.runId || !await getNativeReviewAssignment(tx, {
|
|
companyId: issue.companyId, issueId: issue.id, agentId: actor.agentId,
|
|
contextSnapshot: { nativeReviewInteractionId: interaction.id, nativeReviewDecisionId: target?.revisionId },
|
|
actingRunId: actor.runId,
|
|
issueExecutionRunId: issue.executionRunId,
|
|
})) throw conflict("This completion review is no longer current or assigned to this agent.");
|
|
}
|
|
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 isEquivalentCreateRequest(
|
|
row: IssueThreadInteractionRow,
|
|
input: CreateIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
defaultAddresseeUserId: string | null = null,
|
|
) {
|
|
return (
|
|
row.kind === input.kind &&
|
|
row.requestedResolverPolicy === input.resolverPolicy &&
|
|
(row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null) &&
|
|
(row.addresseeUserId ?? defaultAddresseeUserId) === (input.addresseeUserId ?? defaultAddresseeUserId) &&
|
|
row.continuationPolicy === input.continuationPolicy &&
|
|
(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)
|
|
);
|
|
}
|
|
|
|
async function isEquivalentAcceptedReplay(
|
|
db: Db,
|
|
row: IssueThreadInteractionRow,
|
|
input: CreateIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
replayAcceptedInteractionIds: readonly string[] = [],
|
|
defaultAddresseeUserId: string | null = null,
|
|
) {
|
|
if (
|
|
!replayAcceptedInteractionIds.includes(row.id)
|
|
|| !isRequestConfirmationLikeKind(row.kind)
|
|
|| ["toolAction", "secretProposal", "connectionAuthorization", "qualityReview"].some(key => Object.hasOwn(row.payload, key))
|
|
|| row.status !== "accepted"
|
|
|| row.result?.outcome !== "accepted"
|
|
|| !row.resolvedAt
|
|
|| !actor.agentId
|
|
|| !actor.runId
|
|
|| input.sourceRunId !== actor.runId
|
|
|| !row.sourceRunId
|
|
|| row.sourceRunId === actor.runId
|
|
|| !isEquivalentCreateRequest(row, { ...input, sourceRunId: row.sourceRunId }, actor, defaultAddresseeUserId)
|
|
) return false;
|
|
const [latest] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, row.companyId),
|
|
eq(issueThreadInteractions.issueId, row.issueId),
|
|
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
|
|
)).orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id)).limit(1);
|
|
return latest?.id === row.id;
|
|
}
|
|
|
|
/**
|
|
* 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, companyId: string) {
|
|
await db.transaction(async tx => {
|
|
await notifyStatusCardIssueChange(tx, companyId);
|
|
await tx.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 false;
|
|
if (interaction.kind === "request_confirmation" && interaction.payload.toolAction) return false;
|
|
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 ?? false,
|
|
},
|
|
};
|
|
case "request_confirmation":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
case "request_item_verdicts":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
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;
|
|
}
|
|
|
|
function buildSkippedOutcomeResult(
|
|
row: IssueThreadInteractionRow,
|
|
reason: string | null,
|
|
) {
|
|
if (row.kind === "ask_user_questions") {
|
|
return {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason,
|
|
answers: [],
|
|
cancelled: true,
|
|
cancellationReason: reason,
|
|
summaryMarkdown: null,
|
|
} as const;
|
|
}
|
|
if (row.kind === "request_item_verdicts") {
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as RequestItemVerdictsInteraction;
|
|
return {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason,
|
|
complete: false,
|
|
items: interaction.result?.items ?? [],
|
|
} satisfies RequestItemVerdictsResult;
|
|
}
|
|
if (row.kind === "suggest_tasks") {
|
|
return {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason,
|
|
createdTasks: [],
|
|
skippedClientKeys: [],
|
|
} as const;
|
|
}
|
|
return { version: 1, outcome: "skipped", reason } 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 hasLinkedToolActionRequests(db: Pick<Db, "select">, companyId: string, interactionIds: string[]) {
|
|
if (interactionIds.length === 0) return false;
|
|
const [request] = await db.select({ id: toolActionRequests.id }).from(toolActionRequests)
|
|
.where(and(eq(toolActionRequests.companyId, companyId), inArray(toolActionRequests.interactionId, interactionIds)))
|
|
.limit(1);
|
|
return request !== undefined;
|
|
}
|
|
|
|
async function notifyToolProjectionForInteractions(db: Pick<Db, "select">, companyId: string, interactionIds: string[]) {
|
|
// The persisted link also covers legacy cards without current payload flags.
|
|
if (await hasLinkedToolActionRequests(db, companyId, interactionIds))
|
|
await notifyDeliveryWork(db, DELIVERY_QUEUES.toolReviewProjection, companyId);
|
|
}
|
|
|
|
async function resolveLinkedToolActionRequests(
|
|
db: Pick<Db, "select" | "update">,
|
|
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind">,
|
|
outcome: {
|
|
status: "expired" | "cancelled";
|
|
fromStatuses: Array<"pending" | "approved">;
|
|
actor: InteractionActor;
|
|
now: Date;
|
|
},
|
|
) {
|
|
if (interaction.kind !== "request_confirmation" ||
|
|
!(await hasLinkedToolActionRequests(db, interaction.companyId, [interaction.id]))) return;
|
|
await notifyToolReviewChange(db, interaction.companyId);
|
|
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;
|
|
await notifyDeliveryWork(db, DELIVERY_QUEUES.secretProposalExpiry, interaction.companyId);
|
|
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":
|
|
if (
|
|
interaction.result &&
|
|
"outcome" in interaction.result &&
|
|
interaction.result.outcome === "skipped"
|
|
) {
|
|
return "skipped";
|
|
}
|
|
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"];
|
|
textQuestionIds?: ReadonlySet<string>;
|
|
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`,
|
|
);
|
|
}
|
|
|
|
// Canonical text fields may contain code or intentional whitespace. The
|
|
// legacy custom-answer path keeps its historical newline/trim behavior.
|
|
const otherText = args.textQuestionIds?.has(answer.questionId)
|
|
? answer.otherText ?? ""
|
|
: normalizeEscapedLineBreaks(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?.trim()))
|
|
) {
|
|
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;
|
|
}
|
|
|
|
async function getIssueAttachmentTargetSnapshot(db: Db | any, args: {
|
|
companyId: string; issueId: string; target: RequestConfirmationTarget; lockForUpdate?: boolean;
|
|
}) {
|
|
if (args.target.type !== "issue_attachment") return null;
|
|
// Approval is scoped to the containing task; an arbitrary private task's
|
|
// attachment cannot be made visible through a confirmation elsewhere.
|
|
if (args.target.issueId && args.target.issueId !== args.issueId) return null;
|
|
const query = db.select({ attachmentId: issueAttachments.id, assetId: assets.id, sha256: assets.sha256 })
|
|
.from(issueAttachments).innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, args.companyId)))
|
|
.where(and(eq(issueAttachments.companyId, args.companyId), eq(issueAttachments.issueId, args.issueId),
|
|
eq(issueAttachments.id, args.target.attachmentId), eq(assets.id, args.target.assetId), eq(assets.sha256, args.target.sha256)));
|
|
const rows = await (args.lockForUpdate ? query.for("update", { of: [issueAttachments, assets] }) : query);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
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_attachment") {
|
|
if (!await getIssueAttachmentTargetSnapshot(db, { ...args, target: args.target })) {
|
|
throw unprocessable("request_confirmation target must reference the exact attachment bytes on this task");
|
|
}
|
|
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" && target.type !== "issue_attachment") return null;
|
|
|
|
const snapshot = target.type === "issue_document" ? await getIssueDocumentTargetSnapshot(db, {
|
|
companyId: args.row.companyId,
|
|
issueId: args.row.issueId,
|
|
target,
|
|
}) : null;
|
|
const isCurrent = target.type === "issue_attachment"
|
|
? await getIssueAttachmentTargetSnapshot(db, { companyId: args.row.companyId, issueId: args.row.issueId, target })
|
|
:
|
|
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 expireInTransaction = async (tx: Db | any) => {
|
|
await notifyChatConfirmationResolution(tx, args.row);
|
|
if (args.row.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.row);
|
|
else await notifyIssueReviewState(tx, args.row);
|
|
const [updated] = await tx
|
|
.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(tx, args.row.issueId, args.row.companyId);
|
|
const expired = hydrateInteraction(updated);
|
|
await enqueueTerminalIssueInteractionChatPublications(tx, expired);
|
|
return expired;
|
|
};
|
|
|
|
const expired =
|
|
typeof db.transaction === "function"
|
|
? await db.transaction(expireInTransaction)
|
|
: await expireInTransaction(db);
|
|
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,
|
|
signal?: AbortSignal,
|
|
): Promise<PullRequestMergeState> {
|
|
if (opts.resolvePullRequestState)
|
|
return opts.resolvePullRequestState(companyId, reference, signal);
|
|
return defaultPullRequestStateResolver?.(companyId, reference, signal) ?? "unknown";
|
|
}
|
|
|
|
async function resolvePullRequestStates(
|
|
entries: Array<{
|
|
key: string;
|
|
companyId: string;
|
|
reference: GitHubPullRequestReference;
|
|
}>,
|
|
signal?: AbortSignal,
|
|
) {
|
|
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) {
|
|
signal?.throwIfAborted();
|
|
const entry = pending.shift();
|
|
if (!entry) return;
|
|
const state = await resolvePullRequestState(
|
|
entry.companyId,
|
|
entry.reference,
|
|
signal,
|
|
);
|
|
states.set(entry.key, state);
|
|
}
|
|
}),
|
|
);
|
|
return states;
|
|
}
|
|
async function mergedPullRequestConfirmationPage(scope?: MergeConfirmationScope, after?: string) {
|
|
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, and(
|
|
eq(issueThreadInteractions.issueId, issues.id), eq(issueThreadInteractions.companyId, issues.companyId),
|
|
)).where(and(
|
|
eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "pending"),
|
|
isNull(issues.hiddenAt), sql`${issues.status} NOT IN ('done', 'cancelled')`,
|
|
scope ? eq(issueThreadInteractions.companyId, scope.companyId) : undefined,
|
|
scope?.issueId ? eq(issueThreadInteractions.issueId, scope.issueId) : undefined,
|
|
scope?.interactionId ? eq(issueThreadInteractions.id, scope.interactionId) : undefined,
|
|
after ? sql`${issueThreadInteractions.id} > ${after}::uuid` : undefined,
|
|
sql`NOT EXISTS (SELECT 1 FROM ${toolActionRequests} WHERE ${toolActionRequests.interactionId} = ${issueThreadInteractions.id})`,
|
|
)).orderBy(asc(issueThreadInteractions.id)).limit(100);
|
|
return { rows: rows.flatMap(row => {
|
|
const references = getMergeConfirmationPullRequestReferences(row.interaction);
|
|
return references.length ? [{ ...row, references }] : [];
|
|
}), after: rows.at(-1)?.interaction.id, complete: rows.length < 100 };
|
|
}
|
|
|
|
async function mergedPullRequestConfirmationCandidates(scope?: MergeConfirmationScope) {
|
|
const candidates: Awaited<ReturnType<typeof mergedPullRequestConfirmationPage>>["rows"] = [];
|
|
let after: string | undefined;
|
|
while (true) {
|
|
const page = await mergedPullRequestConfirmationPage(scope, after);
|
|
candidates.push(...page.rows);
|
|
if (page.complete) return candidates;
|
|
after = page.after;
|
|
}
|
|
}
|
|
|
|
async function getIdempotentInteraction(args: {
|
|
issueId: string;
|
|
companyId: string;
|
|
idempotencyKey: string;
|
|
}, handle: Db = db) {
|
|
const [original] = await handle.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, args.companyId), eq(issueThreadInteractions.issueId, args.issueId),
|
|
eq(issueThreadInteractions.idempotencyKey, args.idempotencyKey),
|
|
)).limit(1);
|
|
if (original) return original;
|
|
const [alias] = await handle.select({ interaction: issueThreadInteractions }).from(issueThreadInteractionIdempotencyKeys)
|
|
.innerJoin(issueThreadInteractions, and(eq(issueThreadInteractions.id, issueThreadInteractionIdempotencyKeys.interactionId),
|
|
eq(issueThreadInteractions.companyId, args.companyId), eq(issueThreadInteractions.issueId, args.issueId)))
|
|
.where(and(eq(issueThreadInteractionIdempotencyKeys.companyId, args.companyId),
|
|
eq(issueThreadInteractionIdempotencyKeys.issueId, args.issueId), eq(issueThreadInteractionIdempotencyKeys.idempotencyKey, args.idempotencyKey))).limit(1);
|
|
return alias?.interaction ?? null;
|
|
}
|
|
|
|
// Readiness is a projection, not durable interaction state. Use the same
|
|
// source-run predicate as accept so another run cannot hold a ready card back.
|
|
async function withAcceptanceReadiness(
|
|
interactions: IssueThreadInteraction[],
|
|
issueWorkspace?: { companyId: string; executionWorkspaceId: string | null },
|
|
): Promise<IssueThreadInteraction[]> {
|
|
const guarded = interactions.filter((interaction) =>
|
|
interaction.status === "pending"
|
|
&& interaction.sourceRunId
|
|
&& (interaction.kind === "request_checkbox_confirmation"
|
|
|| (interaction.kind === "request_confirmation"
|
|
// Tool reviews resolve through their own route while the originating
|
|
// run may be waiting for the user's decision.
|
|
&& !interaction.payload.toolAction)),
|
|
);
|
|
if (guarded.length === 0) return interactions;
|
|
const workspace = issueWorkspace ?? await db
|
|
.select({
|
|
companyId: issues.companyId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
})
|
|
.from(issues)
|
|
.where(and(
|
|
eq(issues.id, guarded[0].issueId),
|
|
eq(issues.companyId, guarded[0].companyId),
|
|
))
|
|
.then((rows) => rows[0]);
|
|
if (!workspace?.executionWorkspaceId) return interactions;
|
|
|
|
// Only pending confirmations need checks, once per source run even if that
|
|
// run produced several cards. Historical interactions add no queries.
|
|
const { companyId, executionWorkspaceId } = workspace;
|
|
const sourceRunIds = [...new Set(guarded.map((interaction) => interaction.sourceRunId!))];
|
|
const pendingRunIds = new Set<string>();
|
|
await Promise.all(sourceRunIds.map(async (runId) => {
|
|
if (!await runWorkspaceIsFinalized(db, companyId, executionWorkspaceId, runId)) {
|
|
pendingRunIds.add(runId);
|
|
}
|
|
}));
|
|
const guardedIds = new Set(guarded.map((interaction) => interaction.id));
|
|
return interactions.map((interaction) =>
|
|
guardedIds.has(interaction.id) && pendingRunIds.has(interaction.sourceRunId!)
|
|
? { ...interaction, acceptanceBlocker: "workspace_sync_pending" }
|
|
: interaction,
|
|
);
|
|
}
|
|
|
|
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 (await withAcceptanceReadiness([hydrateInteraction(current)]))[0];
|
|
}
|
|
|
|
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;
|
|
mutationOptions?: InteractionResolutionMutationOptions;
|
|
signal?: AbortSignal;
|
|
}): Promise<{
|
|
interaction: IssueThreadInteraction;
|
|
continuationIssue: IssueWakeTarget | null;
|
|
}> {
|
|
args.signal?.throwIfAborted();
|
|
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 postCommitActivityPublications: ActivityPublication[] = [];
|
|
const result = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
|
await notifyChatConfirmationResolution(tx, args.current);
|
|
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
|
else await notifyIssueReviewState(tx, args.current);
|
|
await args.mutationOptions?.beforeResolveInTransaction?.(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,
|
|
executionRunId: issues.executionRunId,
|
|
hiddenAt: issues.hiddenAt,
|
|
workMode: issues.workMode,
|
|
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);
|
|
|
|
args.signal?.throwIfAborted();
|
|
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",
|
|
);
|
|
}
|
|
if (args.actor.systemId === "system:pr-merged" &&
|
|
(issueContext.hiddenAt || getMergeConfirmationPullRequestReferences(lockedCurrent).length === 0)) {
|
|
throw conflict("Merge confirmation is no longer actionable");
|
|
}
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx as unknown as Db,
|
|
issueContext,
|
|
lockedCurrent,
|
|
args.actor,
|
|
);
|
|
|
|
const lockedTarget = (hydrateInteraction(lockedCurrent) as RequestConfirmationLikeInteraction).payload.target;
|
|
await assertRequestConfirmationTargetIsCurrent(tx, {
|
|
companyId: args.issue.companyId, issueId: args.issue.id, target: lockedTarget, lockForUpdate: true,
|
|
});
|
|
|
|
const interaction = hydrateInteraction(lockedCurrent);
|
|
const selectedOptionIds =
|
|
interaction.kind === "request_checkbox_confirmation"
|
|
? resolveSelectedCheckboxConfirmationOptions({
|
|
interaction,
|
|
selectedOptionIds: args.input.selectedOptionIds,
|
|
})
|
|
: undefined;
|
|
|
|
if (lockedCurrent.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, lockedCurrent.companyId, [lockedCurrent.id]);
|
|
args.signal?.throwIfAborted();
|
|
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",
|
|
);
|
|
}
|
|
|
|
const acceptedInteraction = hydrateInteraction(updated);
|
|
await args.mutationOptions?.afterResolveInTransaction?.(
|
|
tx,
|
|
acceptedInteraction,
|
|
);
|
|
|
|
let continuationIssue: IssueWakeTarget | null = null;
|
|
const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(
|
|
lockedCurrent.payload,
|
|
issueContext.id,
|
|
);
|
|
const acceptedPlanStartsExecution =
|
|
lockedCurrent.kind === "request_confirmation" &&
|
|
acceptedPlanTarget?.issueId === issueContext.id &&
|
|
acceptedPlanTarget.key === "plan" &&
|
|
issueContext.workMode === "planning";
|
|
if (isNativeCompletionReview(lockedCurrent)) {
|
|
const otherPending = await tx.select({ id: issueThreadInteractions.id })
|
|
.from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, issueContext.companyId),
|
|
eq(issueThreadInteractions.issueId, issueContext.id),
|
|
ne(issueThreadInteractions.id, lockedCurrent.id),
|
|
or(
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
and(
|
|
ne(issueThreadInteractions.status, "accepted"),
|
|
sql`${issueThreadInteractions.payload}->'target'->>'key' = 'native_completion_review'`,
|
|
sql`${issueThreadInteractions.payload}->'target'->>'revisionId' = ${JSON.stringify(lockedCurrent.payload)}::jsonb->'target'->>'revisionId'`,
|
|
),
|
|
),
|
|
)).limit(1);
|
|
// Each explicit reviewer must be able to answer independently. Completing
|
|
// on the first answer would cancel the other pending decisions.
|
|
const completedIssue = otherPending.length > 0 || issueContext.status !== "in_review" ? null : await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
status: "done",
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
postCommitActivityPublications,
|
|
);
|
|
if (completedIssue) {
|
|
continuationIssue = {
|
|
id: completedIssue.id,
|
|
assigneeAgentId: completedIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: completedIssue.assigneeUserId ?? null,
|
|
status: completedIssue.status,
|
|
};
|
|
}
|
|
} else 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,
|
|
...(acceptedPlanStartsExecution ? { workMode: "standard" } : {}),
|
|
assigneeAgentId: lockedCurrent.createdByAgentId,
|
|
assigneeUserId: null,
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
postCommitActivityPublications,
|
|
);
|
|
|
|
if (returnedIssue) {
|
|
continuationIssue = {
|
|
id: returnedIssue.id,
|
|
assigneeAgentId: returnedIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: returnedIssue.assigneeUserId ?? null,
|
|
status: returnedIssue.status,
|
|
...(acceptedPlanStartsExecution
|
|
? { workMode: returnedIssue.workMode }
|
|
: {}),
|
|
};
|
|
}
|
|
} else if (acceptedPlanStartsExecution) {
|
|
const executionIssue = await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
workMode: "standard",
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
postCommitActivityPublications,
|
|
);
|
|
if (executionIssue) {
|
|
continuationIssue = {
|
|
id: executionIssue.id,
|
|
assigneeAgentId: executionIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: executionIssue.assigneeUserId ?? null,
|
|
status: executionIssue.status,
|
|
workMode: executionIssue.workMode,
|
|
};
|
|
}
|
|
} else {
|
|
await touchIssue(tx, args.issue.id, args.issue.companyId);
|
|
}
|
|
|
|
// Snapshot the continuation target only after every lifecycle mutation
|
|
// above has settled. Accepted plans and creator-return confirmations can
|
|
// reassign the issue; enqueueing earlier leaves a durable wake addressed
|
|
// to the stale assignee, which the worker must (correctly) discard.
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
acceptedInteraction,
|
|
);
|
|
|
|
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 ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
args.signal?.throwIfAborted();
|
|
return {
|
|
interaction: acceptedInteraction,
|
|
continuationIssue,
|
|
};
|
|
});
|
|
const publish = async (committedDb: Db) => {
|
|
for (const publication of postCommitActivityPublications) publishActivity(publication);
|
|
await emitInteractionResolvedTelemetry(committedDb, result.interaction);
|
|
};
|
|
if (args.mutationOptions?.deferConfirmationCommitEffects) args.mutationOptions.deferConfirmationCommitEffects(publish);
|
|
else await publish(db);
|
|
return result;
|
|
}
|
|
|
|
async function rejectRequestConfirmation(args: {
|
|
issue: { id: string; companyId: string };
|
|
current: IssueThreadInteractionRow;
|
|
input: RejectIssueThreadInteraction;
|
|
actor: InteractionActor;
|
|
mutationOptions?: InteractionResolutionMutationOptions;
|
|
}): 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) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
|
await notifyChatConfirmationResolution(tx, args.current);
|
|
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
|
else await notifyIssueReviewState(tx, args.current);
|
|
await args.mutationOptions?.beforeResolveInTransaction?.(tx);
|
|
const issueContext = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
workMode: issues.workMode,
|
|
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,
|
|
});
|
|
|
|
if (lockedCurrent.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, lockedCurrent.companyId, [lockedCurrent.id]);
|
|
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",
|
|
);
|
|
}
|
|
const rejectedInteraction = hydrateInteraction(resolved);
|
|
await args.mutationOptions?.afterResolveInTransaction?.(
|
|
tx,
|
|
rejectedInteraction,
|
|
);
|
|
const rejectedPlanNeedsRevision =
|
|
lockedCurrent.kind === "request_confirmation" &&
|
|
readAcceptedPlanConfirmationTarget(
|
|
lockedCurrent.payload,
|
|
issueContext.id,
|
|
)?.key === "plan";
|
|
// A response turn can reject its predecessor's proposal before replacing
|
|
// it. Requeueing here would revoke that responding run's tool ownership.
|
|
// Both identities are read under the issue/interaction locks above.
|
|
const activeResponseIsRevisingOlderProposal =
|
|
args.actor.agentId != null &&
|
|
args.actor.runId != null &&
|
|
args.actor.runId === issueContext.executionRunId &&
|
|
lockedCurrent.sourceRunId != null &&
|
|
lockedCurrent.sourceRunId !== issueContext.executionRunId;
|
|
const shouldResumeReviewedIssue =
|
|
issueContext.status === "in_review" &&
|
|
!activeResponseIsRevisingOlderProposal &&
|
|
(lockedCurrent.continuationPolicy === "wake_assignee" ||
|
|
rejectedPlanNeedsRevision);
|
|
if (
|
|
isNativeCompletionReview(lockedCurrent) ||
|
|
shouldResumeReviewedIssue
|
|
) {
|
|
await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
status: "todo",
|
|
assigneeAgentId: issueContext.assigneeAgentId,
|
|
assigneeUserId: null,
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
);
|
|
} else {
|
|
await touchIssue(tx, args.issue.id, args.issue.companyId);
|
|
}
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
rejectedInteraction,
|
|
);
|
|
return resolved;
|
|
});
|
|
|
|
const rejected = hydrateInteraction(updated);
|
|
const publish = (committedDb: Db) => emitInteractionResolvedTelemetry(committedDb, rejected);
|
|
if (args.mutationOptions?.deferConfirmationCommitEffects) args.mutationOptions.deferConfirmationCommitEffects(publish);
|
|
else await publish(db);
|
|
return rejected;
|
|
}
|
|
|
|
return {
|
|
getForIssue,
|
|
createConnectionIntent: async (
|
|
issue: { id: string; companyId: string },
|
|
input: {
|
|
payload: ConnectionIntentInteraction["payload"];
|
|
sourceRunId: string;
|
|
sourceIdentityContextId?: string | null;
|
|
addresseeUserId: string;
|
|
idempotencyKey: string;
|
|
},
|
|
) => {
|
|
const payload = connectionIntentPayloadSchema.parse(input.payload);
|
|
const sameSavedSetup = (candidate: typeof payload) =>
|
|
candidate.savedSetup?.endpointId === payload.savedSetup?.endpointId
|
|
&& candidate.savedSetup?.connectionId === payload.savedSetup?.connectionId
|
|
&& candidate.savedSetup?.action === payload.savedSetup?.action
|
|
&& Boolean(candidate.savedSetup?.credentialRotation) === Boolean(payload.savedSetup?.credentialRotation)
|
|
&& candidate.savedSetup?.credentialEntry?.requireAll === payload.savedSetup?.credentialEntry?.requireAll
|
|
// Preserve the original generation boundary on retries, while keeping
|
|
// requests for different credential fields distinct.
|
|
&& isDeepStrictEqual(Object.keys(candidate.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort(),
|
|
Object.keys(payload.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort());
|
|
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
|
|
|| (existing.kind === "connection_intent"
|
|
? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !sameSavedSetup(connectionIntentPayloadSchema.parse(existing.payload)) || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest))
|
|
: !isDeepStrictEqual(existing.payload, payload))
|
|
) {
|
|
throw conflict(
|
|
"Interaction idempotency key already exists for a different request",
|
|
{
|
|
idempotencyKey: input.idempotencyKey,
|
|
},
|
|
);
|
|
}
|
|
const interaction = hydrateInteraction(existing) as ConnectionIntentInteraction;
|
|
await enqueueIssueInteractionChatPublications(db, interaction);
|
|
return interaction;
|
|
}
|
|
|
|
let inserted = false;
|
|
const created = await db.transaction(async (tx) => {
|
|
const issueRow = await tx
|
|
.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId })
|
|
.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) || issueRow.assigneeAgentId !== payload.requestingAgentId) {
|
|
throw conflict("Cannot create an interaction on a closed issue");
|
|
}
|
|
|
|
const [qualityAlias] = await tx.select({ id: issueThreadInteractionIdempotencyKeys.id }).from(issueThreadInteractionIdempotencyKeys)
|
|
.where(and(eq(issueThreadInteractionIdempotencyKeys.companyId, issue.companyId), eq(issueThreadInteractionIdempotencyKeys.issueId, issue.id),
|
|
eq(issueThreadInteractionIdempotencyKeys.idempotencyKey, input.idempotencyKey))).limit(1);
|
|
if (qualityAlias) throw conflict("Interaction idempotency key already exists for a different request", { idempotencyKey: input.idempotencyKey });
|
|
|
|
// Serialize on the task so retries and later runs share the same live card.
|
|
const pending = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, "connection_intent"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
eq(issueThreadInteractions.createdByAgentId, payload.requestingAgentId),
|
|
eq(issueThreadInteractions.addresseeUserId, input.addresseeUserId),
|
|
));
|
|
const reusable = pending.find((candidate) =>
|
|
connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && sameSavedSetup(connectionIntentPayloadSchema.parse(candidate.payload)) && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest));
|
|
if (reusable) {
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(reusable));
|
|
return reusable;
|
|
}
|
|
|
|
const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, input.sourceRunId), eq(heartbeatRuns.companyId, issue.companyId)));
|
|
if (!sourceRun) throw unprocessable("Interaction source run is unavailable");
|
|
const originCommentIds = await currentContinuationOrigins(tx as unknown as Db, issue.companyId, issue.id, sourceRun.context);
|
|
await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
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,
|
|
originCommentIds,
|
|
sourceIdentityContextId: input.sourceIdentityContextId ?? null,
|
|
title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?`
|
|
: payload.savedSetup?.action === "rotate_credentials" ? `Rotate ${payload.serviceName} webhook secret` : `Connect ${payload.serviceName}`,
|
|
summary: payload.savedSetup?.action === "rotate_credentials"
|
|
? `${payload.requestingAgentName} needs you to rotate the signing secret and reconnect the saved App.`
|
|
: `${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 && candidatePayload.data.purpose === payload.purpose
|
|
);
|
|
})
|
|
.map((candidate) => candidate.id);
|
|
if (supersededIds.length > 0) {
|
|
await notifyChatConfirmationResolution(tx, { companyId: issue.companyId, issueId: issue.id, kind: "connection_intent" });
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(inArray(toolOauthStates.interactionId, supersededIds));
|
|
const superseded = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded",
|
|
supersededByInteractionId: row.id,
|
|
},
|
|
resolvedAt: now(),
|
|
updatedAt: now(),
|
|
})
|
|
.where(inArray(issueThreadInteractions.id, supersededIds))
|
|
.returning();
|
|
for (const previous of superseded) {
|
|
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(previous));
|
|
}
|
|
}
|
|
await touchIssue(tx, issue.id, issue.companyId);
|
|
inserted = true;
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
|
return row;
|
|
});
|
|
|
|
const interaction = hydrateInteraction(created) as ConnectionIntentInteraction;
|
|
if (inserted) 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, issue.companyId);
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
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.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await notifyIssueReviewState(tx, current);
|
|
const [row] = await tx
|
|
.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 (!row) throw interactionAlreadyResolvedError();
|
|
if (status === "accepted" || status === "rejected") {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
|
await tx.insert(connectionIntentDeliveries).values({ interactionId, companyId: issue.companyId }).onConflictDoNothing();
|
|
}
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(row));
|
|
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
|
return row;
|
|
});
|
|
if (!updated) throw interactionAlreadyResolvedError();
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
const interaction = hydrateInteraction(
|
|
updated,
|
|
) as ConnectionIntentInteraction;
|
|
await emitInteractionResolvedTelemetry(db, interaction);
|
|
return interaction;
|
|
},
|
|
mergedPullRequestConfirmationPage,
|
|
mergedPullRequestConfirmationCandidates,
|
|
sweepMergedPullRequestConfirmations: async (
|
|
mergedHints: Array<{
|
|
companyId: string;
|
|
owner: string;
|
|
repo: string;
|
|
number: number;
|
|
}> = [],
|
|
scope?: MergeConfirmationScope,
|
|
signal?: AbortSignal,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
const scopes = scope ? [scope] : mergedHints.length
|
|
? [...new Set(mergedHints.map(hint => hint.companyId))].map(companyId => ({ companyId }))
|
|
: [undefined];
|
|
const candidates: Awaited<ReturnType<typeof mergedPullRequestConfirmationCandidates>> = [];
|
|
for (const candidateScope of scopes) candidates.push(...await mergedPullRequestConfirmationCandidates(candidateScope));
|
|
const eligible = mergedHints.length ? candidates.filter(candidate => mergedHints.some(hint =>
|
|
hint.companyId === candidate.issue.companyId && candidate.references.some(reference =>
|
|
hint.owner.toLowerCase() === reference.owner.toLowerCase() &&
|
|
hint.repo.toLowerCase() === reference.repo.toLowerCase() && hint.number === reference.number),
|
|
)) : candidates;
|
|
if (eligible.length === 0) return { checked: 0, candidates: 0, accepted: 0, woken: 0 };
|
|
|
|
const checkedAt = now().getTime();
|
|
const cacheTtlMs = opts.pullRequestCacheTtlMs ?? 5 * 60 * 1000;
|
|
for (const hint of mergedHints) {
|
|
const key = `${hint.companyId}:${hint.owner.toLowerCase()}/${hint.repo.toLowerCase()}#${hint.number}`;
|
|
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, {
|
|
state: "merged",
|
|
checkedAt,
|
|
});
|
|
}
|
|
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(),
|
|
], signal);
|
|
if (scope && [...refreshedStates.values()].some(state => state === "unknown")) {
|
|
throw new Error("Merge confirmation provider state is unavailable");
|
|
}
|
|
for (const [key, state] of refreshedStates) {
|
|
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, {
|
|
state,
|
|
checkedAt,
|
|
});
|
|
}
|
|
|
|
let accepted = 0;
|
|
let woken = 0;
|
|
for (const candidate of eligible) {
|
|
if (signal?.aborted) break;
|
|
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,
|
|
signal,
|
|
input: {},
|
|
mutationOptions,
|
|
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 ?? await db.select({
|
|
id: issues.id, assigneeAgentId: issues.assigneeAgentId, status: issues.status,
|
|
}).from(issues).where(and(eq(issues.id, candidate.issue.id),
|
|
eq(issues.companyId, candidate.issue.companyId), isNull(issues.hiddenAt))).then(rows => rows[0]);
|
|
const shouldWake =
|
|
resolved.interaction.continuationPolicy === "wake_assignee" ||
|
|
resolved.interaction.continuationPolicy === "wake_assignee_on_accept";
|
|
if (
|
|
!opts.wakeup ||
|
|
!wakeIssue ||
|
|
!shouldWake ||
|
|
!wakeIssue.assigneeAgentId ||
|
|
isTerminalIssueStatus(wakeIssue.status)
|
|
) {
|
|
continue;
|
|
}
|
|
const wake = async () => { 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`,
|
|
// A merged-PR confirmation may already be visible in an external
|
|
// provider conversation. Keep its continuation causally isolated
|
|
// from unrelated queued chat/internal work on the same task.
|
|
allowRunCoalescing: false,
|
|
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",
|
|
},
|
|
});
|
|
};
|
|
if (mutationOptions.deferConfirmationCommitEffects) mutationOptions.deferConfirmationCommitEffects(wake);
|
|
else await wake();
|
|
woken += 1;
|
|
}
|
|
|
|
return {
|
|
checked: candidates.length,
|
|
candidates: eligible.length,
|
|
accepted,
|
|
woken,
|
|
};
|
|
},
|
|
listForIssue: async (issueId: string) => {
|
|
const [rows, issue] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.issueId, issueId))
|
|
.orderBy(
|
|
asc(issueThreadInteractions.createdAt),
|
|
asc(issueThreadInteractions.id),
|
|
),
|
|
db
|
|
.select({
|
|
status: issues.status,
|
|
companyId: issues.companyId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((issueRows) => issueRows[0]),
|
|
]);
|
|
|
|
const interactions = rows.map((row) =>
|
|
hydrateInteraction(
|
|
issue &&
|
|
isTerminalIssueStatus(issue.status) &&
|
|
row.status === "pending"
|
|
? {
|
|
...row,
|
|
status: "expired",
|
|
result: buildAdministrativeOutcomeResult(row, "issue_closed"),
|
|
resolvedAt: row.updatedAt,
|
|
}
|
|
: row,
|
|
),
|
|
);
|
|
return withAcceptanceReadiness(interactions, issue);
|
|
},
|
|
|
|
getById: async (interactionId: string) => {
|
|
const row = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return row ? (await withAcceptanceReadiness([hydrateInteraction(row)]))[0] : 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 notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, proposal.companyId);
|
|
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, issue.companyId);
|
|
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.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
else await notifyIssueReviewState(tx, row);
|
|
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
|
const [resolved] = await tx
|
|
.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 (!resolved) return null;
|
|
const cancelledInteraction = hydrateInteraction(resolved);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
cancelledInteraction,
|
|
);
|
|
return cancelledInteraction;
|
|
});
|
|
if (updated) cancelled.push(updated);
|
|
}
|
|
|
|
for (const [issueId, companyId] of new Map(cancelled.map(interaction => [interaction.issueId, interaction.companyId]))) {
|
|
await touchIssue(db, issueId, companyId);
|
|
}
|
|
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) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
else await notifyIssueReviewState(tx, row);
|
|
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
|
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,
|
|
});
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(updatedRow),
|
|
);
|
|
return updatedRow;
|
|
});
|
|
if (!updated) continue;
|
|
expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
for (const [issueId, companyId] of new Map(expired.map(interaction => [interaction.issueId, interaction.companyId]))) {
|
|
await touchIssue(db, issueId, companyId);
|
|
}
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return { expired: expired.length };
|
|
},
|
|
|
|
create: async (
|
|
issue: { id: string; companyId: string },
|
|
input: CreateIssueThreadInteractionInput,
|
|
actor: InteractionActor,
|
|
options: CreateInteractionOptions = {},
|
|
) => {
|
|
const data = normalizeCreateInteractionInput(
|
|
createIssueThreadInteractionSchema.parse(input),
|
|
);
|
|
const qualityItems = data.kind === "request_confirmation" && data.payload.qualityReview
|
|
? [data.payload] : data.kind === "request_item_verdicts"
|
|
? data.payload.items.filter(item => item.qualityReview) : [];
|
|
if (qualityItems.length) {
|
|
if (data.addresseeAgentId) throw unprocessable("Required quality review must be addressed to a human");
|
|
const [owner] = await db.select({ executionPolicy: issues.executionPolicy }).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
for (const item of qualityItems) item.qualityReview = bindQualityReview(requiredQualityReview(owner?.executionPolicy), item.qualityReview!, item.target, issue.id);
|
|
data.resolverPolicy = "human_only";
|
|
data.continuationPolicy = "wake_assignee";
|
|
if (data.kind === "request_confirmation" || data.kind === "request_item_verdicts") data.payload.supersedeOnUserComment = false;
|
|
}
|
|
// Chat ownership is server-owned and immutable. Ordinary human questions
|
|
// must not depend on a model copying an opaque user identity correctly.
|
|
let defaultAddresseeUserId: string | null = null;
|
|
if (data.kind === "ask_user_questions" && !data.addresseeAgentId) {
|
|
const [conversation] = await db
|
|
.select({ agentId: issues.conversationAgentId, userId: issues.conversationUserId })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
if (conversation?.agentId && conversation.userId) {
|
|
defaultAddresseeUserId = conversation.userId;
|
|
if (data.addresseeUserId && data.addresseeUserId !== defaultAddresseeUserId) {
|
|
throw unprocessable("Chat questions must address the conversation owner; omit addresseeUserId", {
|
|
code: "interaction_chat_addressee_mismatch",
|
|
});
|
|
}
|
|
data.addresseeUserId = defaultAddresseeUserId;
|
|
}
|
|
}
|
|
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, defaultAddresseeUserId)) {
|
|
if (!options.replayAcceptedInteractionIds?.includes(existing.id)) {
|
|
throw conflict(
|
|
"Interaction idempotency key already exists for a different request",
|
|
{ idempotencyKey: normalizedData.idempotencyKey },
|
|
);
|
|
}
|
|
} else if (!options.requireAcceptedReplay) {
|
|
const interaction = hydrateInteraction(existing);
|
|
await enqueueIssueInteractionChatPublications(db, interaction);
|
|
return interaction;
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
}
|
|
|
|
let originCommentIds: string[] = data.sourceCommentId ? [data.sourceCommentId] : [];
|
|
let sourceIdentityContextId: string | null = null;
|
|
let sourceRunContext: Record<string, unknown> | null = null;
|
|
let sourceRunCreatedAt: Date | null = null;
|
|
if (data.sourceRunId) {
|
|
const sourceRun = await db
|
|
.select({
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
|
})
|
|
.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");
|
|
}
|
|
if (data.kind === "ask_user_questions") {
|
|
if (actor.agentId && sourceRun.agentId !== actor.agentId) {
|
|
throw unprocessable("sourceRunId must belong to the creating agent");
|
|
}
|
|
const snapshot = sourceRun.contextSnapshot ?? {};
|
|
const boundIssueIds = [
|
|
sourceRun.nativeIssueId,
|
|
snapshot.issueId,
|
|
snapshot.taskId,
|
|
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
if (boundIssueIds.some((boundIssueId) => boundIssueId !== issue.id)) {
|
|
throw unprocessable("sourceRunId must belong to the same issue");
|
|
}
|
|
}
|
|
sourceRunContext = sourceRun.contextSnapshot;
|
|
sourceRunCreatedAt = sourceRun.createdAt;
|
|
originCommentIds = [...new Set([...originCommentIds, ...await currentContinuationOrigins(db, issue.companyId, issue.id, sourceRun.contextSnapshot)])];
|
|
sourceIdentityContextId = actor.identityContextId ?? sourceRun.activeIdentityContextId;
|
|
if (sourceIdentityContextId) {
|
|
const [origin] = await db.select({id: runIdentityContexts.id}).from(runIdentityContexts).where(and(
|
|
eq(runIdentityContexts.id, sourceIdentityContextId), eq(runIdentityContexts.companyId, issue.companyId),
|
|
eq(runIdentityContexts.runId, data.sourceRunId), eq(runIdentityContexts.status, "accepted"),
|
|
));
|
|
if (!origin) throw unprocessable("Interaction execution identity is unavailable");
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
const [issueRow] = await tx
|
|
.select({ executionPolicy: issues.executionPolicy, status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, issue.id),
|
|
eq(issues.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
// Recheck after taking the shared issue lock: a concurrent request
|
|
// may have registered this retry key while we waited. Key identity is
|
|
// proven by this lookup; request equivalence also checks actor/payload.
|
|
if (normalizedData.idempotencyKey) {
|
|
const existing = await getIdempotentInteraction({ ...issue, issueId: issue.id, idempotencyKey: normalizedData.idempotencyKey }, tx as unknown as Db);
|
|
if (existing) {
|
|
const acceptedReplay = await isEquivalentAcceptedReplay(tx as unknown as Db, existing, normalizedData, actor,
|
|
options.replayAcceptedInteractionIds, defaultAddresseeUserId);
|
|
if ((options.requireAcceptedReplay || !isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) && !acceptedReplay) {
|
|
throw conflict("Interaction idempotency key already exists for a different request", { idempotencyKey: normalizedData.idempotencyKey });
|
|
}
|
|
if (acceptedReplay) return { row: existing, supersededRows: [], reused: true };
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(existing));
|
|
return { row: existing, supersededRows: [], reused: true };
|
|
}
|
|
}
|
|
if (options.requireAcceptedReplay) {
|
|
throw conflict("Interaction idempotency key already exists for a different request", { idempotencyKey: normalizedData.idempotencyKey });
|
|
}
|
|
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
|
|
throw conflict("Cannot create an interaction on a closed issue");
|
|
}
|
|
if (data.addresseeUserId) {
|
|
const [user] = await tx.select({ id: authUsers.id }).from(authUsers)
|
|
.where(eq(authUsers.id, data.addresseeUserId));
|
|
const cloudManaged = isCloudManagedInstance();
|
|
// No-login installs have an implicit board, which need not have an
|
|
// auth row. Never infer this authority in authenticated/Cloud mode.
|
|
const localImplicit = data.addresseeUserId === "local-board"
|
|
&& !cloudManaged && resolveDeploymentMode() === "local_trusted";
|
|
// Use the normal board mutation policy, including viewer restrictions,
|
|
// local/instance-admin eligibility and Cloud's no-stale-admin rule.
|
|
const decision = user || localImplicit ? await authorizationService(tx).decide({
|
|
actor: {
|
|
type: "board",
|
|
userId: data.addresseeUserId,
|
|
source: localImplicit ? "local_implicit" : cloudManaged ? "cloud_tenant" : "session",
|
|
},
|
|
action: "issue:mutate",
|
|
resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, ...issueRow },
|
|
}) : null;
|
|
if (!decision?.allowed) {
|
|
throw unprocessable("addresseeUserId must identify a user authorized to respond in this company", {
|
|
code: "interaction_addressee_user_unavailable",
|
|
});
|
|
}
|
|
}
|
|
if (
|
|
data.kind === "ask_user_questions" &&
|
|
sourceRunContext &&
|
|
sourceRunCreatedAt
|
|
) {
|
|
const delivered = deliveredContinuationCommentIds(sourceRunContext);
|
|
if (delivered.known) {
|
|
const newerHumanComments = await tx
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, issue.companyId),
|
|
eq(issueComments.issueId, issue.id),
|
|
eq(issueComments.authorType, "user"),
|
|
isNotNull(issueComments.authorUserId),
|
|
ne(issueComments.authorUserId, "board-concierge"),
|
|
isNull(issueComments.createdByRunId),
|
|
eq(issueComments.origin, "comment"),
|
|
isNull(issueComments.deletedAt),
|
|
gte(issueComments.createdAt, sourceRunCreatedAt),
|
|
),
|
|
);
|
|
const undelivered = newerHumanComments.filter(
|
|
(comment) => !delivered.ids.has(comment.id),
|
|
);
|
|
if (undelivered.length > 0) {
|
|
throw conflict(
|
|
"New user comments arrived after this run's context; continue after queued comments are delivered",
|
|
{
|
|
reason: "newer_comment_not_delivered",
|
|
commentIds: undelivered.map((comment) => comment.id),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// 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,
|
|
});
|
|
}
|
|
for (const item of qualityItems) {
|
|
const binding = bindQualityReview(requiredQualityReview(issueRow.executionPolicy), item.qualityReview!, item.target, issue.id);
|
|
if (binding.requirementHash !== item.qualityReview!.requirementHash) throw conflict("Quality review requirements changed; read the task again");
|
|
await assertRequestConfirmationTargetIsCurrent(tx, { companyId: issue.companyId, issueId: issue.id, target: item.target, lockForUpdate: true });
|
|
}
|
|
if (qualityItems.length) {
|
|
const keys = qualityItems.map(item => item.qualityReview!.key);
|
|
// The issue lock serializes creates across agents and reviewers.
|
|
// Ten is also the schema limit on required deliverables: bounding
|
|
// task-wide cards bounds every reviewer's queue, even after a human
|
|
// changes the requirement while older cards are still pending.
|
|
const pendingQuality = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
inArray(issueThreadInteractions.kind, ["request_confirmation", "request_item_verdicts"]),
|
|
sql`(jsonb_path_exists(${issueThreadInteractions.payload}, '$.qualityReview ? (@ != null)') OR
|
|
jsonb_path_exists(${issueThreadInteractions.payload}, '$.items[*].qualityReview ? (@ != null)'))`,
|
|
)).limit(11);
|
|
for (const pending of pendingQuality) {
|
|
const interaction = hydrateInteraction(pending);
|
|
const existingKeys = interaction.kind === "request_confirmation" ? [interaction.payload.qualityReview?.key]
|
|
: interaction.kind === "request_item_verdicts" ? interaction.payload.items.filter(item => !interaction.result?.items.some(result => result.id === item.id)).map(item => item.qualityReview?.key) : [];
|
|
if (!existingKeys.some(key => key && keys.includes(key))) continue;
|
|
if (isEquivalentCreateRequest(pending, { ...normalizedData, idempotencyKey: pending.idempotencyKey ?? undefined }, actor, defaultAddresseeUserId)) {
|
|
if (normalizedData.idempotencyKey) {
|
|
const aliases = await tx.select({ id: issueThreadInteractionIdempotencyKeys.id }).from(issueThreadInteractionIdempotencyKeys)
|
|
.where(eq(issueThreadInteractionIdempotencyKeys.interactionId, pending.id)).limit(100);
|
|
if (aliases.length >= 100) throw conflict("This review has reached its retry-key limit; use its existing idempotency key", { code: "quality_review_retry_key_limit", interactionId: pending.id });
|
|
await tx.insert(issueThreadInteractionIdempotencyKeys).values({ companyId: issue.companyId, issueId: issue.id,
|
|
interactionId: pending.id, idempotencyKey: normalizedData.idempotencyKey });
|
|
}
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, interaction);
|
|
return { row: pending, supersededRows: [], reused: true };
|
|
}
|
|
throw conflict("A quality review for this output is already pending; resolve or cancel it before requesting another", {
|
|
code: "quality_review_already_pending", interactionId: pending.id,
|
|
});
|
|
}
|
|
if (pendingQuality.length >= 10) throw conflict("This task already has ten pending quality reviews; resolve or cancel an existing review first", {
|
|
code: "quality_review_pending_limit",
|
|
});
|
|
}
|
|
// An unverified phone caller has no authenticated human identity.
|
|
// Their clarifications use the ordinary task-comment/follow-up queue;
|
|
// a protected native question would otherwise wait indefinitely.
|
|
if (data.kind === "ask_user_questions" && !actor.userId) {
|
|
const [guest] = await tx.select({id: chatVoiceSessions.id}).from(chatVoiceSessions)
|
|
.where(and(eq(chatVoiceSessions.companyId, issue.companyId), eq(chatVoiceSessions.issueId, issue.id),
|
|
eq(chatVoiceSessions.callerAuthority, "guest_intake"))).limit(1);
|
|
if (guest) throw unprocessable("Ask this unverified caller a clarification in a task comment; submit_request will deliver their spoken follow-up. Protected human-input questions require authenticated access.", {code: "voice_guest_use_task_comment"});
|
|
}
|
|
if (data.kind === "ask_user_questions" || data.kind === "request_confirmation") {
|
|
await notifyChatRunMilestones(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
} else {
|
|
await notifyNativeStatusReassessment(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
}
|
|
if (data.kind === "request_confirmation") await notifyMergeConfirmationWork(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
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,
|
|
originCommentIds,
|
|
sourceCommentId: data.sourceCommentId ?? null,
|
|
sourceRunId: data.sourceRunId ?? null,
|
|
sourceIdentityContextId,
|
|
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();
|
|
|
|
if (row.addresseeAgentId || row.addresseeUserId) {
|
|
const [privacyIssue] = await tx.select().from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
await ensureAssignmentIssueAccessGrant(tx, { ...privacyIssue!,
|
|
assigneeAgentId: row.addresseeAgentId, assigneeUserId: row.addresseeUserId,
|
|
}, null, { agentId: actor.agentId, userId: actor.userId });
|
|
}
|
|
|
|
// 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 ordinary task questions. Agent Chat
|
|
// questions remain answerable in history even when another is asked.
|
|
// Scoped to the same agent + issue + kind; other actors are untouched.
|
|
const canSupersedeSiblingCards =
|
|
options.supersedePendingSiblingInteractions !== false &&
|
|
((data.kind === "request_confirmation" &&
|
|
data.payload.toolAction === undefined &&
|
|
data.payload.secretProposal === undefined &&
|
|
data.payload.qualityReview === undefined) ||
|
|
(data.kind === "ask_user_questions" && (!issueRow.conversationAgentId || !issueRow.conversationUserId)));
|
|
if (!actor.agentId || !canSupersedeSiblingCards) {
|
|
await enqueueIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(row),
|
|
);
|
|
return { row, supersededRows: [] };
|
|
}
|
|
|
|
const now = new Date();
|
|
const supersededResult =
|
|
data.kind === "ask_user_questions"
|
|
? buildSupersededByNewerInteractionResult(row.id)
|
|
: buildSupersededByNewerRequestResult(row.id);
|
|
const siblingCondition = 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),
|
|
sql`${issueThreadInteractions.payload}->'qualityReview' IS NULL`,
|
|
);
|
|
if (data.kind === "request_confirmation") {
|
|
// The issue lock above excludes concurrent interaction creation.
|
|
const siblings = await tx.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(siblingCondition);
|
|
await notifyToolProjectionForInteractions(tx, issue.companyId, siblings.map(sibling => sibling.id));
|
|
}
|
|
const supersededRows = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: supersededResult,
|
|
resolvedByAgentId: actor.agentId,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(siblingCondition)
|
|
.returning();
|
|
for (const supersededRow of supersededRows) {
|
|
await resolveLinkedToolActionRequests(tx, supersededRow, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
}
|
|
await cancelPendingIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
{
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
interactionIds: supersededRows.map((candidate) => candidate.id),
|
|
},
|
|
);
|
|
for (const supersededRow of supersededRows) {
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(supersededRow),
|
|
);
|
|
}
|
|
await enqueueIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(row),
|
|
);
|
|
return { row, supersededRows };
|
|
});
|
|
if ("reused" in result && result.reused) return hydrateInteraction(result.row);
|
|
created = result.row;
|
|
superseded = result.supersededRows;
|
|
} catch (error) {
|
|
if (
|
|
!normalizedData.idempotencyKey ||
|
|
!isUniqueViolation(error, ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT)
|
|
) {
|
|
throw error;
|
|
}
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
if (!existing) throw error;
|
|
if (!isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) {
|
|
throw conflict(
|
|
"Interaction idempotency key already exists for a different request",
|
|
{
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
},
|
|
);
|
|
}
|
|
const interaction = hydrateInteraction(existing);
|
|
await enqueueIssueInteractionChatPublications(db, interaction);
|
|
return interaction;
|
|
}
|
|
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
): 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,
|
|
mutationOptions,
|
|
);
|
|
case "request_confirmation": {
|
|
await assertIssueWorkspaceFinalizedForAccept({
|
|
db,
|
|
issue,
|
|
sourceRunId: current.sourceRunId,
|
|
});
|
|
const accepted = await acceptRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
mutationOptions,
|
|
});
|
|
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,
|
|
mutationOptions,
|
|
});
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
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,
|
|
conversationAgentId: issues.conversationAgentId,
|
|
conversationSessionGeneration: issues.conversationSessionGeneration,
|
|
})
|
|
.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 sourceConversation = parentById.get(issue.id)?.conversationAgentId
|
|
? parentById.get(issue.id)!
|
|
: null;
|
|
const createdByClientKey = new Map<
|
|
string,
|
|
SuggestTasksResultCreatedTask
|
|
>();
|
|
const createdWakeTargets: IssueWakeTarget[] = [];
|
|
|
|
await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
if (sourceConversation) {
|
|
await lockAcceptedChatHandoffSource(tx, {
|
|
companyId: issue.companyId, conversationId: sourceConversation.id,
|
|
sessionGeneration: sourceConversation.conversationSessionGeneration,
|
|
userId: actor.userId, agentId: actor.agentId,
|
|
});
|
|
if (interaction.sourceRunId) {
|
|
const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, interaction.sourceRunId), eq(heartbeatRuns.companyId, issue.companyId)));
|
|
if (sourceRun?.context?.conversationSessionGeneration !== sourceConversation.conversationSessionGeneration) {
|
|
throw conflict("The originating conversation was reset; request a new task suggestion");
|
|
}
|
|
}
|
|
}
|
|
await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
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 proposedParentId = task.parentClientKey
|
|
? (createdByClientKey.get(task.parentClientKey)?.issueId ?? null)
|
|
: (task.parentId ??
|
|
interaction.payload.defaultParentId ??
|
|
(sourceConversation ? null : issue.id));
|
|
const parentIssueId = proposedParentId === sourceConversation?.id ? null : proposedParentId;
|
|
if (!parentIssueId && task.parentClientKey) {
|
|
throw unprocessable(
|
|
`Unable to resolve parent for suggested task ${task.clientKey}`,
|
|
);
|
|
}
|
|
|
|
const createInput = {
|
|
title: task.title,
|
|
description: task.description ?? null,
|
|
initialPlan: task.initialPlan ?? null,
|
|
...(task.requiredQualityReview ? { executionPolicy: { mode: "normal" as const, commentRequired: true, stages: [], requiredQualityReview: task.requiredQualityReview } } : {}),
|
|
status: "todo",
|
|
workMode: task.workMode ?? "standard",
|
|
priority: task.priority ?? "medium",
|
|
...resolveChatTaskAssignment(task, sourceConversation?.conversationAgentId),
|
|
projectId: task.projectId ?? issue.projectId,
|
|
goalId: task.goalId ?? issue.goalId,
|
|
billingCode: task.billingCode ?? null,
|
|
createdByAgentId: actor.agentId ?? null,
|
|
createdByUserId: actor.userId ?? null,
|
|
originIdentityContextId: interaction.sourceIdentityContextId ?? null,
|
|
originRunId: interaction.sourceRunId ?? null,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.userId ?? null,
|
|
...(sourceConversation ? { conversationHandoff: {
|
|
conversationId: sourceConversation.id,
|
|
sessionGeneration: sourceConversation.conversationSessionGeneration,
|
|
} } : {}),
|
|
} satisfies Parameters<ReturnType<typeof issueService>["createChild"]>[1];
|
|
const service = issueService(tx as unknown as Db);
|
|
const createdIssue = parentIssueId
|
|
? (await service.createChild(parentIssueId, createInput)).issue
|
|
: await service.create(issue.companyId, createInput);
|
|
|
|
const parentIdentifier =
|
|
createdByClientKey.get(task.parentClientKey ?? "")?.identifier ??
|
|
(parentIssueId ? parentById.get(parentIssueId)?.identifier : null) ??
|
|
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, issue.companyId);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
|
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
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,
|
|
mutationOptions,
|
|
);
|
|
case "request_confirmation":
|
|
case "request_checkbox_confirmation":
|
|
return rejectRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
mutationOptions,
|
|
});
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
): Promise<{
|
|
interaction: IssueThreadInteraction;
|
|
newlyResolvedItemIds: string[];
|
|
}> => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = submitIssueThreadInteractionVerdictsSchema.parse(input);
|
|
const submission = await db.transaction(async (tx) => {
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
// Match completion/confirmation ordering: issue -> interaction -> output.
|
|
// A pending multi-item review can contain already-approved output slots,
|
|
// so a replay must not lock those outputs ahead of task completion.
|
|
const lockedIssue = await tx.select({ id: issues.id, companyId: issues.companyId, status: issues.status })
|
|
.from(issues).where(eq(issues.id, issue.id)).for("update").then(rows => rows[0] ?? null);
|
|
if (!lockedIssue || lockedIssue.companyId !== issue.companyId) throw notFound("Issue not found");
|
|
assertIssueOpenForInteractionResolution(lockedIssue);
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
|
|
await assertRequestConfirmationTargetIsCurrent(tx, {
|
|
companyId: issue.companyId, issueId: issue.id,
|
|
target: interaction.payload.target, lockForUpdate: true,
|
|
});
|
|
for (const item of interaction.payload.items.filter(item => data.verdicts.some(verdict => verdict.id === item.id))) {
|
|
await assertRequestConfirmationTargetIsCurrent(tx, { companyId: issue.companyId, issueId: issue.id, target: item.target, lockForUpdate: true });
|
|
}
|
|
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;
|
|
if (complete) await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
else await notifyNativeStatusReassessment(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
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, issue.companyId);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
|
if (complete) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
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.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
const rows = await tx.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 (rows[0]) await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(rows[0]));
|
|
if (rows[0]) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(rows[0]));
|
|
return rows;
|
|
});
|
|
|
|
if (!updated) {
|
|
throw interactionAlreadyResolvedError();
|
|
}
|
|
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
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;
|
|
origin?: string | null;
|
|
},
|
|
actor: InteractionActor,
|
|
) => {
|
|
if (!comment.authorUserId || comment.origin === "fast_response") 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 [scope] = await db.select({ conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId })
|
|
.from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
|
|
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 (row.kind === "ask_user_questions" && scope?.conversationAgentId && scope.conversationUserId) return false;
|
|
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) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
else await notifyIssueReviewState(tx, row);
|
|
if (row.kind === "connection_intent") {
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(eq(toolOauthStates.interactionId, row.id));
|
|
}
|
|
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
|
const [resolved] = await 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 (!resolved) return null;
|
|
const interaction = hydrateInteraction(resolved);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
interaction,
|
|
);
|
|
return interaction;
|
|
});
|
|
if (updated) expired.push(updated);
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
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),
|
|
eq(issueComments.origin, "comment"),
|
|
),
|
|
)
|
|
.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 updatedInteractions = await db.transaction(async (tx) => {
|
|
await notifyChatRunMilestones(tx, sampleQuestionRow);
|
|
const updatedRows = await tx
|
|
.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();
|
|
const interactions = updatedRows.map(hydrateInteraction);
|
|
for (const interaction of interactions) {
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
interaction,
|
|
);
|
|
}
|
|
return interactions;
|
|
});
|
|
expired.push(...updatedInteractions);
|
|
}
|
|
|
|
if (confirmationRowIds.length > 0) {
|
|
const sampleConfirmationRow = commentRows.find((row) =>
|
|
isRequestConfirmationLikeKind(row.kind),
|
|
);
|
|
if (!sampleConfirmationRow) continue;
|
|
const updatedInteractions = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, sampleConfirmationRow);
|
|
if (commentRows.some(row => row.kind === "request_confirmation")) await notifyChatRunMilestones(tx, sampleConfirmationRow);
|
|
else await notifyIssueReviewState(tx, sampleConfirmationRow);
|
|
await notifyToolProjectionForInteractions(tx, issue.companyId, confirmationRowIds);
|
|
const updatedRows = await tx
|
|
.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();
|
|
const interactions = updatedRows.map(hydrateInteraction);
|
|
for (const interaction of interactions) {
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
interaction,
|
|
);
|
|
}
|
|
return interactions;
|
|
});
|
|
expired.push(...updatedInteractions);
|
|
}
|
|
|
|
for (const row of itemVerdictRows) {
|
|
const updated = await db.transaction(async tx => {
|
|
await notifyIssueReviewState(tx, row);
|
|
const [retired] = await 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 (retired) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(retired));
|
|
return retired;
|
|
});
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
for (const row of connectionIntentRows) {
|
|
const [updated] = await db.transaction(async (tx) => {
|
|
await notifyIssueReviewState(tx, row);
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
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, issue.companyId);
|
|
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.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (row.kind === "request_confirmation") await notifyChatRunMilestones(tx, row);
|
|
else await notifyIssueReviewState(tx, row);
|
|
const [resolved] = await tx
|
|
.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 (!resolved) return null;
|
|
const expiredInteraction = hydrateInteraction(resolved);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
expiredInteraction,
|
|
);
|
|
return expiredInteraction;
|
|
});
|
|
if (updated) expired.push(updated);
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expireConnectionIntentsForOwnershipChange: async (issue: { id: string; companyId: string }) => {
|
|
// A pending connection_intent asks for either an AI account or a tool
|
|
// account, and the two leave different things broken. One bulk write gave
|
|
// every row the AI wording, so an expired Notion request told the reader
|
|
// the AI account was missing. Read the pending rows first and name the
|
|
// subject each row actually asked for.
|
|
const pending = await db
|
|
.select({ id: issueThreadInteractions.id, payload: issueThreadInteractions.payload })
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, "connection_intent"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
const expired: typeof issueThreadInteractions.$inferSelect[] = [];
|
|
for (const row of pending) {
|
|
const payload = row.payload as { purpose?: string; serviceName?: string } | null;
|
|
const subject = payload?.purpose === "ai"
|
|
? "the AI account"
|
|
: `the ${payload?.serviceName?.trim() || "requested"} connection`;
|
|
const updated = await db.transaction(async tx => {
|
|
await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
await notifyChatConfirmationResolution(tx, { companyId: issue.companyId, issueId: issue.id, kind: "connection_intent" });
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "expired",
|
|
reason: `The task was reassigned before the connection request was answered, so ${subject} is still not connected. Reconnect it or choose an available connection.`,
|
|
},
|
|
resolvedAt: now(),
|
|
updatedAt: now(),
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (resolved) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(resolved));
|
|
return resolved;
|
|
});
|
|
if (updated) expired.push(updated);
|
|
}
|
|
if (expired.length) await db.delete(toolOauthStates).where(inArray(toolOauthStates.interactionId, expired.map((item) => item.id)));
|
|
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"),
|
|
// Completed work retains ordinary historical questions for later
|
|
// human answers. Cancellation and governed requests still expire.
|
|
issue.status === "done" ? sql`not ${historicalQuestionCondition()}` : undefined,
|
|
),
|
|
);
|
|
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) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
else await notifyIssueReviewState(tx, row);
|
|
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();
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(resolved),
|
|
);
|
|
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, issue.companyId);
|
|
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 assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
|
else await notifyIssueReviewState(tx, current);
|
|
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();
|
|
const withdrawn = hydrateInteraction(row);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, withdrawn);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
withdrawn,
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
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,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
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,
|
|
textQuestionIds: new Set((interaction.payload.questionSet?.questions ?? [])
|
|
.filter((question) => question.answerMode === "text")
|
|
.map((question) => question.id)),
|
|
answers: input.answers,
|
|
});
|
|
if (interaction.payload.questionSet) {
|
|
try {
|
|
await parseQuestionInteractionAnswers(interaction.payload.questionSet, normalizedAnswers, interaction.payload.questions);
|
|
} catch (error) {
|
|
throw unprocessable(
|
|
error instanceof Error ? error.message : "Invalid question response",
|
|
{ code: "invalid_question_response" },
|
|
);
|
|
}
|
|
}
|
|
|
|
const updated = await db.transaction(async (tx) => {
|
|
// Serialize against task completion/cancellation and use the persisted
|
|
// status, including when the caller read the task before it closed.
|
|
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) throw interactionNotFoundError();
|
|
let historicalAnswer = false;
|
|
if (isTerminalIssueStatus(issueRow.status)) {
|
|
if (issueRow.status === "done" && actor.userId && !actor.agentId && !actor.runId && !actor.systemId) {
|
|
historicalAnswer = (await tx.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions)
|
|
.where(and(eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id),
|
|
historicalQuestionCondition())).limit(1)).length > 0;
|
|
}
|
|
if (!historicalAnswer) throw interactionIssueClosedError();
|
|
}
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatRunMilestones(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(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;
|
|
// This answer updates conversation history only. It must not resume
|
|
// the completed source run or enqueue new work for the closed task.
|
|
if (!historicalAnswer) {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.question);
|
|
await tx
|
|
.insert(issueQuestionResponseDeliveries)
|
|
.values(questionResponseDeliveryValues(answered));
|
|
}
|
|
// Provider callbacks use this hook to atomically claim and complete
|
|
// the action that resolved the interaction. Settle all remaining
|
|
// provider controls only after that winner is durable; otherwise the
|
|
// generic sibling-expiry pass would invalidate a Slack/Teams modal
|
|
// submit token before it can be claimed in this same transaction.
|
|
await mutationOptions.afterResolveInTransaction?.(tx, answered);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
answered,
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
const answered = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, answered);
|
|
return answered;
|
|
},
|
|
|
|
dismissInteraction: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
actor: InteractionActor,
|
|
) => {
|
|
if (!actor.userId || actor.agentId || actor.runId || actor.systemId) {
|
|
throw forbidden("Only a human can dismiss a request");
|
|
}
|
|
return db.transaction(async (tx) => {
|
|
// Use the same lock order as resolution and completion. Dismissal only
|
|
// changes attention state; it never resolves or executes the request.
|
|
const [task] = await tx.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
workMode: issues.workMode,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
}).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))).for("update");
|
|
if (!task) throw interactionNotFoundError();
|
|
const [current] = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, interactionId), eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
)).for("update");
|
|
if (!current) throw interactionNotFoundError();
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
if (current.status !== "pending" || current.dismissedAt) return hydrateInteraction(current);
|
|
if (task.status === "in_review" && isRequestConfirmationLikeKind(current.kind)) {
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(tx as unknown as Db, task, current, actor);
|
|
}
|
|
const now = new Date();
|
|
const [updated] = await tx.update(issueThreadInteractions)
|
|
.set({ dismissedAt: now, dismissedByUserId: actor.userId, updatedAt: now })
|
|
.where(eq(issueThreadInteractions.id, interactionId)).returning();
|
|
return hydrateInteraction(updated!);
|
|
});
|
|
},
|
|
|
|
skipInteraction: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: SkipIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = skipIssueThreadInteractionSchema.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();
|
|
const updated = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
|
else await notifyIssueReviewState(tx, current);
|
|
await resolveLinkedToolActionRequests(tx, current, {
|
|
status: "cancelled",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, current, {
|
|
status: "withdrawn",
|
|
actor,
|
|
reason: reason ?? "Skipped from the task composer",
|
|
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 has begun executing and can no longer be skipped",
|
|
);
|
|
}
|
|
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: buildSkippedOutcomeResult(current, 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 enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(row),
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
const skipped = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, skipped);
|
|
return skipped;
|
|
},
|
|
|
|
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) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatRunMilestones(tx, current);
|
|
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();
|
|
const cancelled = hydrateInteraction(row);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, cancelled);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
cancelled,
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id, issue.companyId);
|
|
const cancelled = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, cancelled);
|
|
return cancelled;
|
|
},
|
|
};
|
|
}
|