mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
Apply existing bounded transient-disconnect retries to audited dashboard, heartbeat-list, and base issue reads. Rebuild each query per attempt and preserve authorization, company scope, enrichment, and final error propagation. Validation: exact-head Greptile 5/5, passing CI, no unresolved review threads, and a clean merge. Detailed verification and limitations are recorded in the pull request. Co-Authored-By: Paperclip <noreply@paperclip.ing>
13473 lines
457 KiB
TypeScript
13473 lines
457 KiB
TypeScript
import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js";
|
|
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
|
|
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
|
|
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
|
|
import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js";
|
|
import { documentService } from "./documents.js";
|
|
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "./task-search.js";
|
|
import { createdFromIssueCondition } from "./issue-creation-origin.js";
|
|
import { executionProjectionsForRuns } from "./execution-projection.js";
|
|
import type { ExecutionProjection } from "@paperclipai/shared";
|
|
import { Buffer } from "node:buffer";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { isExplicitContinuationRetryClaim } from "./explicit-continuation-retry-claim.js";
|
|
import { markdownToPlainText, parseMarkdown } from "chat";
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
gt,
|
|
getTableColumns,
|
|
gte,
|
|
inArray,
|
|
isNotNull,
|
|
isNull,
|
|
like,
|
|
lt,
|
|
ne,
|
|
notExists,
|
|
notInArray,
|
|
or,
|
|
sql,
|
|
type SQL,
|
|
} from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
activityLog,
|
|
chatActions,
|
|
chatConversations,
|
|
chatDeliveries,
|
|
chatEndpoints,
|
|
chatMessageLinks,
|
|
chatPublications,
|
|
agentWakeupRequests,
|
|
agents,
|
|
authUsers,
|
|
approvals,
|
|
assets,
|
|
companies,
|
|
companyMemberships,
|
|
documentRevisions,
|
|
documents,
|
|
goals,
|
|
heartbeatRuns,
|
|
routineRuns,
|
|
executionWorkspaces,
|
|
issueApprovals,
|
|
issueAttachments,
|
|
issueCreateIdempotencyKeys,
|
|
issueInboxArchives,
|
|
issueLabels,
|
|
issueWatchdogs,
|
|
issuePlanDecompositions,
|
|
issueRecoveryActions,
|
|
issueRelations,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueQuestionResponseDeliveries,
|
|
issueWorkProducts,
|
|
issueReadStates,
|
|
issueThreadInteractions,
|
|
toolActionRequests,
|
|
toolActionDeliveries,
|
|
toolInvocations,
|
|
issues,
|
|
labels,
|
|
projectWorkspaces,
|
|
projects,
|
|
toolConnections,
|
|
workspaceOperations,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AcceptedPlanDecomposition,
|
|
IssueComment,
|
|
IssueCommentAuthorType,
|
|
IssueCommentDerivedAuthorSource,
|
|
IssueCommentMetadata,
|
|
IssueCommentPresentation,
|
|
IssueBlockerAttention,
|
|
IssueReviewAttention,
|
|
IssueReviewAttentionPath,
|
|
IssueBlockedInboxAttention,
|
|
IssueBlockedInboxIssueRef,
|
|
IssueRelationIssueSummary,
|
|
IssueWatchdogSummary,
|
|
LowTrustBoundary,
|
|
SuccessfulRunHandoffState,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
clampIssueRequestDepth,
|
|
extractAgentMentionIds,
|
|
extractProjectMentionIds,
|
|
issueCommentAuthorTypeSchema,
|
|
issueCommentMetadataSchema,
|
|
issueCommentPresentationSchema,
|
|
isUuidLike,
|
|
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
|
import { isForeignKeyViolation } from "../db-errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { parseObject } from "../adapters/utils.js";
|
|
import {
|
|
hydrateSuccessfulRunHandoffLiveness,
|
|
SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES,
|
|
} from "./successful-run-handoff-state.js";
|
|
import {
|
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
|
gateProjectExecutionWorkspacePolicy,
|
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
|
isUnrunnableWorktreeCombo,
|
|
parseIssueExecutionWorkspaceSettings,
|
|
parseProjectExecutionWorkspacePolicy,
|
|
resolvePinnedIssueWorkspaceStrategyType,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
type ParsedExecutionWorkspaceMode,
|
|
} from "./execution-workspace-policy.js";
|
|
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
|
import { hasChatRunOwnedProviderInteraction } from "./chat-interaction-arbitration.js";
|
|
import { readChatControlChronology } from "./chat-control-chronology.js";
|
|
import { authorizeChatConversationForBoundRun } from "./native-runtime/chat-attachment-reuse.js";
|
|
import {
|
|
assertDurableChatWakeupReceipt,
|
|
createDurableChatWakeupRequest,
|
|
} from "./durable-chat-wakeup.js";
|
|
import {
|
|
authorizeNativeChatReviewPresentation,
|
|
retryNativeChatReviewPresentation,
|
|
} from "./native-runtime/native-chat-review-presentation.js";
|
|
import {
|
|
buildInitialIssueMonitorFields,
|
|
normalizeIssueExecutionPolicy,
|
|
} from "./issue-execution-policy.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { redactCurrentUserText } from "../log-redaction.js";
|
|
import { redactSensitiveText } from "../redaction.js";
|
|
import {
|
|
resolveIssueGoalId,
|
|
resolveNextIssueGoalId,
|
|
} from "./issue-goal-fallback.js";
|
|
import { getRunLogStore } from "./run-log-store.js";
|
|
import { getDefaultCompanyGoal } from "./goals.js";
|
|
import { assertAssignableAgent } from "./agent-assignability.js";
|
|
import {
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON,
|
|
isExternalChatPresentationContext,
|
|
LEGACY_WITHHELD_RUN_COMMENT,
|
|
projectHistoricalHeartbeatRunComment,
|
|
} from "./heartbeat-run-summary.js";
|
|
import {
|
|
DEFAULT_INSERT_CHUNK_ROWS,
|
|
insertRowsInChunks,
|
|
} from "./batch-insert.js";
|
|
import type {
|
|
ImportIssueRow,
|
|
ImportIssueCommentRow,
|
|
ImportIssueAttachmentRow,
|
|
} from "./import-write-types.js";
|
|
import {
|
|
summarizeIssueWatchdog,
|
|
upsertIssueWatchdogForIssue,
|
|
} from "./task-watchdogs.js";
|
|
import {
|
|
isVerifiedIssueTreeControlInteractionWake,
|
|
issueTreeControlService,
|
|
type ActiveIssueTreePauseHoldGate,
|
|
} from "./issue-tree-control.js";
|
|
import {
|
|
parseIssueGraphLivenessIncidentKey,
|
|
RECOVERY_ORIGIN_KINDS,
|
|
} from "./recovery/origins.js";
|
|
import {
|
|
classifyIssueGraphLiveness,
|
|
classifyIssueReviewPaths,
|
|
type IssueGraphLivenessInput,
|
|
type IssueLivenessFinding,
|
|
} from "./recovery/issue-graph-liveness.js";
|
|
import { visibleIssueCondition } from "./issue-visibility.js";
|
|
import { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js";
|
|
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
|
|
import {
|
|
logActivity,
|
|
persistActivity,
|
|
publishActivity,
|
|
type ActivityPublication,
|
|
} from "./activity-log.js";
|
|
import { buildIssueChanges } from "./issue-change-receipt.js";
|
|
import { projectSafeChatPublication } from "./chat-publication-projection.js";
|
|
import { issueThreadInteractionAttentionAgentAllowed } from "./issue-thread-interaction-resolution.js";
|
|
|
|
const ALL_ISSUE_STATUSES = [
|
|
"backlog",
|
|
"todo",
|
|
"in_progress",
|
|
"in_review",
|
|
"blocked",
|
|
"done",
|
|
"cancelled",
|
|
];
|
|
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
|
const MAX_CHAT_PRESENTATION_ATTACHMENTS = 20;
|
|
export const ISSUE_LIST_DEFAULT_LIMIT = 500;
|
|
export const ISSUE_LIST_MAX_LIMIT = 1000;
|
|
export const ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS = 100;
|
|
export const ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS = 50;
|
|
export const ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS = 50;
|
|
export const ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS = 14;
|
|
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH = 8;
|
|
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES = 100;
|
|
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE = 20;
|
|
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE = 5;
|
|
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE = 5;
|
|
const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
|
|
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
|
|
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
|
|
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
|
|
// Non-human author sentinels that agents post under. These ARE eligible for
|
|
// agent-attribution derivation even though `local-board` is also materialized
|
|
// as a row in the `user` table (it is the implicit board admin). Genuine human
|
|
// users — real signups with their own ids — are never reattributed.
|
|
const NON_HUMAN_SENTINEL_AUTHOR_USER_IDS = new Set<string>(["local-board"]);
|
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
|
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
|
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
|
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
|
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS = 3_000;
|
|
export const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS = 7;
|
|
const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS =
|
|
ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
const ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE = 500;
|
|
const DELETED_ISSUE_COMMENT_BODY = "";
|
|
const ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS = [
|
|
"issue.tree_hold_wakeup_deferred",
|
|
] as const;
|
|
|
|
export type IssuePostCommitAction = {
|
|
type: "cancel_native_question_run";
|
|
runId: string;
|
|
issueId: string;
|
|
issueStatus: string;
|
|
};
|
|
|
|
/** Execute side effects that must never run before the issue transaction commits. */
|
|
export async function executeIssuePostCommitActions(
|
|
db: Db,
|
|
actions: readonly IssuePostCommitAction[],
|
|
): Promise<void> {
|
|
if (actions.length === 0) return;
|
|
const { heartbeatService } = await import("./heartbeat.js");
|
|
const heartbeat = heartbeatService(db);
|
|
const cancelledRunIds = new Set<string>();
|
|
for (const action of actions) {
|
|
if (cancelledRunIds.has(action.runId)) continue;
|
|
cancelledRunIds.add(action.runId);
|
|
try {
|
|
await heartbeat.cancelRun(
|
|
action.runId,
|
|
"Task closed while waiting for operator input",
|
|
{
|
|
resultJson: {
|
|
cancelledByIssueStatus: action.issueStatus,
|
|
cancelledIssueId: action.issueId,
|
|
},
|
|
},
|
|
);
|
|
} catch (err) {
|
|
// The durable marker written by the issue transaction remains available
|
|
// to startup and periodic recovery. Do not report a post-commit failure
|
|
// as though the already-committed issue transition had rolled back.
|
|
logger.warn(
|
|
{ err, runId: action.runId, issueId: action.issueId },
|
|
"native question cancellation deferred to recovery sweep",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function wakeRequestTargetsIssue(issueId: string) {
|
|
return sql`(
|
|
${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}
|
|
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issueId}
|
|
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issueId}
|
|
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issueId}
|
|
)`;
|
|
}
|
|
|
|
function wakeDiagnosticActivityTargetsIssue(issueId: string) {
|
|
return sql`(
|
|
(${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId})
|
|
or ${activityLog.details} ->> 'issueId' = ${issueId}
|
|
or ${activityLog.details} ->> 'rootIssueId' = ${issueId}
|
|
)`;
|
|
}
|
|
|
|
function assertTransition(from: string, to: string) {
|
|
if (from === to) return;
|
|
if (!ALL_ISSUE_STATUSES.includes(to)) {
|
|
throw conflict(`Unknown issue status: ${to}`);
|
|
}
|
|
}
|
|
|
|
function applyStatusSideEffects(
|
|
status: string | undefined,
|
|
patch: Partial<typeof issues.$inferInsert>,
|
|
): Partial<typeof issues.$inferInsert> {
|
|
if (!status) return patch;
|
|
|
|
if (status === "in_progress" && !patch.startedAt) {
|
|
patch.startedAt = new Date();
|
|
}
|
|
if (status === "done") {
|
|
patch.completedAt = new Date();
|
|
}
|
|
if (status === "cancelled") {
|
|
patch.cancelledAt = new Date();
|
|
}
|
|
return patch;
|
|
}
|
|
|
|
function workspaceWorktreeRequiresProjectDetails() {
|
|
return {
|
|
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
};
|
|
}
|
|
|
|
function assertExplicitPinnedWorktreeIssueRunnable(input: {
|
|
projectId: string | null | undefined;
|
|
projectWorkspaceId: string | null | undefined;
|
|
executionWorkspaceId: string | null | undefined;
|
|
executionWorkspacePreference: string | null | undefined;
|
|
executionWorkspaceSettings: unknown;
|
|
}) {
|
|
const settings = parseIssueExecutionWorkspaceSettings(
|
|
input.executionWorkspaceSettings,
|
|
);
|
|
const mode = settings?.mode;
|
|
if (mode !== "isolated_workspace" && mode !== "operator_branch") return;
|
|
|
|
const resolvedMode = mode as ParsedExecutionWorkspaceMode;
|
|
if (
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: input.projectId ?? null,
|
|
projectWorkspaceId: input.projectWorkspaceId ?? null,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
executionWorkspacePreference:
|
|
input.executionWorkspacePreference ?? null,
|
|
},
|
|
resolvedMode,
|
|
resolvedStrategy: resolvePinnedIssueWorkspaceStrategyType({
|
|
mode: resolvedMode,
|
|
issueSettings: settings,
|
|
}),
|
|
hasResolvablePriorSessionWorkspace: false,
|
|
})
|
|
) {
|
|
throw unprocessable(
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
workspaceWorktreeRequiresProjectDetails(),
|
|
);
|
|
}
|
|
}
|
|
|
|
function readStringFromRecord(record: unknown, key: string) {
|
|
if (!record || typeof record !== "object") return null;
|
|
const value = (record as Record<string, unknown>)[key];
|
|
return typeof value === "string" && value.trim().length > 0
|
|
? value.trim()
|
|
: null;
|
|
}
|
|
|
|
async function resolveResponsibleUserIdForIssueCreate(
|
|
reader: DbReader,
|
|
companyId: string,
|
|
input: {
|
|
explicitResponsibleUserId?: string | null;
|
|
createdByUserId?: string | null;
|
|
parentId?: string | null;
|
|
originKind?: string | null;
|
|
originRunId?: string | null;
|
|
actorRunId?: string | null;
|
|
actorResponsibleUserId?: string | null;
|
|
trustExplicitResponsibleUserId?: boolean;
|
|
},
|
|
) {
|
|
const explicitResponsibleUserId = readStringFromRecord(
|
|
input,
|
|
"explicitResponsibleUserId",
|
|
);
|
|
if (
|
|
explicitResponsibleUserId &&
|
|
input.trustExplicitResponsibleUserId === true
|
|
)
|
|
return explicitResponsibleUserId;
|
|
|
|
if (input.originKind === "routine_execution" && input.originRunId) {
|
|
const routineRun = await reader
|
|
.select({ responsibleUserId: routineRuns.responsibleUserId })
|
|
.from(routineRuns)
|
|
.where(
|
|
and(
|
|
eq(routineRuns.companyId, companyId),
|
|
eq(routineRuns.id, input.originRunId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (routineRun?.responsibleUserId) return routineRun.responsibleUserId;
|
|
}
|
|
|
|
const actorResponsibleUserId = readStringFromRecord(
|
|
input,
|
|
"actorResponsibleUserId",
|
|
);
|
|
if (actorResponsibleUserId) return actorResponsibleUserId;
|
|
|
|
if (input.actorRunId) {
|
|
const actorRun = await reader
|
|
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.id, input.actorRunId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (actorRun?.responsibleUserId) return actorRun.responsibleUserId;
|
|
}
|
|
|
|
if (input.parentId) {
|
|
const parent = await reader
|
|
.select({
|
|
responsibleUserId: issues.responsibleUserId,
|
|
createdByUserId: issues.createdByUserId,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(eq(issues.companyId, companyId), eq(issues.id, input.parentId)),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (parent?.responsibleUserId) return parent.responsibleUserId;
|
|
if (parent?.createdByUserId) return parent.createdByUserId;
|
|
}
|
|
|
|
return input.createdByUserId ?? null;
|
|
}
|
|
|
|
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
|
|
settings: ReturnType<typeof parseIssueExecutionWorkspaceSettings>,
|
|
) {
|
|
return {
|
|
environmentId: settings?.environmentId ?? null,
|
|
provisionCommand: settings?.workspaceStrategy?.provisionCommand ?? null,
|
|
runtimeProvisionCommand:
|
|
settings?.workspaceStrategy?.runtimeProvisionCommand ?? null,
|
|
teardownCommand: settings?.workspaceStrategy?.teardownCommand ?? null,
|
|
workspaceRuntime: settings?.workspaceRuntime ?? null,
|
|
};
|
|
}
|
|
|
|
// Accepted-plan children are not realized yet, so carry only unresolved
|
|
// workspace intent and let the first child run render/persist its own branch.
|
|
function buildPreRealizationExecutionWorkspaceSettings(
|
|
raw: unknown,
|
|
): Record<string, unknown> | null {
|
|
const settings = parseIssueExecutionWorkspaceSettings(raw, {
|
|
includeEnvironmentId: true,
|
|
});
|
|
if (!settings) return null;
|
|
const mode =
|
|
settings.mode &&
|
|
settings.mode !== "inherit" &&
|
|
settings.mode !== "reuse_existing"
|
|
? settings.mode
|
|
: null;
|
|
const next: Record<string, unknown> = {};
|
|
if (mode) next.mode = mode;
|
|
if (settings.environmentId !== undefined)
|
|
next.environmentId = settings.environmentId;
|
|
if (settings.workspaceRuntime)
|
|
next.workspaceRuntime = settings.workspaceRuntime;
|
|
if (settings.workspaceStrategy) {
|
|
next.workspaceStrategy = {
|
|
type: settings.workspaceStrategy.type,
|
|
...(settings.workspaceStrategy.baseRef
|
|
? { baseRef: settings.workspaceStrategy.baseRef }
|
|
: {}),
|
|
...(settings.workspaceStrategy.branchTemplate
|
|
? { branchTemplate: settings.workspaceStrategy.branchTemplate }
|
|
: {}),
|
|
...(settings.workspaceStrategy.existingBranch
|
|
? { existingBranch: settings.workspaceStrategy.existingBranch }
|
|
: {}),
|
|
...(settings.workspaceStrategy.worktreeParentDir
|
|
? { worktreeParentDir: settings.workspaceStrategy.worktreeParentDir }
|
|
: {}),
|
|
...(settings.workspaceStrategy.provisionCommand
|
|
? { provisionCommand: settings.workspaceStrategy.provisionCommand }
|
|
: {}),
|
|
...(settings.workspaceStrategy.runtimeProvisionCommand
|
|
? {
|
|
runtimeProvisionCommand:
|
|
settings.workspaceStrategy.runtimeProvisionCommand,
|
|
}
|
|
: {}),
|
|
...(settings.workspaceStrategy.teardownCommand
|
|
? { teardownCommand: settings.workspaceStrategy.teardownCommand }
|
|
: {}),
|
|
};
|
|
}
|
|
return Object.keys(next).length > 0 ? next : null;
|
|
}
|
|
|
|
function toTimestampMs(value: Date | string | null | undefined) {
|
|
if (!value) return null;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
const timestamp = date.getTime();
|
|
return Number.isFinite(timestamp) ? timestamp : null;
|
|
}
|
|
|
|
type IssueCommentRunLogAttributionCandidate = {
|
|
id: string;
|
|
createdAt: Date | string;
|
|
authorAgentId?: string | null;
|
|
authorUserId?: string | null;
|
|
createdByRunId?: string | null;
|
|
};
|
|
|
|
type IssueCommentRunLogAttributionRun = {
|
|
runId: string;
|
|
agentId: string;
|
|
createdAt: Date | string;
|
|
startedAt?: Date | string | null;
|
|
finishedAt?: Date | string | null;
|
|
// Best-effort run log text. May be empty when logs were not read for a tier
|
|
// that does not need them (run-id / run-window-unique); only the
|
|
// `run_log_comment_post` tier consults this.
|
|
logContent: string;
|
|
};
|
|
|
|
type DerivedIssueCommentAttribution = {
|
|
derivedAuthorAgentId: string;
|
|
derivedCreatedByRunId: string;
|
|
derivedAuthorSource: IssueCommentDerivedAuthorSource;
|
|
};
|
|
|
|
/**
|
|
* Resolve a `created_by_run_id` safe for the heartbeat_runs FK; returns null for
|
|
* missing/invalid ids so an unknown run id never 500s a comment insert.
|
|
*/
|
|
async function resolveCommentCreatedByRun(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
runId: string | null | undefined,
|
|
): Promise<{ id: string; contextSnapshot: unknown } | null> {
|
|
const normalized = typeof runId === "string" ? runId.trim() : "";
|
|
if (!normalized || !isUuidLike(normalized)) return null;
|
|
const existing = await dbOrTx
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, normalized),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
),
|
|
)
|
|
.then(
|
|
(rows: Array<{ id: string; contextSnapshot: unknown }>) =>
|
|
rows[0] ?? null,
|
|
);
|
|
return existing;
|
|
}
|
|
|
|
export type ChatPublicationBinding = {
|
|
companyId: string;
|
|
conversationId: string;
|
|
endpointId: string;
|
|
};
|
|
|
|
async function resolvePublishedInteractionPromptBindings(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueId: string,
|
|
interactionId: string,
|
|
agentId: string,
|
|
): Promise<ChatPublicationBinding[]> {
|
|
const [publications, processedActions] = (await Promise.all([
|
|
dbOrTx
|
|
.select({
|
|
companyId: chatConversations.companyId,
|
|
conversationId: chatConversations.id,
|
|
endpointId: chatConversations.endpointId,
|
|
idempotencyKey: chatPublications.idempotencyKey,
|
|
payload: chatPublications.payload,
|
|
})
|
|
.from(chatPublications)
|
|
.innerJoin(
|
|
chatConversations,
|
|
and(
|
|
eq(chatConversations.companyId, chatPublications.companyId),
|
|
eq(chatConversations.endpointId, chatPublications.endpointId),
|
|
eq(chatConversations.id, chatPublications.conversationId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
and(
|
|
eq(chatEndpoints.companyId, chatConversations.companyId),
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, companyId),
|
|
eq(chatPublications.issueId, issueId),
|
|
eq(chatPublications.state, "published"),
|
|
eq(chatConversations.issueId, issueId),
|
|
eq(chatEndpoints.assignedAgentId, agentId),
|
|
eq(
|
|
sql<string>`${chatPublications.payload}->>'interactionId'`,
|
|
interactionId,
|
|
),
|
|
notExists(
|
|
dbOrTx
|
|
.select({ id: chatPublications.id })
|
|
.from(chatPublications)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.conversationId, chatConversations.id),
|
|
eq(chatPublications.state, "published"),
|
|
or(
|
|
like(chatPublications.idempotencyKey, "control:new:%"),
|
|
like(chatPublications.idempotencyKey, "control:close:%"),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
conversationId: chatActions.conversationId,
|
|
kind: chatActions.kind,
|
|
result: chatActions.result,
|
|
})
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, companyId),
|
|
eq(chatActions.status, "processed"),
|
|
or(
|
|
inArray(chatActions.kind, [
|
|
"question_answer",
|
|
"question_form_submit",
|
|
]),
|
|
and(
|
|
eq(chatActions.kind, "confirmation_response"),
|
|
eq(
|
|
sql<string>`${chatActions.result}->>'code'`,
|
|
"confirmation_resolution_committed_by_provider",
|
|
),
|
|
),
|
|
),
|
|
eq(
|
|
sql<string>`${chatActions.payload}->>'interactionId'`,
|
|
interactionId,
|
|
),
|
|
),
|
|
),
|
|
])) as [
|
|
Array<
|
|
ChatPublicationBinding & {
|
|
idempotencyKey: string;
|
|
payload: { card?: { actions?: Array<{ type: string }> } };
|
|
}
|
|
>,
|
|
Array<{ conversationId: string }>,
|
|
];
|
|
const providerWinnerConversationIds = new Set(
|
|
processedActions.map(
|
|
(action: { conversationId: string }) => action.conversationId,
|
|
),
|
|
);
|
|
const exactProviderWinner = providerWinnerConversationIds.size > 0;
|
|
const bindings = publications
|
|
.filter(
|
|
(publication) =>
|
|
publication.idempotencyKey ===
|
|
`interaction:${interactionId}:${publication.endpointId}` &&
|
|
(!exactProviderWinner ||
|
|
providerWinnerConversationIds.has(publication.conversationId)),
|
|
)
|
|
.map((publication) => ({
|
|
companyId: publication.companyId,
|
|
conversationId: publication.conversationId,
|
|
endpointId: publication.endpointId,
|
|
}));
|
|
return [
|
|
...new Map(
|
|
bindings.map((binding) => [binding.conversationId, binding]),
|
|
).values(),
|
|
];
|
|
}
|
|
|
|
function readChatWakeCommentIds(
|
|
contextSnapshot: Record<string, unknown>,
|
|
): string[] {
|
|
const ids: string[] = [];
|
|
const append = (value: unknown) => {
|
|
if (typeof value !== "string") return;
|
|
const normalized = value.trim();
|
|
if (!normalized || !isUuidLike(normalized) || ids.includes(normalized)) {
|
|
return;
|
|
}
|
|
ids.push(normalized);
|
|
};
|
|
|
|
const batched = contextSnapshot.wakeCommentIds;
|
|
if (Array.isArray(batched)) {
|
|
for (const value of batched) append(value);
|
|
}
|
|
append(contextSnapshot.wakeCommentId);
|
|
append(contextSnapshot.commentId);
|
|
return ids;
|
|
}
|
|
|
|
/** A close retires its admitted sources, not every future source in a reusable
|
|
* provider thread. This exception requires a complete, independently admitted
|
|
* fresh batch; active state or copied run context alone never crosses a close.
|
|
* It grants presentation only. Provider dispatch retains its own current gate. */
|
|
async function freshChatSourceAfterPublishedControl(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueId: string,
|
|
runId: string,
|
|
binding: ChatPublicationBinding,
|
|
): Promise<boolean> {
|
|
const [run] = (await dbOrTx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)),
|
|
)
|
|
.limit(1)) as Array<typeof heartbeatRuns.$inferSelect>;
|
|
const context = run?.contextSnapshot ?? {};
|
|
const commentIds = readChatWakeCommentIds(context);
|
|
if (
|
|
!run?.wakeupRequestId ||
|
|
!run.startedAt ||
|
|
!commentIds.length ||
|
|
commentIds.length > 50 ||
|
|
(run.nativeIssueId !== null && run.nativeIssueId !== issueId) ||
|
|
(context.issueId ?? context.taskId) !== issueId
|
|
)
|
|
return false;
|
|
const [owner] = (await dbOrTx
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.id, run.wakeupRequestId),
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, run.agentId),
|
|
eq(agentWakeupRequests.runId, run.id),
|
|
),
|
|
)
|
|
.limit(1)) as Array<typeof agentWakeupRequests.$inferSelect>;
|
|
if (!owner || ["skipped", "cancelled"].includes(owner.status)) return false;
|
|
const admitted = (await dbOrTx
|
|
.select({ commentId: sql<string>`${chatActions.payload}->>'commentId'` })
|
|
.from(chatActions)
|
|
.innerJoin(
|
|
agentWakeupRequests,
|
|
and(
|
|
eq(agentWakeupRequests.id, chatActions.id),
|
|
eq(agentWakeupRequests.companyId, chatActions.companyId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, companyId),
|
|
eq(chatActions.kind, "inbound_wakeup"),
|
|
or(
|
|
eq(agentWakeupRequests.runId, run.id),
|
|
eq(agentWakeupRequests.id, owner.id),
|
|
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${owner.id}`,
|
|
),
|
|
),
|
|
)
|
|
.limit(51)) as Array<{ commentId: string }>;
|
|
if (
|
|
admitted.length !== commentIds.length ||
|
|
admitted.some((row) => !commentIds.includes(row.commentId))
|
|
)
|
|
return false;
|
|
const [latestControl] = (await dbOrTx
|
|
.select({ publishedAt: chatPublications.publishedAt })
|
|
.from(chatPublications)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, companyId),
|
|
eq(chatPublications.endpointId, binding.endpointId),
|
|
eq(chatPublications.conversationId, binding.conversationId),
|
|
eq(chatPublications.state, "published"),
|
|
or(
|
|
like(chatPublications.idempotencyKey, "control:new:%"),
|
|
like(chatPublications.idempotencyKey, "control:close:%"),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(chatPublications.publishedAt))
|
|
.limit(1)) as Array<{ publishedAt: Date | null }>;
|
|
if (!latestControl?.publishedAt) return false;
|
|
const rows = (await dbOrTx
|
|
.select({
|
|
action: chatActions,
|
|
receipt: agentWakeupRequests,
|
|
link: chatMessageLinks,
|
|
delivery: chatDeliveries,
|
|
comment: issueComments,
|
|
conversation: chatConversations,
|
|
endpoint: chatEndpoints,
|
|
connection: toolConnections,
|
|
})
|
|
.from(chatActions)
|
|
.innerJoin(
|
|
agentWakeupRequests,
|
|
and(
|
|
eq(agentWakeupRequests.id, chatActions.id),
|
|
eq(agentWakeupRequests.companyId, chatActions.companyId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
chatMessageLinks,
|
|
and(
|
|
eq(chatMessageLinks.companyId, chatActions.companyId),
|
|
eq(chatMessageLinks.endpointId, chatActions.endpointId),
|
|
eq(chatMessageLinks.conversationId, chatActions.conversationId),
|
|
eq(chatMessageLinks.deliveryId, chatActions.deliveryId),
|
|
sql`${chatMessageLinks.commentId}::text = ${chatActions.payload}->>'commentId'`,
|
|
eq(chatMessageLinks.direction, "inbound"),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
chatDeliveries,
|
|
and(
|
|
eq(chatDeliveries.id, chatActions.deliveryId),
|
|
eq(chatDeliveries.companyId, chatActions.companyId),
|
|
eq(chatDeliveries.endpointId, chatActions.endpointId),
|
|
eq(chatDeliveries.conversationId, chatActions.conversationId),
|
|
eq(chatDeliveries.principalId, chatActions.principalId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
issueComments,
|
|
and(
|
|
eq(issueComments.id, chatMessageLinks.commentId),
|
|
eq(issueComments.companyId, companyId),
|
|
eq(issueComments.issueId, issueId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
chatConversations,
|
|
and(
|
|
eq(chatConversations.id, binding.conversationId),
|
|
eq(chatConversations.id, chatActions.conversationId),
|
|
eq(chatConversations.companyId, companyId),
|
|
eq(chatConversations.issueId, issueId),
|
|
eq(chatConversations.endpointId, binding.endpointId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
and(
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
eq(chatEndpoints.companyId, companyId),
|
|
eq(chatEndpoints.assignedAgentId, run.agentId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
issues,
|
|
and(
|
|
eq(issues.id, issueId),
|
|
eq(issues.companyId, companyId),
|
|
eq(issues.assigneeAgentId, run.agentId),
|
|
isNull(issues.hiddenAt),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
toolConnections,
|
|
and(
|
|
eq(toolConnections.id, chatEndpoints.connectionId),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, companyId),
|
|
eq(chatActions.kind, "inbound_wakeup"),
|
|
or(
|
|
eq(agentWakeupRequests.runId, run.id),
|
|
eq(agentWakeupRequests.id, owner.id),
|
|
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${owner.id}`,
|
|
),
|
|
isNull(issueComments.deletedAt),
|
|
sql`${issueComments.updatedAt} = ${issueComments.createdAt}`,
|
|
sql`${issueComments.createdAt} <= (select started_at from heartbeat_runs where id = ${run.id})`,
|
|
// Compare in SQL, retaining microseconds. A delayed pre-close source or
|
|
// a source preceding any later close remains internal after reopening.
|
|
notExists(
|
|
dbOrTx
|
|
.select({ id: chatPublications.id })
|
|
.from(chatPublications)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, companyId),
|
|
eq(chatPublications.endpointId, binding.endpointId),
|
|
eq(chatPublications.conversationId, binding.conversationId),
|
|
eq(chatPublications.state, "published"),
|
|
or(
|
|
like(chatPublications.idempotencyKey, "control:new:%"),
|
|
like(chatPublications.idempotencyKey, "control:close:%"),
|
|
),
|
|
or(
|
|
isNull(chatPublications.publishedAt),
|
|
gte(chatPublications.publishedAt, chatDeliveries.receivedAt),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.limit(51)) as Array<{
|
|
action: typeof chatActions.$inferSelect;
|
|
receipt: typeof agentWakeupRequests.$inferSelect;
|
|
link: typeof chatMessageLinks.$inferSelect;
|
|
delivery: typeof chatDeliveries.$inferSelect;
|
|
comment: typeof issueComments.$inferSelect;
|
|
conversation: typeof chatConversations.$inferSelect;
|
|
endpoint: typeof chatEndpoints.$inferSelect;
|
|
connection: typeof toolConnections.$inferSelect;
|
|
}>;
|
|
if (
|
|
rows.length !== commentIds.length ||
|
|
new Set(rows.map((row) => row.comment.id)).size !== commentIds.length ||
|
|
rows.some((row) => !commentIds.includes(row.comment.id))
|
|
)
|
|
return false;
|
|
for (const row of rows) {
|
|
const {
|
|
action,
|
|
receipt,
|
|
delivery,
|
|
comment,
|
|
conversation,
|
|
endpoint,
|
|
connection,
|
|
} = row;
|
|
const payload = action.payload;
|
|
const event = parseObject(delivery.normalizedEvent);
|
|
const fence = parseObject(event.runtimeContext);
|
|
const sourceConversation = parseObject(event.conversation);
|
|
const message = parseObject(event.message);
|
|
const sentAt =
|
|
typeof message.providerSentAt === "string" &&
|
|
message.providerSentAt.length <= 64
|
|
? Date.parse(message.providerSentAt)
|
|
: Number.NaN;
|
|
const refs = connection.credentialSecretRefs
|
|
.map((ref) => ({
|
|
configPath: ref.configPath,
|
|
secretId: ref.secretId,
|
|
versionSelector: ref.versionSelector ?? "latest",
|
|
}))
|
|
.sort((left, right) =>
|
|
`${left.configPath}:${left.secretId}:${left.versionSelector}`.localeCompare(
|
|
`${right.configPath}:${right.secretId}:${right.versionSelector}`,
|
|
),
|
|
);
|
|
if (
|
|
action.status !== "processed" ||
|
|
action.providerActionId !== `inbound_wakeup:${delivery.id}` ||
|
|
payload.version !== 1 ||
|
|
!action.principalId ||
|
|
payload.issueId !== issueId ||
|
|
payload.agentId !== run.agentId ||
|
|
payload.commentId !== comment.id ||
|
|
payload.sessionGeneration !== conversation.sessionGeneration ||
|
|
delivery.state !== "processed" ||
|
|
// Receipt time alone cannot make a delayed old provider message fresh.
|
|
// Missing or future provider time denies. The shared control reader below
|
|
// proves chronology against the actual command, not its later receipt.
|
|
!Number.isFinite(sentAt) ||
|
|
// Historical SDK display clocks were sometimes server-local fallbacks.
|
|
// They cannot be retrospectively promoted to post-control chronology.
|
|
(endpoint.provider === "microsoft-teams" &&
|
|
message.providerSentAtSource !== "teams_activity_timestamp") ||
|
|
(endpoint.provider === "telegram" &&
|
|
message.providerSentAtSource !== "telegram_message_date") ||
|
|
sentAt > delivery.receivedAt.getTime() ||
|
|
!delivery.processedAt ||
|
|
!connection.enabled ||
|
|
connection.status !== "active" ||
|
|
fence.generation !==
|
|
Number(parseObject(endpoint.setup).runtimeGeneration ?? 0) ||
|
|
fence.credentialFingerprint !==
|
|
createHash("sha256").update(JSON.stringify(refs)).digest("hex") ||
|
|
sourceConversation.externalConversationId !==
|
|
conversation.externalConversationId ||
|
|
sourceConversation.externalThreadId !== conversation.externalThreadId ||
|
|
message.providerMessageId !== row.link.providerMessageId ||
|
|
!["user", "system"].includes(String(payload.requestedByActorType)) ||
|
|
typeof payload.requestedByActorId !== "string" ||
|
|
receipt.payload?.issueId !== issueId ||
|
|
receipt.payload?.wakeCommentId !== comment.id ||
|
|
(receipt.id !== owner.id &&
|
|
(receipt.status !== "coalesced" ||
|
|
receipt.payload?.coalescedIntoWakeupRequestId !== owner.id)) ||
|
|
(payload.requestedByActorType === "user"
|
|
? comment.authorUserId !== payload.requestedByActorId
|
|
: comment.authorUserId !== null ||
|
|
payload.requestedByActorId !== action.principalId)
|
|
)
|
|
return false;
|
|
// The SQL above independently requires admission after every published
|
|
// control. A provider source after the command but before its confirmation
|
|
// can qualify only with complete exact proof for this published-control set.
|
|
if (
|
|
(await readChatControlChronology(
|
|
dbOrTx,
|
|
endpoint,
|
|
{
|
|
id: conversation.externalThreadId,
|
|
channelId: conversation.externalConversationId,
|
|
},
|
|
delivery,
|
|
{
|
|
requirePublishedControlProofForConversationId: binding.conversationId,
|
|
},
|
|
)) !== "after_all_proven_controls"
|
|
)
|
|
return false;
|
|
try {
|
|
assertDurableChatWakeupReceipt(
|
|
createDurableChatWakeupRequest({
|
|
id: action.id,
|
|
companyId,
|
|
agentId: run.agentId,
|
|
issueId,
|
|
commentId: comment.id,
|
|
requestedByActorType: payload.requestedByActorType as
|
|
"user" | "system",
|
|
requestedByActorId: payload.requestedByActorId,
|
|
requestedAt: action.createdAt,
|
|
authorize: async () => {},
|
|
}),
|
|
receipt,
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const [lifecycle] = await dbOrTx
|
|
.select({ id: chatDeliveries.id })
|
|
.from(chatDeliveries)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.companyId, companyId),
|
|
eq(chatDeliveries.endpointId, binding.endpointId),
|
|
ne(chatDeliveries.state, "filtered"),
|
|
inArray(chatDeliveries.eventKind, [
|
|
"message_updated",
|
|
"message_deleted",
|
|
"message_restored",
|
|
]),
|
|
sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(fence)}::jsonb`,
|
|
sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (lifecycle) return false;
|
|
}
|
|
try {
|
|
const current = await authorizeChatConversationForBoundRun(
|
|
dbOrTx,
|
|
{ companyId, issueId, runId, agentId: run.agentId },
|
|
context,
|
|
"read",
|
|
);
|
|
return (
|
|
current.conversationId === binding.conversationId &&
|
|
current.endpointId === binding.endpointId
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
/^paperclip_runner_chat_attachment_(binding|destination|principal)_denied$/.test(
|
|
error.message,
|
|
)
|
|
)
|
|
return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function resolveChatOriginPublicationBindings(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueId: string,
|
|
runId: string | null,
|
|
): Promise<ChatPublicationBinding[]> {
|
|
if (!runId) return [];
|
|
const explicitEmail = await dbOrTx.select({ id: chatEndpoints.id }).from(chatEndpoints)
|
|
.innerJoin(chatConversations, eq(chatConversations.endpointId, chatEndpoints.id))
|
|
.where(and(eq(chatConversations.companyId, companyId), eq(chatConversations.issueId, issueId), eq(chatEndpoints.publicationMode, "explicit"))).limit(1);
|
|
if (explicitEmail.length) return [];
|
|
|
|
let originRunId = runId;
|
|
let contextSnapshot: Record<string, unknown> | null = null;
|
|
let lineageAgentId: string | null = null;
|
|
const visitedRunIds = new Set<string>();
|
|
for (let depth = 0; depth < 16; depth += 1) {
|
|
if (visitedRunIds.has(originRunId)) return [];
|
|
visitedRunIds.add(originRunId);
|
|
const run = await dbOrTx
|
|
.select({
|
|
agentId: heartbeatRuns.agentId,
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, originRunId),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
),
|
|
)
|
|
.then(
|
|
(
|
|
rows: Array<{
|
|
agentId: string;
|
|
responsibleUserId: string | null;
|
|
contextSnapshot: Record<string, unknown> | null;
|
|
}>,
|
|
) => rows[0] ?? null,
|
|
);
|
|
if (!run?.agentId) return [];
|
|
if (lineageAgentId === null) lineageAgentId = run.agentId;
|
|
else if (run.agentId !== lineageAgentId) return [];
|
|
const snapshot = run?.contextSnapshot;
|
|
if (!snapshot) return [];
|
|
const snapshotIssueId =
|
|
readStringFromRecord(snapshot, "issueId") ??
|
|
readStringFromRecord(snapshot, "taskId");
|
|
if (snapshotIssueId && snapshotIssueId !== issueId) return [];
|
|
|
|
const boardBindings = await slackBoardReplyBindings(dbOrTx, {
|
|
companyId, issueId, agentId: lineageAgentId!,
|
|
commentIds: readChatWakeCommentIds(snapshot), userId: run.responsibleUserId,
|
|
});
|
|
if (boardBindings.length) return boardBindings;
|
|
|
|
// A native runner can emit its continuation immediately after the durable
|
|
// `request.resolve` command is queued, before the delivery worker records
|
|
// its terminal receipt. The source run plus the already-published native
|
|
// prompt is a stronger causal edge than that tiny receipt-commit window.
|
|
const inFlightNativeInteractions: Array<{ interactionId: string }> =
|
|
await dbOrTx
|
|
.select({
|
|
interactionId: issueQuestionResponseDeliveries.interactionId,
|
|
})
|
|
.from(issueQuestionResponseDeliveries)
|
|
.innerJoin(
|
|
issueThreadInteractions,
|
|
and(
|
|
eq(
|
|
issueThreadInteractions.companyId,
|
|
issueQuestionResponseDeliveries.companyId,
|
|
),
|
|
eq(
|
|
issueThreadInteractions.issueId,
|
|
issueQuestionResponseDeliveries.issueId,
|
|
),
|
|
eq(
|
|
issueThreadInteractions.id,
|
|
issueQuestionResponseDeliveries.interactionId,
|
|
),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueQuestionResponseDeliveries.companyId, companyId),
|
|
eq(issueQuestionResponseDeliveries.issueId, issueId),
|
|
eq(issueQuestionResponseDeliveries.sourceRunId, originRunId),
|
|
eq(issueQuestionResponseDeliveries.status, "delivering"),
|
|
eq(issueThreadInteractions.kind, "ask_user_questions"),
|
|
eq(issueThreadInteractions.status, "answered"),
|
|
),
|
|
);
|
|
for (const candidate of inFlightNativeInteractions) {
|
|
const promptBindings = await resolvePublishedInteractionPromptBindings(
|
|
dbOrTx,
|
|
companyId,
|
|
issueId,
|
|
candidate.interactionId,
|
|
lineageAgentId!,
|
|
);
|
|
if (promptBindings.length > 0) return promptBindings;
|
|
}
|
|
|
|
// A question response can be delivered directly into an already-running
|
|
// successor without rewriting that run's pre-existing context snapshot.
|
|
// The durable delivery receipt is the authoritative causal edge and must
|
|
// win even when the target run already has a different `chat:*` source;
|
|
// otherwise a Slack answer steered into a Telegram-origin run on the same
|
|
// issue could publish the result to the wrong provider identity.
|
|
const steeredQuestionParents: Array<{
|
|
interactionId: string;
|
|
sourceRunId: string | null;
|
|
}> = await dbOrTx
|
|
.select({
|
|
interactionId: issueQuestionResponseDeliveries.interactionId,
|
|
sourceRunId: issueQuestionResponseDeliveries.sourceRunId,
|
|
})
|
|
.from(issueQuestionResponseDeliveries)
|
|
.innerJoin(
|
|
issueThreadInteractions,
|
|
and(
|
|
eq(
|
|
issueThreadInteractions.companyId,
|
|
issueQuestionResponseDeliveries.companyId,
|
|
),
|
|
eq(
|
|
issueThreadInteractions.issueId,
|
|
issueQuestionResponseDeliveries.issueId,
|
|
),
|
|
eq(
|
|
issueThreadInteractions.id,
|
|
issueQuestionResponseDeliveries.interactionId,
|
|
),
|
|
eq(
|
|
issueThreadInteractions.sourceRunId,
|
|
issueQuestionResponseDeliveries.sourceRunId,
|
|
),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueQuestionResponseDeliveries.companyId, companyId),
|
|
eq(issueQuestionResponseDeliveries.issueId, issueId),
|
|
eq(issueQuestionResponseDeliveries.targetRunId, originRunId),
|
|
inArray(issueQuestionResponseDeliveries.status, [
|
|
"delivered",
|
|
"fallback_queued",
|
|
]),
|
|
eq(issueThreadInteractions.kind, "ask_user_questions"),
|
|
eq(issueThreadInteractions.status, "answered"),
|
|
),
|
|
);
|
|
if (steeredQuestionParents.length > 0) {
|
|
const parentRunIds = [
|
|
...new Set(
|
|
steeredQuestionParents
|
|
.map((candidate) => candidate.sourceRunId)
|
|
.filter((candidate): candidate is string => Boolean(candidate)),
|
|
),
|
|
];
|
|
// Multiple responses may coalesce into one target only when they share
|
|
// the same verified origin. Incompatible origins are intentionally
|
|
// internal rather than guessing a provider destination.
|
|
if (parentRunIds.length !== 1 || !isUuidLike(parentRunIds[0])) return [];
|
|
const promptBindings = (
|
|
await Promise.all(
|
|
[
|
|
...new Set(
|
|
steeredQuestionParents.map(
|
|
(candidate) => candidate.interactionId,
|
|
),
|
|
),
|
|
].map((interactionId) =>
|
|
resolvePublishedInteractionPromptBindings(
|
|
dbOrTx,
|
|
companyId,
|
|
issueId,
|
|
interactionId,
|
|
lineageAgentId!,
|
|
),
|
|
),
|
|
)
|
|
).flat();
|
|
if (promptBindings.length > 0) {
|
|
return [
|
|
...new Map(
|
|
promptBindings.map((binding) => [binding.conversationId, binding]),
|
|
).values(),
|
|
];
|
|
}
|
|
// Native delivery resumes the source run in place and records that same
|
|
// run as its target. That is already the chat root, not a lineage hop.
|
|
if (parentRunIds[0] !== originRunId) {
|
|
originRunId = parentRunIds[0];
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const contextSource = readStringFromRecord(snapshot, "source");
|
|
if (contextSource === "tool_action_review") {
|
|
// Review results use their own durable wake, not the ordinary interaction
|
|
// response key. Attest the entire batch before following its origin; the
|
|
// model's context/sourceRunId alone never grants publication authority.
|
|
const [wake] = await dbOrTx.select({ payload: agentWakeupRequests.payload,
|
|
idempotencyKey: agentWakeupRequests.idempotencyKey })
|
|
.from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(agentWakeupRequests.agentId, lineageAgentId!),
|
|
eq(agentWakeupRequests.runId, originRunId),
|
|
like(agentWakeupRequests.idempotencyKey, "tool-action-response:%"),
|
|
)).limit(1);
|
|
const requestIds = wake?.payload?.toolActionRequestIds;
|
|
const sourceRunId = readStringFromRecord(snapshot, "sourceRunId");
|
|
if (!Array.isArray(requestIds) || requestIds.length === 0 ||
|
|
requestIds.some((id: unknown) => typeof id !== "string" || !isUuidLike(id)) ||
|
|
!sourceRunId || !isUuidLike(sourceRunId) || sourceRunId !== wake.payload.sourceRunId ||
|
|
wake.idempotencyKey !== `tool-action-response:${requestIds[0]}` ||
|
|
snapshot.interactionId !== wake.payload.interactionId) return [];
|
|
const receipts = await dbOrTx.select({ id: toolActionRequests.id })
|
|
.from(toolActionRequests)
|
|
.innerJoin(toolInvocations, and(
|
|
eq(toolInvocations.id, toolActionRequests.invocationId),
|
|
eq(toolInvocations.companyId, companyId),
|
|
eq(toolInvocations.issueId, issueId),
|
|
eq(toolInvocations.agentId, lineageAgentId!),
|
|
eq(toolInvocations.runId, sourceRunId),
|
|
))
|
|
.innerJoin(toolActionDeliveries, and(
|
|
eq(toolActionDeliveries.actionRequestId, toolActionRequests.id),
|
|
eq(toolActionDeliveries.companyId, companyId),
|
|
eq(toolActionDeliveries.issueId, issueId),
|
|
eq(toolActionDeliveries.interactionId, toolActionRequests.interactionId),
|
|
))
|
|
.where(and(
|
|
eq(toolActionRequests.companyId, companyId),
|
|
eq(toolActionRequests.issueId, issueId),
|
|
eq(toolActionRequests.requestedByAgentId, lineageAgentId!),
|
|
inArray(toolActionRequests.id, requestIds),
|
|
inArray(toolActionRequests.status, ["executed", "failed", "rejected", "expired", "cancelled"]),
|
|
));
|
|
if (receipts.length !== requestIds.length) return [];
|
|
originRunId = sourceRunId;
|
|
continue;
|
|
}
|
|
if (contextSource?.startsWith("chat:")) {
|
|
contextSnapshot = snapshot;
|
|
break;
|
|
}
|
|
if (
|
|
contextSource !== "issue.interaction.respond" &&
|
|
contextSource !== "issue.interaction.accept" &&
|
|
contextSource !== "issue.interaction.reject" &&
|
|
contextSource !== "issue.interaction.cancel" &&
|
|
contextSource !== "issue.interaction.withdraw" &&
|
|
contextSource !== "external_chat.interaction.resolve"
|
|
)
|
|
return [];
|
|
|
|
const interactionId = readStringFromRecord(snapshot, "interactionId");
|
|
const sourceRunId = readStringFromRecord(snapshot, "sourceRunId");
|
|
if (
|
|
!interactionId ||
|
|
!isUuidLike(interactionId) ||
|
|
!sourceRunId ||
|
|
!isUuidLike(sourceRunId)
|
|
)
|
|
return [];
|
|
const interaction = await dbOrTx
|
|
.select({
|
|
kind: issueThreadInteractions.kind,
|
|
status: issueThreadInteractions.status,
|
|
sourceRunId: issueThreadInteractions.sourceRunId,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
eq(issueThreadInteractions.issueId, issueId),
|
|
eq(issueThreadInteractions.sourceRunId, sourceRunId),
|
|
),
|
|
)
|
|
.then(
|
|
(
|
|
rows: Array<{
|
|
kind: string;
|
|
status: string;
|
|
sourceRunId: string | null;
|
|
}>,
|
|
) => rows[0] ?? null,
|
|
);
|
|
if (!interaction?.sourceRunId) return [];
|
|
const isAnsweredQuestion =
|
|
interaction.kind === "ask_user_questions" &&
|
|
interaction.status === "answered";
|
|
const isResolvedConfirmation =
|
|
interaction.kind === "request_confirmation" &&
|
|
(interaction.status === "accepted" || interaction.status === "rejected");
|
|
const isCancelledSupportedInteraction =
|
|
interaction.status === "cancelled" &&
|
|
(interaction.kind === "ask_user_questions" ||
|
|
interaction.kind === "request_confirmation");
|
|
if (
|
|
!isAnsweredQuestion &&
|
|
!isResolvedConfirmation &&
|
|
!isCancelledSupportedInteraction
|
|
)
|
|
return [];
|
|
const responseDelivery = isAnsweredQuestion
|
|
? await dbOrTx
|
|
.select({
|
|
status: issueQuestionResponseDeliveries.status,
|
|
targetRunId: issueQuestionResponseDeliveries.targetRunId,
|
|
})
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(
|
|
and(
|
|
eq(issueQuestionResponseDeliveries.companyId, companyId),
|
|
eq(issueQuestionResponseDeliveries.issueId, issueId),
|
|
eq(issueQuestionResponseDeliveries.interactionId, interactionId),
|
|
eq(issueQuestionResponseDeliveries.sourceRunId, sourceRunId),
|
|
),
|
|
)
|
|
.then(
|
|
(rows: Array<{ status: string; targetRunId: string | null }>) =>
|
|
rows[0] ?? null,
|
|
)
|
|
: null;
|
|
const recordedTarget =
|
|
isAnsweredQuestion &&
|
|
responseDelivery &&
|
|
(responseDelivery.status === "delivered" ||
|
|
responseDelivery.status === "fallback_queued")
|
|
? responseDelivery.targetRunId
|
|
: null;
|
|
if (recordedTarget && recordedTarget !== originRunId) return [];
|
|
const continuationIdempotencyKey = isAnsweredQuestion
|
|
? `question-response:${interactionId}`
|
|
: `interaction:${interactionId}:${interaction.status}`;
|
|
const durableWakeTarget = recordedTarget
|
|
? null
|
|
: await dbOrTx
|
|
.select({ id: agentWakeupRequests.id })
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
eq(
|
|
agentWakeupRequests.idempotencyKey,
|
|
continuationIdempotencyKey,
|
|
),
|
|
eq(agentWakeupRequests.agentId, lineageAgentId!),
|
|
eq(agentWakeupRequests.runId, originRunId),
|
|
inArray(agentWakeupRequests.status, [
|
|
"queued",
|
|
"claimed",
|
|
"running",
|
|
"succeeded",
|
|
"completed",
|
|
"failed",
|
|
"cancelled",
|
|
"coalesced",
|
|
"deferred_issue_execution",
|
|
"retrying",
|
|
"scheduled_retry",
|
|
]),
|
|
),
|
|
)
|
|
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
|
|
if (recordedTarget !== originRunId && !durableWakeTarget) return [];
|
|
const promptBindings = await resolvePublishedInteractionPromptBindings(
|
|
dbOrTx,
|
|
companyId,
|
|
issueId,
|
|
interactionId,
|
|
lineageAgentId!,
|
|
);
|
|
if (promptBindings.length > 0) return promptBindings;
|
|
originRunId = interaction.sourceRunId;
|
|
}
|
|
if (!contextSnapshot || !lineageAgentId) return [];
|
|
|
|
const wakeCommentIds = readChatWakeCommentIds(contextSnapshot);
|
|
if (wakeCommentIds.length === 0) return [];
|
|
const originEndpointId = readStringFromRecord(contextSnapshot, "endpointId");
|
|
|
|
const bindings: Array<ChatPublicationBinding & { hasControl: boolean }> =
|
|
await dbOrTx
|
|
.select({
|
|
companyId: chatConversations.companyId,
|
|
conversationId: chatConversations.id,
|
|
endpointId: chatConversations.endpointId,
|
|
hasControl: sql<boolean>`exists (select 1 from chat_publications control where control.company_id = ${chatConversations.companyId} and control.endpoint_id = ${chatConversations.endpointId} and control.conversation_id = ${chatConversations.id} and control.state = 'published' and (control.idempotency_key like 'control:new:%' or control.idempotency_key like 'control:close:%'))`,
|
|
})
|
|
.from(chatMessageLinks)
|
|
.innerJoin(
|
|
chatConversations,
|
|
and(
|
|
eq(chatConversations.companyId, chatMessageLinks.companyId),
|
|
eq(chatConversations.endpointId, chatMessageLinks.endpointId),
|
|
eq(chatConversations.id, chatMessageLinks.conversationId),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
and(
|
|
eq(chatEndpoints.companyId, chatConversations.companyId),
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatMessageLinks.companyId, companyId),
|
|
eq(chatMessageLinks.direction, "inbound"),
|
|
inArray(chatMessageLinks.commentId, wakeCommentIds),
|
|
eq(chatConversations.issueId, issueId),
|
|
eq(chatEndpoints.assignedAgentId, lineageAgentId),
|
|
...(originEndpointId
|
|
? [eq(chatConversations.endpointId, originEndpointId)]
|
|
: []),
|
|
),
|
|
);
|
|
const authorized: ChatPublicationBinding[] = [];
|
|
// A coalesced batch has one inbound link per comment. Prove each destination
|
|
// once, not once per link (the proof itself still checks the complete batch).
|
|
const candidates = new Map(
|
|
bindings.map((binding) => [binding.conversationId, binding]),
|
|
);
|
|
for (const { hasControl, ...binding } of candidates.values()) {
|
|
if (
|
|
!hasControl ||
|
|
(await freshChatSourceAfterPublishedControl(
|
|
dbOrTx,
|
|
companyId,
|
|
issueId,
|
|
originRunId,
|
|
binding,
|
|
))
|
|
)
|
|
authorized.push(binding);
|
|
}
|
|
return authorized;
|
|
}
|
|
|
|
async function resolveCommentResponsibleUserId(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
createdByRunId: string | null,
|
|
actorResponsibleUserId: string | null | undefined,
|
|
): Promise<string | null> {
|
|
const actorValue = actorResponsibleUserId?.trim() || null;
|
|
if (actorValue) return actorValue;
|
|
if (!createdByRunId) return null;
|
|
return dbOrTx
|
|
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, createdByRunId),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
),
|
|
)
|
|
.then(
|
|
(rows: Array<{ responsibleUserId: string | null }>) =>
|
|
rows[0]?.responsibleUserId?.trim() || null,
|
|
);
|
|
}
|
|
|
|
function withAgentCommentAuthorizationMetadata(
|
|
metadata: IssueCommentMetadata | null,
|
|
authorizationReason: string | null | undefined,
|
|
): IssueCommentMetadata {
|
|
const reason = authorizationReason?.trim() || "internal_agent_write";
|
|
return {
|
|
version: 1,
|
|
...(metadata?.sourceRunId !== undefined
|
|
? { sourceRunId: metadata.sourceRunId }
|
|
: {}),
|
|
authorizationReason: reason,
|
|
sections: metadata?.sections.length
|
|
? metadata.sections
|
|
: [
|
|
{
|
|
title: "Authorization",
|
|
rows: [{ type: "key_value", label: "Reason", value: reason }],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Chat-origin runs may create internal presentation and bookkeeping comments.
|
|
* Only comments produced through an explicit agent write surface are authored
|
|
* for the external conversation and therefore eligible for auto-publication.
|
|
*/
|
|
export function isExplicitExternalAgentComment(
|
|
metadata: IssueCommentMetadata | null | undefined,
|
|
): boolean {
|
|
const reason = metadata?.authorizationReason?.trim() ?? "";
|
|
return reason === "paperclip_runner_protocol" || reason.startsWith("allow_");
|
|
}
|
|
|
|
type SelectedChatPresentationAttachment = {
|
|
id: string;
|
|
commentId: string;
|
|
commentMetadata: IssueCommentMetadata | null;
|
|
originalFilename: string | null;
|
|
};
|
|
|
|
async function listSelectedChatPresentationAttachments(
|
|
dbOrTx: any,
|
|
input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
},
|
|
): Promise<SelectedChatPresentationAttachment[]> {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
id: issueAttachments.id,
|
|
commentId: issueComments.id,
|
|
commentMetadata: issueComments.metadata,
|
|
originalFilename: assets.originalFilename,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
|
.innerJoin(
|
|
issueComments,
|
|
eq(issueAttachments.issueCommentId, issueComments.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueAttachments.companyId, input.companyId),
|
|
eq(issueAttachments.issueId, input.issueId),
|
|
eq(issueAttachments.originatingRunId, input.runId),
|
|
eq(assets.companyId, input.companyId),
|
|
eq(assets.createdByAgentId, input.agentId),
|
|
eq(issueComments.companyId, input.companyId),
|
|
eq(issueComments.issueId, input.issueId),
|
|
eq(issueComments.createdByRunId, input.runId),
|
|
eq(issueComments.authorType, "agent"),
|
|
eq(issueComments.authorAgentId, input.agentId),
|
|
isNull(issueComments.deletedAt),
|
|
sql<boolean>`(
|
|
coalesce(${issueComments.metadata}->>'authorizationReason', '') = 'paperclip_runner_protocol'
|
|
or left(coalesce(${issueComments.metadata}->>'authorizationReason', ''), 6) = 'allow_'
|
|
)`,
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(issueComments.createdAt),
|
|
asc(issueComments.id),
|
|
asc(issueAttachments.createdAt),
|
|
asc(issueAttachments.id),
|
|
);
|
|
return (rows as SelectedChatPresentationAttachment[]).filter((row) =>
|
|
isExplicitExternalAgentComment(row.commentMetadata),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Best-effort agent attribution for comments whose stored author is a non-human
|
|
* sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any
|
|
* comment whose `authorUserId` maps to a genuine user profile so a real board /
|
|
* user comment is never reattributed.
|
|
*
|
|
* Only LOSSLESS signals are used — a comment is reattributed solely when a run
|
|
* provably authored it. Pure run-window timing overlap is intentionally NOT a
|
|
* signal: because agents post through the `local-board` subprocess, an agent
|
|
* comment and a genuine human board comment are indistinguishable rows, so any
|
|
* timing-based guess mis-attributes human board comments that merely coincided
|
|
* with an agent run (Option A).
|
|
*
|
|
* Tiers, in descending confidence (first match wins per comment):
|
|
* 1. `run_id` — the comment's own `createdByRunId` resolves to an agent run
|
|
* (lossless: that run authored the comment).
|
|
* 2. `run_log_comment_post` — an overlapping run log contains the explicit
|
|
* `comment id: {id}` post marker (lossless: the run recorded posting it).
|
|
*/
|
|
export function deriveIssueCommentRunLogAttribution(
|
|
comments: readonly IssueCommentRunLogAttributionCandidate[],
|
|
runs: readonly IssueCommentRunLogAttributionRun[],
|
|
) {
|
|
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>();
|
|
const runById = new Map(runs.map((run) => [run.runId, run] as const));
|
|
|
|
for (const comment of comments) {
|
|
if (comment.authorAgentId || !comment.authorUserId) continue;
|
|
|
|
// Tier 1: the comment carries the run that authored it. Lossless even when
|
|
// the author was recorded as the `local-board` sentinel.
|
|
if (comment.createdByRunId) {
|
|
const ownRun = runById.get(comment.createdByRunId);
|
|
if (ownRun?.agentId) {
|
|
derivedByCommentId.set(comment.id, {
|
|
derivedAuthorAgentId: ownRun.agentId,
|
|
derivedCreatedByRunId: ownRun.runId,
|
|
derivedAuthorSource: "run_id",
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
|
|
if (commentCreatedAtMs === null) continue;
|
|
|
|
const overlappingRuns: Array<{
|
|
run: IssueCommentRunLogAttributionRun;
|
|
runEndMs: number;
|
|
}> = [];
|
|
for (const run of runs) {
|
|
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
|
|
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
|
|
if (runStartMs === null || runEndMs === null) continue;
|
|
if (
|
|
commentCreatedAtMs < runStartMs ||
|
|
commentCreatedAtMs >
|
|
runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS
|
|
) {
|
|
continue;
|
|
}
|
|
overlappingRuns.push({ run, runEndMs });
|
|
}
|
|
|
|
// Tier 2: an overlapping run log explicitly recorded posting this comment.
|
|
let bestLogMatch: {
|
|
runId: string;
|
|
agentId: string;
|
|
distanceMs: number;
|
|
} | null = null;
|
|
for (const { run, runEndMs } of overlappingRuns) {
|
|
if (!run.logContent.includes(`comment id: ${comment.id}`)) continue;
|
|
const distanceMs = Math.abs(runEndMs - commentCreatedAtMs);
|
|
if (!bestLogMatch || distanceMs < bestLogMatch.distanceMs) {
|
|
bestLogMatch = { runId: run.runId, agentId: run.agentId, distanceMs };
|
|
}
|
|
}
|
|
if (bestLogMatch) {
|
|
derivedByCommentId.set(comment.id, {
|
|
derivedAuthorAgentId: bestLogMatch.agentId,
|
|
derivedCreatedByRunId: bestLogMatch.runId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// No lossless signal — leave unresolved. A pure run-window timing overlap is
|
|
// deliberately NOT enough to reattribute (it cannot tell an agent comment
|
|
// from a human board comment that happened during the run).
|
|
}
|
|
|
|
return derivedByCommentId;
|
|
}
|
|
|
|
// Express's default `qs` parser binds repeated query keys to a `string[]`,
|
|
// so a request like `?status=todo&status=in_progress` arrives here as an
|
|
// array. Single-key + comma-separated forms remain valid too; normalize the
|
|
// supported shapes once so the service contract matches runtime reality.
|
|
export function parseStatusFilter(
|
|
input: string | readonly string[] | undefined,
|
|
): string[] {
|
|
if (input === undefined || input === null) return [];
|
|
const entries = Array.isArray(input)
|
|
? input
|
|
: typeof input === "string"
|
|
? [input]
|
|
: [];
|
|
return entries
|
|
.flatMap((entry) => (typeof entry === "string" ? entry.split(",") : []))
|
|
.map((status) => status.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export interface IssueFilters {
|
|
attention?: "blocked";
|
|
status?: string | readonly string[];
|
|
/**
|
|
* Filter by assignee agent ID.
|
|
* - `string` (UUID): match issues assigned to that agent.
|
|
* - `null`: match unassigned issues (IS NULL).
|
|
* - The literal string `"null"` is also accepted as a sentinel for `null`
|
|
* so that query-string callers can pass `?assigneeAgentId=null` directly.
|
|
* The route layer normalises it before calling the service, but the service
|
|
* also normalises it for direct callers.
|
|
*/
|
|
assigneeAgentId?: string | null;
|
|
participantAgentId?: string;
|
|
assigneeUserId?: string;
|
|
touchedByUserId?: string;
|
|
inboxArchivedByUserId?: string;
|
|
unreadForUserId?: string;
|
|
projectId?: string;
|
|
workspaceId?: string;
|
|
executionWorkspaceId?: string;
|
|
parentId?: string;
|
|
descendantOf?: string;
|
|
createdFromIssueId?: string;
|
|
labelId?: string;
|
|
originKind?: string;
|
|
originKindPrefix?: string;
|
|
originId?: string;
|
|
includeRoutineExecutions?: boolean;
|
|
excludeRoutineExecutions?: boolean;
|
|
includePluginOperations?: boolean;
|
|
includeBlockedBy?: boolean;
|
|
includeBlockedInboxAttention?: boolean;
|
|
includeLiveDescendantSummary?: boolean;
|
|
hasPlanDocument?: boolean;
|
|
lowTrustBoundary?: LowTrustBoundary & { companyId: string };
|
|
q?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
sortField?: "updated" | "id";
|
|
afterId?: string;
|
|
sortDir?: "asc" | "desc";
|
|
/** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */
|
|
updatedSince?: string;
|
|
}
|
|
|
|
type IssueRow = typeof issues.$inferSelect & { externalConversationState?: "active" | "waiting" | null };
|
|
type IssueLabelRow = typeof labels.$inferSelect;
|
|
type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect;
|
|
type IssueActiveRunRow = {
|
|
execution?: ExecutionProjection;
|
|
id: string;
|
|
status: string;
|
|
agentId: string;
|
|
invocationSource: string;
|
|
triggerDetail: string | null;
|
|
startedAt: Date | null;
|
|
finishedAt: Date | null;
|
|
createdAt: Date;
|
|
};
|
|
type IssueScheduledRetryRow = {
|
|
runId: string;
|
|
status: "scheduled_retry" | "queued" | "running" | "cancelled";
|
|
agentId: string;
|
|
agentName: string | null;
|
|
retryOfRunId: string | null;
|
|
scheduledRetryAt: Date | null;
|
|
scheduledRetryAttempt: number;
|
|
scheduledRetryReason: string | null;
|
|
retryExhaustedReason?: string | null;
|
|
error?: string | null;
|
|
errorCode?: string | null;
|
|
};
|
|
type IssueWithLabels = IssueRow & {
|
|
labels: IssueLabelRow[];
|
|
labelIds: string[];
|
|
watchdog?: IssueWatchdogSummary | null;
|
|
};
|
|
type IssueWithLabelsAndRun = IssueWithLabels & {
|
|
activeRun: IssueActiveRunRow | null;
|
|
};
|
|
type IssueUserCommentStats = {
|
|
issueId: string;
|
|
myLastCommentAt: Date | null;
|
|
lastExternalCommentAt: Date | null;
|
|
};
|
|
type IssueReadStat = {
|
|
issueId: string;
|
|
myLastReadAt: Date | null;
|
|
};
|
|
type IssueLastActivityStat = {
|
|
issueId: string;
|
|
latestCommentAt: Date | null;
|
|
latestLogAt: Date | null;
|
|
};
|
|
|
|
function serializeAcceptedPlanDecomposition(
|
|
decomposition: IssuePlanDecompositionRow,
|
|
): AcceptedPlanDecomposition {
|
|
return {
|
|
id: decomposition.id,
|
|
companyId: decomposition.companyId,
|
|
sourceIssueId: decomposition.sourceIssueId,
|
|
acceptedPlanRevisionId: decomposition.acceptedPlanRevisionId,
|
|
acceptedInteractionId: decomposition.acceptedInteractionId,
|
|
status: decomposition.status as AcceptedPlanDecomposition["status"],
|
|
requestFingerprint: decomposition.requestFingerprint,
|
|
// Intentionally omit requestedChildren here; the API only needs stable counts
|
|
// and child ids, while the durable table keeps the full child draft payload.
|
|
requestedChildCount: decomposition.requestedChildCount,
|
|
childIssueIds: normalizeIssuePlanDecompositionChildIds(
|
|
decomposition.childIssueIds,
|
|
),
|
|
ownerAgentId: decomposition.ownerAgentId,
|
|
ownerUserId: decomposition.ownerUserId,
|
|
ownerRunId: decomposition.ownerRunId,
|
|
completedAt: decomposition.completedAt,
|
|
createdAt: decomposition.createdAt,
|
|
updatedAt: decomposition.updatedAt,
|
|
};
|
|
}
|
|
type IssueUserContextInput = {
|
|
createdByUserId: string | null;
|
|
assigneeUserId: string | null;
|
|
createdAt: Date | string;
|
|
updatedAt: Date | string;
|
|
};
|
|
type ProjectGoalReader = Pick<Db, "select">;
|
|
type DbReader = Pick<Db, "select">;
|
|
/** Conversation containers cannot acquire new child edges, even with the experiment disabled. */
|
|
async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: string | null) {
|
|
if (!parentId) return;
|
|
const [parent] = await db.select({ conversationAgentId: issues.conversationAgentId })
|
|
.from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, companyId)));
|
|
if (parent?.conversationAgentId) throw unprocessable("Conversations cannot have new subtasks; create a task in a project instead");
|
|
}
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration"> & {
|
|
title?: string;
|
|
initialPlan?: string | null;
|
|
labelIds?: string[];
|
|
blockedByIssueIds?: string[];
|
|
inheritExecutionWorkspaceFromIssueId?: string | null;
|
|
skipExecutionWorkspaceInheritance?: boolean;
|
|
watchdog?: { agentId: string; instructions?: string | null } | null;
|
|
watchdogActorRunId?: string | null;
|
|
actorRunId?: string | null;
|
|
actorResponsibleUserId?: string | null;
|
|
trustExplicitResponsibleUserId?: boolean;
|
|
idempotencyKey?: string | null;
|
|
allowDuplicate?: boolean;
|
|
assertCanReuseIssue?: (issue: typeof issues.$inferSelect) => Promise<void>;
|
|
onDeduplicated?: (reason: "idempotency_key" | "recent_open_title") => void;
|
|
};
|
|
type IssueChildCreateInput = IssueCreateInput & {
|
|
acceptanceCriteria?: string[];
|
|
blockParentUntilDone?: boolean;
|
|
executionWorkspaceInheritanceMode?: "linkage" | "strategy_only";
|
|
actorAgentId?: string | null;
|
|
actorUserId?: string | null;
|
|
};
|
|
type AcceptedPlanDecompositionInput = {
|
|
acceptedPlanRevisionId: string;
|
|
children: IssueChildCreateInput[];
|
|
actorAgentId?: string | null;
|
|
actorUserId?: string | null;
|
|
actorRunId?: string | null;
|
|
};
|
|
type AcceptedPlanDocumentInteraction = {
|
|
id: string;
|
|
};
|
|
type IssueRelationSummaryMap = {
|
|
blockedBy: IssueRelationIssueSummary[];
|
|
blocks: IssueRelationIssueSummary[];
|
|
};
|
|
type IssueBlockerDiagnosticsIssueRow = {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
parentId: string | null;
|
|
identifier: string | null;
|
|
title: string;
|
|
status: (typeof ALL_ISSUE_STATUSES)[number];
|
|
priority: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
};
|
|
type IssueWakeDiagnosticsWakeRequestRow = {
|
|
agentId: string;
|
|
source: string;
|
|
reason: string | null;
|
|
status: string;
|
|
coalescedCount: number;
|
|
runId: string | null;
|
|
requestedAt: Date;
|
|
claimedAt: Date | null;
|
|
finishedAt: Date | null;
|
|
error: string | null;
|
|
};
|
|
type IssueWakeDiagnosticsActivityRow = {
|
|
action: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
details: Record<string, unknown> | null;
|
|
createdAt: Date;
|
|
};
|
|
type IssueSubtreeDiagnosticsIssueRow = IssueBlockerDiagnosticsIssueRow & {
|
|
depth: number;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
type IssueSubtreeDiagnosticsBlockerRow = IssueBlockerDiagnosticsIssueRow & {
|
|
blockedIssueId: string;
|
|
relationCreatedAt: Date;
|
|
};
|
|
type IssueSubtreeDiagnosticsWakeRequestRow =
|
|
IssueWakeDiagnosticsWakeRequestRow & {
|
|
issueId: string;
|
|
};
|
|
type IssueSubtreeDiagnosticsActivityRow = IssueWakeDiagnosticsActivityRow & {
|
|
issueId: string;
|
|
};
|
|
type IssueSubtreeDiagnosticsBlockerResultRow =
|
|
IssueSubtreeDiagnosticsBlockerRow & {
|
|
rowNumber: number | string;
|
|
};
|
|
type IssueSubtreeDiagnosticsWakeRequestResultRow =
|
|
IssueSubtreeDiagnosticsWakeRequestRow & {
|
|
rowNumber: number | string;
|
|
};
|
|
type IssueSubtreeDiagnosticsActivityResultRow =
|
|
IssueSubtreeDiagnosticsActivityRow & {
|
|
rowNumber: number | string;
|
|
};
|
|
export type IssueDependencyReadiness = {
|
|
issueId: string;
|
|
blockerIssueIds: string[];
|
|
unresolvedBlockerIssueIds: string[];
|
|
unresolvedBlockerCount: number;
|
|
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
|
|
pendingFinalizeBlockerIssueIds: string[];
|
|
allBlockersDone: boolean;
|
|
isDependencyReady: boolean;
|
|
};
|
|
export type ChildIssueCompletionSummary = {
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
status: string;
|
|
priority: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
updatedAt: Date;
|
|
summary: string | null;
|
|
};
|
|
|
|
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
|
if (actorRunId) return checkoutRunId === actorRunId;
|
|
return checkoutRunId == null;
|
|
}
|
|
|
|
export const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set([
|
|
"succeeded",
|
|
"interrupted",
|
|
"failed",
|
|
"cancelled",
|
|
"timed_out",
|
|
]);
|
|
const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200;
|
|
const ISSUE_LIST_DESCRIPTION_MAX_BYTES = ISSUE_LIST_DESCRIPTION_MAX_CHARS * 4;
|
|
|
|
function escapeLikePattern(value: string): string {
|
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
}
|
|
|
|
export function clampIssueListLimit(limit: number): number {
|
|
return Math.min(ISSUE_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit)));
|
|
}
|
|
|
|
function chunkList<T>(values: T[], size: number): T[][] {
|
|
const chunks: T[][] = [];
|
|
for (let index = 0; index < values.length; index += size) {
|
|
chunks.push(values.slice(index, index + size));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function truncateInlineSummary(
|
|
value: string | null | undefined,
|
|
maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS,
|
|
) {
|
|
const normalized = value?.trim();
|
|
if (!normalized) return null;
|
|
return normalized.length > maxChars
|
|
? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]`
|
|
: normalized;
|
|
}
|
|
|
|
function truncateByCodePoint(value: string, maxChars: number): string {
|
|
if (value.length <= maxChars) return value;
|
|
return Array.from(value).slice(0, maxChars).join("");
|
|
}
|
|
|
|
function decodeDatabaseTextPreview(
|
|
value: string | null | undefined,
|
|
maxChars: number,
|
|
): string | null {
|
|
if (value == null) return null;
|
|
return truncateByCodePoint(
|
|
Buffer.from(value, "base64").toString("utf8"),
|
|
maxChars,
|
|
);
|
|
}
|
|
|
|
function appendAcceptanceCriteriaToDescription(
|
|
description: string | null | undefined,
|
|
acceptanceCriteria: string[] | undefined,
|
|
) {
|
|
const criteria = (acceptanceCriteria ?? [])
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
if (criteria.length === 0) return description ?? null;
|
|
const base = description?.trim() ?? "";
|
|
const criteriaMarkdown = [
|
|
"## Acceptance Criteria",
|
|
"",
|
|
...criteria.map((item) => `- ${item}`),
|
|
].join("\n");
|
|
return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown;
|
|
}
|
|
|
|
function normalizeAcceptedPlanDecompositionFingerprintValue(
|
|
value: unknown,
|
|
): unknown {
|
|
if (value === undefined) return null;
|
|
if (
|
|
value == null ||
|
|
typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "boolean"
|
|
) {
|
|
return value;
|
|
}
|
|
if (value instanceof Date) return value.toISOString();
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) =>
|
|
normalizeAcceptedPlanDecompositionFingerprintValue(item),
|
|
);
|
|
}
|
|
if (typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
return Object.fromEntries(
|
|
Object.keys(record)
|
|
.sort()
|
|
.map((key) => [
|
|
key,
|
|
normalizeAcceptedPlanDecompositionFingerprintValue(record[key]),
|
|
]),
|
|
);
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
const ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS = new Set([
|
|
"id",
|
|
"companyId",
|
|
"parentId",
|
|
"identifier",
|
|
"checkoutRunId",
|
|
"executionRunId",
|
|
"executionLockedAt",
|
|
"startedAt",
|
|
"completedAt",
|
|
"cancelledAt",
|
|
"hiddenAt",
|
|
"createdAt",
|
|
"updatedAt",
|
|
"createdByAgentId",
|
|
"createdByUserId",
|
|
"updatedByAgentId",
|
|
"updatedByUserId",
|
|
"actorAgentId",
|
|
"actorUserId",
|
|
"executionWorkspaceInheritanceMode",
|
|
"skipExecutionWorkspaceInheritance",
|
|
]);
|
|
|
|
function normalizeAcceptedPlanDecompositionFingerprintChild(
|
|
child: IssueChildCreateInput,
|
|
) {
|
|
return Object.fromEntries(
|
|
Object.entries(child).filter(
|
|
([key]) =>
|
|
!ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS.has(key),
|
|
),
|
|
);
|
|
}
|
|
|
|
function createAcceptedPlanDecompositionRequestFingerprint(input: {
|
|
acceptedPlanRevisionId: string;
|
|
children: IssueChildCreateInput[];
|
|
}) {
|
|
const canonical = JSON.stringify(
|
|
normalizeAcceptedPlanDecompositionFingerprintValue({
|
|
acceptedPlanRevisionId: input.acceptedPlanRevisionId,
|
|
children: input.children.map(
|
|
normalizeAcceptedPlanDecompositionFingerprintChild,
|
|
),
|
|
}),
|
|
);
|
|
return createHash("sha256").update(canonical).digest("hex");
|
|
}
|
|
|
|
function normalizeIssuePlanDecompositionChildIds(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(
|
|
(item): item is string => typeof item === "string" && item.length > 0,
|
|
);
|
|
}
|
|
|
|
export function readAcceptedPlanConfirmationTarget(
|
|
payload: unknown,
|
|
fallbackIssueId?: string,
|
|
): {
|
|
revisionId: string;
|
|
key: string;
|
|
issueId: string;
|
|
} | null {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
return null;
|
|
const target = (payload as Record<string, unknown>).target;
|
|
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
return null;
|
|
const record = target as Record<string, unknown>;
|
|
if (record.type !== "issue_document") return null;
|
|
const revisionId = readStringFromRecord(record, "revisionId");
|
|
const key = readStringFromRecord(record, "key");
|
|
const issueId = readStringFromRecord(record, "issueId") ?? fallbackIssueId;
|
|
if (!revisionId || !key || !issueId) return null;
|
|
return { revisionId, key, issueId };
|
|
}
|
|
|
|
async function resolveAcceptedPlanClaimOwner(input: {
|
|
dbOrTx: Pick<Db, "select">;
|
|
claim: Pick<
|
|
typeof issuePlanDecompositions.$inferSelect,
|
|
"ownerAgentId" | "ownerUserId" | "ownerRunId"
|
|
>;
|
|
actorAgentId?: string | null;
|
|
actorUserId?: string | null;
|
|
actorRunId?: string | null;
|
|
}) {
|
|
const nextOwner = {
|
|
ownerAgentId: input.actorAgentId ?? null,
|
|
ownerUserId: input.actorUserId ?? null,
|
|
ownerRunId: input.actorRunId ?? null,
|
|
};
|
|
if (
|
|
input.claim.ownerAgentId === nextOwner.ownerAgentId &&
|
|
input.claim.ownerUserId === nextOwner.ownerUserId &&
|
|
input.claim.ownerRunId === nextOwner.ownerRunId
|
|
) {
|
|
return nextOwner;
|
|
}
|
|
|
|
if (!input.claim.ownerRunId) {
|
|
return nextOwner;
|
|
}
|
|
|
|
const existingOwnerRun = await input.dbOrTx
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, input.claim.ownerRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
existingOwnerRun &&
|
|
!TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingOwnerRun.status)
|
|
) {
|
|
return {
|
|
ownerAgentId: input.claim.ownerAgentId,
|
|
ownerUserId: input.claim.ownerUserId,
|
|
ownerRunId: input.claim.ownerRunId,
|
|
};
|
|
}
|
|
|
|
return nextOwner;
|
|
}
|
|
|
|
async function findAcceptedPlanDocumentInteraction(
|
|
dbOrTx: Pick<Db, "select">,
|
|
input: {
|
|
companyId: string;
|
|
sourceIssueId: string;
|
|
acceptedPlanRevisionId: string;
|
|
},
|
|
): Promise<AcceptedPlanDocumentInteraction | null> {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
id: issueThreadInteractions.id,
|
|
payload: issueThreadInteractions.payload,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.sourceIssueId),
|
|
eq(issueThreadInteractions.kind, "request_confirmation"),
|
|
eq(issueThreadInteractions.status, "accepted"),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(issueThreadInteractions.resolvedAt),
|
|
desc(issueThreadInteractions.createdAt),
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const target = readAcceptedPlanConfirmationTarget(
|
|
row.payload,
|
|
input.sourceIssueId,
|
|
);
|
|
if (
|
|
target?.issueId === input.sourceIssueId &&
|
|
target.key === "plan" &&
|
|
target.revisionId === input.acceptedPlanRevisionId
|
|
) {
|
|
return { id: row.id };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createIssueDependencyReadiness(
|
|
issueId: string,
|
|
): IssueDependencyReadiness {
|
|
return {
|
|
issueId,
|
|
blockerIssueIds: [],
|
|
unresolvedBlockerIssueIds: [],
|
|
unresolvedBlockerCount: 0,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
allBlockersDone: true,
|
|
isDependencyReady: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the set of execution-workspace ids whose most recent workspace operation
|
|
* is NOT a successful `workspace_finalize`. These workspaces have either an in-flight
|
|
* run, a failed finalize, or never reached the finalize barrier — dependents that
|
|
* read this workspace must wait until finalize succeeds.
|
|
*
|
|
* Workspaces with no recorded operations are considered finalized (nothing has
|
|
* touched them since they were realized).
|
|
*/
|
|
export async function listUnfinalizedExecutionWorkspaceIds(
|
|
dbOrTx: Pick<Db, "select">,
|
|
companyId: string,
|
|
executionWorkspaceIds: string[],
|
|
): Promise<Set<string>> {
|
|
const unfinalized = new Set<string>();
|
|
if (executionWorkspaceIds.length === 0) return unfinalized;
|
|
|
|
// Pull every workspace op for the candidate workspaces and pick the latest per
|
|
// workspace in memory. Per-workspace LATERAL queries would be tighter, but the
|
|
// candidate set is tiny in practice (one workspace per blocker per readiness call).
|
|
const rows = await dbOrTx
|
|
.select({
|
|
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
|
phase: workspaceOperations.phase,
|
|
status: workspaceOperations.status,
|
|
startedAt: workspaceOperations.startedAt,
|
|
})
|
|
.from(workspaceOperations)
|
|
.where(
|
|
and(
|
|
eq(workspaceOperations.companyId, companyId),
|
|
inArray(
|
|
workspaceOperations.executionWorkspaceId,
|
|
executionWorkspaceIds,
|
|
),
|
|
),
|
|
);
|
|
|
|
const latestByWorkspace = new Map<
|
|
string,
|
|
{ phase: string; status: string; startedAt: Date }
|
|
>();
|
|
for (const row of rows) {
|
|
if (!row.executionWorkspaceId) continue;
|
|
const current = latestByWorkspace.get(row.executionWorkspaceId);
|
|
if (!current || row.startedAt > current.startedAt) {
|
|
latestByWorkspace.set(row.executionWorkspaceId, {
|
|
phase: row.phase,
|
|
status: row.status,
|
|
startedAt: row.startedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const workspaceId of executionWorkspaceIds) {
|
|
const latest = latestByWorkspace.get(workspaceId);
|
|
if (!latest) continue; // no ops recorded → treat as finalized
|
|
if (latest.phase === "workspace_finalize" && latest.status === "succeeded")
|
|
continue;
|
|
unfinalized.add(workspaceId);
|
|
}
|
|
|
|
return unfinalized;
|
|
}
|
|
|
|
async function listPendingFinalizeBlockerIssueIds(
|
|
dbOrTx: Pick<Db, "select">,
|
|
companyId: string,
|
|
blockerWorkspacePairs: Array<{
|
|
blockerIssueId: string;
|
|
executionWorkspaceId: string;
|
|
}>,
|
|
): Promise<Set<string>> {
|
|
const pending = new Set<string>();
|
|
const blockerIssueIds = [
|
|
...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId)),
|
|
];
|
|
const executionWorkspaceIds = [
|
|
...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId)),
|
|
];
|
|
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0)
|
|
return pending;
|
|
const blockerWorkspaceKeys = new Set(
|
|
blockerWorkspacePairs.map(
|
|
(pair) => `${pair.blockerIssueId}:${pair.executionWorkspaceId}`,
|
|
),
|
|
);
|
|
|
|
const rows = await dbOrTx
|
|
.select({
|
|
issueId: workspaceOperations.issueId,
|
|
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
|
phase: workspaceOperations.phase,
|
|
status: workspaceOperations.status,
|
|
startedAt: workspaceOperations.startedAt,
|
|
})
|
|
.from(workspaceOperations)
|
|
.where(
|
|
and(
|
|
eq(workspaceOperations.companyId, companyId),
|
|
inArray(
|
|
workspaceOperations.executionWorkspaceId,
|
|
executionWorkspaceIds,
|
|
),
|
|
),
|
|
);
|
|
|
|
const latestAttributedByBlockerWorkspace = new Map<
|
|
string,
|
|
{ phase: string; status: string; startedAt: Date }
|
|
>();
|
|
const latestUnattributedByWorkspace = new Map<
|
|
string,
|
|
{ phase: string; status: string; startedAt: Date }
|
|
>();
|
|
const latestSuccessfulFinalizeByWorkspace = new Map<string, Date>();
|
|
for (const row of rows) {
|
|
if (!row.executionWorkspaceId) continue;
|
|
if (row.phase === "workspace_finalize" && row.status === "succeeded") {
|
|
const current = latestSuccessfulFinalizeByWorkspace.get(
|
|
row.executionWorkspaceId,
|
|
);
|
|
if (!current || row.startedAt > current) {
|
|
latestSuccessfulFinalizeByWorkspace.set(
|
|
row.executionWorkspaceId,
|
|
row.startedAt,
|
|
);
|
|
}
|
|
}
|
|
if (row.issueId) {
|
|
const key = `${row.issueId}:${row.executionWorkspaceId}`;
|
|
if (!blockerWorkspaceKeys.has(key)) continue;
|
|
const current = latestAttributedByBlockerWorkspace.get(key);
|
|
if (!current || row.startedAt > current.startedAt) {
|
|
latestAttributedByBlockerWorkspace.set(key, {
|
|
phase: row.phase,
|
|
status: row.status,
|
|
startedAt: row.startedAt,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const current = latestUnattributedByWorkspace.get(row.executionWorkspaceId);
|
|
if (!current || row.startedAt > current.startedAt) {
|
|
latestUnattributedByWorkspace.set(row.executionWorkspaceId, {
|
|
phase: row.phase,
|
|
status: row.status,
|
|
startedAt: row.startedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const pair of blockerWorkspacePairs) {
|
|
const latest =
|
|
latestAttributedByBlockerWorkspace.get(
|
|
`${pair.blockerIssueId}:${pair.executionWorkspaceId}`,
|
|
) ?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId);
|
|
if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker
|
|
if (latest.phase === "workspace_finalize" && latest.status === "succeeded")
|
|
continue;
|
|
const laterSuccessfulFinalize = latestSuccessfulFinalizeByWorkspace.get(
|
|
pair.executionWorkspaceId,
|
|
);
|
|
if (laterSuccessfulFinalize && laterSuccessfulFinalize > latest.startedAt)
|
|
continue;
|
|
pending.add(pair.blockerIssueId);
|
|
}
|
|
|
|
return pending;
|
|
}
|
|
|
|
/**
|
|
* Whether a heartbeat run has reached a terminal state or no longer exists.
|
|
* A terminal/missing run can make no further progress on its execution
|
|
* workspace, so callers must not wait on it to advance an in-flight operation.
|
|
*/
|
|
export async function heartbeatRunIsTerminalOrMissing(
|
|
dbOrTx: Pick<Db, "select">,
|
|
runId: string,
|
|
): Promise<boolean> {
|
|
const run = await dbOrTx
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows: Array<{ status: string }>) => rows[0] ?? null);
|
|
if (!run) return true;
|
|
return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status);
|
|
}
|
|
|
|
/**
|
|
* Returns whether a specific run's sync-back on a specific execution workspace
|
|
* has settled — i.e. the accept/review gates that guard against a still-in-flight
|
|
* worktree sync no longer need to block on this run.
|
|
*
|
|
* Semantics:
|
|
* - No operations recorded → settled. The run never touched the workspace state
|
|
* the gates protect.
|
|
* - Earlier phases recorded but no `workspace_finalize` yet → NOT settled. The
|
|
* sync-back hasn't been attempted; the gate should wait for it.
|
|
* - Latest `workspace_finalize` reached a terminal status (`succeeded`, `failed`,
|
|
* or `skipped`) → settled. A finalize that ran and finished is done even if it
|
|
* failed: it will not retry within this run, so continuing to block would wedge
|
|
* the gate forever — a failed sync-back must not permanently block a
|
|
* confirmation accept behind a misleading "still syncing" error.
|
|
* - Latest `workspace_finalize` is still `running` → in flight, so NOT settled —
|
|
* unless the owning run has itself ended, in which case the `running` record is
|
|
* stale (the process died mid-finalize) and we treat it as settled rather than
|
|
* wait on a run that can never make progress.
|
|
*/
|
|
export async function runWorkspaceIsFinalized(
|
|
dbOrTx: Pick<Db, "select">,
|
|
companyId: string,
|
|
executionWorkspaceId: string,
|
|
runId: string,
|
|
): Promise<boolean> {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
phase: workspaceOperations.phase,
|
|
status: workspaceOperations.status,
|
|
startedAt: workspaceOperations.startedAt,
|
|
})
|
|
.from(workspaceOperations)
|
|
.where(
|
|
and(
|
|
eq(workspaceOperations.companyId, companyId),
|
|
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
|
|
eq(workspaceOperations.heartbeatRunId, runId),
|
|
),
|
|
);
|
|
|
|
if (rows.length === 0) return true;
|
|
|
|
let latestFinalize: { status: string; startedAt: Date } | null = null;
|
|
for (const row of rows) {
|
|
if (row.phase !== "workspace_finalize") continue;
|
|
if (!latestFinalize || row.startedAt > latestFinalize.startedAt)
|
|
latestFinalize = row;
|
|
}
|
|
|
|
// The run touched the workspace but hasn't reached the sync-back phase yet.
|
|
if (!latestFinalize) return false;
|
|
|
|
// A finalize that reached any terminal status is settled — including `failed`
|
|
// and `skipped`. It will not retry within this run, so gates must stop waiting.
|
|
if (latestFinalize.status !== "running") return true;
|
|
|
|
// Finalize is still marked `running`. It is only genuinely in flight while the
|
|
// owning run is alive; a `running` record left behind by an ended run is stale.
|
|
return heartbeatRunIsTerminalOrMissing(dbOrTx, runId);
|
|
}
|
|
|
|
async function listIssueDependencyReadinessMap(
|
|
dbOrTx: Pick<Db, "select">,
|
|
companyId: string,
|
|
issueIds: string[],
|
|
) {
|
|
const uniqueIssueIds = [...new Set(issueIds.filter(Boolean))];
|
|
const readinessMap = new Map<string, IssueDependencyReadiness>();
|
|
for (const issueId of uniqueIssueIds) {
|
|
readinessMap.set(issueId, createIssueDependencyReadiness(issueId));
|
|
}
|
|
if (uniqueIssueIds.length === 0) return readinessMap;
|
|
|
|
const blockerRows = await dbOrTx
|
|
.select({
|
|
issueId: issueRelations.relatedIssueId,
|
|
blockerIssueId: issueRelations.issueId,
|
|
blockerStatus: issues.status,
|
|
blockerExecutionWorkspaceId: issues.executionWorkspaceId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
|
|
),
|
|
);
|
|
|
|
// Collect issue/workspace pairs of "done" blockers — these are the only ones
|
|
// subject to the workspace-finalize barrier. Blockers that aren't done already
|
|
// mark the dependent as not-ready and don't need a finalize check.
|
|
const doneBlockerWorkspacePairs: Array<{
|
|
blockerIssueId: string;
|
|
executionWorkspaceId: string;
|
|
}> = [];
|
|
for (const row of blockerRows) {
|
|
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
|
|
doneBlockerWorkspacePairs.push({
|
|
blockerIssueId: row.blockerIssueId,
|
|
executionWorkspaceId: row.blockerExecutionWorkspaceId,
|
|
});
|
|
}
|
|
}
|
|
const pendingFinalizeBlockerIssueIds =
|
|
await listPendingFinalizeBlockerIssueIds(
|
|
dbOrTx,
|
|
companyId,
|
|
doneBlockerWorkspacePairs,
|
|
);
|
|
|
|
for (const row of blockerRows) {
|
|
const current =
|
|
readinessMap.get(row.issueId) ??
|
|
createIssueDependencyReadiness(row.issueId);
|
|
current.blockerIssueIds.push(row.blockerIssueId);
|
|
// Only done blockers resolve dependents; cancelled blockers stay unresolved
|
|
// until an operator removes or replaces the blocker relationship explicitly.
|
|
if (row.blockerStatus !== "done") {
|
|
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
|
|
current.unresolvedBlockerCount += 1;
|
|
current.allBlockersDone = false;
|
|
current.isDependencyReady = false;
|
|
} else if (
|
|
row.blockerExecutionWorkspaceId &&
|
|
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
|
|
) {
|
|
// Workspace-finalize barrier: the blocker's most recent run on its
|
|
// execution workspace hasn't recorded a successful workspace_finalize.
|
|
// Treat the dependent as not-ready until sync-back lands (or the run
|
|
// finalizes); a subsequent finalize wake will re-evaluate readiness.
|
|
// `allBlockersDone` is cleared too so that callers using it as a
|
|
// proxy for "this dependent can proceed" still see the gate.
|
|
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
|
|
current.unresolvedBlockerCount += 1;
|
|
current.pendingFinalizeBlockerIssueIds.push(row.blockerIssueId);
|
|
current.allBlockersDone = false;
|
|
current.isDependencyReady = false;
|
|
}
|
|
readinessMap.set(row.issueId, current);
|
|
}
|
|
|
|
return readinessMap;
|
|
}
|
|
|
|
async function listUnresolvedBlockerDetails(
|
|
dbOrTx: Pick<Db, "select">,
|
|
companyId: string,
|
|
unresolvedBlockerIssueIds: string[],
|
|
pendingFinalizeBlockerIssueIds: string[] = [],
|
|
) {
|
|
if (unresolvedBlockerIssueIds.length === 0) return [];
|
|
const pendingFinalizeIds = new Set(pendingFinalizeBlockerIssueIds);
|
|
const rows = await dbOrTx
|
|
.select({
|
|
issueId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
inArray(issues.id, unresolvedBlockerIssueIds),
|
|
),
|
|
);
|
|
const rowsById = new Map(rows.map((row) => [row.issueId, row]));
|
|
return unresolvedBlockerIssueIds.map((issueId) => {
|
|
const row = rowsById.get(issueId);
|
|
return {
|
|
issueId,
|
|
identifier: row?.identifier ?? null,
|
|
title: row?.title ?? null,
|
|
reason: pendingFinalizeIds.has(issueId)
|
|
? ("pending_finalize" as const)
|
|
: ("not_done" as const),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function listUnresolvedBlockerIssueIds(
|
|
dbOrTx: Pick<Db, "select">,
|
|
companyId: string,
|
|
blockerIssueIds: string[],
|
|
) {
|
|
const uniqueBlockerIssueIds = [...new Set(blockerIssueIds.filter(Boolean))];
|
|
if (uniqueBlockerIssueIds.length === 0) return [];
|
|
return dbOrTx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
inArray(issues.id, uniqueBlockerIssueIds),
|
|
// Cancelled blockers intentionally remain unresolved until the relation changes.
|
|
ne(issues.status, "done"),
|
|
),
|
|
)
|
|
.then((rows) => rows.map((row) => row.id));
|
|
}
|
|
async function getProjectDefaultGoalId(
|
|
db: ProjectGoalReader,
|
|
companyId: string,
|
|
projectId: string | null | undefined,
|
|
) {
|
|
if (!projectId) return null;
|
|
const row = await db
|
|
.select({ goalId: projects.goalId })
|
|
.from(projects)
|
|
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
return row?.goalId ?? null;
|
|
}
|
|
|
|
async function getWorkspaceInheritanceIssue(
|
|
db: DbReader,
|
|
companyId: string,
|
|
issueId: string,
|
|
) {
|
|
const issue = await db
|
|
.select({
|
|
id: issues.id,
|
|
projectId: issues.projectId,
|
|
projectWorkspaceId: issues.projectWorkspaceId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) {
|
|
throw notFound("Workspace inheritance issue not found");
|
|
}
|
|
return issue;
|
|
}
|
|
|
|
// Mine participation fails closed. Add new user-authored issue mutation actions
|
|
// here instead of admitting every issue activity, because reads, previews, and
|
|
// denied resource requests are audited too.
|
|
const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [
|
|
"issue.accepted_plan_decomposition_updated",
|
|
"issue.admin_force_release",
|
|
"issue.approval_linked",
|
|
"issue.approval_unlinked",
|
|
"issue.approvers_updated",
|
|
"issue.assigned",
|
|
"issue.attachment_added",
|
|
"issue.attachment_removed",
|
|
"issue.blockers.updated",
|
|
"issue.blockers_updated",
|
|
"issue.checked_out",
|
|
"issue.checkout",
|
|
"issue.child_created",
|
|
"issue.comment_cancelled",
|
|
"issue.document_annotation_comment_added",
|
|
"issue.document_annotation_remapped",
|
|
"issue.document_annotation_thread_created",
|
|
"issue.document_annotation_thread_resolved",
|
|
"issue.document_deleted",
|
|
"issue.document_locked",
|
|
"issue.document_restored",
|
|
"issue.document_unlocked",
|
|
"issue.document_updated",
|
|
"issue.document_upserted",
|
|
"issue.feedback_vote_saved",
|
|
"issue.inbox_touched",
|
|
"issue.low_trust_output_promoted",
|
|
"issue.monitor_cleared",
|
|
"issue.monitor_scheduled",
|
|
"issue.recovery_action_resolved",
|
|
"issue.relations.updated",
|
|
"issue.released",
|
|
"issue.reviewers_updated",
|
|
"issue.scheduled_retry_retry_now",
|
|
"issue.successful_run_handoff_resolved",
|
|
"issue.task_watchdog_fingerprint_reviewed",
|
|
"issue.thread_interaction_accepted",
|
|
"issue.thread_interaction_answered",
|
|
"issue.thread_interaction_cancelled",
|
|
"issue.thread_interaction_created",
|
|
"issue.thread_interaction_item_verdicts_submitted",
|
|
"issue.thread_interaction_withdrawn",
|
|
"issue.tree_cancel_status_updated",
|
|
"issue.tree_hold_created",
|
|
"issue.tree_hold_released",
|
|
"issue.tree_restore_status_updated",
|
|
"issue.updated",
|
|
"issue.watchdog_created",
|
|
"issue.watchdog_removed",
|
|
"issue.work_product_created",
|
|
"issue.work_product_deleted",
|
|
"issue.work_product_updated",
|
|
] as const;
|
|
|
|
function touchedByUserCondition(companyId: string, userId: string) {
|
|
return sql<boolean>`
|
|
(
|
|
${issues.createdByUserId} = ${userId}
|
|
OR ${issues.assigneeUserId} = ${userId}
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${activityLog}
|
|
WHERE ${activityLog.entityType} = 'issue'
|
|
AND ${activityLog.entityId} = ${issues.id}::text
|
|
AND ${activityLog.companyId} = ${companyId}
|
|
AND ${activityLog.actorType} = 'user'
|
|
AND ${activityLog.actorId} = ${userId}
|
|
AND ${activityLog.action} IN (${sql.join(
|
|
ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map(
|
|
(action) => sql`${action}`,
|
|
),
|
|
sql`, `,
|
|
)})
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${issueComments}
|
|
WHERE ${issueComments.issueId} = ${issues.id}
|
|
AND ${issueComments.companyId} = ${companyId}
|
|
AND ${issueComments.authorUserId} = ${userId}
|
|
)
|
|
)
|
|
`;
|
|
}
|
|
|
|
function participatedByAgentCondition(companyId: string, agentId: string) {
|
|
return sql<boolean>`
|
|
(
|
|
${issues.createdByAgentId} = ${agentId}
|
|
OR ${issues.assigneeAgentId} = ${agentId}
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${issueComments}
|
|
WHERE ${issueComments.issueId} = ${issues.id}
|
|
AND ${issueComments.companyId} = ${companyId}
|
|
AND ${issueComments.authorAgentId} = ${agentId}
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${activityLog}
|
|
WHERE ${activityLog.companyId} = ${companyId}
|
|
AND ${activityLog.entityType} = 'issue'
|
|
AND ${activityLog.entityId} = ${issues.id}::text
|
|
AND ${activityLog.agentId} = ${agentId}
|
|
)
|
|
)
|
|
`;
|
|
}
|
|
|
|
function myLastCommentAtExpr(companyId: string, userId: string) {
|
|
return sql<Date | null>`
|
|
(
|
|
SELECT MAX(${issueComments.createdAt})
|
|
FROM ${issueComments}
|
|
WHERE ${issueComments.issueId} = ${issues.id}
|
|
AND ${issueComments.companyId} = ${companyId}
|
|
AND ${issueComments.authorUserId} = ${userId}
|
|
)
|
|
`;
|
|
}
|
|
|
|
function myLastReadAtExpr(companyId: string, userId: string) {
|
|
return sql<Date | null>`
|
|
(
|
|
SELECT MAX(${issueReadStates.lastReadAt})
|
|
FROM ${issueReadStates}
|
|
WHERE ${issueReadStates.issueId} = ${issues.id}
|
|
AND ${issueReadStates.companyId} = ${companyId}
|
|
AND ${issueReadStates.userId} = ${userId}
|
|
)
|
|
`;
|
|
}
|
|
|
|
function myLastTouchAtExpr(companyId: string, userId: string) {
|
|
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
|
|
const myLastReadAt = myLastReadAtExpr(companyId, userId);
|
|
return sql<Date | null>`
|
|
GREATEST(
|
|
COALESCE(${myLastCommentAt}, to_timestamp(0)),
|
|
COALESCE(${myLastReadAt}, to_timestamp(0)),
|
|
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
|
|
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
|
|
)
|
|
`;
|
|
}
|
|
|
|
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
|
|
"issue.read_marked",
|
|
"issue.read_unmarked",
|
|
"issue.inbox_archived",
|
|
"issue.inbox_unarchived",
|
|
] as const;
|
|
|
|
function issueLatestCommentAtExpr(companyId: string) {
|
|
return sql<Date | null>`
|
|
(
|
|
SELECT MAX(${issueComments.createdAt})
|
|
FROM ${issueComments}
|
|
WHERE ${issueComments.issueId} = ${issues.id}
|
|
AND ${issueComments.companyId} = ${companyId}
|
|
)
|
|
`;
|
|
}
|
|
|
|
function issueLatestLogAtExpr(companyId: string) {
|
|
return sql<Date | null>`
|
|
(
|
|
SELECT MAX(${activityLog.createdAt})
|
|
FROM ${activityLog}
|
|
WHERE ${activityLog.companyId} = ${companyId}
|
|
AND ${activityLog.entityType} = 'issue'
|
|
AND ${activityLog.entityId} = ${issues.id}::text
|
|
AND ${activityLog.action} NOT IN (${sql.join(
|
|
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
|
sql`, `,
|
|
)})
|
|
)
|
|
`;
|
|
}
|
|
|
|
function issueCanonicalLastActivityAtExpr(companyId: string) {
|
|
const latestCommentAt = issueLatestCommentAtExpr(companyId);
|
|
const latestLogAt = issueLatestLogAtExpr(companyId);
|
|
return sql<Date>`
|
|
GREATEST(
|
|
${issues.updatedAt},
|
|
COALESCE(${latestCommentAt}, to_timestamp(0)),
|
|
COALESCE(${latestLogAt}, to_timestamp(0))
|
|
)
|
|
`;
|
|
}
|
|
|
|
function unreadForUserCondition(companyId: string, userId: string) {
|
|
const touchedCondition = touchedByUserCondition(companyId, userId);
|
|
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
|
return sql<boolean>`
|
|
(
|
|
${touchedCondition}
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM ${issueComments}
|
|
WHERE ${issueComments.issueId} = ${issues.id}
|
|
AND ${issueComments.companyId} = ${companyId}
|
|
AND (
|
|
${issueComments.authorUserId} IS NULL
|
|
OR ${issueComments.authorUserId} <> ${userId}
|
|
)
|
|
AND ${issueComments.createdAt} > ${myLastTouchAt}
|
|
)
|
|
)
|
|
`;
|
|
}
|
|
|
|
function inboxVisibleForUserCondition(companyId: string, userId: string) {
|
|
return sql<boolean>`
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM ${issueInboxArchives}
|
|
WHERE ${issueInboxArchives.issueId} = ${issues.id}
|
|
AND ${issueInboxArchives.companyId} = ${companyId}
|
|
AND ${issueInboxArchives.userId} = ${userId}
|
|
AND NOT (
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM ${issueThreadInteractions}
|
|
WHERE ${issueThreadInteractions.issueId} = ${issues.id}
|
|
AND ${issueThreadInteractions.companyId} = ${companyId}
|
|
AND ${issueThreadInteractions.kind} IN (
|
|
'suggest_tasks',
|
|
'ask_user_questions',
|
|
'request_confirmation'
|
|
)
|
|
AND ${issueThreadInteractions.createdAt} > ${issueInboxArchives.archivedAt}
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${activityLog}
|
|
WHERE ${activityLog.companyId} = ${companyId}
|
|
AND ${activityLog.entityType} = 'issue'
|
|
AND ${activityLog.entityId} = ${issues.id}::text
|
|
AND ${activityLog.action} = 'issue.updated'
|
|
AND ${activityLog.createdAt} > ${issueInboxArchives.archivedAt}
|
|
AND ${activityLog.details}->>'status' IN ('in_review', 'blocked', 'done')
|
|
AND ${activityLog.details}->'_previous'->>'status'
|
|
IS DISTINCT FROM ${activityLog.details}->>'status'
|
|
AND NOT (
|
|
${activityLog.details}->>'status' = 'done'
|
|
AND ${issues.completedAt} IS NOT NULL
|
|
AND ${issueInboxArchives.archivedAt} >= ${issues.completedAt}
|
|
)
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${issueComments}
|
|
WHERE ${issueComments.issueId} = ${issues.id}
|
|
AND ${issueComments.companyId} = ${companyId}
|
|
AND ${issueComments.createdAt} > ${issueInboxArchives.archivedAt}
|
|
AND ${issueComments.deletedAt} IS NULL
|
|
AND (
|
|
(
|
|
${issueComments.authorUserId} IS NOT NULL
|
|
AND ${issueComments.authorUserId} <> ${userId}
|
|
AND ${issueComments.authorAgentId} IS NULL
|
|
AND ${issueComments.derivedAuthorAgentId} IS NULL
|
|
)
|
|
OR POSITION(${`](user://${userId})`} IN ${issueComments.body}) > 0
|
|
)
|
|
)
|
|
)
|
|
)
|
|
`;
|
|
}
|
|
|
|
const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [
|
|
"plugin:paperclipai.content-machine:case",
|
|
"plugin:paperclipai.content-machine:evaluation",
|
|
"plugin:paperclipai.content-machine:source-sync",
|
|
] as const;
|
|
|
|
function nonPluginOperationIssueCondition() {
|
|
return sql<boolean>`NOT (
|
|
${issues.originKind} LIKE 'plugin:%:operation'
|
|
OR ${issues.originKind} LIKE 'plugin:%:operation:%'
|
|
OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)}
|
|
)`;
|
|
}
|
|
|
|
function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
|
|
return Boolean(
|
|
filters?.includePluginOperations ||
|
|
filters?.originKind ||
|
|
filters?.originKindPrefix ||
|
|
filters?.originId ||
|
|
filters?.projectId,
|
|
);
|
|
}
|
|
|
|
export function deriveIssueUserContext(
|
|
issue: IssueUserContextInput,
|
|
userId: string,
|
|
stats:
|
|
| {
|
|
myLastCommentAt: Date | string | null;
|
|
myLastReadAt: Date | string | null;
|
|
lastExternalCommentAt: Date | string | null;
|
|
}
|
|
| null
|
|
| undefined,
|
|
) {
|
|
const normalizeDate = (value: Date | string | null | undefined) => {
|
|
if (!value) return null;
|
|
if (value instanceof Date)
|
|
return Number.isNaN(value.getTime()) ? null : value;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
};
|
|
|
|
const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
|
|
const myLastReadAt = normalizeDate(stats?.myLastReadAt);
|
|
const createdTouchAt =
|
|
issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
|
|
const assignedTouchAt =
|
|
issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
|
|
const myLastTouchAt =
|
|
[myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
|
|
.filter((value): value is Date => value instanceof Date)
|
|
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
|
|
const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
|
|
const isUnreadForMe = Boolean(
|
|
myLastTouchAt &&
|
|
lastExternalCommentAt &&
|
|
lastExternalCommentAt.getTime() > myLastTouchAt.getTime(),
|
|
);
|
|
|
|
return {
|
|
myLastTouchAt,
|
|
lastExternalCommentAt,
|
|
isUnreadForMe,
|
|
};
|
|
}
|
|
|
|
function latestIssueActivityAt(
|
|
...values: Array<Date | string | null | undefined>
|
|
): Date | null {
|
|
const normalized = values
|
|
.map((value) => {
|
|
if (!value) return null;
|
|
if (value instanceof Date)
|
|
return Number.isNaN(value.getTime()) ? null : value;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
})
|
|
.filter((value): value is Date => value instanceof Date)
|
|
.sort((a, b) => b.getTime() - a.getTime());
|
|
return normalized[0] ?? null;
|
|
}
|
|
|
|
type InboxArchiveAttributionRow = {
|
|
issueId: string;
|
|
archivedAt: Date;
|
|
archivedByActorType: "user" | "agent";
|
|
archivedByAgentId: string | null;
|
|
archivedByRunId: string | null;
|
|
};
|
|
|
|
async function inboxArchiveRowsForIssues(
|
|
dbOrTx: Db,
|
|
companyId: string,
|
|
userId: string,
|
|
issueIds: string[],
|
|
): Promise<InboxArchiveAttributionRow[]> {
|
|
if (issueIds.length === 0) return [];
|
|
return dbOrTx
|
|
.select({
|
|
issueId: issueInboxArchives.issueId,
|
|
archivedAt: issueInboxArchives.archivedAt,
|
|
archivedByActorType: issueInboxArchives.archivedByActorType,
|
|
archivedByAgentId: issueInboxArchives.archivedByAgentId,
|
|
archivedByRunId: issueInboxArchives.archivedByRunId,
|
|
})
|
|
.from(issueInboxArchives)
|
|
.where(
|
|
and(
|
|
eq(issueInboxArchives.companyId, companyId),
|
|
eq(issueInboxArchives.userId, userId),
|
|
inArray(issueInboxArchives.issueId, issueIds),
|
|
),
|
|
);
|
|
}
|
|
|
|
function activeInboxArchiveFields(
|
|
archive: InboxArchiveAttributionRow | undefined,
|
|
lastActivityAt: Date,
|
|
) {
|
|
if (!archive || archive.archivedAt.getTime() < lastActivityAt.getTime())
|
|
return {};
|
|
return {
|
|
archivedAt: archive.archivedAt,
|
|
archivedByActorType: archive.archivedByActorType,
|
|
archivedByAgentId: archive.archivedByAgentId,
|
|
archivedByRunId: archive.archivedByRunId,
|
|
};
|
|
}
|
|
|
|
function issueListOrderBy(
|
|
companyId: string,
|
|
{
|
|
hasSearch,
|
|
priorityOrder,
|
|
searchOrder,
|
|
sortField,
|
|
sortDir,
|
|
}: {
|
|
hasSearch: boolean;
|
|
priorityOrder: SQL;
|
|
searchOrder: SQL;
|
|
sortField?: IssueFilters["sortField"];
|
|
sortDir?: IssueFilters["sortDir"];
|
|
},
|
|
) {
|
|
if (sortField === "id") return [sortDir === "desc" ? desc(issues.id) : asc(issues.id)];
|
|
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
|
if (sortField === "updated") {
|
|
const activityOrder =
|
|
sortDir === "asc"
|
|
? asc(canonicalLastActivityAt)
|
|
: desc(canonicalLastActivityAt);
|
|
const updatedOrder =
|
|
sortDir === "asc" ? asc(issues.updatedAt) : desc(issues.updatedAt);
|
|
const idOrder = sortDir === "asc" ? asc(issues.id) : desc(issues.id);
|
|
return hasSearch
|
|
? [asc(searchOrder), activityOrder, updatedOrder, idOrder]
|
|
: [activityOrder, updatedOrder, idOrder];
|
|
}
|
|
|
|
return [
|
|
hasSearch ? asc(searchOrder) : asc(priorityOrder),
|
|
asc(priorityOrder),
|
|
desc(canonicalLastActivityAt),
|
|
desc(issues.updatedAt),
|
|
desc(issues.id),
|
|
];
|
|
}
|
|
|
|
async function labelMapForIssues(
|
|
dbOrTx: any,
|
|
issueIds: string[],
|
|
): Promise<Map<string, IssueLabelRow[]>> {
|
|
const map = new Map<string, IssueLabelRow[]>();
|
|
if (issueIds.length === 0) return map;
|
|
for (const issueIdChunk of chunkList(
|
|
issueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
issueId: issueLabels.issueId,
|
|
label: labels,
|
|
})
|
|
.from(issueLabels)
|
|
.innerJoin(labels, eq(issueLabels.labelId, labels.id))
|
|
.where(inArray(issueLabels.issueId, issueIdChunk))
|
|
.orderBy(asc(labels.name), asc(labels.id));
|
|
|
|
for (const row of rows) {
|
|
const existing = map.get(row.issueId);
|
|
if (existing) existing.push(row.label);
|
|
else map.set(row.issueId, [row.label]);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function withIssueLabels(
|
|
dbOrTx: any,
|
|
rows: IssueRow[],
|
|
): Promise<IssueWithLabels[]> {
|
|
if (rows.length === 0) return [];
|
|
const issueIds = rows.map((row) => row.id);
|
|
const [labelsByIssueId, watchdogByIssueId] = await Promise.all([
|
|
labelMapForIssues(dbOrTx, issueIds),
|
|
watchdogMapForIssues(dbOrTx, rows),
|
|
]);
|
|
return rows.map((row) => {
|
|
const issueLabels = labelsByIssueId.get(row.id) ?? [];
|
|
return {
|
|
...row,
|
|
labels: issueLabels,
|
|
labelIds: issueLabels.map((label) => label.id),
|
|
watchdog: watchdogByIssueId.get(row.id) ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function watchdogMapForIssues(
|
|
dbOrTx: any,
|
|
rows: IssueRow[],
|
|
): Promise<Map<string, IssueWatchdogSummary>> {
|
|
const map = new Map<string, IssueWatchdogSummary>();
|
|
if (rows.length === 0) return map;
|
|
const byCompany = new Map<string, string[]>();
|
|
for (const row of rows) {
|
|
const ids = byCompany.get(row.companyId) ?? [];
|
|
ids.push(row.id);
|
|
byCompany.set(row.companyId, ids);
|
|
}
|
|
for (const [companyId, issueIds] of byCompany.entries()) {
|
|
for (const issueIdChunk of chunkList(
|
|
[...new Set(issueIds)],
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const watchdogRows = await dbOrTx
|
|
.select()
|
|
.from(issueWatchdogs)
|
|
.where(
|
|
and(
|
|
eq(issueWatchdogs.companyId, companyId),
|
|
inArray(issueWatchdogs.issueId, issueIdChunk),
|
|
eq(issueWatchdogs.status, "active"),
|
|
),
|
|
);
|
|
for (const row of watchdogRows) {
|
|
map.set(row.issueId, summarizeIssueWatchdog(row));
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
|
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
|
|
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = [
|
|
"queued",
|
|
"deferred_issue_execution",
|
|
];
|
|
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
|
|
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = [
|
|
"pending",
|
|
"revision_requested",
|
|
];
|
|
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND =
|
|
"harness_liveness_escalation";
|
|
const BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES = ["done", "cancelled"];
|
|
function lowTrustBoundaryIssueCondition(
|
|
companyId: string,
|
|
boundary: (LowTrustBoundary & { companyId: string }) | null | undefined,
|
|
) {
|
|
if (!boundary || boundary.companyId !== companyId) return null;
|
|
const clauses: SQL[] = [];
|
|
const issueIds = [...new Set(boundary.issueIds ?? [])];
|
|
const projectIds = [...new Set(boundary.projectIds ?? [])];
|
|
if (issueIds.length > 0) clauses.push(inArray(issues.id, issueIds));
|
|
if (projectIds.length > 0)
|
|
clauses.push(inArray(issues.projectId, projectIds));
|
|
if (boundary.rootIssueId) {
|
|
clauses.push(sql<boolean>`
|
|
${issues.id} IN (
|
|
WITH RECURSIVE descendants(id) AS (
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.id} = ${boundary.rootIssueId}
|
|
UNION
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
JOIN descendants ON ${issues.parentId} = descendants.id
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
)
|
|
SELECT id FROM descendants
|
|
)
|
|
`);
|
|
}
|
|
if (clauses.length === 0) return sql<boolean>`false`;
|
|
return or(...clauses);
|
|
}
|
|
|
|
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
|
|
export const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
|
export const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
|
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set([
|
|
"active",
|
|
"idle",
|
|
"running",
|
|
"error",
|
|
]);
|
|
|
|
type IssueBlockerAttentionNode = {
|
|
id: string;
|
|
companyId: string;
|
|
parentId: string | null;
|
|
identifier: string | null;
|
|
title: string;
|
|
status: string;
|
|
executionRunId?: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
};
|
|
type IssueBlockerAttentionInputNode = Pick<
|
|
IssueBlockerAttentionNode,
|
|
| "id"
|
|
| "companyId"
|
|
| "parentId"
|
|
| "identifier"
|
|
| "title"
|
|
| "status"
|
|
| "assigneeAgentId"
|
|
| "assigneeUserId"
|
|
> & { executionRunId?: string | null };
|
|
|
|
type IssueBlockerAttentionEdge = {
|
|
issueId: string;
|
|
blockerIssueId: string;
|
|
};
|
|
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
|
|
issueId: string | null;
|
|
blockerIssueId: string;
|
|
};
|
|
type IssueBlockerAttentionActivePathRow = {
|
|
issueId: string | null;
|
|
};
|
|
type IssueBlockerAttentionAgentRow = {
|
|
id: string;
|
|
companyId: string;
|
|
status: string;
|
|
};
|
|
|
|
async function activeRunMapForIssues(
|
|
dbOrTx: any,
|
|
issueRows: IssueWithLabels[],
|
|
): Promise<Map<string, IssueActiveRunRow>> {
|
|
const map = new Map<string, IssueActiveRunRow>();
|
|
const runIds = issueRows
|
|
.map((row) => row.executionRunId)
|
|
.filter((id): id is string => id != null);
|
|
if (runIds.length === 0) return map;
|
|
|
|
for (const runIdChunk of chunkList(
|
|
[...new Set(runIds)],
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
status: heartbeatRuns.status,
|
|
agentId: heartbeatRuns.agentId,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
triggerDetail: heartbeatRuns.triggerDetail,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
inArray(heartbeatRuns.id, runIdChunk),
|
|
inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES),
|
|
),
|
|
);
|
|
|
|
for (const row of rows) {
|
|
map.set(row.id, row);
|
|
}
|
|
}
|
|
for (const companyId of new Set(issueRows.map(row => row.companyId))) {
|
|
const scopedIds = issueRows.filter(row => row.companyId === companyId).flatMap(row => row.executionRunId && map.has(row.executionRunId) ? [row.executionRunId] : []);
|
|
const projections = await executionProjectionsForRuns(dbOrTx, companyId, scopedIds);
|
|
for (const [runId, execution] of projections) {
|
|
const row = map.get(runId);
|
|
if (row) row.execution = execution;
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function liveDescendantCountMapForIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueIds: string[],
|
|
): Promise<Map<string, number>> {
|
|
const uniqueIssueIds = [...new Set(issueIds)];
|
|
const map = new Map<string, number>();
|
|
if (uniqueIssueIds.length === 0) return map;
|
|
|
|
for (const issueIdChunk of chunkList(
|
|
uniqueIssueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const targetRows = issueIdChunk.map((issueId) => sql`(${issueId}::uuid)`);
|
|
const rows = await dbOrTx.execute(sql<{
|
|
issueId: string;
|
|
liveDescendantCount: number;
|
|
}>`
|
|
WITH RECURSIVE
|
|
target_issues(issue_id) AS (
|
|
VALUES ${sql.join(targetRows, sql`, `)}
|
|
),
|
|
live_issues(live_issue_id, parent_id) AS (
|
|
SELECT DISTINCT live_issue.id, live_issue.parent_id
|
|
FROM issues live_issue
|
|
JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id
|
|
WHERE live_issue.company_id = ${companyId}
|
|
AND live_issue.hidden_at IS NULL
|
|
AND live_issue.harness_kind IS NULL
|
|
AND live_run.company_id = ${companyId}
|
|
AND live_run.status IN ('queued', 'running')
|
|
UNION
|
|
SELECT DISTINCT live_issue.id, live_issue.parent_id
|
|
FROM heartbeat_runs live_run
|
|
JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId')
|
|
WHERE live_issue.company_id = ${companyId}
|
|
AND live_issue.hidden_at IS NULL
|
|
AND live_issue.harness_kind IS NULL
|
|
AND live_run.company_id = ${companyId}
|
|
AND live_run.status IN ('queued', 'running')
|
|
),
|
|
live_ancestors(live_issue_id, ancestor_id, next_parent_id, visited_issue_ids) AS (
|
|
SELECT live_issues.live_issue_id, parent.id, parent.parent_id, ARRAY[live_issues.live_issue_id, parent.id]
|
|
FROM live_issues
|
|
JOIN issues parent ON parent.id = live_issues.parent_id
|
|
WHERE parent.company_id = ${companyId}
|
|
AND parent.hidden_at IS NULL
|
|
AND parent.harness_kind IS NULL
|
|
UNION ALL
|
|
SELECT
|
|
live_ancestors.live_issue_id,
|
|
parent.id,
|
|
parent.parent_id,
|
|
live_ancestors.visited_issue_ids || parent.id
|
|
FROM live_ancestors
|
|
JOIN issues parent ON parent.id = live_ancestors.next_parent_id
|
|
WHERE parent.company_id = ${companyId}
|
|
AND parent.hidden_at IS NULL
|
|
AND parent.harness_kind IS NULL
|
|
AND NOT parent.id = ANY(live_ancestors.visited_issue_ids)
|
|
)
|
|
SELECT
|
|
live_ancestors.ancestor_id::text AS "issueId",
|
|
count(DISTINCT live_ancestors.live_issue_id)::int AS "liveDescendantCount"
|
|
FROM live_ancestors
|
|
JOIN target_issues ON target_issues.issue_id = live_ancestors.ancestor_id
|
|
WHERE live_ancestors.ancestor_id <> live_ancestors.live_issue_id
|
|
GROUP BY live_ancestors.ancestor_id
|
|
`);
|
|
|
|
const resultRows = Array.isArray(rows)
|
|
? rows
|
|
: Array.from(rows as Iterable<unknown>);
|
|
for (const row of resultRows) {
|
|
if (typeof row !== "object" || row === null) continue;
|
|
const issueId = (row as { issueId?: unknown }).issueId;
|
|
const liveDescendantCount = (row as { liveDescendantCount?: unknown })
|
|
.liveDescendantCount;
|
|
if (typeof issueId !== "string") continue;
|
|
const count =
|
|
typeof liveDescendantCount === "number"
|
|
? liveDescendantCount
|
|
: Number(liveDescendantCount);
|
|
if (Number.isFinite(count)) map.set(issueId, count);
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function createIssueBlockerAttention(
|
|
input: Partial<IssueBlockerAttention> = {},
|
|
): IssueBlockerAttention {
|
|
return {
|
|
state: input.state ?? "none",
|
|
reason: input.reason ?? null,
|
|
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
|
|
coveredBlockerCount: input.coveredBlockerCount ?? 0,
|
|
stalledBlockerCount: input.stalledBlockerCount ?? 0,
|
|
attentionBlockerCount: input.attentionBlockerCount ?? 0,
|
|
pendingFinalizeBlockerIssueIds: input.pendingFinalizeBlockerIssueIds ?? [],
|
|
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
|
|
sampleStalledBlockerIdentifier:
|
|
input.sampleStalledBlockerIdentifier ?? null,
|
|
blockingTreeLive: input.blockingTreeLive ?? false,
|
|
directBlockerIssueId: input.directBlockerIssueId ?? null,
|
|
terminalBlockerIssueId: input.terminalBlockerIssueId ?? null,
|
|
terminalBlocker: input.terminalBlocker ?? null,
|
|
};
|
|
}
|
|
|
|
function blockerSampleIdentifier(
|
|
node: IssueBlockerAttentionNode | null | undefined,
|
|
) {
|
|
return node?.identifier ?? node?.id ?? null;
|
|
}
|
|
|
|
function appendBlockerAttentionEdges(
|
|
edgesByIssueId: Map<string, IssueBlockerAttentionEdge[]>,
|
|
rows: IssueBlockerAttentionEdge[],
|
|
) {
|
|
for (const row of rows) {
|
|
const existing = edgesByIssueId.get(row.issueId) ?? [];
|
|
if (!existing.some((edge) => edge.blockerIssueId === row.blockerIssueId)) {
|
|
existing.push(row);
|
|
edgesByIssueId.set(row.issueId, existing);
|
|
}
|
|
}
|
|
}
|
|
|
|
type IssueRelationSummaryRow = {
|
|
relatedId: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
status: string;
|
|
priority: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
};
|
|
|
|
function summarizeIssueRelationRow(
|
|
row: IssueRelationSummaryRow,
|
|
): IssueRelationIssueSummary {
|
|
return {
|
|
id: row.relatedId,
|
|
identifier: row.identifier,
|
|
title: row.title,
|
|
status: row.status as IssueRelationIssueSummary["status"],
|
|
priority: row.priority as IssueRelationIssueSummary["priority"],
|
|
assigneeAgentId: row.assigneeAgentId,
|
|
assigneeUserId: row.assigneeUserId,
|
|
};
|
|
}
|
|
|
|
async function terminalExplicitBlockersByRoot(
|
|
companyId: string,
|
|
roots: IssueRelationIssueSummary[],
|
|
dbOrTx: DbReader,
|
|
): Promise<Map<string, IssueRelationIssueSummary[]>> {
|
|
const rootIds = [...new Set(roots.map((root) => root.id))];
|
|
const terminalByRoot = new Map<string, IssueRelationIssueSummary[]>();
|
|
if (rootIds.length === 0) return terminalByRoot;
|
|
|
|
const nodesById = new Map<string, IssueRelationIssueSummary>();
|
|
const edgesByIssueId = new Map<string, string[]>();
|
|
for (const root of roots) nodesById.set(root.id, root);
|
|
|
|
let frontier = rootIds;
|
|
for (
|
|
let depth = 0;
|
|
frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH;
|
|
depth += 1
|
|
) {
|
|
const nextFrontier = new Set<string>();
|
|
for (const chunk of chunkList(
|
|
[...new Set(frontier)],
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
currentIssueId: issueRelations.relatedIssueId,
|
|
relatedId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
inArray(issueRelations.relatedIssueId, chunk),
|
|
eq(issues.companyId, companyId),
|
|
ne(issues.status, "done"),
|
|
),
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? [];
|
|
if (!existingEdges.includes(row.relatedId)) {
|
|
existingEdges.push(row.relatedId);
|
|
edgesByIssueId.set(row.currentIssueId, existingEdges);
|
|
}
|
|
if (!nodesById.has(row.relatedId)) {
|
|
nodesById.set(row.relatedId, summarizeIssueRelationRow(row));
|
|
nextFrontier.add(row.relatedId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break;
|
|
frontier = [...nextFrontier];
|
|
}
|
|
|
|
const collectTerminal = (
|
|
issueId: string,
|
|
seen: Set<string>,
|
|
): IssueRelationIssueSummary[] => {
|
|
if (seen.has(issueId)) return [];
|
|
const node = nodesById.get(issueId);
|
|
if (!node || node.status === "done") return [];
|
|
const nextSeen = new Set(seen);
|
|
nextSeen.add(issueId);
|
|
const downstreamIds = edgesByIssueId.get(issueId) ?? [];
|
|
if (downstreamIds.length === 0) return [node];
|
|
return downstreamIds.flatMap((downstreamId) =>
|
|
collectTerminal(downstreamId, nextSeen),
|
|
);
|
|
};
|
|
|
|
for (const rootId of rootIds) {
|
|
const deduped = new Map<string, IssueRelationIssueSummary>();
|
|
for (const blocker of collectTerminal(rootId, new Set())) {
|
|
if (blocker.id !== rootId) deduped.set(blocker.id, blocker);
|
|
}
|
|
if (deduped.size > 0) {
|
|
terminalByRoot.set(
|
|
rootId,
|
|
[...deduped.values()].sort((a, b) => a.title.localeCompare(b.title)),
|
|
);
|
|
}
|
|
}
|
|
|
|
return terminalByRoot;
|
|
}
|
|
|
|
async function listIssueBlockerAttentionMap(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueRows: IssueBlockerAttentionInputNode[],
|
|
): Promise<Map<string, IssueBlockerAttention>> {
|
|
const roots = issueRows.filter(
|
|
(row) => row.companyId === companyId && row.status === "blocked",
|
|
);
|
|
const attentionMap = new Map<string, IssueBlockerAttention>();
|
|
for (const row of issueRows) {
|
|
if (row.status !== "blocked") {
|
|
attentionMap.set(row.id, createIssueBlockerAttention());
|
|
}
|
|
}
|
|
if (roots.length === 0) return attentionMap;
|
|
|
|
const nodesById = new Map<string, IssueBlockerAttentionNode>();
|
|
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
|
|
for (const root of roots) nodesById.set(root.id, { ...root });
|
|
|
|
let frontier = roots.map((root) => root.id);
|
|
let truncated = false;
|
|
const pendingFinalizeBlockerIssueIds = new Set<string>();
|
|
for (
|
|
let depth = 0;
|
|
frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH;
|
|
depth += 1
|
|
) {
|
|
const nextFrontier = new Set<string>();
|
|
|
|
for (const chunk of chunkList(
|
|
[...new Set(frontier)],
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const readinessByIssueId = await listIssueDependencyReadinessMap(
|
|
dbOrTx,
|
|
companyId,
|
|
chunk,
|
|
);
|
|
for (const readiness of readinessByIssueId.values()) {
|
|
for (const blockerIssueId of readiness.pendingFinalizeBlockerIssueIds) {
|
|
pendingFinalizeBlockerIssueIds.add(blockerIssueId);
|
|
}
|
|
}
|
|
const explicitBlockerRowsPromise: Promise<
|
|
IssueBlockerAttentionQueryRow[]
|
|
> = dbOrTx
|
|
.select({
|
|
issueId: issueRelations.relatedIssueId,
|
|
blockerIssueId: issues.id,
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
parentId: issues.parentId,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
inArray(issueRelations.relatedIssueId, chunk),
|
|
eq(issues.companyId, companyId),
|
|
),
|
|
);
|
|
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
|
|
.select({
|
|
issueId: issues.parentId,
|
|
blockerIssueId: issues.id,
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
parentId: issues.parentId,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
inArray(issues.parentId, chunk),
|
|
notInArray(
|
|
issues.status,
|
|
BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES,
|
|
),
|
|
),
|
|
);
|
|
const [explicitBlockerRows, childRows] = await Promise.all([
|
|
explicitBlockerRowsPromise,
|
|
childRowsPromise,
|
|
]);
|
|
|
|
const unresolvedExplicitBlockerRows = explicitBlockerRows.filter(
|
|
(row) =>
|
|
row.status !== "done" ||
|
|
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId),
|
|
);
|
|
appendBlockerAttentionEdges(edgesByIssueId, [
|
|
...unresolvedExplicitBlockerRows
|
|
.filter(
|
|
(row): row is IssueBlockerAttentionQueryRow & { issueId: string } =>
|
|
row.issueId !== null,
|
|
)
|
|
.map((row) => ({
|
|
issueId: row.issueId,
|
|
blockerIssueId: row.blockerIssueId,
|
|
})),
|
|
...childRows
|
|
.filter(
|
|
(row): row is IssueBlockerAttentionQueryRow & { issueId: string } =>
|
|
row.issueId !== null,
|
|
)
|
|
.map((row) => ({
|
|
issueId: row.issueId,
|
|
blockerIssueId: row.blockerIssueId,
|
|
})),
|
|
]);
|
|
|
|
for (const row of [...unresolvedExplicitBlockerRows, ...childRows]) {
|
|
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
|
|
nodesById.set(row.blockerIssueId, {
|
|
id: row.blockerIssueId,
|
|
companyId: row.companyId,
|
|
parentId: row.parentId,
|
|
identifier: row.identifier,
|
|
title: row.title,
|
|
status: row.status,
|
|
executionRunId: row.executionRunId,
|
|
assigneeAgentId: row.assigneeAgentId,
|
|
assigneeUserId: row.assigneeUserId,
|
|
});
|
|
nextFrontier.add(row.blockerIssueId);
|
|
}
|
|
}
|
|
|
|
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
frontier = [...nextFrontier];
|
|
}
|
|
if (frontier.length > 0) truncated = true;
|
|
|
|
const nodeIds = [...nodesById.keys()];
|
|
const activeIssueIds = new Set<string>();
|
|
const agentIds = new Set<string>();
|
|
const issueIdByExecutionRunId = new Map<string, string>();
|
|
for (const node of nodesById.values()) {
|
|
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
|
|
if (node.executionRunId)
|
|
issueIdByExecutionRunId.set(node.executionRunId, node.id);
|
|
}
|
|
|
|
for (const chunk of chunkList(
|
|
[...issueIdByExecutionRunId.keys()],
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const runRows: Array<{ id: string }> = await dbOrTx
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES),
|
|
inArray(heartbeatRuns.id, chunk),
|
|
),
|
|
);
|
|
|
|
for (const row of runRows) {
|
|
const issueId = issueIdByExecutionRunId.get(row.id);
|
|
if (issueId) activeIssueIds.add(issueId);
|
|
}
|
|
}
|
|
|
|
for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
|
const wakeRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> =
|
|
dbOrTx
|
|
.select({
|
|
issueId: sql<
|
|
string | null
|
|
>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
inArray(
|
|
agentWakeupRequests.status,
|
|
BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES,
|
|
),
|
|
sql`${agentWakeupRequests.runId} is null`,
|
|
inArray(
|
|
sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
|
chunk,
|
|
),
|
|
),
|
|
);
|
|
const wakeRows = await wakeRowsPromise;
|
|
for (const row of wakeRows) {
|
|
if (row.issueId) activeIssueIds.add(row.issueId);
|
|
}
|
|
}
|
|
|
|
const explicitWaitCandidateIds = [...nodesById.values()]
|
|
.filter((node) => node.status !== "done")
|
|
.map((node) => node.id);
|
|
const explicitWaitingIssueIds = new Set<string>();
|
|
if (explicitWaitCandidateIds.length > 0) {
|
|
for (const chunk of chunkList(
|
|
explicitWaitCandidateIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const interactionRows: Array<{ issueId: string }> = await dbOrTx
|
|
.select({ issueId: issueThreadInteractions.issueId })
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
inArray(
|
|
issueThreadInteractions.status,
|
|
BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES,
|
|
),
|
|
inArray(issueThreadInteractions.issueId, chunk),
|
|
),
|
|
);
|
|
for (const row of interactionRows)
|
|
explicitWaitingIssueIds.add(row.issueId);
|
|
|
|
const approvalRows: Array<{ issueId: string }> = await dbOrTx
|
|
.select({ issueId: issueApprovals.issueId })
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(
|
|
and(
|
|
eq(issueApprovals.companyId, companyId),
|
|
inArray(
|
|
approvals.status,
|
|
BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES,
|
|
),
|
|
inArray(issueApprovals.issueId, chunk),
|
|
),
|
|
);
|
|
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
|
|
}
|
|
|
|
// Recovery rows are intentionally company-wide: a liveness escalation for
|
|
// the same leaf blocker represents an active waiting path even when that
|
|
// blocker is reached through another blocked graph.
|
|
const recoveryRows: Array<{ id: string; originId: string | null }> =
|
|
await dbOrTx
|
|
.select({ id: issues.id, originId: issues.originId })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
|
|
visibleIssueCondition(),
|
|
notInArray(
|
|
issues.status,
|
|
BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES,
|
|
),
|
|
),
|
|
);
|
|
for (const row of recoveryRows) {
|
|
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
|
|
if (!parsed || parsed.companyId !== companyId) continue;
|
|
explicitWaitingIssueIds.add(row.id);
|
|
explicitWaitingIssueIds.add(parsed.issueId);
|
|
explicitWaitingIssueIds.add(parsed.leafIssueId);
|
|
}
|
|
|
|
const recoveryActionRows: Array<{
|
|
id: string;
|
|
sourceIssueId: string;
|
|
status: string;
|
|
ownerType: string;
|
|
ownerAgentId: string | null;
|
|
ownerUserId: string | null;
|
|
}> = await dbOrTx
|
|
.select({
|
|
id: issueRecoveryActions.id,
|
|
sourceIssueId: issueRecoveryActions.sourceIssueId,
|
|
status: issueRecoveryActions.status,
|
|
ownerType: issueRecoveryActions.ownerType,
|
|
ownerAgentId: issueRecoveryActions.ownerAgentId,
|
|
ownerUserId: issueRecoveryActions.ownerUserId,
|
|
})
|
|
.from(issueRecoveryActions)
|
|
.where(
|
|
and(
|
|
eq(issueRecoveryActions.companyId, companyId),
|
|
inArray(issueRecoveryActions.status, ["active", "escalated"]),
|
|
inArray(issueRecoveryActions.sourceIssueId, explicitWaitCandidateIds),
|
|
),
|
|
);
|
|
const recoveryActionIds = recoveryActionRows.map((row) => row.id);
|
|
const liveRecoveryActionIds = new Set<string>();
|
|
for (const chunk of chunkList(
|
|
recoveryActionIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const [runRows, wakeRows] = await Promise.all([
|
|
dbOrTx
|
|
.select({
|
|
recoveryActionId: sql<
|
|
string | null
|
|
>`${heartbeatRuns.contextSnapshot} ->> 'recoveryActionId'`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
inArray(
|
|
heartbeatRuns.status,
|
|
BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES,
|
|
),
|
|
inArray(
|
|
sql<string>`${heartbeatRuns.contextSnapshot} ->> 'recoveryActionId'`,
|
|
chunk,
|
|
),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
recoveryActionId: sql<
|
|
string | null
|
|
>`${agentWakeupRequests.payload} ->> 'recoveryActionId'`,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
inArray(
|
|
agentWakeupRequests.status,
|
|
BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES,
|
|
),
|
|
inArray(
|
|
sql<string>`${agentWakeupRequests.payload} ->> 'recoveryActionId'`,
|
|
chunk,
|
|
),
|
|
),
|
|
),
|
|
]);
|
|
for (const row of [...runRows, ...wakeRows]) {
|
|
if (row.recoveryActionId)
|
|
liveRecoveryActionIds.add(row.recoveryActionId);
|
|
}
|
|
}
|
|
for (const row of recoveryActionRows) {
|
|
const healthy =
|
|
(row.status === "escalated" && row.ownerType === "board") ||
|
|
Boolean(row.ownerUserId) ||
|
|
(Boolean(row.ownerAgentId) && liveRecoveryActionIds.has(row.id));
|
|
if (healthy) explicitWaitingIssueIds.add(row.sourceIssueId);
|
|
}
|
|
}
|
|
|
|
const agentRows: IssueBlockerAttentionAgentRow[] =
|
|
agentIds.size > 0
|
|
? await dbOrTx
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
status: agents.status,
|
|
})
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.companyId, companyId),
|
|
inArray(agents.id, [...agentIds]),
|
|
),
|
|
)
|
|
: [];
|
|
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
|
|
|
|
type PathClassification = {
|
|
covered: boolean;
|
|
stalled: boolean;
|
|
sampleBlockerIdentifier: string | null;
|
|
sampleStalledBlockerIdentifier: string | null;
|
|
terminalBlockerIssueId?: string | null;
|
|
};
|
|
const classifyPath = (
|
|
nodeId: string,
|
|
seen: Set<string>,
|
|
): PathClassification => {
|
|
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
|
|
if (truncated || seen.has(nodeId)) {
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: sample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
const node = nodesById.get(nodeId);
|
|
if (!node || node.companyId !== companyId) {
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeId,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
const nodeSample = blockerSampleIdentifier(node);
|
|
if (
|
|
node.status === "done" &&
|
|
!pendingFinalizeBlockerIssueIds.has(node.id)
|
|
) {
|
|
return {
|
|
covered: true,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
if (explicitWaitingIssueIds.has(node.id)) {
|
|
return {
|
|
covered: true,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
if (node.assigneeUserId && node.status !== "cancelled") {
|
|
return {
|
|
covered: true,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
if (node.status === "in_review") {
|
|
const hasWaitingPath =
|
|
activeIssueIds.has(node.id) || Boolean(node.assigneeUserId);
|
|
if (hasWaitingPath) {
|
|
return {
|
|
covered: true,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
return {
|
|
covered: false,
|
|
stalled: true,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: nodeSample,
|
|
terminalBlockerIssueId: node.id,
|
|
};
|
|
}
|
|
if (activeIssueIds.has(node.id)) {
|
|
return {
|
|
covered: true,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
if (node.status === "cancelled") {
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
terminalBlockerIssueId: node.id,
|
|
};
|
|
}
|
|
if (node.status === "backlog" && node.assigneeAgentId) {
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
terminalBlockerIssueId: node.id,
|
|
};
|
|
}
|
|
|
|
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => {
|
|
const blocker = nodesById.get(edge.blockerIssueId);
|
|
return (
|
|
blocker?.status !== "done" ||
|
|
pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
|
|
);
|
|
});
|
|
if (downstream.length > 0) {
|
|
const nextSeen = new Set(seen);
|
|
nextSeen.add(nodeId);
|
|
const classified = downstream.map((edge) =>
|
|
classifyPath(edge.blockerIssueId, nextSeen),
|
|
);
|
|
const stalledChild = classified.find(
|
|
(result) => result.stalled || result.sampleStalledBlockerIdentifier,
|
|
);
|
|
const sampleStalled =
|
|
stalledChild?.sampleStalledBlockerIdentifier ?? null;
|
|
const hardAttention =
|
|
classified.find(
|
|
(result) =>
|
|
!result.covered && !result.stalled && result.terminalBlockerIssueId,
|
|
) ?? classified.find((result) => !result.covered && !result.stalled);
|
|
if (hardAttention) {
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
|
|
sampleStalledBlockerIdentifier: sampleStalled,
|
|
terminalBlockerIssueId: hardAttention.terminalBlockerIssueId ?? null,
|
|
};
|
|
}
|
|
const stalledEntry = classified.find((result) => result.stalled);
|
|
if (stalledEntry) {
|
|
return {
|
|
covered: false,
|
|
stalled: true,
|
|
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
|
|
sampleStalledBlockerIdentifier: sampleStalled,
|
|
terminalBlockerIssueId: stalledEntry.terminalBlockerIssueId ?? null,
|
|
};
|
|
}
|
|
return {
|
|
covered: true,
|
|
stalled: false,
|
|
sampleBlockerIdentifier:
|
|
classified[0]?.sampleBlockerIdentifier ?? nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
};
|
|
}
|
|
|
|
if (node.assigneeAgentId) {
|
|
const assignee = agentsById.get(node.assigneeAgentId);
|
|
if (
|
|
!assignee ||
|
|
assignee.companyId !== companyId ||
|
|
!BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)
|
|
) {
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
terminalBlockerIssueId: node.id,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
covered: false,
|
|
stalled: false,
|
|
sampleBlockerIdentifier: nodeSample,
|
|
sampleStalledBlockerIdentifier: null,
|
|
terminalBlockerIssueId: node.id,
|
|
};
|
|
};
|
|
|
|
const pathHasLiveWork = (nodeId: string, seen: Set<string>): boolean => {
|
|
if (seen.has(nodeId)) return false;
|
|
const node = nodesById.get(nodeId);
|
|
if (!node || node.companyId !== companyId) return false;
|
|
if (
|
|
node.status === "in_progress" ||
|
|
activeIssueIds.has(node.id) ||
|
|
explicitWaitingIssueIds.has(node.id) ||
|
|
Boolean(node.assigneeUserId)
|
|
)
|
|
return true;
|
|
|
|
const nextSeen = new Set(seen);
|
|
nextSeen.add(nodeId);
|
|
return (edgesByIssueId.get(node.id) ?? []).some((edge) => {
|
|
const blocker = nodesById.get(edge.blockerIssueId);
|
|
if (
|
|
blocker?.status === "done" &&
|
|
!pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
|
|
)
|
|
return false;
|
|
return pathHasLiveWork(edge.blockerIssueId, nextSeen);
|
|
});
|
|
};
|
|
|
|
const issueIdForSample = (sample: string | null | undefined) => {
|
|
if (!sample) return null;
|
|
for (const node of nodesById.values()) {
|
|
if (node.id === sample || node.identifier === sample) return node.id;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
for (const root of roots) {
|
|
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => {
|
|
const blocker = nodesById.get(edge.blockerIssueId);
|
|
return (
|
|
blocker?.status !== "done" ||
|
|
pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
|
|
);
|
|
});
|
|
if (topLevelEdges.length === 0) {
|
|
attentionMap.set(
|
|
root.id,
|
|
createIssueBlockerAttention({
|
|
state: "needs_attention",
|
|
reason: "attention_required",
|
|
terminalBlockerIssueId: root.id,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const classified = topLevelEdges.map((edge) => ({
|
|
edge,
|
|
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
|
|
}));
|
|
const coveredBlockerCount = classified.filter(
|
|
(entry) => entry.result.covered,
|
|
).length;
|
|
const stalledBlockerCount = classified.filter(
|
|
(entry) => entry.result.stalled,
|
|
).length;
|
|
const attentionBlockerCount =
|
|
classified.length - coveredBlockerCount - stalledBlockerCount;
|
|
const hardAttentionEntry =
|
|
classified.find(
|
|
(entry) =>
|
|
!entry.result.covered &&
|
|
!entry.result.stalled &&
|
|
entry.result.terminalBlockerIssueId,
|
|
) ??
|
|
classified.find(
|
|
(entry) => !entry.result.covered && !entry.result.stalled,
|
|
);
|
|
const stalledEntry =
|
|
classified.find(
|
|
(entry) => entry.result.stalled && entry.result.terminalBlockerIssueId,
|
|
) ?? classified.find((entry) => entry.result.stalled);
|
|
const sampleEntry =
|
|
hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null;
|
|
const sampleNode = sampleEntry
|
|
? nodesById.get(sampleEntry.edge.blockerIssueId)
|
|
: null;
|
|
const sampleStalledFromChain = classified
|
|
.map((entry) => entry.result.sampleStalledBlockerIdentifier)
|
|
.find((value) => value);
|
|
const sampledTerminalIdentifier = sampleEntry?.result.stalled
|
|
? (sampleEntry.result.sampleStalledBlockerIdentifier ??
|
|
sampleEntry.result.sampleBlockerIdentifier)
|
|
: (sampleEntry?.result.sampleBlockerIdentifier ??
|
|
blockerSampleIdentifier(sampleNode));
|
|
const terminalBlockerIssueId =
|
|
sampleEntry?.result.terminalBlockerIssueId ??
|
|
issueIdForSample(sampledTerminalIdentifier);
|
|
const terminalBlockerNode = terminalBlockerIssueId
|
|
? (nodesById.get(terminalBlockerIssueId) ?? null)
|
|
: null;
|
|
|
|
let state: IssueBlockerAttention["state"];
|
|
let reason: IssueBlockerAttention["reason"];
|
|
if (attentionBlockerCount > 0) {
|
|
state = "needs_attention";
|
|
reason = "attention_required";
|
|
} else if (stalledBlockerCount > 0) {
|
|
state = "stalled";
|
|
reason = "stalled_review";
|
|
} else {
|
|
state = "covered";
|
|
reason = topLevelEdges.every(
|
|
(edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id,
|
|
)
|
|
? "active_child"
|
|
: "active_dependency";
|
|
}
|
|
|
|
attentionMap.set(
|
|
root.id,
|
|
createIssueBlockerAttention({
|
|
state,
|
|
reason,
|
|
unresolvedBlockerCount: topLevelEdges.length,
|
|
coveredBlockerCount,
|
|
stalledBlockerCount,
|
|
attentionBlockerCount,
|
|
pendingFinalizeBlockerIssueIds: topLevelEdges
|
|
.map((edge) => edge.blockerIssueId)
|
|
.filter((blockerIssueId) =>
|
|
pendingFinalizeBlockerIssueIds.has(blockerIssueId),
|
|
),
|
|
sampleBlockerIdentifier:
|
|
sampleEntry?.result.sampleBlockerIdentifier ??
|
|
blockerSampleIdentifier(sampleNode),
|
|
sampleStalledBlockerIdentifier:
|
|
stalledEntry?.result.sampleStalledBlockerIdentifier ??
|
|
sampleStalledFromChain ??
|
|
null,
|
|
blockingTreeLive: topLevelEdges.some((edge) =>
|
|
pathHasLiveWork(edge.blockerIssueId, new Set([root.id])),
|
|
),
|
|
directBlockerIssueId: sampleEntry?.edge.blockerIssueId ?? null,
|
|
terminalBlockerIssueId,
|
|
terminalBlocker: terminalBlockerNode
|
|
? {
|
|
id: terminalBlockerNode.id,
|
|
identifier: terminalBlockerNode.identifier,
|
|
title: terminalBlockerNode.title,
|
|
}
|
|
: null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return attentionMap;
|
|
}
|
|
|
|
type IssueReviewAttentionInput = Pick<IssueRow, "id" | "companyId" | "status">;
|
|
|
|
function reviewPathLabel(
|
|
kind: IssueReviewAttentionPath["kind"],
|
|
detail?: string | null,
|
|
) {
|
|
switch (kind) {
|
|
case "execution_participant":
|
|
return "Execution review participant";
|
|
case "interaction":
|
|
return detail
|
|
? `Pending ${detail.replaceAll("_", " ")}`
|
|
: "Pending issue interaction";
|
|
case "approval":
|
|
return "Linked approval";
|
|
case "monitor":
|
|
return "Scheduled review monitor";
|
|
case "human_reviewer":
|
|
return "Human reviewer";
|
|
case "active_run":
|
|
return "Active review run";
|
|
case "queued_wake":
|
|
return detail
|
|
? `Queued ${detail.replaceAll("_", " ")} wake`
|
|
: "Queued review wake";
|
|
case "recovery":
|
|
return "Open review recovery";
|
|
}
|
|
}
|
|
|
|
function reviewAttentionNone(): IssueReviewAttention {
|
|
return { state: "none", paths: [], reason: null };
|
|
}
|
|
|
|
async function listIssueReviewAttentionMap(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueRows: IssueReviewAttentionInput[],
|
|
): Promise<Map<string, IssueReviewAttention>> {
|
|
const result = new Map<string, IssueReviewAttention>();
|
|
for (const row of issueRows) result.set(row.id, reviewAttentionNone());
|
|
|
|
const reviewIds = issueRows
|
|
.filter((row) => row.companyId === companyId && row.status === "in_review")
|
|
.map((row) => row.id);
|
|
if (reviewIds.length === 0) return result;
|
|
|
|
const reviewIssues: IssueRow[] = [];
|
|
for (const chunk of chunkList(
|
|
reviewIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
reviewIssues.push(
|
|
...(await dbOrTx
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(eq(issues.companyId, companyId), inArray(issues.id, chunk), nonIdleSlackIssueCondition()),
|
|
)),
|
|
);
|
|
}
|
|
if (reviewIssues.length === 0) return result;
|
|
|
|
const [
|
|
agentRows,
|
|
activeRunRows,
|
|
wakeRows,
|
|
interactionRows,
|
|
approvalRows,
|
|
recoveryActionRows,
|
|
recoveryIssueRows,
|
|
] = await Promise.all([
|
|
dbOrTx
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
name: agents.name,
|
|
role: agents.role,
|
|
title: agents.title,
|
|
status: agents.status,
|
|
reportsTo: agents.reportsTo,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId)),
|
|
dbOrTx
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
issueId: sql<
|
|
string | null
|
|
>`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
inArray(
|
|
sql<string>`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`,
|
|
reviewIds,
|
|
),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
id: agentWakeupRequests.id,
|
|
companyId: agentWakeupRequests.companyId,
|
|
issueId: sql<string | null>`coalesce(
|
|
${agentWakeupRequests.payload} ->> 'issueId',
|
|
${agentWakeupRequests.payload} ->> 'taskId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
|
|
)`,
|
|
agentId: agentWakeupRequests.agentId,
|
|
status: agentWakeupRequests.status,
|
|
reason: agentWakeupRequests.reason,
|
|
createdAt: agentWakeupRequests.requestedAt,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
inArray(agentWakeupRequests.status, [
|
|
"queued",
|
|
"deferred_issue_execution",
|
|
"claimed",
|
|
]),
|
|
inArray(
|
|
sql<string>`coalesce(
|
|
${agentWakeupRequests.payload} ->> 'issueId',
|
|
${agentWakeupRequests.payload} ->> 'taskId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
|
|
)`,
|
|
reviewIds,
|
|
),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
id: issueThreadInteractions.id,
|
|
companyId: issueThreadInteractions.companyId,
|
|
issueId: issueThreadInteractions.issueId,
|
|
status: issueThreadInteractions.status,
|
|
kind: issueThreadInteractions.kind,
|
|
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
|
sourceRunId: issueThreadInteractions.sourceRunId,
|
|
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
|
effectiveResolverPolicy:
|
|
issueThreadInteractions.effectiveResolverPolicy,
|
|
resolverPolicyProvenance:
|
|
issueThreadInteractions.resolverPolicyProvenance,
|
|
createdAt: issueThreadInteractions.createdAt,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
inArray(issueThreadInteractions.issueId, reviewIds),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
id: approvals.id,
|
|
companyId: issueApprovals.companyId,
|
|
issueId: issueApprovals.issueId,
|
|
status: approvals.status,
|
|
createdAt: approvals.createdAt,
|
|
})
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(
|
|
and(
|
|
eq(issueApprovals.companyId, companyId),
|
|
eq(approvals.companyId, companyId),
|
|
inArray(approvals.status, ["pending", "revision_requested"]),
|
|
inArray(issueApprovals.issueId, reviewIds),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
id: issueRecoveryActions.id,
|
|
companyId: issueRecoveryActions.companyId,
|
|
issueId: issueRecoveryActions.sourceIssueId,
|
|
status: issueRecoveryActions.status,
|
|
createdAt: issueRecoveryActions.createdAt,
|
|
})
|
|
.from(issueRecoveryActions)
|
|
.where(
|
|
and(
|
|
eq(issueRecoveryActions.companyId, companyId),
|
|
inArray(issueRecoveryActions.status, ["active", "escalated"]),
|
|
inArray(issueRecoveryActions.sourceIssueId, reviewIds),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
originKind: issues.originKind,
|
|
originId: issues.originId,
|
|
status: issues.status,
|
|
createdAt: issues.createdAt,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
inArray(issues.originKind, [
|
|
RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
|
|
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
|
|
]),
|
|
visibleIssueCondition(),
|
|
notInArray(issues.status, ["done", "cancelled"]),
|
|
),
|
|
),
|
|
]);
|
|
|
|
const recoveryPaths = [
|
|
...(recoveryActionRows as Array<{
|
|
id: string;
|
|
companyId: string;
|
|
issueId: string;
|
|
status: string;
|
|
createdAt: Date;
|
|
}>),
|
|
];
|
|
for (const recovery of recoveryIssueRows as Array<{
|
|
id: string;
|
|
companyId: string;
|
|
originKind: string;
|
|
originId: string | null;
|
|
status: string;
|
|
createdAt: Date;
|
|
}>) {
|
|
if (
|
|
recovery.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery &&
|
|
recovery.originId &&
|
|
reviewIds.includes(recovery.originId)
|
|
) {
|
|
recoveryPaths.push({ ...recovery, issueId: recovery.originId });
|
|
continue;
|
|
}
|
|
const parsed = parseIssueGraphLivenessIncidentKey(recovery.originId);
|
|
if (parsed?.companyId === companyId && reviewIds.includes(parsed.issueId)) {
|
|
recoveryPaths.push({ ...recovery, issueId: parsed.issueId });
|
|
}
|
|
}
|
|
|
|
const livenessInput: IssueGraphLivenessInput = {
|
|
issues: reviewIssues.map((issue) => ({
|
|
id: issue.id,
|
|
companyId: issue.companyId,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
status: issue.status,
|
|
projectId: issue.projectId,
|
|
goalId: issue.goalId,
|
|
parentId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
createdByAgentId: issue.createdByAgentId,
|
|
createdByUserId: issue.createdByUserId,
|
|
conversationAgentId: issue.conversationAgentId,
|
|
conversationUserId: issue.conversationUserId,
|
|
conversationState: issue.conversationState,
|
|
executionPolicy: issue.executionPolicy,
|
|
executionState: issue.executionState,
|
|
monitorNextCheckAt: issue.monitorNextCheckAt,
|
|
monitorAttemptCount: issue.monitorAttemptCount,
|
|
})),
|
|
relations: [],
|
|
agents: agentRows,
|
|
activeRuns: activeRunRows,
|
|
queuedWakeRequests: wakeRows,
|
|
pendingInteractions: interactionRows,
|
|
pendingApprovals: approvalRows,
|
|
openRecoveryIssues: recoveryPaths,
|
|
now: new Date(),
|
|
};
|
|
const findingsByIssueId = new Map(
|
|
classifyIssueGraphLiveness(livenessInput).map((finding) => [
|
|
finding.issueId,
|
|
finding,
|
|
]),
|
|
);
|
|
const agentNameById = new Map(
|
|
(agentRows as Array<{ id: string; name: string }>).map((agent) => [
|
|
agent.id,
|
|
agent.name,
|
|
]),
|
|
);
|
|
const userIds = new Set<string>();
|
|
for (const issue of reviewIssues) {
|
|
if (issue.assigneeUserId) userIds.add(issue.assigneeUserId);
|
|
const participant = parseObject(issue.executionState).currentParticipant;
|
|
if (
|
|
participant &&
|
|
typeof participant === "object" &&
|
|
!Array.isArray(participant)
|
|
) {
|
|
const userId = (participant as Record<string, unknown>).userId;
|
|
if (typeof userId === "string") userIds.add(userId);
|
|
}
|
|
}
|
|
const userRows =
|
|
userIds.size > 0
|
|
? await dbOrTx
|
|
.select({ id: authUsers.id, name: authUsers.name })
|
|
.from(authUsers)
|
|
.where(inArray(authUsers.id, [...userIds]))
|
|
: [];
|
|
const userNameById = new Map(
|
|
(userRows as Array<{ id: string; name: string }>).map((user) => [
|
|
user.id,
|
|
user.name,
|
|
]),
|
|
);
|
|
const interactionKindById = new Map(
|
|
(interactionRows as Array<{ id: string; kind: string }>).map((row) => [
|
|
row.id,
|
|
row.kind,
|
|
]),
|
|
);
|
|
const interactionAudienceById = new Map(
|
|
(
|
|
interactionRows as Array<{
|
|
id: string;
|
|
createdByAgentId: string | null;
|
|
sourceRunId: string | null;
|
|
addresseeAgentId: string | null;
|
|
effectiveResolverPolicy: string;
|
|
resolverPolicyProvenance: string | null;
|
|
}>
|
|
).map((row) => [row.id, row]),
|
|
);
|
|
const wakeReasonById = new Map(
|
|
(wakeRows as Array<{ id: string; reason: string | null }>).map((row) => [
|
|
row.id,
|
|
row.reason,
|
|
]),
|
|
);
|
|
|
|
for (const issue of reviewIssues) {
|
|
const pathFacts = classifyIssueReviewPaths(
|
|
livenessInput,
|
|
livenessInput.issues.find((entry) => entry.id === issue.id)!,
|
|
);
|
|
const paths: IssueReviewAttentionPath[] = pathFacts.map((path) => {
|
|
const interactionAudience =
|
|
path.kind === "interaction" && path.ref
|
|
? (interactionAudienceById.get(path.ref) ?? null)
|
|
: null;
|
|
const candidateAgentId =
|
|
interactionAudience?.addresseeAgentId ?? issue.assigneeAgentId;
|
|
const interactionResponderAgentId =
|
|
interactionAudience &&
|
|
candidateAgentId &&
|
|
issueThreadInteractionAttentionAgentAllowed({
|
|
agentId: candidateAgentId,
|
|
interaction: interactionAudience,
|
|
})
|
|
? candidateAgentId
|
|
: null;
|
|
return {
|
|
kind: path.kind,
|
|
label: reviewPathLabel(
|
|
path.kind,
|
|
path.kind === "interaction" && path.ref
|
|
? (interactionKindById.get(path.ref) ?? null)
|
|
: path.kind === "queued_wake" && path.ref
|
|
? (wakeReasonById.get(path.ref) ?? null)
|
|
: null,
|
|
),
|
|
responder: path.agentId
|
|
? (agentNameById.get(path.agentId) ?? path.agentId)
|
|
: path.userId
|
|
? (userNameById.get(path.userId) ?? path.userId)
|
|
: path.kind === "interaction" && interactionResponderAgentId
|
|
? (agentNameById.get(interactionResponderAgentId) ??
|
|
interactionResponderAgentId)
|
|
: path.kind === "interaction" || path.kind === "approval"
|
|
? "Board"
|
|
: null,
|
|
since: path.since
|
|
? (path.since instanceof Date
|
|
? path.since
|
|
: new Date(path.since)
|
|
).toISOString()
|
|
: issue.updatedAt.toISOString(),
|
|
ref: path.ref,
|
|
};
|
|
});
|
|
|
|
if (paths.length > 0) {
|
|
result.set(issue.id, {
|
|
state: "covered",
|
|
paths,
|
|
reason:
|
|
paths.length === 1
|
|
? "Review has a maintained action path."
|
|
: `Review has ${paths.length} maintained action paths.`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const finding = findingsByIssueId.get(issue.id);
|
|
result.set(issue.id, {
|
|
state: "stalled",
|
|
paths: [],
|
|
reason:
|
|
finding?.reason ??
|
|
"Issue is in review without a maintained action path.",
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const issueListSelect = {
|
|
externalConversationState: externalConversationStateSql(),
|
|
conversationAgentId: issues.conversationAgentId,
|
|
conversationUserId: issues.conversationUserId,
|
|
conversationState: issues.conversationState,
|
|
conversationSessionGeneration: issues.conversationSessionGeneration,
|
|
conversationBoundaryCommentId: issues.conversationBoundaryCommentId,
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
projectWorkspaceId: issues.projectWorkspaceId,
|
|
goalId: issues.goalId,
|
|
parentId: issues.parentId,
|
|
title: issues.title,
|
|
titleNeedsGeneration: issues.titleNeedsGeneration,
|
|
description: sql<string | null>`
|
|
CASE
|
|
WHEN ${issues.description} IS NULL THEN NULL
|
|
ELSE encode(
|
|
substring(
|
|
convert_to(${issues.description}, current_setting('server_encoding'))
|
|
FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_BYTES}
|
|
),
|
|
'base64'
|
|
)
|
|
END
|
|
`,
|
|
descriptionTruncated: sql<boolean>`
|
|
CASE
|
|
WHEN ${issues.description} IS NULL THEN false
|
|
ELSE length(${issues.description}) > ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}
|
|
END
|
|
`,
|
|
status: issues.status,
|
|
statusVersion: issues.statusVersion,
|
|
lastStatusDecisionId: issues.lastStatusDecisionId,
|
|
workMode: issues.workMode,
|
|
harnessKind: issues.harnessKind,
|
|
priority: issues.priority,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
responsibleUserId: issues.responsibleUserId,
|
|
issueNumber: issues.issueNumber,
|
|
identifier: issues.identifier,
|
|
originKind: issues.originKind,
|
|
originId: issues.originId,
|
|
originRunId: issues.originRunId,
|
|
originIdentityContextId: issues.originIdentityContextId,
|
|
continuationIdentityContextId: issues.continuationIdentityContextId,
|
|
originFingerprint: issues.originFingerprint,
|
|
requestDepth: issues.requestDepth,
|
|
billingCode: issues.billingCode,
|
|
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
|
executionPolicy: sql<null>`null`,
|
|
executionState: sql<null>`null`,
|
|
monitorNextCheckAt: issues.monitorNextCheckAt,
|
|
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
|
|
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
|
|
monitorAttemptCount: issues.monitorAttemptCount,
|
|
monitorNotes: issues.monitorNotes,
|
|
monitorScheduledBy: issues.monitorScheduledBy,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
|
executionWorkspaceSettings: sql<null>`null`,
|
|
sourceTrust: issues.sourceTrust,
|
|
unblockDescriptor: issues.unblockDescriptor,
|
|
blockedTransitionAt: issues.blockedTransitionAt,
|
|
blockedOwnerNotifiedAt: issues.blockedOwnerNotifiedAt,
|
|
startedAt: issues.startedAt,
|
|
completedAt: issues.completedAt,
|
|
cancelledAt: issues.cancelledAt,
|
|
hiddenAt: issues.hiddenAt,
|
|
createdAt: issues.createdAt,
|
|
updatedAt: issues.updatedAt,
|
|
};
|
|
|
|
function withActiveRuns(
|
|
issueRows: IssueWithLabels[],
|
|
runMap: Map<string, IssueActiveRunRow>,
|
|
): IssueWithLabelsAndRun[] {
|
|
return issueRows.map((row) => ({
|
|
...row,
|
|
activeRun: row.executionRunId
|
|
? (runMap.get(row.executionRunId) ?? null)
|
|
: null,
|
|
}));
|
|
}
|
|
|
|
async function userCommentStatsForIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
userId: string,
|
|
issueIds: string[],
|
|
): Promise<IssueUserCommentStats[]> {
|
|
const stats: IssueUserCommentStats[] = [];
|
|
for (const issueIdChunk of chunkList(
|
|
issueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
issueId: issueComments.issueId,
|
|
myLastCommentAt: sql<Date | null>`
|
|
MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END)
|
|
`,
|
|
lastExternalCommentAt: sql<Date | null>`
|
|
MAX(
|
|
CASE
|
|
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId}
|
|
THEN ${issueComments.createdAt}
|
|
END
|
|
)
|
|
`,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, companyId),
|
|
inArray(issueComments.issueId, issueIdChunk),
|
|
),
|
|
)
|
|
.groupBy(issueComments.issueId);
|
|
stats.push(...rows);
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
async function userReadStatsForIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
userId: string,
|
|
issueIds: string[],
|
|
): Promise<IssueReadStat[]> {
|
|
const stats: IssueReadStat[] = [];
|
|
for (const issueIdChunk of chunkList(
|
|
issueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
issueId: issueReadStates.issueId,
|
|
myLastReadAt: issueReadStates.lastReadAt,
|
|
})
|
|
.from(issueReadStates)
|
|
.where(
|
|
and(
|
|
eq(issueReadStates.companyId, companyId),
|
|
eq(issueReadStates.userId, userId),
|
|
inArray(issueReadStates.issueId, issueIdChunk),
|
|
),
|
|
);
|
|
stats.push(...rows);
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
async function lastActivityStatsForIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueIds: string[],
|
|
): Promise<IssueLastActivityStat[]> {
|
|
const byIssueId = new Map<string, IssueLastActivityStat>();
|
|
for (const issueIdChunk of chunkList(
|
|
issueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const [commentRows, logRows] = await Promise.all([
|
|
dbOrTx
|
|
.select({
|
|
issueId: issueComments.issueId,
|
|
latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, companyId),
|
|
inArray(issueComments.issueId, issueIdChunk),
|
|
),
|
|
)
|
|
.groupBy(issueComments.issueId),
|
|
dbOrTx
|
|
.select({
|
|
issueId: activityLog.entityId,
|
|
latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`,
|
|
})
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
inArray(activityLog.entityId, issueIdChunk),
|
|
sql`${activityLog.action} NOT IN (${sql.join(
|
|
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map(
|
|
(action) => sql`${action}`,
|
|
),
|
|
sql`, `,
|
|
)})`,
|
|
),
|
|
)
|
|
.groupBy(activityLog.entityId),
|
|
]);
|
|
|
|
for (const row of commentRows) {
|
|
byIssueId.set(row.issueId, {
|
|
issueId: row.issueId,
|
|
latestCommentAt: row.latestCommentAt,
|
|
latestLogAt: null,
|
|
});
|
|
}
|
|
for (const row of logRows) {
|
|
const existing = byIssueId.get(row.issueId);
|
|
if (existing) existing.latestLogAt = row.latestLogAt;
|
|
else {
|
|
byIssueId.set(row.issueId, {
|
|
issueId: row.issueId,
|
|
latestCommentAt: null,
|
|
latestLogAt: row.latestLogAt,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return [...byIssueId.values()];
|
|
}
|
|
|
|
async function blockedByMapForIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueIds: string[],
|
|
): Promise<Map<string, IssueRelationIssueSummary[]>> {
|
|
const map = new Map<string, IssueRelationIssueSummary[]>();
|
|
const uniqueIssueIds = [...new Set(issueIds)];
|
|
if (uniqueIssueIds.length === 0) return map;
|
|
|
|
for (const issueId of uniqueIssueIds) {
|
|
map.set(issueId, []);
|
|
}
|
|
|
|
for (const issueIdChunk of chunkList(
|
|
uniqueIssueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
currentIssueId: issueRelations.relatedIssueId,
|
|
relatedId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
inArray(issueRelations.relatedIssueId, issueIdChunk),
|
|
),
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const blockedBy = map.get(row.currentIssueId);
|
|
if (!blockedBy) continue;
|
|
blockedBy.push({
|
|
id: row.relatedId,
|
|
identifier: row.identifier,
|
|
title: row.title,
|
|
status: row.status as IssueRelationIssueSummary["status"],
|
|
priority: row.priority as IssueRelationIssueSummary["priority"],
|
|
assigneeAgentId: row.assigneeAgentId,
|
|
assigneeUserId: row.assigneeUserId,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const blockedBy of map.values()) {
|
|
blockedBy.sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
const BLOCKED_INBOX_TERMINAL_STATUSES = ["done", "cancelled"] as const;
|
|
const BLOCKED_INBOX_ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
|
|
const BLOCKED_INBOX_ACTIVE_WAKE_STATUSES =
|
|
SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES;
|
|
const BLOCKED_INBOX_PENDING_INTERACTION_STATUSES = ["pending"] as const;
|
|
const BLOCKED_INBOX_PENDING_APPROVAL_STATUSES = [
|
|
"pending",
|
|
"revision_requested",
|
|
] as const;
|
|
const BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS = [
|
|
"harness_liveness_escalation",
|
|
"stranded_issue_recovery",
|
|
] as const;
|
|
const BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
|
|
"issue.successful_run_handoff_required",
|
|
"issue.successful_run_handoff_resolved",
|
|
"issue.successful_run_handoff_escalated",
|
|
] as const;
|
|
|
|
type BlockedInboxIssueRow = IssueRow & {
|
|
labels?: IssueLabelRow[];
|
|
labelIds?: string[];
|
|
};
|
|
type BlockedInboxInteractionRow = {
|
|
id: string;
|
|
issueId: string;
|
|
kind: string;
|
|
createdAt: Date;
|
|
};
|
|
type BlockedInboxApprovalRow = {
|
|
approvalId: string;
|
|
issueId: string;
|
|
createdAt: Date;
|
|
};
|
|
|
|
function issueRef(
|
|
row:
|
|
| Pick<
|
|
IssueRow,
|
|
| "id"
|
|
| "identifier"
|
|
| "title"
|
|
| "status"
|
|
| "priority"
|
|
| "assigneeAgentId"
|
|
| "assigneeUserId"
|
|
>
|
|
| null
|
|
| undefined,
|
|
): IssueBlockedInboxIssueRef | null {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
identifier: row.identifier,
|
|
title: row.title,
|
|
status: row.status as IssueBlockedInboxIssueRef["status"],
|
|
priority: row.priority as IssueBlockedInboxIssueRef["priority"],
|
|
assigneeAgentId: row.assigneeAgentId,
|
|
assigneeUserId: row.assigneeUserId,
|
|
};
|
|
}
|
|
|
|
function hasPlanDocumentCondition(
|
|
companyId: string,
|
|
hasPlanDocument: boolean,
|
|
): SQL {
|
|
const existsPlanDocument = sql<boolean>`
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM ${issueDocuments}
|
|
WHERE ${issueDocuments.companyId} = ${companyId}
|
|
AND ${issueDocuments.issueId} = ${issues.id}
|
|
AND ${issueDocuments.key} = 'plan'
|
|
)
|
|
`;
|
|
return hasPlanDocument
|
|
? existsPlanDocument
|
|
: sql<boolean>`NOT ${existsPlanDocument}`;
|
|
}
|
|
|
|
function isoDate(value: Date | string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function attentionBase(input: {
|
|
state: IssueBlockedInboxAttention["state"];
|
|
reason: IssueBlockedInboxAttention["reason"];
|
|
severity: IssueBlockedInboxAttention["severity"];
|
|
stoppedSinceAt: Date | string | null | undefined;
|
|
owner: IssueBlockedInboxAttention["owner"];
|
|
action: IssueBlockedInboxAttention["action"];
|
|
sourceIssue: IssueBlockedInboxIssueRef | null;
|
|
leafIssue?: IssueBlockedInboxIssueRef | null;
|
|
recoveryIssue?: IssueBlockedInboxIssueRef | null;
|
|
approvalId?: string | null;
|
|
interactionId?: string | null;
|
|
sampleIssueIdentifier?: string | null;
|
|
externalDetailsRedacted?: boolean;
|
|
}): IssueBlockedInboxAttention {
|
|
return {
|
|
kind: "blocked",
|
|
state: input.state,
|
|
reason: input.reason,
|
|
severity: input.severity,
|
|
stoppedSinceAt: isoDate(input.stoppedSinceAt),
|
|
owner: input.owner,
|
|
action: input.action,
|
|
sourceIssue: input.sourceIssue,
|
|
leafIssue: input.leafIssue ?? null,
|
|
recoveryIssue: input.recoveryIssue ?? null,
|
|
approvalId: input.approvalId ?? null,
|
|
interactionId: input.interactionId ?? null,
|
|
sampleIssueIdentifier:
|
|
input.sampleIssueIdentifier ??
|
|
input.leafIssue?.identifier ??
|
|
input.recoveryIssue?.identifier ??
|
|
input.sourceIssue?.identifier ??
|
|
null,
|
|
redaction: {
|
|
externalDetailsRedacted: input.externalDetailsRedacted ?? false,
|
|
secretFieldsOmitted: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function readSuccessfulRunHandoffFromActivity(row: {
|
|
action: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
details: Record<string, unknown> | null;
|
|
createdAt: Date;
|
|
}): SuccessfulRunHandoffState | null {
|
|
const details = row.details ?? {};
|
|
const state =
|
|
row.action === "issue.successful_run_handoff_required"
|
|
? "required"
|
|
: row.action === "issue.successful_run_handoff_resolved"
|
|
? "resolved"
|
|
: row.action === "issue.successful_run_handoff_escalated"
|
|
? "escalated"
|
|
: null;
|
|
if (!state) return null;
|
|
|
|
const detectedProgressSummary =
|
|
readStringFromRecord(details, "detectedProgressSummary") ??
|
|
readStringFromRecord(details, "detected_progress_summary") ??
|
|
null;
|
|
|
|
return {
|
|
state,
|
|
required: state === "required",
|
|
hasLiveContinuation: false,
|
|
sourceRunId:
|
|
readStringFromRecord(details, "sourceRunId") ??
|
|
readStringFromRecord(details, "source_run_id") ??
|
|
readStringFromRecord(details, "resumeFromRunId") ??
|
|
row.runId ??
|
|
null,
|
|
correctiveRunId:
|
|
readStringFromRecord(details, "correctiveRunId") ??
|
|
readStringFromRecord(details, "corrective_run_id") ??
|
|
(state !== "required" ? row.runId : null),
|
|
assigneeAgentId:
|
|
readStringFromRecord(details, "assigneeAgentId") ??
|
|
readStringFromRecord(details, "agentId") ??
|
|
row.agentId ??
|
|
null,
|
|
detectedProgressSummary: detectedProgressSummary
|
|
? redactSensitiveText(detectedProgressSummary)
|
|
: null,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
async function listSuccessfulRunHandoffMapForIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueIds: string[],
|
|
options?: { hydrateLiveness?: boolean },
|
|
): Promise<Map<string, SuccessfulRunHandoffState>> {
|
|
const uniqueIssueIds = [...new Set(issueIds)];
|
|
const states = new Map<string, SuccessfulRunHandoffState>();
|
|
if (uniqueIssueIds.length === 0) return states;
|
|
|
|
for (const issueIdChunk of chunkList(
|
|
uniqueIssueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({
|
|
entityId: activityLog.entityId,
|
|
action: activityLog.action,
|
|
agentId: activityLog.agentId,
|
|
runId: activityLog.runId,
|
|
details: activityLog.details,
|
|
createdAt: activityLog.createdAt,
|
|
})
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
inArray(activityLog.entityId, issueIdChunk),
|
|
inArray(activityLog.action, [
|
|
...BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS,
|
|
]),
|
|
),
|
|
)
|
|
.orderBy(
|
|
activityLog.entityId,
|
|
desc(activityLog.createdAt),
|
|
desc(activityLog.id),
|
|
);
|
|
|
|
for (const row of rows as Array<{
|
|
entityId: string;
|
|
action: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
details: Record<string, unknown> | null;
|
|
createdAt: Date;
|
|
}>) {
|
|
if (states.has(row.entityId)) continue;
|
|
const state = readSuccessfulRunHandoffFromActivity(row);
|
|
if (state) states.set(row.entityId, state);
|
|
}
|
|
}
|
|
|
|
return options?.hydrateLiveness === false
|
|
? states
|
|
: hydrateSuccessfulRunHandoffLiveness(dbOrTx, companyId, states);
|
|
}
|
|
|
|
function externalWaitFromDescription(
|
|
description: string | null,
|
|
): { owner: string; action: string } | null {
|
|
if (!description) return null;
|
|
const owner = description
|
|
.match(/^\s*external owner\s*:\s*(.+)$/im)?.[1]
|
|
?.trim();
|
|
const action = description
|
|
.match(/^\s*external action\s*:\s*(.+)$/im)?.[1]
|
|
?.trim();
|
|
if (!owner || !action) return null;
|
|
return {
|
|
owner: owner.slice(0, 120),
|
|
action: action.slice(0, 240),
|
|
};
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function redactExternalWaitDescription(
|
|
description: string | null | undefined,
|
|
external: { owner: string; action: string } | null,
|
|
) {
|
|
if (!description) return null;
|
|
let redacted = description
|
|
.split(/\r?\n/)
|
|
.filter((line) => !/^\s*external\s+(?:owner|action)\s*:/i.test(line))
|
|
.join("\n");
|
|
|
|
for (const value of [external?.owner, external?.action]) {
|
|
if (!value) continue;
|
|
redacted = redacted.replace(
|
|
new RegExp(escapeRegExp(value), "gi"),
|
|
"[redacted external wait detail]",
|
|
);
|
|
}
|
|
|
|
redacted = redacted.replace(/\n{3,}/g, "\n\n").trim();
|
|
return redacted.length > 0 ? redacted : null;
|
|
}
|
|
|
|
function blockedInboxResponseDescription(
|
|
attention: IssueBlockedInboxAttention,
|
|
row: BlockedInboxIssueRow,
|
|
) {
|
|
if (!attention.redaction.externalDetailsRedacted) return row.description;
|
|
return redactExternalWaitDescription(
|
|
row.description,
|
|
externalWaitFromDescription(row.description),
|
|
);
|
|
}
|
|
|
|
function blockedInboxSearchText(
|
|
attention: IssueBlockedInboxAttention,
|
|
row: BlockedInboxIssueRow,
|
|
) {
|
|
return [
|
|
row.identifier,
|
|
row.title,
|
|
blockedInboxResponseDescription(attention, row),
|
|
attention.sourceIssue?.identifier,
|
|
attention.sourceIssue?.title,
|
|
attention.leafIssue?.identifier,
|
|
attention.leafIssue?.title,
|
|
attention.recoveryIssue?.identifier,
|
|
attention.recoveryIssue?.title,
|
|
attention.action.label,
|
|
attention.action.detail,
|
|
]
|
|
.filter(
|
|
(value): value is string => typeof value === "string" && value.length > 0,
|
|
)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function blockedInboxSeverityRank(
|
|
severity: IssueBlockedInboxAttention["severity"],
|
|
) {
|
|
switch (severity) {
|
|
case "critical":
|
|
return 0;
|
|
case "high":
|
|
return 1;
|
|
case "medium":
|
|
return 2;
|
|
case "low":
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
function issuePriorityRank(priority: string) {
|
|
switch (priority) {
|
|
case "critical":
|
|
return 0;
|
|
case "high":
|
|
return 1;
|
|
case "medium":
|
|
return 2;
|
|
case "low":
|
|
return 3;
|
|
default:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
function compareBlockedInboxRows(
|
|
left: BlockedInboxIssueRow & {
|
|
blockedInboxAttention: IssueBlockedInboxAttention;
|
|
lastActivityAt?: Date | null;
|
|
},
|
|
right: BlockedInboxIssueRow & {
|
|
blockedInboxAttention: IssueBlockedInboxAttention;
|
|
lastActivityAt?: Date | null;
|
|
},
|
|
) {
|
|
const leftAttention = left.blockedInboxAttention;
|
|
const rightAttention = right.blockedInboxAttention;
|
|
const severity =
|
|
blockedInboxSeverityRank(leftAttention.severity) -
|
|
blockedInboxSeverityRank(rightAttention.severity);
|
|
if (severity !== 0) return severity;
|
|
|
|
const leftStopped = leftAttention.stoppedSinceAt
|
|
? new Date(leftAttention.stoppedSinceAt).getTime()
|
|
: Number.POSITIVE_INFINITY;
|
|
const rightStopped = rightAttention.stoppedSinceAt
|
|
? new Date(rightAttention.stoppedSinceAt).getTime()
|
|
: Number.POSITIVE_INFINITY;
|
|
if (leftStopped !== rightStopped) return leftStopped - rightStopped;
|
|
|
|
const priority =
|
|
issuePriorityRank(left.priority) - issuePriorityRank(right.priority);
|
|
if (priority !== 0) return priority;
|
|
|
|
const leftActivity = left.lastActivityAt
|
|
? new Date(left.lastActivityAt).getTime()
|
|
: new Date(left.updatedAt).getTime();
|
|
const rightActivity = right.lastActivityAt
|
|
? new Date(right.lastActivityAt).getTime()
|
|
: new Date(right.updatedAt).getTime();
|
|
if (leftActivity !== rightActivity) return rightActivity - leftActivity;
|
|
|
|
return right.id.localeCompare(left.id);
|
|
}
|
|
|
|
async function listIssueBlockedInboxAttentionMap(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
issueRows: BlockedInboxIssueRow[],
|
|
): Promise<Map<string, IssueBlockedInboxAttention>> {
|
|
const rowIssueIds = [...new Set(issueRows.map((row) => row.id))];
|
|
const result = new Map<string, IssueBlockedInboxAttention>();
|
|
if (rowIssueIds.length === 0) return result;
|
|
|
|
const [graphIssueRows, graphRelationRows, companyAgentRows] =
|
|
await Promise.all([
|
|
dbOrTx
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
visibleIssueCondition(),
|
|
ne(issues.status, "done"),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
companyId: issueRelations.companyId,
|
|
blockerIssueId: issueRelations.issueId,
|
|
blockedIssueId: issueRelations.relatedIssueId,
|
|
})
|
|
.from(issueRelations)
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
name: agents.name,
|
|
role: agents.role,
|
|
title: agents.title,
|
|
status: agents.status,
|
|
reportsTo: agents.reportsTo,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId)),
|
|
]);
|
|
|
|
const graphIssues = graphIssueRows as IssueRow[];
|
|
const graphRelations = graphRelationRows as Array<{
|
|
companyId: string;
|
|
blockerIssueId: string;
|
|
blockedIssueId: string;
|
|
}>;
|
|
const companyAgents = companyAgentRows as Array<{
|
|
id: string;
|
|
companyId: string;
|
|
name: string;
|
|
role: string;
|
|
title: string | null;
|
|
status: string;
|
|
reportsTo: string | null;
|
|
}>;
|
|
const graphIssueIds = graphIssues.map((issue) => issue.id);
|
|
const issuesById = new Map<string, IssueRow>(
|
|
graphIssues.map((issue) => [issue.id, issue]),
|
|
);
|
|
|
|
const [
|
|
activeRunRows,
|
|
wakeRows,
|
|
scheduledRetryRows,
|
|
interactionRows,
|
|
approvalRows,
|
|
handoffMap,
|
|
] = await Promise.all([
|
|
graphIssueIds.length === 0
|
|
? Promise.resolve([])
|
|
: dbOrTx
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
issueId: sql<string | null>`coalesce(
|
|
${heartbeatRuns.contextSnapshot} ->> 'issueId',
|
|
${heartbeatRuns.contextSnapshot} ->> 'taskId'
|
|
)`,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
inArray(heartbeatRuns.status, [
|
|
...BLOCKED_INBOX_ACTIVE_RUN_STATUSES,
|
|
]),
|
|
inArray(
|
|
sql<string>`coalesce(
|
|
${heartbeatRuns.contextSnapshot} ->> 'issueId',
|
|
${heartbeatRuns.contextSnapshot} ->> 'taskId'
|
|
)`,
|
|
graphIssueIds,
|
|
),
|
|
),
|
|
),
|
|
graphIssueIds.length === 0
|
|
? Promise.resolve([])
|
|
: dbOrTx
|
|
.select({
|
|
companyId: agentWakeupRequests.companyId,
|
|
issueId: sql<string | null>`coalesce(
|
|
${agentWakeupRequests.payload} ->> 'issueId',
|
|
${agentWakeupRequests.payload} ->> 'taskId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
|
|
)`,
|
|
agentId: agentWakeupRequests.agentId,
|
|
status: agentWakeupRequests.status,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
inArray(agentWakeupRequests.status, [
|
|
...BLOCKED_INBOX_ACTIVE_WAKE_STATUSES,
|
|
]),
|
|
inArray(
|
|
sql<string>`coalesce(
|
|
${agentWakeupRequests.payload} ->> 'issueId',
|
|
${agentWakeupRequests.payload} ->> 'taskId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
|
|
)`,
|
|
graphIssueIds,
|
|
),
|
|
),
|
|
),
|
|
graphIssueIds.length === 0
|
|
? Promise.resolve([])
|
|
: dbOrTx
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
issueId: sql<string | null>`coalesce(
|
|
${heartbeatRuns.contextSnapshot} ->> 'issueId',
|
|
${heartbeatRuns.contextSnapshot} ->> 'taskId'
|
|
)`,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.status, "scheduled_retry"),
|
|
inArray(
|
|
sql<string>`coalesce(
|
|
${heartbeatRuns.contextSnapshot} ->> 'issueId',
|
|
${heartbeatRuns.contextSnapshot} ->> 'taskId'
|
|
)`,
|
|
graphIssueIds,
|
|
),
|
|
),
|
|
),
|
|
graphIssueIds.length === 0
|
|
? Promise.resolve([])
|
|
: dbOrTx
|
|
.select({
|
|
id: issueThreadInteractions.id,
|
|
issueId: issueThreadInteractions.issueId,
|
|
kind: issueThreadInteractions.kind,
|
|
createdAt: issueThreadInteractions.createdAt,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
inArray(issueThreadInteractions.status, [
|
|
...BLOCKED_INBOX_PENDING_INTERACTION_STATUSES,
|
|
]),
|
|
inArray(issueThreadInteractions.issueId, graphIssueIds),
|
|
),
|
|
),
|
|
graphIssueIds.length === 0
|
|
? Promise.resolve([])
|
|
: dbOrTx
|
|
.select({
|
|
approvalId: approvals.id,
|
|
issueId: issueApprovals.issueId,
|
|
createdAt: approvals.createdAt,
|
|
})
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(
|
|
and(
|
|
eq(issueApprovals.companyId, companyId),
|
|
eq(approvals.companyId, companyId),
|
|
inArray(approvals.status, [
|
|
...BLOCKED_INBOX_PENDING_APPROVAL_STATUSES,
|
|
]),
|
|
inArray(issueApprovals.issueId, graphIssueIds),
|
|
),
|
|
),
|
|
listSuccessfulRunHandoffMapForIssues(dbOrTx, companyId, rowIssueIds, {
|
|
hydrateLiveness: false,
|
|
}),
|
|
]);
|
|
|
|
const pendingInteractions = (
|
|
interactionRows as BlockedInboxInteractionRow[]
|
|
).map((row) => ({
|
|
companyId,
|
|
issueId: row.issueId,
|
|
status: "pending",
|
|
}));
|
|
const pendingApprovals = (approvalRows as BlockedInboxApprovalRow[]).map(
|
|
(row) => ({
|
|
companyId,
|
|
issueId: row.issueId,
|
|
status: "pending",
|
|
}),
|
|
);
|
|
|
|
const openRecoveryIssues = graphIssues
|
|
.filter((issue) =>
|
|
BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(
|
|
issue.originKind as (typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS)[number],
|
|
),
|
|
)
|
|
.filter(
|
|
(issue) =>
|
|
!BLOCKED_INBOX_TERMINAL_STATUSES.includes(
|
|
issue.status as (typeof BLOCKED_INBOX_TERMINAL_STATUSES)[number],
|
|
),
|
|
)
|
|
.flatMap((issue) => {
|
|
const entries = [{ companyId, issueId: issue.id, status: issue.status }];
|
|
if (issue.originKind === "harness_liveness_escalation") {
|
|
const parsed = parseIssueGraphLivenessIncidentKey(issue.originId);
|
|
if (parsed?.companyId === companyId) {
|
|
entries.push({
|
|
companyId,
|
|
issueId: parsed.issueId,
|
|
status: issue.status,
|
|
});
|
|
entries.push({
|
|
companyId,
|
|
issueId: parsed.leafIssueId,
|
|
status: issue.status,
|
|
});
|
|
}
|
|
} else if (
|
|
issue.originKind === "stranded_issue_recovery" &&
|
|
issue.originId
|
|
) {
|
|
entries.push({
|
|
companyId,
|
|
issueId: issue.originId,
|
|
status: issue.status,
|
|
});
|
|
}
|
|
return entries;
|
|
});
|
|
|
|
const findings = classifyIssueGraphLiveness({
|
|
issues: graphIssues.map((issue) => ({
|
|
id: issue.id,
|
|
companyId: issue.companyId,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
status: issue.status,
|
|
projectId: issue.projectId,
|
|
goalId: issue.goalId,
|
|
parentId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
createdByAgentId: issue.createdByAgentId,
|
|
createdByUserId: issue.createdByUserId,
|
|
conversationAgentId: issue.conversationAgentId,
|
|
conversationUserId: issue.conversationUserId,
|
|
conversationState: issue.conversationState,
|
|
executionPolicy: issue.executionPolicy,
|
|
executionState: issue.executionState,
|
|
monitorNextCheckAt: issue.monitorNextCheckAt,
|
|
monitorAttemptCount: issue.monitorAttemptCount,
|
|
})),
|
|
relations: graphRelations,
|
|
agents: companyAgents,
|
|
activeRuns: (
|
|
activeRunRows as Array<{
|
|
companyId: string;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
status: string;
|
|
}>
|
|
).flatMap((row) =>
|
|
row.issueId
|
|
? [
|
|
{
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
agentId: row.agentId,
|
|
status: row.status,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
queuedWakeRequests: [
|
|
...(wakeRows as Array<{
|
|
companyId: string;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
status: string;
|
|
}>),
|
|
...(scheduledRetryRows as Array<{
|
|
companyId: string;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
status: string;
|
|
}>),
|
|
].flatMap((row) =>
|
|
row.issueId
|
|
? [
|
|
{
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
agentId: row.agentId,
|
|
status: row.status,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
pendingInteractions,
|
|
pendingApprovals,
|
|
openRecoveryIssues,
|
|
now: new Date(),
|
|
});
|
|
const findingByIssueId = new Map<string, IssueLivenessFinding>();
|
|
for (const finding of findings) {
|
|
if (!findingByIssueId.has(finding.issueId))
|
|
findingByIssueId.set(finding.issueId, finding);
|
|
}
|
|
|
|
const interactionByIssueId = new Map<string, BlockedInboxInteractionRow>();
|
|
for (const row of interactionRows as BlockedInboxInteractionRow[]) {
|
|
if (!interactionByIssueId.has(row.issueId))
|
|
interactionByIssueId.set(row.issueId, row);
|
|
}
|
|
const approvalByIssueId = new Map<string, BlockedInboxApprovalRow>();
|
|
for (const row of approvalRows as BlockedInboxApprovalRow[]) {
|
|
if (!approvalByIssueId.has(row.issueId))
|
|
approvalByIssueId.set(row.issueId, row);
|
|
}
|
|
const liveHandoffRunIssueIds = new Set(
|
|
[
|
|
...(activeRunRows as Array<{ issueId: string | null }>),
|
|
...(scheduledRetryRows as Array<{ issueId: string | null }>),
|
|
].flatMap((row) => (row.issueId ? [row.issueId] : [])),
|
|
);
|
|
const liveHandoffWakeIssueIds = new Set(
|
|
(wakeRows as Array<{ issueId: string | null }>).flatMap((row) =>
|
|
row.issueId ? [row.issueId] : [],
|
|
),
|
|
);
|
|
|
|
for (const row of issueRows) {
|
|
if (
|
|
row.companyId !== companyId ||
|
|
BLOCKED_INBOX_TERMINAL_STATUSES.includes(
|
|
row.status as (typeof BLOCKED_INBOX_TERMINAL_STATUSES)[number],
|
|
) ||
|
|
row.hiddenAt
|
|
) {
|
|
continue;
|
|
}
|
|
const source = issueRef(row);
|
|
const handoff = handoffMap.get(row.id);
|
|
const hasLiveHandoffContinuation = Boolean(
|
|
(handoff?.state === "required" || handoff?.state === "escalated") &&
|
|
(liveHandoffRunIssueIds.has(row.id) ||
|
|
liveHandoffWakeIssueIds.has(row.id)),
|
|
);
|
|
if (
|
|
handoff &&
|
|
!hasLiveHandoffContinuation &&
|
|
(handoff.required || handoff.state === "escalated")
|
|
) {
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "missing_disposition",
|
|
reason: "missing_successful_run_disposition",
|
|
severity: "high",
|
|
stoppedSinceAt: handoff.createdAt ?? row.updatedAt,
|
|
owner: {
|
|
type: row.assigneeAgentId
|
|
? "agent"
|
|
: row.assigneeUserId
|
|
? "user"
|
|
: "unknown",
|
|
agentId: row.assigneeAgentId,
|
|
userId: row.assigneeUserId,
|
|
label: null,
|
|
},
|
|
action: {
|
|
label: "Choose disposition",
|
|
detail:
|
|
"Choose exactly one final disposition: done, cancelled, review/input, blocked with owner, delegated follow-up, or queued continuation.",
|
|
},
|
|
sourceIssue: source,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(
|
|
row.originKind as (typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS)[number],
|
|
)
|
|
) {
|
|
let sourceIssue: IssueBlockedInboxIssueRef | null = null;
|
|
let leafIssue: IssueBlockedInboxIssueRef | null = null;
|
|
if (row.originKind === "harness_liveness_escalation") {
|
|
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
|
|
if (parsed?.companyId === companyId) {
|
|
sourceIssue = issueRef(issuesById.get(parsed.issueId));
|
|
leafIssue = issueRef(issuesById.get(parsed.leafIssueId));
|
|
}
|
|
} else if (row.originKind === "stranded_issue_recovery" && row.originId) {
|
|
sourceIssue = issueRef(issuesById.get(row.originId));
|
|
}
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "recovery_open",
|
|
reason: "open_recovery_issue",
|
|
severity: "high",
|
|
stoppedSinceAt: row.createdAt,
|
|
owner: {
|
|
type: row.assigneeAgentId
|
|
? "agent"
|
|
: row.assigneeUserId
|
|
? "user"
|
|
: "unknown",
|
|
agentId: row.assigneeAgentId,
|
|
userId: row.assigneeUserId,
|
|
label: null,
|
|
},
|
|
action: {
|
|
label: "Resolve recovery",
|
|
detail:
|
|
"Restore a live path for the source work or record why this recovery issue is a false positive.",
|
|
},
|
|
sourceIssue: sourceIssue ?? source,
|
|
leafIssue,
|
|
recoveryIssue: source,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const interaction = interactionByIssueId.get(row.id);
|
|
if (interaction) {
|
|
const isUserQuestion =
|
|
interaction.kind === "ask_user_questions" &&
|
|
Boolean(row.assigneeUserId);
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "awaiting_decision",
|
|
reason: isUserQuestion
|
|
? "pending_user_decision"
|
|
: "pending_board_decision",
|
|
severity: "medium",
|
|
stoppedSinceAt: interaction.createdAt,
|
|
owner: isUserQuestion
|
|
? {
|
|
type: "user",
|
|
agentId: null,
|
|
userId: row.assigneeUserId,
|
|
label: null,
|
|
}
|
|
: { type: "board", agentId: null, userId: null, label: "Board" },
|
|
action: {
|
|
label: isUserQuestion ? "Answer question" : "Answer confirmation",
|
|
detail:
|
|
"Respond to the pending issue-thread interaction so the assignee has a live next action.",
|
|
},
|
|
sourceIssue: source,
|
|
interactionId: interaction.id,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const approval = approvalByIssueId.get(row.id);
|
|
if (approval) {
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "awaiting_decision",
|
|
reason: "pending_board_decision",
|
|
severity: "medium",
|
|
stoppedSinceAt: approval.createdAt,
|
|
owner: { type: "board", agentId: null, userId: null, label: "Board" },
|
|
action: {
|
|
label: "Decide approval",
|
|
detail:
|
|
"Approve, reject, or request revision on the linked approval.",
|
|
},
|
|
sourceIssue: source,
|
|
approvalId: approval.approvalId,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const finding = findingByIssueId.get(row.id);
|
|
if (finding) {
|
|
const leaf =
|
|
finding.dependencyPath.length > 1
|
|
? issuesById.get(
|
|
finding.dependencyPath[finding.dependencyPath.length - 1]!
|
|
.issueId,
|
|
)
|
|
: issuesById.get(finding.recoveryIssueId);
|
|
const ownerAgentId =
|
|
finding.state === "blocked_by_unassigned_issue"
|
|
? null
|
|
: (finding.recommendedOwnerAgentId ??
|
|
row.assigneeAgentId ??
|
|
leaf?.assigneeAgentId ??
|
|
null);
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "needs_attention",
|
|
reason: finding.state as IssueBlockedInboxAttention["reason"],
|
|
severity:
|
|
finding.state === "blocked_by_assigned_backlog_issue" ||
|
|
finding.state === "in_review_without_action_path"
|
|
? "high"
|
|
: finding.severity === "critical"
|
|
? "critical"
|
|
: "high",
|
|
stoppedSinceAt: leaf?.updatedAt ?? row.updatedAt,
|
|
owner: {
|
|
type: ownerAgentId
|
|
? "agent"
|
|
: leaf?.assigneeUserId
|
|
? "user"
|
|
: "unknown",
|
|
agentId: ownerAgentId,
|
|
userId: leaf?.assigneeUserId ?? null,
|
|
label: null,
|
|
},
|
|
action: {
|
|
label: (() => {
|
|
switch (finding.state) {
|
|
case "blocked_by_unassigned_issue":
|
|
return "Assign blocker";
|
|
case "blocked_by_assigned_backlog_issue":
|
|
return "Resume parked blocker";
|
|
case "blocked_by_uninvokable_assignee":
|
|
return "Assign active owner";
|
|
case "blocked_by_cancelled_issue":
|
|
return "Replace blocker";
|
|
case "invalid_review_participant":
|
|
return "Repair review participant";
|
|
case "in_review_without_action_path":
|
|
return "Choose review path";
|
|
}
|
|
})(),
|
|
detail: finding.recommendedAction,
|
|
},
|
|
sourceIssue: source,
|
|
leafIssue: issueRef(leaf),
|
|
recoveryIssue: issueRef(issuesById.get(finding.recoveryIssueId)),
|
|
sampleIssueIdentifier: leaf?.identifier ?? finding.identifier,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const hasMonitor = Boolean(
|
|
row.monitorNextCheckAt && row.monitorNextCheckAt.getTime() > Date.now(),
|
|
);
|
|
const external =
|
|
row.status === "blocked" && !hasMonitor
|
|
? externalWaitFromDescription(row.description)
|
|
: null;
|
|
if (external) {
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "external_wait",
|
|
reason: "external_owner_action",
|
|
severity: "medium",
|
|
stoppedSinceAt: row.updatedAt,
|
|
owner: { type: "external", agentId: null, userId: null, label: null },
|
|
action: {
|
|
label: "External owner action",
|
|
detail: null,
|
|
},
|
|
sourceIssue: source,
|
|
externalDetailsRedacted: true,
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const blockerAttention = await listIssueBlockerAttentionMap(
|
|
dbOrTx,
|
|
companyId,
|
|
[row],
|
|
);
|
|
const blockerState = blockerAttention.get(row.id);
|
|
if (
|
|
row.status === "blocked" &&
|
|
(blockerState?.state === "needs_attention" ||
|
|
blockerState?.state === "stalled")
|
|
) {
|
|
result.set(
|
|
row.id,
|
|
attentionBase({
|
|
state: "needs_attention",
|
|
reason: "blocked_chain_stalled",
|
|
severity: "high",
|
|
stoppedSinceAt: row.updatedAt,
|
|
owner: { type: "unknown", agentId: null, userId: null, label: null },
|
|
action: {
|
|
label: "Inspect blocker chain",
|
|
detail:
|
|
"Inspect the stalled blocker or review leaf and make the next owner/action explicit.",
|
|
},
|
|
sourceIssue: source,
|
|
sampleIssueIdentifier:
|
|
blockerState.sampleStalledBlockerIdentifier ??
|
|
blockerState.sampleBlockerIdentifier,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function parseIssueAssigneeAgentFilter(
|
|
assigneeAgentId: IssueFilters["assigneeAgentId"],
|
|
): string | null | undefined {
|
|
const normalizedRaw =
|
|
typeof assigneeAgentId === "string"
|
|
? assigneeAgentId.trim()
|
|
: assigneeAgentId;
|
|
const normalized = normalizedRaw === "" ? undefined : normalizedRaw;
|
|
if (typeof normalized !== "string") return normalized;
|
|
return normalized.toLowerCase() === "null" ? null : normalized;
|
|
}
|
|
|
|
function assertValidAssigneeAgentFilter(
|
|
assigneeAgentFilter: string | null | undefined,
|
|
) {
|
|
if (
|
|
typeof assigneeAgentFilter === "string" &&
|
|
!isUuidLike(assigneeAgentFilter)
|
|
) {
|
|
throw unprocessable("assigneeAgentId must be a UUID or 'null'");
|
|
}
|
|
}
|
|
|
|
async function blockedInboxIssueConditions(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
filters?: IssueFilters,
|
|
) {
|
|
const conditions = [
|
|
eq(issues.companyId, companyId),
|
|
visibleIssueCondition(),
|
|
notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]),
|
|
];
|
|
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
|
const inboxArchivedByUserId =
|
|
filters?.inboxArchivedByUserId?.trim() || undefined;
|
|
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
|
const contextUserId =
|
|
unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
|
|
|
|
if (filters?.createdFromIssueId) {
|
|
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
|
|
}
|
|
if (filters?.descendantOf) {
|
|
conditions.push(sql<boolean>`
|
|
${issues.id} IN (
|
|
WITH RECURSIVE descendants(id) AS (
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.parentId} = ${filters.descendantOf}
|
|
UNION
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
JOIN descendants ON ${issues.parentId} = descendants.id
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
)
|
|
SELECT id FROM descendants
|
|
)
|
|
`);
|
|
}
|
|
const lowTrustCondition = lowTrustBoundaryIssueCondition(
|
|
companyId,
|
|
filters?.lowTrustBoundary,
|
|
);
|
|
if (lowTrustCondition) conditions.push(lowTrustCondition);
|
|
const statuses = parseStatusFilter(filters?.status);
|
|
if (statuses.length > 0) {
|
|
conditions.push(
|
|
statuses.length === 1
|
|
? eq(issues.status, statuses[0]!)
|
|
: inArray(issues.status, statuses),
|
|
);
|
|
}
|
|
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
|
|
filters?.assigneeAgentId,
|
|
);
|
|
assertValidAssigneeAgentFilter(assigneeAgentFilter);
|
|
if (assigneeAgentFilter === null) {
|
|
conditions.push(isNull(issues.assigneeAgentId));
|
|
} else if (assigneeAgentFilter) {
|
|
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
|
|
}
|
|
if (filters?.participantAgentId)
|
|
conditions.push(
|
|
participatedByAgentCondition(companyId, filters.participantAgentId),
|
|
);
|
|
if (filters?.assigneeUserId)
|
|
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
|
if (touchedByUserId)
|
|
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
|
if (inboxArchivedByUserId)
|
|
conditions.push(
|
|
inboxVisibleForUserCondition(companyId, inboxArchivedByUserId),
|
|
);
|
|
if (unreadForUserId)
|
|
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
|
if (filters?.projectId)
|
|
conditions.push(eq(issues.projectId, filters.projectId));
|
|
if (filters?.workspaceId) {
|
|
conditions.push(
|
|
or(
|
|
eq(issues.executionWorkspaceId, filters.workspaceId),
|
|
eq(issues.projectWorkspaceId, filters.workspaceId),
|
|
)!,
|
|
);
|
|
}
|
|
if (filters?.executionWorkspaceId)
|
|
conditions.push(
|
|
eq(issues.executionWorkspaceId, filters.executionWorkspaceId),
|
|
);
|
|
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
|
if (filters?.originKind)
|
|
conditions.push(eq(issues.originKind, filters.originKind));
|
|
if (filters?.originKindPrefix)
|
|
conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
|
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
|
if (filters?.hasPlanDocument !== undefined) {
|
|
conditions.push(
|
|
hasPlanDocumentCondition(companyId, filters.hasPlanDocument),
|
|
);
|
|
}
|
|
if (!shouldIncludePluginOperationIssues(filters))
|
|
conditions.push(nonPluginOperationIssueCondition());
|
|
if (filters?.labelId) {
|
|
const labeledIssueIds = await dbOrTx
|
|
.select({ issueId: issueLabels.issueId })
|
|
.from(issueLabels)
|
|
.where(
|
|
and(
|
|
eq(issueLabels.companyId, companyId),
|
|
eq(issueLabels.labelId, filters.labelId),
|
|
),
|
|
);
|
|
if (labeledIssueIds.length === 0)
|
|
return { conditions: [sql<boolean>`false`], contextUserId };
|
|
conditions.push(
|
|
inArray(
|
|
issues.id,
|
|
labeledIssueIds.map((row: { issueId: string }) => row.issueId),
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
filters?.excludeRoutineExecutions &&
|
|
!filters?.originKind &&
|
|
!filters?.originId
|
|
) {
|
|
conditions.push(ne(issues.originKind, "routine_execution"));
|
|
}
|
|
|
|
return { conditions, contextUserId };
|
|
}
|
|
|
|
async function listBlockedInboxIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
filters?: IssueFilters,
|
|
): Promise<
|
|
Array<
|
|
IssueWithLabelsAndRun & {
|
|
blockedBy?: IssueRelationIssueSummary[];
|
|
blockerAttention?: IssueBlockerAttention;
|
|
reviewAttention?: IssueReviewAttention;
|
|
blockedInboxAttention: IssueBlockedInboxAttention;
|
|
liveDescendantCount?: number;
|
|
lastActivityAt: Date;
|
|
myLastTouchAt?: Date | null;
|
|
lastExternalCommentAt?: Date | null;
|
|
isUnreadForMe?: boolean;
|
|
}
|
|
>
|
|
> {
|
|
const { conditions, contextUserId } = await blockedInboxIssueConditions(
|
|
dbOrTx,
|
|
companyId,
|
|
filters,
|
|
);
|
|
|
|
const rows = (
|
|
await dbOrTx
|
|
.select(issueListSelect)
|
|
.from(issues)
|
|
.where(and(...conditions))
|
|
.orderBy(
|
|
desc(issueCanonicalLastActivityAtExpr(companyId)),
|
|
desc(issues.updatedAt),
|
|
desc(issues.id),
|
|
)
|
|
).map((row: any) => ({
|
|
...row,
|
|
description: decodeDatabaseTextPreview(
|
|
row.description,
|
|
ISSUE_LIST_DESCRIPTION_MAX_CHARS,
|
|
),
|
|
}));
|
|
const withLabels = await withIssueLabels(dbOrTx, rows);
|
|
const withRuns = withActiveRuns(
|
|
withLabels,
|
|
await activeRunMapForIssues(dbOrTx, withLabels),
|
|
);
|
|
if (withRuns.length === 0) return [];
|
|
|
|
const issueIds = withRuns.map((row) => row.id);
|
|
const includeLiveDescendantSummary =
|
|
filters?.includeLiveDescendantSummary === true;
|
|
const [
|
|
statsRows,
|
|
readRows,
|
|
lastActivityRows,
|
|
blockedByMap,
|
|
blockerAttentionByIssueId,
|
|
reviewAttentionByIssueId,
|
|
blockedInboxAttentionByIssueId,
|
|
liveDescendantCountByIssueId,
|
|
] = await Promise.all([
|
|
contextUserId
|
|
? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds)
|
|
: Promise.resolve([]),
|
|
contextUserId
|
|
? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds)
|
|
: Promise.resolve([]),
|
|
lastActivityStatsForIssues(dbOrTx, companyId, issueIds),
|
|
blockedByMapForIssues(dbOrTx, companyId, issueIds),
|
|
listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns),
|
|
listIssueReviewAttentionMap(dbOrTx, companyId, withRuns),
|
|
listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns),
|
|
includeLiveDescendantSummary
|
|
? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds)
|
|
: Promise.resolve(new Map<string, number>()),
|
|
]);
|
|
|
|
const rawSearchInput = filters?.q?.trim() ?? "";
|
|
const rawSearch = rawSearchInput.toLowerCase();
|
|
const commentSearchMatchIssueIds = new Set<string>();
|
|
if (rawSearchInput) {
|
|
const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`;
|
|
for (const issueIdChunk of chunkList(
|
|
issueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const rows = await dbOrTx
|
|
.select({ issueId: issueComments.issueId })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, companyId),
|
|
inArray(issueComments.issueId, issueIdChunk),
|
|
isNull(issueComments.deletedAt),
|
|
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
|
|
),
|
|
);
|
|
for (const row of rows as Array<{ issueId: string }>)
|
|
commentSearchMatchIssueIds.add(row.issueId);
|
|
}
|
|
}
|
|
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
|
const readByIssueId = new Map(
|
|
readRows.map((row) => [row.issueId, row.myLastReadAt]),
|
|
);
|
|
const lastActivityByIssueId = new Map(
|
|
lastActivityRows.map((row) => [row.issueId, row]),
|
|
);
|
|
|
|
const enriched = withRuns
|
|
.flatMap((row) => {
|
|
const blockedInboxAttention = blockedInboxAttentionByIssueId.get(row.id);
|
|
if (!blockedInboxAttention) return [];
|
|
if (
|
|
rawSearch &&
|
|
!blockedInboxSearchText(blockedInboxAttention, row).includes(
|
|
rawSearch,
|
|
) &&
|
|
!commentSearchMatchIssueIds.has(row.id)
|
|
)
|
|
return [];
|
|
|
|
const activity = lastActivityByIssueId.get(row.id);
|
|
const lastActivityAt =
|
|
latestIssueActivityAt(
|
|
row.updatedAt,
|
|
activity?.latestCommentAt ?? null,
|
|
activity?.latestLogAt ?? null,
|
|
) ?? row.updatedAt;
|
|
return [
|
|
{
|
|
...row,
|
|
description: blockedInboxResponseDescription(
|
|
blockedInboxAttention,
|
|
row,
|
|
),
|
|
blockedBy: blockedByMap.get(row.id) ?? [],
|
|
lastActivityAt,
|
|
...(blockerAttentionByIssueId.has(row.id)
|
|
? { blockerAttention: blockerAttentionByIssueId.get(row.id) }
|
|
: {}),
|
|
reviewAttention:
|
|
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
|
|
blockedInboxAttention,
|
|
...(includeLiveDescendantSummary
|
|
? {
|
|
liveDescendantCount:
|
|
liveDescendantCountByIssueId.get(row.id) ?? 0,
|
|
}
|
|
: {}),
|
|
...(contextUserId
|
|
? deriveIssueUserContext(row, contextUserId, {
|
|
myLastCommentAt:
|
|
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
|
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
|
lastExternalCommentAt:
|
|
statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
|
})
|
|
: {}),
|
|
},
|
|
];
|
|
})
|
|
.sort(compareBlockedInboxRows);
|
|
|
|
const offset =
|
|
typeof filters?.offset === "number" && Number.isFinite(filters.offset)
|
|
? Math.max(0, Math.floor(filters.offset))
|
|
: 0;
|
|
const limit =
|
|
typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
|
? Math.max(1, Math.floor(filters.limit))
|
|
: undefined;
|
|
return limit === undefined
|
|
? enriched.slice(offset)
|
|
: enriched.slice(offset, offset + limit);
|
|
}
|
|
|
|
async function countBlockedInboxIssues(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
filters?: IssueFilters,
|
|
): Promise<number> {
|
|
const { conditions } = await blockedInboxIssueConditions(
|
|
dbOrTx,
|
|
companyId,
|
|
filters,
|
|
);
|
|
const rows = (await dbOrTx
|
|
.select()
|
|
.from(issues)
|
|
.where(and(...conditions))) as IssueRow[];
|
|
if (rows.length === 0) return 0;
|
|
|
|
const blockedInboxAttentionByIssueId =
|
|
await listIssueBlockedInboxAttentionMap(dbOrTx, companyId, rows);
|
|
const rawSearchInput = filters?.q?.trim() ?? "";
|
|
const rawSearch = rawSearchInput.toLowerCase();
|
|
const commentSearchMatchIssueIds = new Set<string>();
|
|
if (rawSearchInput) {
|
|
const issueIds = rows.map((row) => row.id);
|
|
const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`;
|
|
for (const issueIdChunk of chunkList(
|
|
issueIds,
|
|
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
|
|
)) {
|
|
const commentRows = await dbOrTx
|
|
.select({ issueId: issueComments.issueId })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, companyId),
|
|
inArray(issueComments.issueId, issueIdChunk),
|
|
isNull(issueComments.deletedAt),
|
|
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
|
|
),
|
|
);
|
|
for (const row of commentRows as Array<{ issueId: string }>)
|
|
commentSearchMatchIssueIds.add(row.issueId);
|
|
}
|
|
}
|
|
|
|
return rows.reduce((count: number, row: IssueRow) => {
|
|
const attention = blockedInboxAttentionByIssueId.get(row.id);
|
|
if (!attention) return count;
|
|
if (
|
|
rawSearch &&
|
|
!blockedInboxSearchText(attention, row).includes(rawSearch) &&
|
|
!commentSearchMatchIssueIds.has(row.id)
|
|
)
|
|
return count;
|
|
return count + 1;
|
|
}, 0);
|
|
}
|
|
|
|
export async function readIssueCommentRunLogText(run: {
|
|
runId?: string | null;
|
|
logStore: string | null;
|
|
logRef: string | null;
|
|
logBytes: number | null;
|
|
}) {
|
|
if (run.logStore !== "local_file" || !run.logRef) return "";
|
|
// A timed-out finalization leaves size unknown even when earlier entries
|
|
// exist. Read those logs within the same byte budget as a known-size log.
|
|
if (run.logBytes !== null && (!Number.isFinite(run.logBytes) || run.logBytes <= 0)) return "";
|
|
|
|
const logRef = run.logRef;
|
|
const store = getRunLogStore();
|
|
let offset = 0;
|
|
let content = "";
|
|
let nextOffset: number | undefined = 0;
|
|
const controller = new AbortController();
|
|
let readTimer: NodeJS.Timeout | undefined;
|
|
|
|
const readChunks = async () => {
|
|
while (nextOffset !== undefined) {
|
|
controller.signal.throwIfAborted();
|
|
const remainingBytes =
|
|
ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES -
|
|
Buffer.byteLength(content, "utf8");
|
|
if (remainingBytes <= 0) break;
|
|
const chunk = await store.read(
|
|
{ store: "local_file", logRef },
|
|
{
|
|
offset,
|
|
limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes),
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
controller.signal.throwIfAborted();
|
|
content += chunk.content;
|
|
nextOffset = chunk.nextOffset;
|
|
offset = chunk.nextOffset ?? 0;
|
|
}
|
|
};
|
|
|
|
try {
|
|
await Promise.race([
|
|
readChunks(),
|
|
new Promise<never>((_resolve, reject) => {
|
|
readTimer = setTimeout(() => {
|
|
const reason = new DOMException("Attribution log read timed out", "TimeoutError");
|
|
// Cancellation closes storage work where supported, but filesystem
|
|
// I/O can delay stream destruction. Keep the response deadline too.
|
|
reject(reason);
|
|
controller.abort(reason);
|
|
}, ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS);
|
|
readTimer.unref?.();
|
|
}),
|
|
]);
|
|
} catch (err) {
|
|
// Attribution enriches already-authorized comments. Missing, failed, or
|
|
// stalled storage must not prevent listing them; keep any evidence read.
|
|
// Do not log raw provider errors, which can contain credentialed URLs.
|
|
logger.warn(
|
|
{ runId: run.runId ?? undefined, logRef, status: err instanceof HttpError ? err.status : undefined },
|
|
"could not read heartbeat run log while deriving optional issue comment metadata",
|
|
);
|
|
} finally {
|
|
clearTimeout(readTimer);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
export function issueService(db: Db) {
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const treeControlSvc = issueTreeControlService(db);
|
|
|
|
function provisionalTitleFromDescription(description: string) {
|
|
const simpleTitle = description.trim().replace(/\s+/g, " ").slice(0, 120);
|
|
try {
|
|
type MarkdownNode = {
|
|
type: string;
|
|
alt?: string | null;
|
|
children?: MarkdownNode[];
|
|
position?: { start: { offset?: number }; end: { offset?: number } };
|
|
};
|
|
const imageRanges: Array<{ start: number; end: number }> = [];
|
|
const imageAlts: string[] = [];
|
|
const visit = (node: MarkdownNode) => {
|
|
if (node.type === "image" || node.type === "imageReference") {
|
|
const start = node.position?.start.offset;
|
|
const end = node.position?.end.offset;
|
|
if (start !== undefined && end !== undefined) imageRanges.push({ start, end });
|
|
if (node.alt?.trim()) imageAlts.push(node.alt.trim());
|
|
}
|
|
node.children?.forEach(visit);
|
|
};
|
|
visit(parseMarkdown(description) as MarkdownNode);
|
|
const withoutImages = imageRanges
|
|
.sort((a, b) => b.start - a.start)
|
|
.reduce((text, range) => `${text.slice(0, range.start)} ${text.slice(range.end)}`, description);
|
|
const plainText = markdownToPlainText(withoutImages).trim().replace(/\s+/g, " ");
|
|
const fallback = imageRanges.length > 0
|
|
? imageAlts.join(" ") || "Image"
|
|
: simpleTitle;
|
|
return (plainText || fallback).slice(0, 120);
|
|
} catch {
|
|
return simpleTitle;
|
|
}
|
|
}
|
|
|
|
function normalizeCreateIssueTitle(title: string) {
|
|
return title.trim().replace(/\s+/g, " ").toLowerCase();
|
|
}
|
|
|
|
async function getIssueByUuid(id: string) {
|
|
const row = await retryIdempotentDatabaseOperation(() => db
|
|
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null));
|
|
if (!row) return null;
|
|
const [enriched] = await withIssueLabels(db, [row]);
|
|
return enriched;
|
|
}
|
|
|
|
async function getIssueByIdentifier(identifier: string) {
|
|
const row = await retryIdempotentDatabaseOperation(() => db
|
|
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
|
|
.from(issues)
|
|
.where(eq(issues.identifier, identifier.toUpperCase()))
|
|
.then((rows) => rows[0] ?? null));
|
|
if (!row) return null;
|
|
const [enriched] = await withIssueLabels(db, [row]);
|
|
return enriched;
|
|
}
|
|
|
|
async function projectHistoricalRunComments<
|
|
T extends { body: string; createdByRunId: string | null },
|
|
>(comments: T[]): Promise<T[]> {
|
|
const runIds = [
|
|
...new Set(
|
|
comments.flatMap((comment) =>
|
|
comment.createdByRunId && comment.body === LEGACY_WITHHELD_RUN_COMMENT
|
|
? [comment.createdByRunId]
|
|
: [],
|
|
),
|
|
),
|
|
];
|
|
if (runIds.length === 0) return comments;
|
|
const runResults = await db
|
|
.select({ id: heartbeatRuns.id, resultJson: heartbeatRuns.resultJson })
|
|
.from(heartbeatRuns)
|
|
.where(inArray(heartbeatRuns.id, runIds));
|
|
const resultByRunId = new Map(
|
|
runResults.map((run) => [run.id, parseObject(run.resultJson)]),
|
|
);
|
|
return comments.map((comment) => {
|
|
if (!comment.createdByRunId) return comment;
|
|
const body = projectHistoricalHeartbeatRunComment(
|
|
comment.body,
|
|
resultByRunId.get(comment.createdByRunId),
|
|
);
|
|
return body === comment.body ? comment : { ...comment, body };
|
|
});
|
|
}
|
|
|
|
async function getCurrentScheduledRetriesForIssues(
|
|
issueIds: string[],
|
|
companyId: string,
|
|
dbOrTx: DbReader = db,
|
|
): Promise<Map<string, IssueScheduledRetryRow>> {
|
|
const uniqueIssueIds = [...new Set(issueIds)];
|
|
if (uniqueIssueIds.length === 0) return new Map();
|
|
|
|
const contextIssueId = sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
|
|
const rows = await dbOrTx
|
|
.select({
|
|
issueId: contextIssueId,
|
|
runId: heartbeatRuns.id,
|
|
status: heartbeatRuns.status,
|
|
agentId: heartbeatRuns.agentId,
|
|
agentName: agents.name,
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
|
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
|
error: heartbeatRuns.error,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
inArray(heartbeatRuns.status, [
|
|
"scheduled_retry",
|
|
"queued",
|
|
"running",
|
|
]),
|
|
isNotNull(heartbeatRuns.scheduledRetryReason),
|
|
inArray(contextIssueId, uniqueIssueIds),
|
|
),
|
|
)
|
|
.orderBy(
|
|
sql`case ${heartbeatRuns.status}
|
|
when 'running' then 0
|
|
when 'queued' then 1
|
|
else 2
|
|
end`,
|
|
asc(heartbeatRuns.scheduledRetryAt),
|
|
asc(heartbeatRuns.createdAt),
|
|
asc(heartbeatRuns.id),
|
|
);
|
|
|
|
const currentByIssueId = new Map<string, IssueScheduledRetryRow>();
|
|
for (const row of rows) {
|
|
if (currentByIssueId.has(row.issueId)) continue;
|
|
const status = row.status;
|
|
if (
|
|
status !== "scheduled_retry" &&
|
|
status !== "queued" &&
|
|
status !== "running"
|
|
)
|
|
continue;
|
|
currentByIssueId.set(row.issueId, { ...row, status });
|
|
}
|
|
return currentByIssueId;
|
|
}
|
|
|
|
async function getCurrentScheduledRetryForIssue(
|
|
issueId: string,
|
|
companyId: string,
|
|
): Promise<IssueScheduledRetryRow | null> {
|
|
const currentByIssueId = await getCurrentScheduledRetriesForIssues(
|
|
[issueId],
|
|
companyId,
|
|
);
|
|
return currentByIssueId.get(issueId) ?? null;
|
|
}
|
|
|
|
function deriveIssueCommentAuthorType(comment: {
|
|
authorType?: string | null;
|
|
authorAgentId?: string | null;
|
|
authorUserId?: string | null;
|
|
}): IssueCommentAuthorType {
|
|
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
|
|
if (explicit.success) return explicit.data;
|
|
if (comment.authorAgentId) return "agent";
|
|
if (comment.authorUserId) return "user";
|
|
return "system";
|
|
}
|
|
|
|
function assertIssueCommentAuthorTypeAllowed(
|
|
actor: { agentId?: string | null; userId?: string | null },
|
|
authorType: IssueCommentAuthorType,
|
|
) {
|
|
if (actor.agentId && authorType !== "agent") {
|
|
throw unprocessable("Comment authorType must match authenticated actor");
|
|
}
|
|
if (actor.userId && authorType !== "user") {
|
|
throw unprocessable("Comment authorType must match authenticated actor");
|
|
}
|
|
if (!actor.agentId && !actor.userId && authorType !== "system") {
|
|
throw unprocessable(
|
|
"System comments cannot use user or agent authorType without an author id",
|
|
);
|
|
}
|
|
}
|
|
|
|
function redactIssueComment<
|
|
T extends {
|
|
body: string;
|
|
authorType?: string | null;
|
|
authorAgentId?: string | null;
|
|
authorUserId?: string | null;
|
|
presentation?: unknown;
|
|
metadata?: unknown;
|
|
deletedAt?: Date | string | null;
|
|
deletedByType?: "agent" | "user" | null;
|
|
deletedByAgentId?: string | null;
|
|
deletedByUserId?: string | null;
|
|
deletedByRunId?: string | null;
|
|
},
|
|
>(
|
|
comment: T,
|
|
censorUsernameInLogs: boolean,
|
|
): T & {
|
|
authorType: IssueCommentAuthorType;
|
|
presentation: IssueCommentPresentation | null;
|
|
metadata: IssueCommentMetadata | null;
|
|
} {
|
|
const deletedAt = comment.deletedAt ?? null;
|
|
if (deletedAt) {
|
|
return {
|
|
...comment,
|
|
authorType: deriveIssueCommentAuthorType(comment),
|
|
body: "",
|
|
presentation: null,
|
|
metadata: null,
|
|
deletedAt,
|
|
deletedByType: comment.deletedByType ?? null,
|
|
deletedByAgentId: comment.deletedByAgentId ?? null,
|
|
deletedByUserId: comment.deletedByUserId ?? null,
|
|
deletedByRunId: comment.deletedByRunId ?? null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...comment,
|
|
authorType: deriveIssueCommentAuthorType(comment),
|
|
body: redactCurrentUserText(comment.body, {
|
|
enabled: censorUsernameInLogs,
|
|
}),
|
|
presentation: issueCommentPresentationSchema
|
|
.nullable()
|
|
.catch(null)
|
|
.parse(comment.presentation ?? null),
|
|
metadata: issueCommentMetadataSchema
|
|
.nullable()
|
|
.catch(null)
|
|
.parse(comment.metadata ?? null),
|
|
};
|
|
}
|
|
|
|
|
|
// Persist a resolved attribution so subsequent reads stop re-scanning run
|
|
// logs (and old "Board" threads stay fixed durably). Best-effort: a write
|
|
// failure must never break the read path. The `IS NULL` guard keeps this
|
|
// idempotent and avoids clobbering a value another reader just stored.
|
|
async function persistDerivedIssueCommentAttribution(
|
|
derivedByCommentId: ReadonlyMap<string, DerivedIssueCommentAttribution>,
|
|
) {
|
|
if (derivedByCommentId.size === 0) return;
|
|
// One bulk `UPDATE ... FROM (VALUES ...)` so the read path is never blocked
|
|
// on N sequential round-trips for a large legacy thread. The `IS NULL` guard
|
|
// keeps this idempotent and avoids clobbering a value another reader just
|
|
// stored. Best-effort: a write failure must never break the read path.
|
|
const rows = [...derivedByCommentId].map(
|
|
([commentId, derived]) =>
|
|
sql`(${commentId}::uuid, ${derived.derivedAuthorAgentId}::uuid, ${derived.derivedCreatedByRunId}::uuid, ${derived.derivedAuthorSource}::text)`,
|
|
);
|
|
try {
|
|
await db.execute(sql`
|
|
UPDATE ${issueComments} AS c
|
|
SET derived_author_agent_id = v.agent_id,
|
|
derived_created_by_run_id = v.run_id,
|
|
derived_author_source = v.source
|
|
FROM (VALUES ${sql.join(rows, sql`, `)}) AS v(comment_id, agent_id, run_id, source)
|
|
WHERE c.id = v.comment_id AND c.derived_author_agent_id IS NULL
|
|
`);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, commentIds: [...derivedByCommentId.keys()] },
|
|
"failed to persist derived issue-comment attribution",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function enrichCommentsWithDerivedAgentAttribution<
|
|
T extends {
|
|
id: string;
|
|
companyId: string;
|
|
issueId: string;
|
|
authorAgentId?: string | null;
|
|
authorUserId?: string | null;
|
|
createdByRunId?: string | null;
|
|
derivedAuthorAgentId?: string | null;
|
|
createdAt: Date | string;
|
|
},
|
|
>(comments: readonly T[]) {
|
|
// Candidates: a non-human author, no stored agent, and not already resolved
|
|
// by a previous read / the backfill migration.
|
|
const preliminary = comments.filter(
|
|
(comment) =>
|
|
!comment.authorAgentId &&
|
|
!!comment.authorUserId &&
|
|
!comment.derivedAuthorAgentId,
|
|
);
|
|
if (preliminary.length === 0) return comments;
|
|
|
|
const companyId = comments[0]?.companyId ?? null;
|
|
const issueId = comments[0]?.issueId ?? null;
|
|
if (!companyId || !issueId) return comments;
|
|
|
|
// Guard: never reattribute a comment whose author maps to a genuine user
|
|
// profile. Only the non-human sentinels agents post under (e.g.
|
|
// `local-board`) are eligible — even though `local-board` is itself a row in
|
|
// the `user` table, so a plain "exists in user table" check would wrongly
|
|
// exclude every mis-attributed agent comment.
|
|
const nonSentinelAuthorUserIds = [
|
|
...new Set(
|
|
preliminary
|
|
.map((comment) => comment.authorUserId)
|
|
.filter(
|
|
(id): id is string =>
|
|
!!id && !NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(id),
|
|
),
|
|
),
|
|
];
|
|
const genuineUserIds = nonSentinelAuthorUserIds.length
|
|
? new Set(
|
|
(
|
|
await db
|
|
.select({ id: authUsers.id })
|
|
.from(authUsers)
|
|
.where(inArray(authUsers.id, nonSentinelAuthorUserIds))
|
|
).map((row) => row.id),
|
|
)
|
|
: new Set<string>();
|
|
// `preliminary` already guarantees a truthy `authorUserId`, so only the two
|
|
// "not a genuine user" arms are live: the explicit non-human sentinel, or an
|
|
// author id absent from the `user` table.
|
|
const candidates = preliminary.filter(
|
|
(comment) =>
|
|
NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(comment.authorUserId!) ||
|
|
!genuineUserIds.has(comment.authorUserId!),
|
|
);
|
|
if (candidates.length === 0) return comments;
|
|
|
|
const minCommentCreatedAtMs = candidates.reduce<number | null>(
|
|
(min, comment) => {
|
|
const timestamp = toTimestampMs(comment.createdAt);
|
|
if (timestamp === null) return min;
|
|
return min === null ? timestamp : Math.min(min, timestamp);
|
|
},
|
|
null,
|
|
);
|
|
const maxCommentCreatedAtMs = candidates.reduce<number | null>(
|
|
(max, comment) => {
|
|
const timestamp = toTimestampMs(comment.createdAt);
|
|
if (timestamp === null) return max;
|
|
return max === null ? timestamp : Math.max(max, timestamp);
|
|
},
|
|
null,
|
|
);
|
|
if (minCommentCreatedAtMs === null || maxCommentCreatedAtMs === null)
|
|
return comments;
|
|
|
|
const minCommentCreatedAt = new Date(minCommentCreatedAtMs).toISOString();
|
|
const maxCommentCreatedAt = new Date(
|
|
maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS,
|
|
).toISOString();
|
|
|
|
// The runs the comments' own `createdByRunId` point at — fetched
|
|
// unconditionally so the lossless run-id tier resolves even when a run is
|
|
// not otherwise associated with the issue.
|
|
const ownRunIds = [
|
|
...new Set(
|
|
candidates
|
|
.map((comment) => comment.createdByRunId)
|
|
.filter((id): id is string => !!id),
|
|
),
|
|
];
|
|
|
|
const runs = await db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
agentId: heartbeatRuns.agentId,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
logStore: heartbeatRuns.logStore,
|
|
logRef: heartbeatRuns.logRef,
|
|
logBytes: heartbeatRuns.logBytes,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
or(
|
|
and(
|
|
or(
|
|
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
|
sql`exists (
|
|
select 1
|
|
from ${activityLog}
|
|
where ${activityLog.companyId} = ${companyId}
|
|
and ${activityLog.entityType} = 'issue'
|
|
and ${activityLog.entityId} = ${issueId}
|
|
and ${activityLog.runId} = ${heartbeatRuns.id}
|
|
)`,
|
|
),
|
|
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`,
|
|
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`,
|
|
),
|
|
ownRunIds.length > 0
|
|
? inArray(heartbeatRuns.id, ownRunIds)
|
|
: sql`false`,
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt));
|
|
|
|
if (runs.length === 0) return comments;
|
|
|
|
// Pass 1: resolve the run-id tier, which never reads log bodies. Most
|
|
// comments resolve here, so we avoid object-storage reads entirely.
|
|
const runsWithoutLogs = runs.map((run) => ({ ...run, logContent: "" }));
|
|
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>(
|
|
deriveIssueCommentRunLogAttribution(candidates, runsWithoutLogs),
|
|
);
|
|
|
|
// Pass 2: for comments still unresolved after the run-id tier, read the logs
|
|
// of any run whose window overlaps such a comment, to look for the explicit
|
|
// `comment id:` post marker. The marker is a lossless signal regardless of
|
|
// how many runs overlap, so we do not short-circuit on the single-run case.
|
|
const unresolved = candidates.filter(
|
|
(comment) => !derivedByCommentId.has(comment.id),
|
|
);
|
|
if (unresolved.length > 0) {
|
|
const runIdsToRead = new Set<string>();
|
|
for (const run of runs) {
|
|
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
|
|
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
|
|
if (runStartMs === null || runEndMs === null) continue;
|
|
for (const comment of unresolved) {
|
|
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
|
|
if (commentCreatedAtMs === null) continue;
|
|
if (
|
|
commentCreatedAtMs >= runStartMs &&
|
|
commentCreatedAtMs <=
|
|
runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS
|
|
) {
|
|
runIdsToRead.add(run.runId);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (runIdsToRead.size > 0) {
|
|
const runsToRead = runs.filter((run) => runIdsToRead.has(run.runId));
|
|
const logByRunId = new Map<string, string>();
|
|
for (
|
|
let index = 0;
|
|
index < runsToRead.length;
|
|
index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS
|
|
) {
|
|
const batch = runsToRead.slice(
|
|
index,
|
|
index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS,
|
|
);
|
|
await Promise.all(
|
|
batch.map(async (run) => {
|
|
logByRunId.set(run.runId, await readIssueCommentRunLogText(run));
|
|
}),
|
|
);
|
|
}
|
|
const runsWithLogs = runs.map((run) => ({
|
|
...run,
|
|
logContent: logByRunId.get(run.runId) ?? "",
|
|
}));
|
|
for (const [commentId, derived] of deriveIssueCommentRunLogAttribution(
|
|
unresolved,
|
|
runsWithLogs,
|
|
)) {
|
|
derivedByCommentId.set(commentId, derived);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (derivedByCommentId.size === 0) return comments;
|
|
|
|
await persistDerivedIssueCommentAttribution(derivedByCommentId);
|
|
|
|
return comments.map((comment) => {
|
|
const derived = derivedByCommentId.get(comment.id);
|
|
return derived ? { ...comment, ...derived } : comment;
|
|
});
|
|
}
|
|
|
|
async function isTreeHoldInteractionCheckoutAllowed(
|
|
companyId: string,
|
|
checkoutRunId: string | null,
|
|
_gate: ActiveIssueTreePauseHoldGate,
|
|
) {
|
|
if (!checkoutRunId) return false;
|
|
const run = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
agentId: heartbeatRuns.agentId,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, checkoutRunId),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const issueId = readStringFromRecord(run?.contextSnapshot, "issueId");
|
|
if (!run || !issueId) return false;
|
|
return isVerifiedIssueTreeControlInteractionWake(db, {
|
|
companyId,
|
|
issueId,
|
|
agentId: run.agentId,
|
|
runId: run.id,
|
|
wakeupRequestId: run.wakeupRequestId,
|
|
contextSnapshot: run.contextSnapshot as
|
|
Record<string, unknown> | null | undefined,
|
|
});
|
|
}
|
|
|
|
async function assertAssignableUser(companyId: string, userId: string) {
|
|
const membership = await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!membership) {
|
|
throw notFound("Assignee user not found");
|
|
}
|
|
}
|
|
|
|
async function assertValidProjectWorkspace(
|
|
companyId: string,
|
|
projectId: string | null | undefined,
|
|
projectWorkspaceId: string,
|
|
dbOrTx: DbReader = db,
|
|
) {
|
|
const workspace = await dbOrTx
|
|
.select({
|
|
id: projectWorkspaces.id,
|
|
companyId: projectWorkspaces.companyId,
|
|
projectId: projectWorkspaces.projectId,
|
|
})
|
|
.from(projectWorkspaces)
|
|
.where(eq(projectWorkspaces.id, projectWorkspaceId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!workspace) throw notFound("Project workspace not found");
|
|
if (workspace.companyId !== companyId)
|
|
throw unprocessable("Project workspace must belong to same company");
|
|
if (projectId && workspace.projectId !== projectId) {
|
|
throw unprocessable(
|
|
"Project workspace must belong to the selected project",
|
|
);
|
|
}
|
|
return workspace;
|
|
}
|
|
|
|
async function assertValidExecutionWorkspace(
|
|
companyId: string,
|
|
projectId: string | null | undefined,
|
|
executionWorkspaceId: string,
|
|
dbOrTx: DbReader = db,
|
|
) {
|
|
const workspace = await dbOrTx
|
|
.select({
|
|
id: executionWorkspaces.id,
|
|
companyId: executionWorkspaces.companyId,
|
|
projectId: executionWorkspaces.projectId,
|
|
})
|
|
.from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.id, executionWorkspaceId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!workspace) throw notFound("Execution workspace not found");
|
|
if (workspace.companyId !== companyId)
|
|
throw unprocessable("Execution workspace must belong to same company");
|
|
if (projectId && workspace.projectId !== projectId) {
|
|
throw unprocessable(
|
|
"Execution workspace must belong to the selected project",
|
|
);
|
|
}
|
|
return workspace;
|
|
}
|
|
|
|
async function assertValidLabelIds(
|
|
companyId: string,
|
|
labelIds: string[],
|
|
dbOrTx: any = db,
|
|
) {
|
|
if (labelIds.length === 0) return;
|
|
const existing = await dbOrTx
|
|
.select({ id: labels.id })
|
|
.from(labels)
|
|
.where(
|
|
and(eq(labels.companyId, companyId), inArray(labels.id, labelIds)),
|
|
);
|
|
if (existing.length !== new Set(labelIds).size) {
|
|
throw unprocessable("One or more labels are invalid for this company");
|
|
}
|
|
}
|
|
|
|
async function syncIssueLabels(
|
|
issueId: string,
|
|
companyId: string,
|
|
labelIds: string[],
|
|
dbOrTx: any = db,
|
|
) {
|
|
const deduped = [...new Set(labelIds)];
|
|
await assertValidLabelIds(companyId, deduped, dbOrTx);
|
|
await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId));
|
|
if (deduped.length === 0) return;
|
|
await dbOrTx.insert(issueLabels).values(
|
|
deduped.map((labelId) => ({
|
|
issueId,
|
|
labelId,
|
|
companyId,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function getIssueRelationSummaryMap(
|
|
companyId: string,
|
|
issueIds: string[],
|
|
dbOrTx: DbReader = db,
|
|
): Promise<Map<string, IssueRelationSummaryMap>> {
|
|
const uniqueIssueIds = [...new Set(issueIds)];
|
|
const empty = new Map<string, IssueRelationSummaryMap>();
|
|
for (const issueId of uniqueIssueIds) {
|
|
empty.set(issueId, { blockedBy: [], blocks: [] });
|
|
}
|
|
if (uniqueIssueIds.length === 0) return empty;
|
|
|
|
const [blockedByRows, blockingRows] = await Promise.all([
|
|
dbOrTx
|
|
.select({
|
|
currentIssueId: issueRelations.relatedIssueId,
|
|
relatedId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
|
|
),
|
|
),
|
|
dbOrTx
|
|
.select({
|
|
currentIssueId: issueRelations.issueId,
|
|
relatedId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
inArray(issueRelations.issueId, uniqueIssueIds),
|
|
),
|
|
),
|
|
]);
|
|
|
|
for (const row of blockedByRows) {
|
|
empty
|
|
.get(row.currentIssueId)
|
|
?.blockedBy.push(summarizeIssueRelationRow(row));
|
|
}
|
|
for (const row of blockingRows) {
|
|
empty
|
|
.get(row.currentIssueId)
|
|
?.blocks.push(summarizeIssueRelationRow(row));
|
|
}
|
|
|
|
const terminalByRoot = await terminalExplicitBlockersByRoot(
|
|
companyId,
|
|
[...empty.values()].flatMap((relations) => relations.blockedBy),
|
|
dbOrTx,
|
|
);
|
|
|
|
for (const relations of empty.values()) {
|
|
relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title));
|
|
for (const blocker of relations.blockedBy) {
|
|
const terminalBlockers = terminalByRoot.get(blocker.id);
|
|
if (terminalBlockers && terminalBlockers.length > 0) {
|
|
blocker.terminalBlockers = terminalBlockers;
|
|
}
|
|
}
|
|
relations.blocks.sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
|
|
const relationSummaries: IssueRelationIssueSummary[] = [];
|
|
const collectRelationSummary = (summary: IssueRelationIssueSummary) => {
|
|
relationSummaries.push(summary);
|
|
for (const terminal of summary.terminalBlockers ?? [])
|
|
collectRelationSummary(terminal);
|
|
};
|
|
for (const relations of empty.values()) {
|
|
for (const blocker of relations.blockedBy)
|
|
collectRelationSummary(blocker);
|
|
for (const blocking of relations.blocks) collectRelationSummary(blocking);
|
|
}
|
|
const scheduledRetryByIssueId = await getCurrentScheduledRetriesForIssues(
|
|
relationSummaries.map((summary) => summary.id),
|
|
companyId,
|
|
dbOrTx,
|
|
);
|
|
for (const summary of relationSummaries) {
|
|
summary.scheduledRetry = scheduledRetryByIssueId.get(summary.id) ?? null;
|
|
}
|
|
|
|
return empty;
|
|
}
|
|
|
|
async function withIssueRelationSummaries<T extends { id: string }>(
|
|
companyId: string,
|
|
rows: T[],
|
|
dbOrTx: DbReader = db,
|
|
): Promise<Array<T & IssueRelationSummaryMap>> {
|
|
if (rows.length === 0) return [];
|
|
const relationMap = await getIssueRelationSummaryMap(
|
|
companyId,
|
|
rows.map((row) => row.id),
|
|
dbOrTx,
|
|
);
|
|
return rows.map((row) => ({
|
|
...row,
|
|
...(relationMap.get(row.id) ?? { blockedBy: [], blocks: [] }),
|
|
}));
|
|
}
|
|
|
|
async function assertNoBlockingCycles(
|
|
companyId: string,
|
|
issueId: string,
|
|
blockerIssueIds: string[],
|
|
dbOrTx: DbReader = db,
|
|
) {
|
|
if (blockerIssueIds.length === 0) return;
|
|
|
|
const rows = await dbOrTx
|
|
.select({
|
|
blockerIssueId: issueRelations.issueId,
|
|
blockedIssueId: issueRelations.relatedIssueId,
|
|
})
|
|
.from(issueRelations)
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
),
|
|
);
|
|
|
|
const adjacency = new Map<string, string[]>();
|
|
for (const row of rows) {
|
|
const list = adjacency.get(row.blockerIssueId) ?? [];
|
|
list.push(row.blockedIssueId);
|
|
adjacency.set(row.blockerIssueId, list);
|
|
}
|
|
|
|
for (const blockerIssueId of blockerIssueIds) {
|
|
const queue = [...(adjacency.get(issueId) ?? [])];
|
|
const visited = new Set<string>([issueId]);
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!;
|
|
if (current === blockerIssueId) {
|
|
throw unprocessable("Blocking relations cannot contain cycles");
|
|
}
|
|
if (visited.has(current)) continue;
|
|
visited.add(current);
|
|
queue.push(...(adjacency.get(current) ?? []));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncBlockedByIssueIds(
|
|
issueId: string,
|
|
companyId: string,
|
|
blockedByIssueIds: string[],
|
|
actor: { agentId?: string | null; userId?: string | null } = {},
|
|
dbOrTx: any = db,
|
|
) {
|
|
const deduped = [...new Set(blockedByIssueIds)];
|
|
if (deduped.some((candidate) => candidate === issueId)) {
|
|
throw unprocessable("Issue cannot be blocked by itself");
|
|
}
|
|
|
|
if (deduped.length > 0) {
|
|
const lockedIssueIds = [issueId, ...deduped].sort();
|
|
await dbOrTx.execute(
|
|
sql`SELECT ${issues.id} FROM ${issues}
|
|
WHERE ${and(eq(issues.companyId, companyId), inArray(issues.id, lockedIssueIds))}
|
|
ORDER BY ${issues.id}
|
|
FOR UPDATE`,
|
|
);
|
|
const relatedIssues = await dbOrTx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(
|
|
and(eq(issues.companyId, companyId), inArray(issues.id, deduped)),
|
|
);
|
|
if (relatedIssues.length !== deduped.length) {
|
|
throw unprocessable(
|
|
"Blocked-by issues must belong to the same company",
|
|
);
|
|
}
|
|
await assertNoBlockingCycles(companyId, issueId, deduped, dbOrTx);
|
|
}
|
|
|
|
await dbOrTx
|
|
.delete(issueRelations)
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, companyId),
|
|
eq(issueRelations.relatedIssueId, issueId),
|
|
eq(issueRelations.type, "blocks"),
|
|
),
|
|
);
|
|
|
|
if (deduped.length === 0) return;
|
|
|
|
await dbOrTx.insert(issueRelations).values(
|
|
deduped.map((blockerIssueId) => ({
|
|
companyId,
|
|
issueId: blockerIssueId,
|
|
relatedIssueId: issueId,
|
|
type: "blocks",
|
|
createdByAgentId: actor.agentId ?? null,
|
|
createdByUserId: actor.userId ?? null,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function isTerminalOrMissingHeartbeatRun(
|
|
runId: string,
|
|
dbOrTx: DbReader = db,
|
|
) {
|
|
return heartbeatRunIsTerminalOrMissing(dbOrTx, runId);
|
|
}
|
|
|
|
async function adoptStaleCheckoutRun(input: {
|
|
issueId: string;
|
|
actorAgentId: string;
|
|
actorRunId: string;
|
|
expectedCheckoutRunId: string;
|
|
}) {
|
|
return db.transaction(async (tx) => {
|
|
const lockedIssue = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, input.issueId))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!lockedIssue) {
|
|
return { adopted: null, latest: null };
|
|
}
|
|
|
|
if (
|
|
lockedIssue.status !== "in_progress" ||
|
|
lockedIssue.assigneeAgentId !== input.actorAgentId ||
|
|
lockedIssue.checkoutRunId !== input.expectedCheckoutRunId
|
|
) {
|
|
return { adopted: null, latest: lockedIssue };
|
|
}
|
|
|
|
await Promise.all([
|
|
tx.execute(
|
|
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.expectedCheckoutRunId} for update`,
|
|
),
|
|
tx.execute(
|
|
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
|
|
),
|
|
]);
|
|
const [existingRun, actorRun] = await Promise.all([
|
|
tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, input.expectedCheckoutRunId))
|
|
.then((rows) => rows[0] ?? null),
|
|
tx
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, input.actorRunId))
|
|
.then((rows) => rows[0] ?? null),
|
|
]);
|
|
const stale =
|
|
!existingRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingRun.status);
|
|
if (isExplicitContinuationRetryClaim(lockedIssue, existingRun)) {
|
|
return { adopted: null, latest: lockedIssue };
|
|
}
|
|
if (lockedIssue.executionRunId && lockedIssue.executionRunId !== input.expectedCheckoutRunId) {
|
|
const executionRun = await tx.select().from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, lockedIssue.executionRunId)).for("update")
|
|
.then(rows => rows[0] ?? null);
|
|
if (isExplicitContinuationRetryClaim(lockedIssue, executionRun)) {
|
|
return { adopted: null, latest: lockedIssue };
|
|
}
|
|
}
|
|
const actorLive =
|
|
actorRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status);
|
|
if (!stale || !actorLive) {
|
|
return { adopted: null, latest: lockedIssue };
|
|
}
|
|
|
|
const now = new Date();
|
|
const adopted = await tx
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId: input.actorRunId,
|
|
executionRunId: input.actorRunId,
|
|
executionLockedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issues.id, input.issueId),
|
|
eq(issues.status, "in_progress"),
|
|
eq(issues.assigneeAgentId, input.actorAgentId),
|
|
eq(issues.checkoutRunId, input.expectedCheckoutRunId),
|
|
),
|
|
)
|
|
.returning({
|
|
id: issues.id,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.then((rows) => rows[0] ?? null);
|
|
if (adopted) {
|
|
return { adopted, latest: adopted };
|
|
}
|
|
|
|
const latest = await tx
|
|
.select({
|
|
id: issues.id,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, input.issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
return { adopted: null, latest };
|
|
});
|
|
}
|
|
|
|
async function adoptUnownedCheckoutRun(input: {
|
|
issueId: string;
|
|
actorAgentId: string;
|
|
actorRunId: string;
|
|
}) {
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
|
|
);
|
|
const actorRun = await tx
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, input.actorRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!actorRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status))
|
|
return null;
|
|
|
|
const now = new Date();
|
|
const adopted = await tx
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId: input.actorRunId,
|
|
executionRunId: input.actorRunId,
|
|
executionLockedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issues.id, input.issueId),
|
|
eq(issues.status, "in_progress"),
|
|
eq(issues.assigneeAgentId, input.actorAgentId),
|
|
isNull(issues.checkoutRunId),
|
|
or(
|
|
isNull(issues.executionRunId),
|
|
eq(issues.executionRunId, input.actorRunId),
|
|
),
|
|
),
|
|
)
|
|
.returning({
|
|
id: issues.id,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return adopted;
|
|
});
|
|
}
|
|
|
|
async function clearExecutionRunIfTerminal(
|
|
issueId: string,
|
|
): Promise<boolean> {
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`,
|
|
);
|
|
const issue = await tx
|
|
.select({ id: issues.id, companyId: issues.companyId, executionRunId: issues.executionRunId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue?.executionRunId) return false;
|
|
|
|
await tx.execute(
|
|
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`,
|
|
);
|
|
const run = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, issue.executionRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
|
|
if (isExplicitContinuationRetryClaim(issue, run)) return false;
|
|
|
|
const updated = await tx
|
|
.update(issues)
|
|
.set({
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issues.id, issueId),
|
|
eq(issues.executionRunId, issue.executionRunId),
|
|
),
|
|
)
|
|
.returning({ id: issues.id })
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return Boolean(updated);
|
|
});
|
|
}
|
|
|
|
// Symmetric to clearExecutionRunIfTerminal. Clears checkoutRunId (and the
|
|
// bundled execution lock cols) when the row's checkoutRunId points at a
|
|
// heartbeat run that is terminal or no longer exists. No assignee/status
|
|
// precondition. Explicit retry claims remain owned by queue-first settlement.
|
|
async function clearCheckoutRunIfTerminal(issueId: string): Promise<boolean> {
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`,
|
|
);
|
|
const issue = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue?.checkoutRunId) return false;
|
|
|
|
await tx.execute(
|
|
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.checkoutRunId} for update`,
|
|
);
|
|
const run = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, issue.checkoutRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
|
|
if (isExplicitContinuationRetryClaim(issue, run)) return false;
|
|
|
|
if (
|
|
issue.executionRunId &&
|
|
issue.executionRunId !== issue.checkoutRunId
|
|
) {
|
|
await tx.execute(
|
|
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`,
|
|
);
|
|
const executionRun = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, issue.executionRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
executionRun &&
|
|
!TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status)
|
|
)
|
|
return false;
|
|
if (isExplicitContinuationRetryClaim(issue, executionRun)) return false;
|
|
}
|
|
|
|
const updated = await tx
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issues.id, issueId),
|
|
eq(issues.checkoutRunId, issue.checkoutRunId),
|
|
issue.executionRunId
|
|
? eq(issues.executionRunId, issue.executionRunId)
|
|
: isNull(issues.executionRunId),
|
|
),
|
|
)
|
|
.returning({ id: issues.id })
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return Boolean(updated);
|
|
});
|
|
}
|
|
|
|
async function addStopRelayCommentIfNeeded(
|
|
child: typeof issues.$inferSelect,
|
|
dbOrTx: any = db,
|
|
) {
|
|
if (
|
|
!child.parentId ||
|
|
(child.status !== "blocked" && child.status !== "cancelled")
|
|
)
|
|
return null;
|
|
|
|
const relayKey = `issue-stop-relay:${child.id}:${child.status}`;
|
|
await dbOrTx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtextextended(${relayKey}, 0))`,
|
|
);
|
|
|
|
const childIdentifier = child.identifier?.trim() || child.id;
|
|
const childPrefix = childIdentifier.split("-")[0] || "PAP";
|
|
const body = `System relay: [${childIdentifier}](/${childPrefix}/issues/${childIdentifier}) transitioned to \`${child.status}\`.`;
|
|
const existingRelay = await dbOrTx
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, child.companyId),
|
|
eq(issueComments.issueId, child.parentId),
|
|
eq(issueComments.authorType, "system"),
|
|
eq(issueComments.body, body),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
|
|
if (existingRelay) return null;
|
|
|
|
const parent = await dbOrTx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
status: issues.status,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, child.parentId),
|
|
eq(issues.companyId, child.companyId),
|
|
),
|
|
)
|
|
.then(
|
|
(
|
|
rows: Array<{
|
|
id: string;
|
|
companyId: string;
|
|
assigneeAgentId: string | null;
|
|
status: string;
|
|
}>,
|
|
) => rows[0] ?? null,
|
|
);
|
|
if (!parent) return null;
|
|
|
|
const [comment] = await dbOrTx
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId: child.companyId,
|
|
issueId: parent.id,
|
|
authorType: "system",
|
|
body,
|
|
})
|
|
.returning();
|
|
await dbOrTx
|
|
.update(issues)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(issues.id, parent.id));
|
|
|
|
return { comment, parent };
|
|
}
|
|
|
|
async function archiveInbox(
|
|
companyId: string,
|
|
issueId: string,
|
|
userId: string,
|
|
archivedAt: Date = new Date(),
|
|
attribution?: {
|
|
archivedByActorType: "user" | "agent";
|
|
archivedByAgentId?: string | null;
|
|
archivedByRunId?: string | null;
|
|
},
|
|
dbOrTx: any = db,
|
|
) {
|
|
const now = new Date();
|
|
const [row] = await dbOrTx
|
|
.insert(issueInboxArchives)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
userId,
|
|
archivedByActorType: attribution?.archivedByActorType ?? "user",
|
|
archivedByAgentId: attribution?.archivedByAgentId ?? null,
|
|
archivedByRunId: attribution?.archivedByRunId ?? null,
|
|
archivedAt,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
issueInboxArchives.companyId,
|
|
issueInboxArchives.issueId,
|
|
issueInboxArchives.userId,
|
|
],
|
|
set: {
|
|
archivedAt,
|
|
archivedByActorType: attribution?.archivedByActorType ?? "user",
|
|
archivedByAgentId: attribution?.archivedByAgentId ?? null,
|
|
archivedByRunId: attribution?.archivedByRunId ?? null,
|
|
updatedAt: now,
|
|
},
|
|
})
|
|
.returning();
|
|
return row;
|
|
}
|
|
|
|
const service = {
|
|
clearExecutionRunIfTerminal,
|
|
clearCheckoutRunIfTerminal,
|
|
addStopRelayCommentIfNeeded,
|
|
|
|
list: async (companyId: string, filters?: IssueFilters) => {
|
|
if (filters?.sortField === "id" && filters.attention) {
|
|
throw unprocessable("ID ordering is not supported for blocked attention lists");
|
|
}
|
|
if (filters?.afterId !== undefined && (
|
|
!isUuidLike(filters.afterId) || filters.sortField !== "id" ||
|
|
filters.sortDir !== "asc" || (filters.offset ?? 0) !== 0
|
|
)) {
|
|
throw unprocessable("afterId requires a UUID, ascending ID order and no offset");
|
|
}
|
|
if (filters?.attention === "blocked") {
|
|
return listBlockedInboxIssues(db, companyId, {
|
|
...filters,
|
|
includeBlockedBy: true,
|
|
includeBlockedInboxAttention: true,
|
|
});
|
|
}
|
|
|
|
const conditions = [
|
|
eq(issues.companyId, companyId),
|
|
visibleIssueCondition(),
|
|
];
|
|
if (!filters?.q?.trim()) {
|
|
conditions.push(isNull(issues.conversationAgentId));
|
|
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
|
conditions.push(nonIdleSlackIssueCondition());
|
|
}
|
|
}
|
|
if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId));
|
|
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
|
|
filters?.assigneeAgentId,
|
|
);
|
|
assertValidAssigneeAgentFilter(assigneeAgentFilter);
|
|
const limit =
|
|
typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
|
? Math.max(1, Math.floor(filters.limit))
|
|
: undefined;
|
|
const offset =
|
|
typeof filters?.offset === "number" && Number.isFinite(filters.offset)
|
|
? Math.max(0, Math.floor(filters.offset))
|
|
: 0;
|
|
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
|
const inboxArchivedByUserId =
|
|
filters?.inboxArchivedByUserId?.trim() || undefined;
|
|
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
|
const contextUserId =
|
|
unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
|
|
const includeBlockedBy = filters?.includeBlockedBy === true;
|
|
const includeBlockedInboxAttention =
|
|
filters?.includeBlockedInboxAttention === true;
|
|
const includeLiveDescendantSummary =
|
|
filters?.includeLiveDescendantSummary === true;
|
|
const rawSearch = filters?.q?.trim() ?? "";
|
|
const hasSearch = rawSearch.length > 0;
|
|
const taskSearch = parseTaskSearch(rawSearch);
|
|
if (filters?.createdFromIssueId) {
|
|
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
|
|
}
|
|
if (filters?.descendantOf) {
|
|
conditions.push(sql<boolean>`
|
|
${issues.id} IN (
|
|
WITH RECURSIVE descendants(id) AS (
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.parentId} = ${filters.descendantOf}
|
|
UNION
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
JOIN descendants ON ${issues.parentId} = descendants.id
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
)
|
|
SELECT id FROM descendants
|
|
)
|
|
`);
|
|
}
|
|
const lowTrustCondition = lowTrustBoundaryIssueCondition(
|
|
companyId,
|
|
filters?.lowTrustBoundary,
|
|
);
|
|
if (lowTrustCondition) conditions.push(lowTrustCondition);
|
|
const statuses = parseStatusFilter(filters?.status);
|
|
if (statuses.length === 1) {
|
|
conditions.push(eq(issues.status, statuses[0]));
|
|
} else if (statuses.length > 1) {
|
|
conditions.push(inArray(issues.status, statuses));
|
|
}
|
|
if (assigneeAgentFilter === null) {
|
|
conditions.push(isNull(issues.assigneeAgentId));
|
|
} else if (assigneeAgentFilter) {
|
|
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
|
|
}
|
|
if (filters?.participantAgentId) {
|
|
conditions.push(
|
|
participatedByAgentCondition(companyId, filters.participantAgentId),
|
|
);
|
|
}
|
|
if (filters?.assigneeUserId) {
|
|
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
|
}
|
|
if (touchedByUserId) {
|
|
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
|
}
|
|
if (inboxArchivedByUserId) {
|
|
conditions.push(
|
|
inboxVisibleForUserCondition(companyId, inboxArchivedByUserId),
|
|
);
|
|
}
|
|
if (unreadForUserId) {
|
|
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
|
}
|
|
if (filters?.projectId)
|
|
conditions.push(eq(issues.projectId, filters.projectId));
|
|
if (filters?.workspaceId) {
|
|
conditions.push(
|
|
or(
|
|
eq(issues.executionWorkspaceId, filters.workspaceId),
|
|
eq(issues.projectWorkspaceId, filters.workspaceId),
|
|
)!,
|
|
);
|
|
}
|
|
if (filters?.executionWorkspaceId) {
|
|
conditions.push(
|
|
eq(issues.executionWorkspaceId, filters.executionWorkspaceId),
|
|
);
|
|
}
|
|
if (filters?.parentId)
|
|
conditions.push(eq(issues.parentId, filters.parentId));
|
|
if (filters?.originKind)
|
|
conditions.push(eq(issues.originKind, filters.originKind));
|
|
if (filters?.originKindPrefix)
|
|
conditions.push(
|
|
like(issues.originKind, `${filters.originKindPrefix}%`),
|
|
);
|
|
if (filters?.originId)
|
|
conditions.push(eq(issues.originId, filters.originId));
|
|
if (filters?.hasPlanDocument !== undefined) {
|
|
conditions.push(
|
|
hasPlanDocumentCondition(companyId, filters.hasPlanDocument),
|
|
);
|
|
}
|
|
if (!shouldIncludePluginOperationIssues(filters)) {
|
|
conditions.push(nonPluginOperationIssueCondition());
|
|
}
|
|
if (filters?.labelId) {
|
|
const labeledIssueIds = await db
|
|
.select({ issueId: issueLabels.issueId })
|
|
.from(issueLabels)
|
|
.where(
|
|
and(
|
|
eq(issueLabels.companyId, companyId),
|
|
eq(issueLabels.labelId, filters.labelId),
|
|
),
|
|
);
|
|
if (labeledIssueIds.length === 0) return [];
|
|
conditions.push(
|
|
inArray(
|
|
issues.id,
|
|
labeledIssueIds.map((row) => row.issueId),
|
|
),
|
|
);
|
|
}
|
|
if (filters?.updatedSince) {
|
|
const since = new Date(filters.updatedSince);
|
|
if (Number.isFinite(since.getTime())) {
|
|
conditions.push(gt(issues.updatedAt, since));
|
|
}
|
|
}
|
|
if (
|
|
filters?.excludeRoutineExecutions &&
|
|
!filters?.originKind &&
|
|
!filters?.originId
|
|
) {
|
|
conditions.push(ne(issues.originKind, "routine_execution"));
|
|
}
|
|
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
|
const searchOrder = sql<number>`-task_search.score`;
|
|
const issueSource = db.select(issueListSelect).from(issues);
|
|
const searchedSource = hasSearch
|
|
? issueSource.innerJoin(sql`(
|
|
${taskSearchCtes(companyId, taskSearch, true, and(...conditions))}
|
|
SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m
|
|
) task_search`, sql`task_search.id = ${issues.id}`)
|
|
: issueSource;
|
|
const baseQuery = searchedSource
|
|
.where(and(...conditions))
|
|
.orderBy(
|
|
...issueListOrderBy(companyId, {
|
|
hasSearch,
|
|
priorityOrder,
|
|
searchOrder,
|
|
sortField: filters?.sortField,
|
|
sortDir: filters?.sortDir,
|
|
}),
|
|
);
|
|
const pageQuery =
|
|
offset > 0
|
|
? limit === undefined
|
|
? baseQuery.offset(offset)
|
|
: baseQuery.limit(limit).offset(offset)
|
|
: limit === undefined
|
|
? baseQuery
|
|
: baseQuery.limit(limit);
|
|
const rows = (await pageQuery).map((row) => ({
|
|
...row,
|
|
description: decodeDatabaseTextPreview(
|
|
row.description,
|
|
ISSUE_LIST_DESCRIPTION_MAX_CHARS,
|
|
),
|
|
}));
|
|
const withLabels = await withIssueLabels(db, rows);
|
|
const runMap = await activeRunMapForIssues(db, withLabels);
|
|
const withRuns = withActiveRuns(withLabels, runMap);
|
|
if (withRuns.length === 0) {
|
|
return withRuns;
|
|
}
|
|
|
|
const issueIds = withRuns.map((row) => row.id);
|
|
const [
|
|
statsRows,
|
|
readRows,
|
|
lastActivityRows,
|
|
archiveRows,
|
|
blockedByMap,
|
|
liveDescendantCountByIssueId,
|
|
] = await Promise.all([
|
|
contextUserId
|
|
? userCommentStatsForIssues(db, companyId, contextUserId, issueIds)
|
|
: Promise.resolve([]),
|
|
contextUserId
|
|
? userReadStatsForIssues(db, companyId, contextUserId, issueIds)
|
|
: Promise.resolve([]),
|
|
lastActivityStatsForIssues(db, companyId, issueIds),
|
|
contextUserId
|
|
? inboxArchiveRowsForIssues(db, companyId, contextUserId, issueIds)
|
|
: Promise.resolve([]),
|
|
includeBlockedBy
|
|
? blockedByMapForIssues(db, companyId, issueIds)
|
|
: Promise.resolve(new Map<string, IssueRelationIssueSummary[]>()),
|
|
includeLiveDescendantSummary
|
|
? liveDescendantCountMapForIssues(db, companyId, issueIds)
|
|
: Promise.resolve(new Map<string, number>()),
|
|
]);
|
|
const statsByIssueId = new Map(
|
|
statsRows.map((row) => [row.issueId, row]),
|
|
);
|
|
const lastActivityByIssueId = new Map(
|
|
lastActivityRows.map((row) => [row.issueId, row]),
|
|
);
|
|
const archiveByIssueId = new Map(
|
|
archiveRows.map((row) => [row.issueId, row]),
|
|
);
|
|
const [
|
|
blockerAttentionByIssueId,
|
|
reviewAttentionByIssueId,
|
|
blockedInboxAttentionByIssueId,
|
|
] = await Promise.all([
|
|
listIssueBlockerAttentionMap(db, companyId, withRuns),
|
|
listIssueReviewAttentionMap(db, companyId, withRuns),
|
|
includeBlockedInboxAttention
|
|
? listIssueBlockedInboxAttentionMap(db, companyId, withRuns)
|
|
: Promise.resolve(new Map<string, IssueBlockedInboxAttention>()),
|
|
]);
|
|
|
|
if (!contextUserId) {
|
|
return withRuns.map((row) => {
|
|
const activity = lastActivityByIssueId.get(row.id);
|
|
const lastActivityAt =
|
|
latestIssueActivityAt(
|
|
row.updatedAt,
|
|
activity?.latestCommentAt ?? null,
|
|
activity?.latestLogAt ?? null,
|
|
) ?? row.updatedAt;
|
|
return {
|
|
...row,
|
|
...(includeBlockedBy
|
|
? { blockedBy: blockedByMap.get(row.id) ?? [] }
|
|
: {}),
|
|
lastActivityAt,
|
|
...(blockerAttentionByIssueId.has(row.id)
|
|
? { blockerAttention: blockerAttentionByIssueId.get(row.id) }
|
|
: {}),
|
|
reviewAttention:
|
|
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
|
|
...(includeBlockedInboxAttention
|
|
? {
|
|
blockedInboxAttention:
|
|
blockedInboxAttentionByIssueId.get(row.id) ?? null,
|
|
}
|
|
: {}),
|
|
...(includeLiveDescendantSummary
|
|
? {
|
|
liveDescendantCount:
|
|
liveDescendantCountByIssueId.get(row.id) ?? 0,
|
|
}
|
|
: {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
const readByIssueId = new Map(
|
|
readRows.map((row) => [row.issueId, row.myLastReadAt]),
|
|
);
|
|
|
|
return withRuns.map((row) => {
|
|
const activity = lastActivityByIssueId.get(row.id);
|
|
const lastActivityAt =
|
|
latestIssueActivityAt(
|
|
row.updatedAt,
|
|
activity?.latestCommentAt ?? null,
|
|
activity?.latestLogAt ?? null,
|
|
) ?? row.updatedAt;
|
|
return {
|
|
...row,
|
|
...activeInboxArchiveFields(
|
|
archiveByIssueId.get(row.id),
|
|
lastActivityAt,
|
|
),
|
|
...(includeBlockedBy
|
|
? { blockedBy: blockedByMap.get(row.id) ?? [] }
|
|
: {}),
|
|
lastActivityAt,
|
|
...(blockerAttentionByIssueId.has(row.id)
|
|
? { blockerAttention: blockerAttentionByIssueId.get(row.id) }
|
|
: {}),
|
|
reviewAttention:
|
|
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
|
|
...(includeBlockedInboxAttention
|
|
? {
|
|
blockedInboxAttention:
|
|
blockedInboxAttentionByIssueId.get(row.id) ?? null,
|
|
}
|
|
: {}),
|
|
...(includeLiveDescendantSummary
|
|
? {
|
|
liveDescendantCount:
|
|
liveDescendantCountByIssueId.get(row.id) ?? 0,
|
|
}
|
|
: {}),
|
|
...deriveIssueUserContext(row, contextUserId, {
|
|
myLastCommentAt:
|
|
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
|
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
|
lastExternalCommentAt:
|
|
statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
|
}),
|
|
};
|
|
});
|
|
},
|
|
|
|
count: async (companyId: string, filters?: IssueFilters) => {
|
|
if (filters?.attention === "blocked") {
|
|
return countBlockedInboxIssues(db, companyId, filters);
|
|
}
|
|
|
|
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
|
|
if (!filters?.q?.trim()) {
|
|
conditions.push(isNull(issues.conversationAgentId));
|
|
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
|
conditions.push(nonIdleSlackIssueCondition());
|
|
}
|
|
}
|
|
const statuses = parseStatusFilter(filters?.status);
|
|
if (statuses.length === 1)
|
|
conditions.push(eq(issues.status, statuses[0]!));
|
|
else if (statuses.length > 1)
|
|
conditions.push(inArray(issues.status, statuses));
|
|
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
|
|
filters?.assigneeAgentId,
|
|
);
|
|
assertValidAssigneeAgentFilter(assigneeAgentFilter);
|
|
if (assigneeAgentFilter === null) {
|
|
conditions.push(isNull(issues.assigneeAgentId));
|
|
} else if (assigneeAgentFilter) {
|
|
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
|
|
}
|
|
if (filters?.assigneeUserId)
|
|
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
|
if (filters?.projectId)
|
|
conditions.push(eq(issues.projectId, filters.projectId));
|
|
if (filters?.workspaceId) {
|
|
conditions.push(
|
|
or(
|
|
eq(issues.executionWorkspaceId, filters.workspaceId),
|
|
eq(issues.projectWorkspaceId, filters.workspaceId),
|
|
)!,
|
|
);
|
|
}
|
|
if (filters?.executionWorkspaceId)
|
|
conditions.push(
|
|
eq(issues.executionWorkspaceId, filters.executionWorkspaceId),
|
|
);
|
|
if (filters?.parentId)
|
|
conditions.push(eq(issues.parentId, filters.parentId));
|
|
if (filters?.originKind)
|
|
conditions.push(eq(issues.originKind, filters.originKind));
|
|
if (filters?.originKindPrefix)
|
|
conditions.push(
|
|
like(issues.originKind, `${filters.originKindPrefix}%`),
|
|
);
|
|
if (filters?.originId)
|
|
conditions.push(eq(issues.originId, filters.originId));
|
|
if (filters?.hasPlanDocument !== undefined) {
|
|
conditions.push(
|
|
hasPlanDocumentCondition(companyId, filters.hasPlanDocument),
|
|
);
|
|
}
|
|
if (!shouldIncludePluginOperationIssues(filters))
|
|
conditions.push(nonPluginOperationIssueCondition());
|
|
const [row] = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(issues)
|
|
.where(and(...conditions));
|
|
return Number(row?.count ?? 0);
|
|
},
|
|
|
|
countUnreadTouchedByUser: async (
|
|
companyId: string,
|
|
userId: string,
|
|
status?: string | readonly string[],
|
|
) => {
|
|
const conditions = [
|
|
eq(issues.companyId, companyId),
|
|
visibleIssueCondition(),
|
|
nonPluginOperationIssueCondition(),
|
|
unreadForUserCondition(companyId, userId),
|
|
];
|
|
const statuses = parseStatusFilter(status);
|
|
if (statuses.length === 1) {
|
|
conditions.push(eq(issues.status, statuses[0]));
|
|
} else if (statuses.length > 1) {
|
|
conditions.push(inArray(issues.status, statuses));
|
|
}
|
|
const [row] = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(issues)
|
|
.where(and(...conditions));
|
|
return Number(row?.count ?? 0);
|
|
},
|
|
|
|
markRead: async (
|
|
companyId: string,
|
|
issueId: string,
|
|
userId: string,
|
|
readAt: Date = new Date(),
|
|
) => {
|
|
const now = new Date();
|
|
const [row] = await db
|
|
.insert(issueReadStates)
|
|
.values({
|
|
companyId,
|
|
issueId,
|
|
userId,
|
|
lastReadAt: readAt,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
issueReadStates.companyId,
|
|
issueReadStates.issueId,
|
|
issueReadStates.userId,
|
|
],
|
|
set: {
|
|
lastReadAt: readAt,
|
|
updatedAt: now,
|
|
},
|
|
})
|
|
.returning();
|
|
return row;
|
|
},
|
|
|
|
markUnread: async (companyId: string, issueId: string, userId: string) => {
|
|
const deleted = await db
|
|
.delete(issueReadStates)
|
|
.where(
|
|
and(
|
|
eq(issueReadStates.companyId, companyId),
|
|
eq(issueReadStates.issueId, issueId),
|
|
eq(issueReadStates.userId, userId),
|
|
),
|
|
)
|
|
.returning();
|
|
return deleted.length > 0;
|
|
},
|
|
|
|
archiveInbox,
|
|
|
|
/**
|
|
* Seed inbox archives for a batch of freshly imported issues so a company
|
|
* import does not flood the importing user's inbox. Imported issues are
|
|
* historical work, not new inbox items, but the inbox "mine" query surfaces
|
|
* every touched-and-not-archived issue; a 1000-task import would otherwise
|
|
* bury the inbox. Seeding a per-user inbox archive keeps them hidden via
|
|
* `inboxVisibleForUserCondition`, while genuine new activity on an imported
|
|
* issue still resurfaces it. This runs only on import (mirroring how
|
|
* `pauseAutomations` threads an import-only suppression) and never touches
|
|
* normal issue creation. Rows carry the same "user"-attributed shape a
|
|
* manual inbox archive writes, batched to stay under Postgres bind limits.
|
|
*/
|
|
archiveImportedInbox: async (
|
|
companyId: string,
|
|
issueIds: string[],
|
|
userId: string,
|
|
archivedAt: Date = new Date(),
|
|
): Promise<void> => {
|
|
if (issueIds.length === 0) return;
|
|
const now = new Date();
|
|
const rows = issueIds.map((issueId) => ({
|
|
companyId,
|
|
issueId,
|
|
userId,
|
|
archivedByActorType: "user" as const,
|
|
archivedByAgentId: null,
|
|
archivedByRunId: null,
|
|
archivedAt,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}));
|
|
await insertRowsInChunks(db, issueInboxArchives, rows);
|
|
},
|
|
|
|
unarchiveInbox: async (
|
|
companyId: string,
|
|
issueId: string,
|
|
userId: string,
|
|
) => {
|
|
const [row] = await db
|
|
.delete(issueInboxArchives)
|
|
.where(
|
|
and(
|
|
eq(issueInboxArchives.companyId, companyId),
|
|
eq(issueInboxArchives.issueId, issueId),
|
|
eq(issueInboxArchives.userId, userId),
|
|
),
|
|
)
|
|
.returning();
|
|
return row ?? null;
|
|
},
|
|
|
|
getActiveInboxArchiveFields: async (
|
|
issue: Pick<IssueRow, "id" | "companyId" | "updatedAt">,
|
|
userId: string,
|
|
) => {
|
|
const [[activity], [archive]] = await Promise.all([
|
|
lastActivityStatsForIssues(db, issue.companyId, [issue.id]),
|
|
inboxArchiveRowsForIssues(db, issue.companyId, userId, [issue.id]),
|
|
]);
|
|
const lastActivityAt =
|
|
latestIssueActivityAt(
|
|
issue.updatedAt,
|
|
activity?.latestCommentAt ?? null,
|
|
activity?.latestLogAt ?? null,
|
|
) ?? issue.updatedAt;
|
|
return activeInboxArchiveFields(archive, lastActivityAt);
|
|
},
|
|
|
|
/**
|
|
* Walk the full parent chain from `parentIssueId` (inclusive) looking for
|
|
* a still-open ancestor created by `agentId`. Used to refuse agent
|
|
* delegation cycles: an agent assigning a new child to the agent that
|
|
* created an open ancestor is handing the same work back to its own
|
|
* delegator (A→B→A hot-potato). One recursive query covers the entire
|
|
* chain regardless of depth; `UNION` (not `UNION ALL`) deduplicates
|
|
* revisited rows, so a parent graph corrupted into a loop terminates
|
|
* instead of recursing forever.
|
|
*/
|
|
findOpenAncestorCreatedByAgent: async (
|
|
parentIssueId: string,
|
|
agentId: string,
|
|
): Promise<{
|
|
id: string;
|
|
identifier: string | null;
|
|
parentId: string | null;
|
|
createdByAgentId: string | null;
|
|
status: string;
|
|
} | null> => {
|
|
const rows = await db.execute(sql`
|
|
WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
SELECT id, parent_id FROM issues WHERE id = ${parentIssueId}
|
|
UNION
|
|
SELECT parent.id, parent.parent_id
|
|
FROM issues parent
|
|
JOIN ancestors ON parent.id = ancestors.parent_id
|
|
)
|
|
SELECT i.id, i.identifier, i.parent_id, i.created_by_agent_id, i.status
|
|
FROM issues i
|
|
JOIN ancestors a ON a.id = i.id
|
|
WHERE i.created_by_agent_id = ${agentId}
|
|
AND i.status NOT IN ('done', 'cancelled')
|
|
LIMIT 1
|
|
`);
|
|
const first = (Array.isArray(rows) ? rows[0] : null) as Record<
|
|
string,
|
|
unknown
|
|
> | null;
|
|
if (!first) return null;
|
|
return {
|
|
id: String(first.id),
|
|
identifier:
|
|
typeof first.identifier === "string" ? first.identifier : null,
|
|
parentId: typeof first.parent_id === "string" ? first.parent_id : null,
|
|
createdByAgentId:
|
|
typeof first.created_by_agent_id === "string"
|
|
? first.created_by_agent_id
|
|
: null,
|
|
status: String(first.status),
|
|
};
|
|
},
|
|
|
|
getById: async (raw: string) => {
|
|
const id = raw.trim();
|
|
const identifier = normalizeIssueReferenceIdentifier(id);
|
|
if (identifier) {
|
|
return getIssueByIdentifier(identifier);
|
|
}
|
|
if (!isUuidLike(id)) {
|
|
return null;
|
|
}
|
|
return getIssueByUuid(id);
|
|
},
|
|
|
|
getByIdForUpdate: async (id: string, dbOrTx: any) => {
|
|
return dbOrTx
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.for("update")
|
|
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
|
},
|
|
|
|
getByIdentifier: async (identifier: string) => {
|
|
return getIssueByIdentifier(identifier);
|
|
},
|
|
|
|
getCurrentScheduledRetry: async (issueId: string) => {
|
|
const issue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) throw notFound("Issue not found");
|
|
return getCurrentScheduledRetryForIssue(issue.id, issue.companyId);
|
|
},
|
|
|
|
getRelationSummaries: async (issueId: string) => {
|
|
const issue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) throw notFound("Issue not found");
|
|
const relations = await getIssueRelationSummaryMap(
|
|
issue.companyId,
|
|
[issueId],
|
|
db,
|
|
);
|
|
return relations.get(issueId) ?? { blockedBy: [], blocks: [] };
|
|
},
|
|
|
|
getBlockerDiagnostics: async (
|
|
issueId: string,
|
|
maxBlockers = ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
|
|
) => {
|
|
const issue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
const cappedMax = Math.max(
|
|
0,
|
|
Math.min(maxBlockers, ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS),
|
|
);
|
|
const blockerRows = await db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
parentId: issues.parentId,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, issue.companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
eq(issueRelations.relatedIssueId, issue.id),
|
|
eq(issues.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.orderBy(asc(issues.title), asc(issues.id))
|
|
.limit(cappedMax + 1);
|
|
|
|
const readiness = await listIssueDependencyReadinessMap(
|
|
db,
|
|
issue.companyId,
|
|
[issue.id],
|
|
);
|
|
|
|
return {
|
|
blockers: blockerRows.slice(
|
|
0,
|
|
cappedMax,
|
|
) as IssueBlockerDiagnosticsIssueRow[],
|
|
readiness:
|
|
readiness.get(issue.id) ?? createIssueDependencyReadiness(issue.id),
|
|
truncated: blockerRows.length > cappedMax,
|
|
};
|
|
},
|
|
|
|
getWakeDiagnostics: async (
|
|
issueId: string,
|
|
opts?: {
|
|
maxWakeRequests?: number;
|
|
maxActivityRecords?: number;
|
|
lookbackDays?: number;
|
|
},
|
|
) => {
|
|
const issue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
const maxWakeRequests = Math.max(
|
|
0,
|
|
Math.min(
|
|
opts?.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
|
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
|
),
|
|
);
|
|
const maxActivityRecords = Math.max(
|
|
0,
|
|
Math.min(
|
|
opts?.maxActivityRecords ??
|
|
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
|
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
|
),
|
|
);
|
|
const lookbackDays = Math.max(
|
|
1,
|
|
Math.min(
|
|
opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
|
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
|
),
|
|
);
|
|
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
|
|
|
|
const wakeRows = await db
|
|
.select({
|
|
agentId: agentWakeupRequests.agentId,
|
|
source: agentWakeupRequests.source,
|
|
reason: agentWakeupRequests.reason,
|
|
status: agentWakeupRequests.status,
|
|
coalescedCount: agentWakeupRequests.coalescedCount,
|
|
runId: agentWakeupRequests.runId,
|
|
requestedAt: agentWakeupRequests.requestedAt,
|
|
claimedAt: agentWakeupRequests.claimedAt,
|
|
finishedAt: agentWakeupRequests.finishedAt,
|
|
error: agentWakeupRequests.error,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, issue.companyId),
|
|
gte(agentWakeupRequests.requestedAt, since),
|
|
wakeRequestTargetsIssue(issue.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(agentWakeupRequests.requestedAt),
|
|
desc(agentWakeupRequests.createdAt),
|
|
)
|
|
.limit(maxWakeRequests + 1);
|
|
|
|
const activityRows = await db
|
|
.select({
|
|
action: activityLog.action,
|
|
entityType: activityLog.entityType,
|
|
entityId: activityLog.entityId,
|
|
agentId: activityLog.agentId,
|
|
runId: activityLog.runId,
|
|
details: activityLog.details,
|
|
createdAt: activityLog.createdAt,
|
|
})
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, issue.companyId),
|
|
gte(activityLog.createdAt, since),
|
|
inArray(activityLog.action, [
|
|
...ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS,
|
|
]),
|
|
wakeDiagnosticActivityTargetsIssue(issue.id),
|
|
),
|
|
)
|
|
.orderBy(desc(activityLog.createdAt))
|
|
.limit(maxActivityRecords + 1);
|
|
|
|
return {
|
|
wakeRequests: wakeRows.slice(
|
|
0,
|
|
maxWakeRequests,
|
|
) as IssueWakeDiagnosticsWakeRequestRow[],
|
|
activityRecords: activityRows.slice(
|
|
0,
|
|
maxActivityRecords,
|
|
) as IssueWakeDiagnosticsActivityRow[],
|
|
truncatedWakeRequests: wakeRows.length > maxWakeRequests,
|
|
truncatedActivityRecords: activityRows.length > maxActivityRecords,
|
|
caps: {
|
|
maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
|
maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
|
|
lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
|
},
|
|
};
|
|
},
|
|
|
|
getSubtreeDiagnostics: async (
|
|
issueId: string,
|
|
opts?: {
|
|
maxDepth?: number;
|
|
maxNodes?: number;
|
|
maxBlockersPerNode?: number;
|
|
maxWakeRequestsPerNode?: number;
|
|
maxActivityRecordsPerNode?: number;
|
|
lookbackDays?: number;
|
|
},
|
|
) => {
|
|
const issue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
const maxDepth = Math.max(
|
|
0,
|
|
Math.min(
|
|
opts?.maxDepth ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH,
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH,
|
|
),
|
|
);
|
|
const maxNodes = Math.max(
|
|
1,
|
|
Math.min(
|
|
opts?.maxNodes ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES,
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES,
|
|
),
|
|
);
|
|
const maxBlockersPerNode = Math.max(
|
|
0,
|
|
Math.min(
|
|
opts?.maxBlockersPerNode ??
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE,
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE,
|
|
),
|
|
);
|
|
const maxWakeRequestsPerNode = Math.max(
|
|
0,
|
|
Math.min(
|
|
opts?.maxWakeRequestsPerNode ??
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE,
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE,
|
|
),
|
|
);
|
|
const maxActivityRecordsPerNode = Math.max(
|
|
0,
|
|
Math.min(
|
|
opts?.maxActivityRecordsPerNode ??
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE,
|
|
ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE,
|
|
),
|
|
);
|
|
const lookbackDays = Math.max(
|
|
1,
|
|
Math.min(
|
|
opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
|
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
|
|
),
|
|
);
|
|
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
|
|
const sinceIso = since.toISOString();
|
|
|
|
const rawSubtreeRows =
|
|
await db.execute(sql<IssueSubtreeDiagnosticsIssueRow>`
|
|
WITH RECURSIVE issue_tree AS (
|
|
SELECT
|
|
id,
|
|
company_id,
|
|
project_id,
|
|
parent_id,
|
|
identifier,
|
|
title,
|
|
status,
|
|
priority,
|
|
assignee_agent_id,
|
|
assignee_user_id,
|
|
created_at,
|
|
updated_at,
|
|
0 AS depth,
|
|
ARRAY[id] AS path
|
|
FROM issues
|
|
WHERE company_id = ${issue.companyId}
|
|
AND id = ${issue.id}
|
|
AND hidden_at IS NULL
|
|
AND harness_kind IS NULL
|
|
UNION ALL
|
|
SELECT
|
|
child.id,
|
|
child.company_id,
|
|
child.project_id,
|
|
child.parent_id,
|
|
child.identifier,
|
|
child.title,
|
|
child.status,
|
|
child.priority,
|
|
child.assignee_agent_id,
|
|
child.assignee_user_id,
|
|
child.created_at,
|
|
child.updated_at,
|
|
issue_tree.depth + 1,
|
|
issue_tree.path || child.id
|
|
FROM issues child
|
|
JOIN issue_tree ON child.parent_id = issue_tree.id
|
|
WHERE child.company_id = ${issue.companyId}
|
|
AND child.hidden_at IS NULL
|
|
AND child.harness_kind IS NULL
|
|
AND issue_tree.depth < ${maxDepth + 1}
|
|
AND NOT child.id = ANY(issue_tree.path)
|
|
)
|
|
SELECT
|
|
id,
|
|
company_id AS "companyId",
|
|
project_id AS "projectId",
|
|
parent_id AS "parentId",
|
|
identifier,
|
|
title,
|
|
status,
|
|
priority,
|
|
assignee_agent_id AS "assigneeAgentId",
|
|
assignee_user_id AS "assigneeUserId",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
depth::int AS depth
|
|
FROM issue_tree
|
|
ORDER BY depth ASC, created_at ASC, id ASC
|
|
LIMIT ${maxNodes + 1}
|
|
`);
|
|
const subtreeRows = Array.from(rawSubtreeRows).map((row) => ({
|
|
...row,
|
|
depth: Number(row.depth),
|
|
}));
|
|
const rowsWithinDepth = subtreeRows.filter(
|
|
(row) => row.depth <= maxDepth,
|
|
);
|
|
const nodes = rowsWithinDepth.slice(
|
|
0,
|
|
maxNodes,
|
|
) as IssueSubtreeDiagnosticsIssueRow[];
|
|
const truncatedNodes = rowsWithinDepth.length > maxNodes;
|
|
const truncatedDepth =
|
|
truncatedNodes || subtreeRows.some((row) => row.depth > maxDepth);
|
|
const nodeIds = nodes.map((node) => node.id);
|
|
|
|
const readiness =
|
|
nodeIds.length > 0
|
|
? await listIssueDependencyReadinessMap(db, issue.companyId, nodeIds)
|
|
: new Map<string, IssueDependencyReadiness>();
|
|
const blockersByIssueId = new Map<
|
|
string,
|
|
IssueSubtreeDiagnosticsBlockerRow[]
|
|
>();
|
|
const wakeRequestsByIssueId = new Map<
|
|
string,
|
|
IssueSubtreeDiagnosticsWakeRequestRow[]
|
|
>();
|
|
const activityRecordsByIssueId = new Map<
|
|
string,
|
|
IssueSubtreeDiagnosticsActivityRow[]
|
|
>();
|
|
const truncatedBlockerIssueIds = new Set<string>();
|
|
const truncatedWakeIssueIds = new Set<string>();
|
|
const truncatedActivityIssueIds = new Set<string>();
|
|
|
|
if (nodeIds.length > 0) {
|
|
const nodeIdValues = sql.join(
|
|
nodeIds.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
);
|
|
const rawBlockerRows = Array.from(
|
|
await db.execute(sql`
|
|
WITH blocker_rows AS (
|
|
SELECT
|
|
blocker.id,
|
|
blocker.company_id AS "companyId",
|
|
blocker.project_id AS "projectId",
|
|
blocker.parent_id AS "parentId",
|
|
blocker.identifier,
|
|
blocker.title,
|
|
blocker.status,
|
|
blocker.priority,
|
|
blocker.assignee_agent_id AS "assigneeAgentId",
|
|
blocker.assignee_user_id AS "assigneeUserId",
|
|
relation.related_issue_id AS "blockedIssueId",
|
|
relation.created_at AS "relationCreatedAt",
|
|
row_number() OVER (
|
|
PARTITION BY relation.related_issue_id
|
|
ORDER BY blocker.title ASC, blocker.id ASC
|
|
)::int AS "rowNumber"
|
|
FROM issue_relations relation
|
|
INNER JOIN issues blocker ON blocker.id = relation.issue_id
|
|
WHERE relation.company_id = ${issue.companyId}
|
|
AND relation.type = 'blocks'
|
|
AND blocker.company_id = ${issue.companyId}
|
|
AND blocker.hidden_at IS NULL
|
|
AND blocker.harness_kind IS NULL
|
|
AND relation.related_issue_id::text IN (${nodeIdValues})
|
|
)
|
|
SELECT *
|
|
FROM blocker_rows
|
|
WHERE "rowNumber" <= ${maxBlockersPerNode + 1}
|
|
ORDER BY "blockedIssueId" ASC, "rowNumber" ASC
|
|
`),
|
|
) as IssueSubtreeDiagnosticsBlockerResultRow[];
|
|
for (const row of rawBlockerRows) {
|
|
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
|
|
if (normalized.rowNumber > maxBlockersPerNode) {
|
|
truncatedBlockerIssueIds.add(normalized.blockedIssueId);
|
|
continue;
|
|
}
|
|
const rows = blockersByIssueId.get(normalized.blockedIssueId) ?? [];
|
|
rows.push(normalized);
|
|
blockersByIssueId.set(normalized.blockedIssueId, rows);
|
|
}
|
|
|
|
const wakeTargetIssueIdSql = sql<string>`
|
|
coalesce(
|
|
wake.payload ->> 'issueId',
|
|
wake.payload ->> 'taskId',
|
|
wake.payload -> '_paperclipWakeContext' ->> 'issueId',
|
|
wake.payload -> '_paperclipWakeContext' ->> 'taskId'
|
|
)
|
|
`;
|
|
const rawWakeRows = Array.from(
|
|
await db.execute(sql`
|
|
WITH wake_rows AS (
|
|
SELECT
|
|
${wakeTargetIssueIdSql} AS "issueId",
|
|
wake.agent_id AS "agentId",
|
|
wake.source,
|
|
wake.reason,
|
|
wake.status,
|
|
wake.coalesced_count AS "coalescedCount",
|
|
wake.run_id AS "runId",
|
|
wake.requested_at AS "requestedAt",
|
|
wake.claimed_at AS "claimedAt",
|
|
wake.finished_at AS "finishedAt",
|
|
wake.error,
|
|
row_number() OVER (
|
|
PARTITION BY ${wakeTargetIssueIdSql}
|
|
ORDER BY wake.requested_at DESC, wake.created_at DESC
|
|
)::int AS "rowNumber"
|
|
FROM agent_wakeup_requests wake
|
|
WHERE wake.company_id = ${issue.companyId}
|
|
AND wake.requested_at >= ${sinceIso}::timestamptz
|
|
AND ${wakeTargetIssueIdSql} IN (${nodeIdValues})
|
|
)
|
|
SELECT *
|
|
FROM wake_rows
|
|
WHERE "rowNumber" <= ${maxWakeRequestsPerNode + 1}
|
|
ORDER BY "issueId" ASC, "requestedAt" DESC
|
|
`),
|
|
) as IssueSubtreeDiagnosticsWakeRequestResultRow[];
|
|
for (const row of rawWakeRows) {
|
|
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
|
|
if (normalized.rowNumber > maxWakeRequestsPerNode) {
|
|
truncatedWakeIssueIds.add(normalized.issueId);
|
|
continue;
|
|
}
|
|
const rows = wakeRequestsByIssueId.get(normalized.issueId) ?? [];
|
|
rows.push(normalized);
|
|
wakeRequestsByIssueId.set(normalized.issueId, rows);
|
|
}
|
|
|
|
const activityTargetIssueIdSql = sql<string>`
|
|
coalesce(
|
|
CASE WHEN activity.entity_type = 'issue' THEN activity.entity_id ELSE NULL END,
|
|
activity.details ->> 'issueId',
|
|
activity.details ->> 'rootIssueId'
|
|
)
|
|
`;
|
|
const activityActionValues = sql.join(
|
|
ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS.map(
|
|
(action) => sql`${action}`,
|
|
),
|
|
sql`, `,
|
|
);
|
|
const rawActivityRows = Array.from(
|
|
await db.execute(sql`
|
|
WITH activity_rows AS (
|
|
SELECT
|
|
${activityTargetIssueIdSql} AS "issueId",
|
|
activity.action,
|
|
activity.entity_type AS "entityType",
|
|
activity.entity_id AS "entityId",
|
|
activity.agent_id AS "agentId",
|
|
activity.run_id AS "runId",
|
|
activity.details,
|
|
activity.created_at AS "createdAt",
|
|
row_number() OVER (
|
|
PARTITION BY ${activityTargetIssueIdSql}
|
|
ORDER BY activity.created_at DESC, activity.id DESC
|
|
)::int AS "rowNumber"
|
|
FROM activity_log activity
|
|
WHERE activity.company_id = ${issue.companyId}
|
|
AND activity.created_at >= ${sinceIso}::timestamptz
|
|
AND activity.action IN (${activityActionValues})
|
|
AND ${activityTargetIssueIdSql} IN (${nodeIdValues})
|
|
)
|
|
SELECT *
|
|
FROM activity_rows
|
|
WHERE "rowNumber" <= ${maxActivityRecordsPerNode + 1}
|
|
ORDER BY "issueId" ASC, "createdAt" DESC
|
|
`),
|
|
) as IssueSubtreeDiagnosticsActivityResultRow[];
|
|
for (const row of rawActivityRows) {
|
|
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
|
|
if (normalized.rowNumber > maxActivityRecordsPerNode) {
|
|
truncatedActivityIssueIds.add(normalized.issueId);
|
|
continue;
|
|
}
|
|
const rows = activityRecordsByIssueId.get(normalized.issueId) ?? [];
|
|
rows.push(normalized);
|
|
activityRecordsByIssueId.set(normalized.issueId, rows);
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodes,
|
|
blockersByIssueId,
|
|
readinessByIssueId: readiness,
|
|
wakeRequestsByIssueId,
|
|
activityRecordsByIssueId,
|
|
truncatedNodes,
|
|
truncatedDepth,
|
|
truncatedBlockerIssueIds,
|
|
truncatedWakeIssueIds,
|
|
truncatedActivityIssueIds,
|
|
caps: {
|
|
maxDepth,
|
|
maxNodes,
|
|
maxBlockersPerNode,
|
|
maxWakeRequestsPerNode,
|
|
maxActivityRecordsPerNode,
|
|
lookbackDays,
|
|
},
|
|
};
|
|
},
|
|
|
|
getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => {
|
|
const issue = await dbOrTx
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then(
|
|
(rows: Array<{ id: string; companyId: string }>) => rows[0] ?? null,
|
|
);
|
|
if (!issue) throw notFound("Issue not found");
|
|
const readiness = await listIssueDependencyReadinessMap(
|
|
dbOrTx,
|
|
issue.companyId,
|
|
[issueId],
|
|
);
|
|
return readiness.get(issueId) ?? createIssueDependencyReadiness(issueId);
|
|
},
|
|
|
|
listDependencyReadiness: async (
|
|
companyId: string,
|
|
issueIds: string[],
|
|
dbOrTx: any = db,
|
|
) => {
|
|
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
|
|
},
|
|
|
|
listBlockerAttention: async (
|
|
companyId: string,
|
|
issueRows: IssueBlockerAttentionInputNode[],
|
|
dbOrTx: any = db,
|
|
) => {
|
|
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
|
},
|
|
|
|
listReviewAttention: async (
|
|
companyId: string,
|
|
issueRows: IssueReviewAttentionInput[],
|
|
dbOrTx: any = db,
|
|
) => {
|
|
return listIssueReviewAttentionMap(dbOrTx, companyId, issueRows);
|
|
},
|
|
|
|
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
|
const blockerIssue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, blockerIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!blockerIssue) return [];
|
|
|
|
const candidates = await db
|
|
.select({
|
|
id: issues.id,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
status: issues.status,
|
|
blockedTransitionAt: issues.blockedTransitionAt,
|
|
})
|
|
.from(issueRelations)
|
|
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, blockerIssue.companyId),
|
|
eq(issueRelations.type, "blocks"),
|
|
eq(issueRelations.issueId, blockerIssueId),
|
|
isNull(issues.conversationAgentId),
|
|
),
|
|
);
|
|
if (candidates.length === 0) return [];
|
|
|
|
const wakeableCandidates = candidates.filter(
|
|
(candidate) =>
|
|
candidate.assigneeAgentId &&
|
|
!["backlog", "done", "cancelled"].includes(candidate.status),
|
|
);
|
|
if (wakeableCandidates.length === 0) return [];
|
|
|
|
// Defer to the unified readiness check so that a dependent only fires when
|
|
// (a) every blocker is done AND (b) every done blocker's workspace has
|
|
// recorded a successful workspace_finalize. The finalize hook also calls
|
|
// this function on completion, so a wake initially gated by an in-flight
|
|
// sync-back will re-fire once the restore lands locally.
|
|
const readinessMap = await listIssueDependencyReadinessMap(
|
|
db,
|
|
blockerIssue.companyId,
|
|
wakeableCandidates.map((candidate) => candidate.id),
|
|
);
|
|
|
|
return wakeableCandidates
|
|
.map((candidate) => {
|
|
const readiness =
|
|
readinessMap.get(candidate.id) ??
|
|
createIssueDependencyReadiness(candidate.id);
|
|
return { candidate, readiness };
|
|
})
|
|
.filter(
|
|
({ readiness }) =>
|
|
readiness.isDependencyReady && readiness.blockerIssueIds.length > 0,
|
|
)
|
|
.map(({ candidate, readiness }) => ({
|
|
id: candidate.id,
|
|
assigneeAgentId: candidate.assigneeAgentId!,
|
|
blockerIssueIds: readiness.blockerIssueIds,
|
|
blockedTransitionAt: candidate.blockedTransitionAt,
|
|
}));
|
|
},
|
|
|
|
getWakeableParentAfterChildCompletion: async (
|
|
parentIssueId: string,
|
|
completedChildResult?: { issueId: string; summary: string | null } | null,
|
|
) => {
|
|
const parent = await db
|
|
.select({
|
|
id: issues.id,
|
|
conversationAgentId: issues.conversationAgentId,
|
|
originKind: issues.originKind,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
status: issues.status,
|
|
companyId: issues.companyId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, parentIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!parent || parent.conversationAgentId || !parent.assigneeAgentId || ["backlog", "cancelled"].includes(parent.status) ||
|
|
(parent.status === "done" && parent.originKind !== "onboarding_first_task")) {
|
|
return null;
|
|
}
|
|
|
|
const children = await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
updatedAt: issues.updatedAt,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, parent.companyId),
|
|
eq(issues.parentId, parentIssueId),
|
|
),
|
|
)
|
|
.orderBy(asc(issues.issueNumber), asc(issues.createdAt));
|
|
if (children.length === 0) return null;
|
|
if (
|
|
!children.every(
|
|
(child) => child.status === "done" || child.status === "cancelled",
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const childIdsForSummaries = children
|
|
.slice(0, MAX_CHILD_COMPLETION_SUMMARIES)
|
|
.map((child) => child.id);
|
|
const commentRows =
|
|
childIdsForSummaries.length > 0
|
|
? await db
|
|
.select({
|
|
issueId: issueComments.issueId,
|
|
body: issueComments.body,
|
|
createdAt: issueComments.createdAt,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, parent.companyId),
|
|
inArray(issueComments.issueId, childIdsForSummaries),
|
|
isNull(issueComments.deletedAt),
|
|
),
|
|
)
|
|
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
|
: [];
|
|
const latestCommentByIssueId = new Map<string, string>();
|
|
for (const comment of commentRows) {
|
|
if (!latestCommentByIssueId.has(comment.issueId)) {
|
|
latestCommentByIssueId.set(comment.issueId, comment.body);
|
|
}
|
|
}
|
|
const childIssueSummaries: ChildIssueCompletionSummary[] = children
|
|
.slice(0, MAX_CHILD_COMPLETION_SUMMARIES)
|
|
.map((child) => ({
|
|
...child,
|
|
summary: truncateInlineSummary(
|
|
child.id === completedChildResult?.issueId
|
|
? (completedChildResult.summary ??
|
|
latestCommentByIssueId.get(child.id))
|
|
: latestCommentByIssueId.get(child.id),
|
|
),
|
|
}));
|
|
|
|
return {
|
|
onboardingCompletion: parent.originKind === "onboarding_first_task",
|
|
id: parent.id,
|
|
assigneeAgentId: parent.assigneeAgentId,
|
|
childIssueIds: children.map((child) => child.id),
|
|
childIssueSummaries,
|
|
childIssueSummaryTruncated:
|
|
children.length > childIssueSummaries.length,
|
|
};
|
|
},
|
|
|
|
createChild: async (parentIssueId: string, data: IssueChildCreateInput) => {
|
|
const parent = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, parentIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!parent) throw notFound("Parent issue not found");
|
|
await assertExecutionTaskParent(db, parent.companyId, parent.id);
|
|
|
|
const idempotencyKey = data.idempotencyKey?.trim();
|
|
if (idempotencyKey) {
|
|
const existingChild = await db
|
|
.select({ issue: issues })
|
|
.from(issueCreateIdempotencyKeys)
|
|
.innerJoin(issues, eq(issueCreateIdempotencyKeys.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueCreateIdempotencyKeys.companyId, parent.companyId),
|
|
eq(issueCreateIdempotencyKeys.idempotencyKey, idempotencyKey),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0]?.issue ?? null);
|
|
if (existingChild) {
|
|
if (existingChild.parentId !== parent.id) {
|
|
throw conflict(
|
|
"Child creation idempotency key belongs to another parent issue",
|
|
);
|
|
}
|
|
await data.assertCanReuseIssue?.(existingChild);
|
|
data.onDeduplicated?.("idempotency_key");
|
|
const [enriched] = await withIssueLabels(db, [existingChild]);
|
|
const [withRelations] = await withIssueRelationSummaries(
|
|
parent.companyId,
|
|
[enriched],
|
|
db,
|
|
);
|
|
return {
|
|
issue: withRelations,
|
|
parentBlockerAdded: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
const [{ childCount }] = await db
|
|
.select({ childCount: sql<number>`count(*)::int` })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, parent.companyId),
|
|
eq(issues.parentId, parent.id),
|
|
),
|
|
);
|
|
if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) {
|
|
throw unprocessable(
|
|
`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`,
|
|
);
|
|
}
|
|
|
|
const {
|
|
acceptanceCriteria,
|
|
blockParentUntilDone,
|
|
executionWorkspaceInheritanceMode = "linkage",
|
|
actorAgentId,
|
|
actorUserId,
|
|
...issueData
|
|
} = data;
|
|
|
|
const inheritStrategyOnly =
|
|
executionWorkspaceInheritanceMode === "strategy_only";
|
|
// A child may target another project. Parent workspace identity is only
|
|
// valid inside the parent's project, so do not forward it across that
|
|
// boundary; create() then resolves the target project's own workspaces.
|
|
const childProjectId = issueData.projectId ?? parent.projectId;
|
|
const childInheritsParentProject = childProjectId === parent.projectId;
|
|
const hasExplicitExecutionWorkspaceOverride =
|
|
issueData.executionWorkspaceId !== undefined ||
|
|
issueData.executionWorkspacePreference !== undefined ||
|
|
issueData.executionWorkspaceSettings !== undefined;
|
|
const inheritedPreRealizationWorkspaceSettings =
|
|
inheritStrategyOnly && !hasExplicitExecutionWorkspaceOverride
|
|
? buildPreRealizationExecutionWorkspaceSettings(
|
|
parent.executionWorkspaceSettings,
|
|
)
|
|
: null;
|
|
let child = await issueService(db).create(parent.companyId, {
|
|
...issueData,
|
|
parentId: parent.id,
|
|
projectId: childProjectId,
|
|
projectWorkspaceId:
|
|
issueData.projectWorkspaceId ??
|
|
(inheritStrategyOnly && childInheritsParentProject
|
|
? parent.projectWorkspaceId
|
|
: undefined),
|
|
goalId: issueData.goalId ?? parent.goalId,
|
|
actorResponsibleUserId: issueData.actorResponsibleUserId ?? null,
|
|
trustExplicitResponsibleUserId:
|
|
issueData.trustExplicitResponsibleUserId === true,
|
|
requestDepth: clampIssueRequestDepth(
|
|
Math.max(
|
|
clampIssueRequestDepth(parent.requestDepth) + 1,
|
|
issueData.requestDepth ?? 0,
|
|
),
|
|
),
|
|
description: appendAcceptanceCriteriaToDescription(
|
|
issueData.description,
|
|
acceptanceCriteria,
|
|
),
|
|
...(inheritedPreRealizationWorkspaceSettings
|
|
? {
|
|
executionWorkspaceSettings:
|
|
inheritedPreRealizationWorkspaceSettings,
|
|
}
|
|
: {}),
|
|
...(inheritStrategyOnly
|
|
? { skipExecutionWorkspaceInheritance: true }
|
|
: { inheritExecutionWorkspaceFromIssueId: parent.id }),
|
|
});
|
|
|
|
if (blockParentUntilDone) {
|
|
const existingBlockers = await db
|
|
.select({ blockerIssueId: issueRelations.issueId })
|
|
.from(issueRelations)
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, parent.companyId),
|
|
eq(issueRelations.relatedIssueId, parent.id),
|
|
eq(issueRelations.type, "blocks"),
|
|
),
|
|
);
|
|
await syncBlockedByIssueIds(
|
|
parent.id,
|
|
parent.companyId,
|
|
[
|
|
...new Set([
|
|
...existingBlockers.map((row) => row.blockerIssueId),
|
|
child.id,
|
|
]),
|
|
],
|
|
{ agentId: actorAgentId ?? null, userId: actorUserId ?? null },
|
|
);
|
|
[child] = await withIssueRelationSummaries(
|
|
parent.companyId,
|
|
[child],
|
|
db,
|
|
);
|
|
}
|
|
|
|
return {
|
|
issue: child,
|
|
parentBlockerAdded: Boolean(blockParentUntilDone),
|
|
};
|
|
},
|
|
|
|
decomposeAcceptedPlan: async (
|
|
sourceIssueId: string,
|
|
data: AcceptedPlanDecompositionInput,
|
|
) => {
|
|
const sourceIssue = await db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
goalId: issues.goalId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, sourceIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!sourceIssue) throw notFound("Source issue not found");
|
|
|
|
const requestFingerprint =
|
|
createAcceptedPlanDecompositionRequestFingerprint({
|
|
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
|
|
children: data.children,
|
|
});
|
|
|
|
const initialClaim = await db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${sourceIssue.id} for update`,
|
|
);
|
|
|
|
const belongsToPlanDocument = await tx
|
|
.select({ revisionId: documentRevisions.id })
|
|
.from(issueDocuments)
|
|
.innerJoin(
|
|
documentRevisions,
|
|
eq(issueDocuments.documentId, documentRevisions.documentId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueDocuments.companyId, sourceIssue.companyId),
|
|
eq(issueDocuments.issueId, sourceIssue.id),
|
|
eq(issueDocuments.key, "plan"),
|
|
eq(documentRevisions.id, data.acceptedPlanRevisionId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!belongsToPlanDocument) {
|
|
throw unprocessable(
|
|
"acceptedPlanRevisionId must belong to the source issue's plan document",
|
|
);
|
|
}
|
|
|
|
const acceptedInteraction = await findAcceptedPlanDocumentInteraction(
|
|
tx,
|
|
{
|
|
companyId: sourceIssue.companyId,
|
|
sourceIssueId: sourceIssue.id,
|
|
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
|
|
},
|
|
);
|
|
if (!acceptedInteraction) {
|
|
throw unprocessable(
|
|
"acceptedPlanRevisionId must have an accepted plan confirmation",
|
|
);
|
|
}
|
|
|
|
const existing = await tx
|
|
.select()
|
|
.from(issuePlanDecompositions)
|
|
.where(
|
|
and(
|
|
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
|
|
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
|
|
eq(
|
|
issuePlanDecompositions.acceptedPlanRevisionId,
|
|
data.acceptedPlanRevisionId,
|
|
),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
const now = new Date();
|
|
if (!existing) {
|
|
const [created] = await tx
|
|
.insert(issuePlanDecompositions)
|
|
.values({
|
|
companyId: sourceIssue.companyId,
|
|
sourceIssueId: sourceIssue.id,
|
|
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
|
|
acceptedInteractionId: acceptedInteraction.id,
|
|
status: "in_flight",
|
|
requestFingerprint,
|
|
requestedChildCount: data.children.length,
|
|
requestedChildren: data.children as unknown as Record<
|
|
string,
|
|
unknown
|
|
>[],
|
|
childIssueIds: [],
|
|
ownerAgentId: data.actorAgentId ?? null,
|
|
ownerUserId: data.actorUserId ?? null,
|
|
ownerRunId: data.actorRunId ?? null,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
if (!created)
|
|
throw new Error(
|
|
"Failed to create accepted-plan decomposition claim",
|
|
);
|
|
return created;
|
|
}
|
|
|
|
if (existing.requestFingerprint !== requestFingerprint) {
|
|
throw conflict(
|
|
"Accepted-plan decomposition already exists for this revision with a different child set",
|
|
);
|
|
}
|
|
|
|
return existing;
|
|
});
|
|
|
|
let currentClaim = initialClaim;
|
|
const newlyCreatedIssues: Array<typeof issues.$inferSelect> = [];
|
|
|
|
while (true) {
|
|
const step = await db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issuePlanDecompositions.id}
|
|
from ${issuePlanDecompositions}
|
|
where ${issuePlanDecompositions.id} = ${currentClaim.id}
|
|
for update`,
|
|
);
|
|
|
|
const claim = await tx
|
|
.select()
|
|
.from(issuePlanDecompositions)
|
|
.where(eq(issuePlanDecompositions.id, currentClaim.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!claim)
|
|
throw notFound("Accepted-plan decomposition claim not found");
|
|
if (claim.requestFingerprint !== requestFingerprint) {
|
|
throw conflict(
|
|
"Accepted-plan decomposition already exists for this revision with a different child set",
|
|
);
|
|
}
|
|
|
|
const existingChildIssueIds = normalizeIssuePlanDecompositionChildIds(
|
|
claim.childIssueIds,
|
|
);
|
|
if (
|
|
claim.status === "completed" ||
|
|
existingChildIssueIds.length >= data.children.length
|
|
) {
|
|
const nextIds = existingChildIssueIds.slice(
|
|
0,
|
|
data.children.length,
|
|
);
|
|
if (
|
|
claim.status === "completed" &&
|
|
nextIds.length === data.children.length
|
|
) {
|
|
return {
|
|
claim,
|
|
createdIssue: null,
|
|
};
|
|
}
|
|
|
|
const completedAt = claim.completedAt ?? new Date();
|
|
const ownerPatch = await resolveAcceptedPlanClaimOwner({
|
|
dbOrTx: tx,
|
|
claim,
|
|
actorAgentId: data.actorAgentId,
|
|
actorUserId: data.actorUserId,
|
|
actorRunId: data.actorRunId,
|
|
});
|
|
const [completed] = await tx
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "completed",
|
|
childIssueIds: nextIds,
|
|
completedAt,
|
|
...ownerPatch,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim.id))
|
|
.returning();
|
|
if (!completed)
|
|
throw new Error(
|
|
"Failed to complete accepted-plan decomposition claim",
|
|
);
|
|
return {
|
|
claim: completed,
|
|
createdIssue: null,
|
|
};
|
|
}
|
|
|
|
const nextChildInput = data.children[existingChildIssueIds.length];
|
|
if (!nextChildInput) {
|
|
throw new Error(
|
|
"Accepted-plan decomposition child cursor moved past the requested children",
|
|
);
|
|
}
|
|
|
|
const createdChild = await issueService(
|
|
tx as unknown as Db,
|
|
).createChild(sourceIssue.id, {
|
|
...nextChildInput,
|
|
executionWorkspaceInheritanceMode: "strategy_only",
|
|
});
|
|
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
|
|
const now = new Date();
|
|
const nextStatus =
|
|
nextIds.length === data.children.length ? "completed" : "in_flight";
|
|
const ownerPatch = await resolveAcceptedPlanClaimOwner({
|
|
dbOrTx: tx,
|
|
claim,
|
|
actorAgentId: data.actorAgentId,
|
|
actorUserId: data.actorUserId,
|
|
actorRunId: data.actorRunId,
|
|
});
|
|
const [updatedClaim] = await tx
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: nextStatus,
|
|
childIssueIds: nextIds,
|
|
completedAt: nextStatus === "completed" ? now : null,
|
|
...ownerPatch,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim.id))
|
|
.returning();
|
|
if (!updatedClaim)
|
|
throw new Error(
|
|
"Failed to persist accepted-plan decomposition progress",
|
|
);
|
|
return {
|
|
claim: updatedClaim,
|
|
createdIssue: createdChild.issue,
|
|
};
|
|
});
|
|
|
|
currentClaim = step.claim;
|
|
if (step.createdIssue) {
|
|
newlyCreatedIssues.push(step.createdIssue);
|
|
}
|
|
if (step.claim.status === "completed") break;
|
|
}
|
|
|
|
const childIssueIds = normalizeIssuePlanDecompositionChildIds(
|
|
currentClaim.childIssueIds,
|
|
);
|
|
const childIssueRows =
|
|
childIssueIds.length > 0
|
|
? await db
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, sourceIssue.companyId),
|
|
inArray(issues.id, childIssueIds),
|
|
),
|
|
)
|
|
: [];
|
|
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
|
|
const orderedChildIssues = childIssueIds
|
|
.map((childIssueId) => childIssueMap.get(childIssueId))
|
|
.filter((row): row is typeof issues.$inferSelect => Boolean(row));
|
|
|
|
const decomposition = serializeAcceptedPlanDecomposition(currentClaim);
|
|
|
|
return {
|
|
decomposition,
|
|
childIssueIds: decomposition.childIssueIds,
|
|
childIssues: orderedChildIssues,
|
|
newlyCreatedIssues,
|
|
};
|
|
},
|
|
|
|
listAcceptedPlanDecompositions: async (sourceIssueId: string) => {
|
|
const sourceIssue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, sourceIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!sourceIssue) return [];
|
|
|
|
const rows = await db
|
|
.select({
|
|
decomposition: issuePlanDecompositions,
|
|
revisionNumber: documentRevisions.revisionNumber,
|
|
})
|
|
.from(issuePlanDecompositions)
|
|
.leftJoin(
|
|
documentRevisions,
|
|
eq(
|
|
documentRevisions.id,
|
|
issuePlanDecompositions.acceptedPlanRevisionId,
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
|
|
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
|
|
),
|
|
)
|
|
.orderBy(desc(issuePlanDecompositions.createdAt));
|
|
|
|
if (rows.length === 0) return [];
|
|
|
|
const allChildIds = new Set<string>();
|
|
for (const row of rows) {
|
|
for (const childId of normalizeIssuePlanDecompositionChildIds(
|
|
row.decomposition.childIssueIds,
|
|
)) {
|
|
allChildIds.add(childId);
|
|
}
|
|
}
|
|
|
|
const childIssueRows =
|
|
allChildIds.size > 0
|
|
? await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, sourceIssue.companyId),
|
|
inArray(issues.id, Array.from(allChildIds)),
|
|
),
|
|
)
|
|
: [];
|
|
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
|
|
|
|
return rows.map((row) => {
|
|
const decomposition = serializeAcceptedPlanDecomposition(
|
|
row.decomposition,
|
|
);
|
|
const childIds = decomposition.childIssueIds;
|
|
return {
|
|
...decomposition,
|
|
acceptedPlanRevisionNumber: row.revisionNumber ?? null,
|
|
childIssues: childIds
|
|
.map((childId) => childIssueMap.get(childId) ?? null)
|
|
.filter(
|
|
(entry): entry is NonNullable<typeof entry> => entry !== null,
|
|
),
|
|
};
|
|
});
|
|
},
|
|
|
|
listConversations: async (companyId: string, userId: string) => db.select().from(issues).where(and(
|
|
eq(issues.companyId, companyId), eq(issues.conversationUserId, userId), isNotNull(issues.conversationAgentId),
|
|
)).orderBy(desc(issues.updatedAt), asc(issues.id)),
|
|
|
|
getConversation: async (companyId: string, agentId: string, userId: string) => db.select().from(issues).where(and(
|
|
eq(issues.companyId, companyId), eq(issues.conversationAgentId, agentId), eq(issues.conversationUserId, userId),
|
|
)).then((rows) => rows[0] ?? null),
|
|
|
|
create: async (
|
|
companyId: string,
|
|
data: IssueCreateInput,
|
|
dbOrTx: Db | DbTransaction = db,
|
|
) => {
|
|
const {
|
|
initialPlan,
|
|
labelIds: inputLabelIds,
|
|
blockedByIssueIds,
|
|
inheritExecutionWorkspaceFromIssueId,
|
|
skipExecutionWorkspaceInheritance,
|
|
watchdog,
|
|
watchdogActorRunId,
|
|
actorRunId,
|
|
actorResponsibleUserId,
|
|
trustExplicitResponsibleUserId,
|
|
idempotencyKey: rawIdempotencyKey,
|
|
allowDuplicate,
|
|
assertCanReuseIssue,
|
|
onDeduplicated,
|
|
...issueData
|
|
} = data;
|
|
const explicitTitle = issueData.title?.trim();
|
|
const provisionalTitle = issueData.description
|
|
? provisionalTitleFromDescription(issueData.description)
|
|
: undefined;
|
|
const resolvedTitle = explicitTitle || provisionalTitle;
|
|
if (!resolvedTitle) throw unprocessable("Provide a title or task description");
|
|
const titleNeedsGeneration = !explicitTitle;
|
|
// A prompt prefix is not a task identity: distinct requests can share it.
|
|
const deduplicateByTitle = allowDuplicate === false && !titleNeedsGeneration;
|
|
issueData.title = resolvedTitle;
|
|
const isolatedWorkspacesEnabled = (
|
|
await instanceSettings.getExperimental()
|
|
).enableIsolatedWorkspaces;
|
|
if (!isolatedWorkspacesEnabled) {
|
|
delete issueData.executionWorkspaceId;
|
|
delete issueData.executionWorkspacePreference;
|
|
delete issueData.executionWorkspaceSettings;
|
|
}
|
|
if (data.assigneeAgentId && data.assigneeUserId) {
|
|
throw unprocessable("Issue can only have one assignee");
|
|
}
|
|
if (data.assigneeAgentId) {
|
|
await assertAssignableAgent(db, companyId, data.assigneeAgentId, {
|
|
kind: "work",
|
|
});
|
|
}
|
|
if (data.assigneeUserId) {
|
|
await assertAssignableUser(companyId, data.assigneeUserId);
|
|
}
|
|
if (
|
|
data.status === "in_progress" &&
|
|
!data.assigneeAgentId &&
|
|
!data.assigneeUserId
|
|
) {
|
|
throw unprocessable("in_progress issues require an assignee");
|
|
}
|
|
const persist = async (tx: DbTransaction) => {
|
|
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
|
|
if (issueData.conversationAgentId && issueData.conversationUserId) {
|
|
const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`;
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${identity}, 0))`);
|
|
const [existing] = await tx.select().from(issues).where(and(eq(issues.companyId, companyId),
|
|
eq(issues.conversationAgentId, issueData.conversationAgentId), eq(issues.conversationUserId, issueData.conversationUserId)));
|
|
if (existing) {
|
|
const [enriched] = await withIssueLabels(tx, [existing]);
|
|
const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx);
|
|
return withRelations;
|
|
}
|
|
}
|
|
const idempotencyKey = rawIdempotencyKey?.trim() || null;
|
|
const normalizedTitle = normalizeCreateIssueTitle(resolvedTitle);
|
|
if (deduplicateByTitle) {
|
|
const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`;
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`,
|
|
);
|
|
}
|
|
if (idempotencyKey) {
|
|
const idempotencyGuardKey = `issue-create:idempotency:${companyId}:${idempotencyKey}`;
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtextextended(${idempotencyGuardKey}, 0))`,
|
|
);
|
|
}
|
|
|
|
let existingIssue: typeof issues.$inferSelect | undefined;
|
|
let deduplicationReason:
|
|
"idempotency_key" | "recent_open_title" | null = null;
|
|
if (idempotencyKey) {
|
|
const idempotencyKeyRetentionCutoff = new Date(
|
|
Date.now() - ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS,
|
|
);
|
|
await tx.execute(sql`
|
|
delete from ${issueCreateIdempotencyKeys}
|
|
where ${issueCreateIdempotencyKeys.id} in (
|
|
select ${issueCreateIdempotencyKeys.id}
|
|
from ${issueCreateIdempotencyKeys}
|
|
where ${issueCreateIdempotencyKeys.companyId} = ${companyId}
|
|
and ${issueCreateIdempotencyKeys.createdAt} < ${idempotencyKeyRetentionCutoff.toISOString()}::timestamptz
|
|
order by ${issueCreateIdempotencyKeys.createdAt} asc, ${issueCreateIdempotencyKeys.id} asc
|
|
limit ${ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE}
|
|
)
|
|
`);
|
|
|
|
[existingIssue] = await tx
|
|
.select()
|
|
.from(issueCreateIdempotencyKeys)
|
|
.innerJoin(
|
|
issues,
|
|
eq(issueCreateIdempotencyKeys.issueId, issues.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueCreateIdempotencyKeys.companyId, companyId),
|
|
eq(issueCreateIdempotencyKeys.idempotencyKey, idempotencyKey),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows.map((row) => row.issues));
|
|
if (existingIssue) deduplicationReason = "idempotency_key";
|
|
}
|
|
if (!existingIssue && deduplicateByTitle) {
|
|
[existingIssue] = await tx
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
issueData.parentId
|
|
? eq(issues.parentId, issueData.parentId)
|
|
: isNull(issues.parentId),
|
|
isNull(issues.hiddenAt),
|
|
notInArray(issues.status, ["done", "cancelled"]),
|
|
gte(
|
|
issues.createdAt,
|
|
new Date(Date.now() - 48 * 60 * 60 * 1000),
|
|
),
|
|
sql`lower(regexp_replace(btrim(${issues.title}), '\\s+', ' ', 'g')) = ${normalizedTitle}`,
|
|
),
|
|
)
|
|
.orderBy(asc(issues.createdAt), asc(issues.id))
|
|
.limit(1);
|
|
if (existingIssue) deduplicationReason = "recent_open_title";
|
|
}
|
|
if (existingIssue) {
|
|
// A duplicate may have a different scope or assignee than the proposed task.
|
|
await assertCanReuseIssue?.(existingIssue);
|
|
if (idempotencyKey) {
|
|
await tx
|
|
.insert(issueCreateIdempotencyKeys)
|
|
.values({ companyId, idempotencyKey, issueId: existingIssue.id })
|
|
.onConflictDoNothing();
|
|
}
|
|
if (deduplicationReason) onDeduplicated?.(deduplicationReason);
|
|
const [enriched] = await withIssueLabels(tx, [existingIssue]);
|
|
const [withRelations] = await withIssueRelationSummaries(
|
|
companyId,
|
|
[enriched],
|
|
tx,
|
|
);
|
|
return withRelations;
|
|
}
|
|
|
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
|
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
|
|
let executionWorkspaceId = issueData.executionWorkspaceId ?? null;
|
|
let executionWorkspacePreference =
|
|
issueData.executionWorkspacePreference ?? null;
|
|
let executionWorkspaceSettings =
|
|
(issueData.executionWorkspaceSettings as
|
|
Record<string, unknown> | null | undefined) ?? null;
|
|
const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance
|
|
? null
|
|
: (inheritExecutionWorkspaceFromIssueId ??
|
|
issueData.parentId ??
|
|
null);
|
|
const hasExplicitExecutionWorkspaceOverride =
|
|
issueData.executionWorkspaceId !== undefined ||
|
|
issueData.executionWorkspacePreference !== undefined ||
|
|
issueData.executionWorkspaceSettings !== undefined;
|
|
if (workspaceInheritanceIssueId) {
|
|
const workspaceSource = await getWorkspaceInheritanceIssue(
|
|
tx,
|
|
companyId,
|
|
workspaceInheritanceIssueId,
|
|
);
|
|
if (issueData.projectId == null && workspaceSource.projectId) {
|
|
issueData.projectId = workspaceSource.projectId;
|
|
}
|
|
// Workspace linkage is only inheritable inside the source project. A
|
|
// cross-project child (for example, a Paperclip ID issue created from
|
|
// a Paperclip App parent) must fall through to its own project's
|
|
// default workspaces, otherwise the inherited ids fail the
|
|
// project-match assertions below and the create is impossible without
|
|
// the caller naming the target workspaces explicitly.
|
|
const inheritsSourceProject =
|
|
issueData.projectId == null ||
|
|
issueData.projectId === workspaceSource.projectId;
|
|
if (
|
|
inheritsSourceProject &&
|
|
projectWorkspaceId == null &&
|
|
workspaceSource.projectWorkspaceId
|
|
) {
|
|
projectWorkspaceId = workspaceSource.projectWorkspaceId;
|
|
}
|
|
if (
|
|
inheritsSourceProject &&
|
|
isolatedWorkspacesEnabled &&
|
|
!hasExplicitExecutionWorkspaceOverride &&
|
|
workspaceSource.executionWorkspaceId
|
|
) {
|
|
const sourceWorkspace = await tx
|
|
.select({
|
|
id: executionWorkspaces.id,
|
|
mode: executionWorkspaces.mode,
|
|
})
|
|
.from(executionWorkspaces)
|
|
.where(
|
|
eq(
|
|
executionWorkspaces.id,
|
|
workspaceSource.executionWorkspaceId,
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (sourceWorkspace) {
|
|
executionWorkspaceId = sourceWorkspace.id;
|
|
executionWorkspacePreference = "reuse_existing";
|
|
executionWorkspaceSettings = {
|
|
...((workspaceSource.executionWorkspaceSettings as
|
|
Record<string, unknown> | null | undefined) ?? {}),
|
|
mode: issueExecutionWorkspaceModeForPersistedWorkspace(
|
|
sourceWorkspace.mode,
|
|
),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (issueData.projectId == null && projectWorkspaceId) {
|
|
const workspace = await assertValidProjectWorkspace(
|
|
companyId,
|
|
null,
|
|
projectWorkspaceId,
|
|
tx,
|
|
);
|
|
issueData.projectId = workspace.projectId;
|
|
}
|
|
if (issueData.projectId == null && executionWorkspaceId) {
|
|
const workspace = await assertValidExecutionWorkspace(
|
|
companyId,
|
|
null,
|
|
executionWorkspaceId,
|
|
tx,
|
|
);
|
|
issueData.projectId = workspace.projectId;
|
|
}
|
|
const projectGoalId = await getProjectDefaultGoalId(
|
|
tx,
|
|
companyId,
|
|
issueData.projectId,
|
|
);
|
|
// Cache the project policy lookup for this insert so the default
|
|
// workspace-settings block does not re-query the project row.
|
|
let projectPolicyCached: ReturnType<
|
|
typeof parseProjectExecutionWorkspacePolicy
|
|
> | null = null;
|
|
let projectPolicyLoaded = false;
|
|
const loadProjectPolicyOnce = async () => {
|
|
if (projectPolicyLoaded) return projectPolicyCached;
|
|
projectPolicyLoaded = true;
|
|
if (!issueData.projectId) return null;
|
|
const projectRow = await tx
|
|
.select({
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
|
})
|
|
.from(projects)
|
|
.where(
|
|
and(
|
|
eq(projects.id, issueData.projectId),
|
|
eq(projects.companyId, companyId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
projectPolicyCached = parseProjectExecutionWorkspacePolicy(
|
|
projectRow?.executionWorkspacePolicy,
|
|
);
|
|
return projectPolicyCached;
|
|
};
|
|
|
|
if (
|
|
executionWorkspaceSettings == null &&
|
|
executionWorkspaceId == null &&
|
|
issueData.projectId
|
|
) {
|
|
executionWorkspaceSettings =
|
|
defaultIssueExecutionWorkspaceSettingsForProject(
|
|
gateProjectExecutionWorkspacePolicy(
|
|
await loadProjectPolicyOnce(),
|
|
isolatedWorkspacesEnabled,
|
|
),
|
|
) as Record<string, unknown> | null;
|
|
}
|
|
if (!projectWorkspaceId && issueData.projectId) {
|
|
const project = await tx
|
|
.select({
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
|
})
|
|
.from(projects)
|
|
.where(
|
|
and(
|
|
eq(projects.id, issueData.projectId),
|
|
eq(projects.companyId, companyId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const projectPolicy = parseProjectExecutionWorkspacePolicy(
|
|
project?.executionWorkspacePolicy,
|
|
);
|
|
projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
|
|
if (!projectWorkspaceId) {
|
|
projectWorkspaceId = await tx
|
|
.select({ id: projectWorkspaces.id })
|
|
.from(projectWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(projectWorkspaces.projectId, issueData.projectId),
|
|
eq(projectWorkspaces.companyId, companyId),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(projectWorkspaces.isPrimary),
|
|
asc(projectWorkspaces.createdAt),
|
|
asc(projectWorkspaces.id),
|
|
)
|
|
.then((rows) => rows[0]?.id ?? null);
|
|
}
|
|
}
|
|
if (projectWorkspaceId) {
|
|
await assertValidProjectWorkspace(
|
|
companyId,
|
|
issueData.projectId,
|
|
projectWorkspaceId,
|
|
tx,
|
|
);
|
|
}
|
|
if (executionWorkspaceId) {
|
|
await assertValidExecutionWorkspace(
|
|
companyId,
|
|
issueData.projectId,
|
|
executionWorkspaceId,
|
|
tx,
|
|
);
|
|
}
|
|
if (
|
|
isolatedWorkspacesEnabled &&
|
|
issueData.executionWorkspaceSettings !== undefined
|
|
) {
|
|
assertExplicitPinnedWorktreeIssueRunnable({
|
|
projectId: issueData.projectId ?? null,
|
|
projectWorkspaceId,
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference,
|
|
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
|
|
});
|
|
}
|
|
// Self-correcting counter: use MAX(issue_number) + 1 if the counter
|
|
// has drifted below the actual max, preventing identifier collisions.
|
|
const [maxRow] = await tx
|
|
.select({
|
|
maxNum: sql<number>`coalesce(max(${issues.issueNumber}), 0)`,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.companyId, companyId));
|
|
const currentMax = maxRow?.maxNum ?? 0;
|
|
|
|
const [company] = await tx
|
|
.update(companies)
|
|
.set({
|
|
issueCounter: sql`greatest(${companies.issueCounter}, ${currentMax}) + 1`,
|
|
})
|
|
.where(eq(companies.id, companyId))
|
|
.returning({
|
|
issueCounter: companies.issueCounter,
|
|
issuePrefix: companies.issuePrefix,
|
|
});
|
|
|
|
const issueNumber = company.issueCounter;
|
|
const identifier = `${company.issuePrefix}-${issueNumber}`;
|
|
const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(
|
|
tx,
|
|
companyId,
|
|
{
|
|
explicitResponsibleUserId: issueData.responsibleUserId ?? null,
|
|
createdByUserId: issueData.createdByUserId ?? null,
|
|
parentId: issueData.parentId ?? null,
|
|
originKind: issueData.originKind ?? "manual",
|
|
originRunId: issueData.originRunId ?? null,
|
|
actorRunId: actorRunId ?? null,
|
|
actorResponsibleUserId: actorResponsibleUserId ?? null,
|
|
trustExplicitResponsibleUserId:
|
|
trustExplicitResponsibleUserId === true,
|
|
},
|
|
);
|
|
|
|
const values = {
|
|
...issueData,
|
|
titleNeedsGeneration,
|
|
originRunId: issueData.originRunId ?? actorRunId ?? null,
|
|
responsibleUserId,
|
|
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
|
|
originKind: issueData.originKind ?? "manual",
|
|
goalId: resolveIssueGoalId({
|
|
projectId: issueData.projectId,
|
|
goalId: issueData.goalId,
|
|
projectGoalId,
|
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
|
}),
|
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
|
...(executionWorkspaceId ? { executionWorkspaceId } : {}),
|
|
...(executionWorkspacePreference
|
|
? { executionWorkspacePreference }
|
|
: {}),
|
|
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
|
companyId,
|
|
issueNumber,
|
|
identifier,
|
|
} as typeof issues.$inferInsert;
|
|
if (values.status === "in_progress" && !values.startedAt) {
|
|
values.startedAt = new Date();
|
|
}
|
|
if (values.status === "done") {
|
|
values.completedAt = new Date();
|
|
}
|
|
if (values.status === "cancelled") {
|
|
values.cancelledAt = new Date();
|
|
}
|
|
Object.assign(
|
|
values,
|
|
buildInitialIssueMonitorFields({
|
|
policy: normalizeIssueExecutionPolicy(
|
|
issueData.executionPolicy ?? null,
|
|
),
|
|
status: values.status ?? "backlog",
|
|
assigneeAgentId: values.assigneeAgentId ?? null,
|
|
assigneeUserId: values.assigneeUserId ?? null,
|
|
}),
|
|
);
|
|
|
|
const [issue] = await tx.insert(issues).values(values).returning();
|
|
await recordChatHandoff(tx, issue, actorRunId);
|
|
if (idempotencyKey) {
|
|
await tx.insert(issueCreateIdempotencyKeys).values({
|
|
companyId,
|
|
idempotencyKey,
|
|
issueId: issue.id,
|
|
});
|
|
}
|
|
if (watchdog) {
|
|
await upsertIssueWatchdogForIssue(tx, companyId, issue.id, {
|
|
agentId: watchdog.agentId,
|
|
instructions: watchdog.instructions,
|
|
actor: {
|
|
agentId: issueData.createdByAgentId ?? null,
|
|
userId: issueData.createdByUserId ?? null,
|
|
runId: watchdogActorRunId ?? null,
|
|
},
|
|
});
|
|
}
|
|
if (inputLabelIds) {
|
|
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
|
|
}
|
|
if (blockedByIssueIds !== undefined) {
|
|
await syncBlockedByIssueIds(
|
|
issue.id,
|
|
companyId,
|
|
blockedByIssueIds,
|
|
{
|
|
agentId: issueData.createdByAgentId ?? null,
|
|
userId: issueData.createdByUserId ?? null,
|
|
},
|
|
tx,
|
|
);
|
|
}
|
|
if (initialPlan?.trim()) {
|
|
await documentService(tx as unknown as Db).upsertIssueDocument({
|
|
issueId: issue.id, key: "plan", title: "Plan", format: "markdown", body: initialPlan,
|
|
createdByAgentId: issueData.createdByAgentId, createdByUserId: issueData.createdByUserId,
|
|
createdByRunId: actorRunId,
|
|
sourceTrust: issue.sourceTrust,
|
|
});
|
|
}
|
|
const [enriched] = await withIssueLabels(tx, [issue]);
|
|
const [withRelations] = await withIssueRelationSummaries(
|
|
companyId,
|
|
[enriched],
|
|
tx,
|
|
);
|
|
return withRelations;
|
|
};
|
|
if (dbOrTx === db) return db.transaction(persist);
|
|
return persist(dbOrTx as DbTransaction);
|
|
},
|
|
|
|
/**
|
|
* Batched issue insert for company import.
|
|
*
|
|
* Company import used to call {@link create} once per issue — each call a
|
|
* separate network round-trip that inserted the issue, then serialized the
|
|
* issue's comments/documents behind the returned id. This inserts a whole
|
|
* bundle of pre-resolved issues (ids already generated by the caller) in
|
|
* chunked multi-row statements, so a thousand-issue import issues a handful
|
|
* of statements instead of thousands.
|
|
*
|
|
* The per-issue derivations {@link create} performs are reproduced for the
|
|
* import subset: a single contiguous identifier range is allocated from the
|
|
* company counter, per-project goal/workspace/policy defaults are computed
|
|
* once and cached, assignable-agent and workspace validation run per
|
|
* distinct id, and monitor notes land un-armed on the row. Dedup,
|
|
* idempotency, watchdogs, workspace inheritance and blocked-by wiring — none
|
|
* of which import uses — are intentionally omitted.
|
|
*/
|
|
importIssues: async (
|
|
companyId: string,
|
|
rows: ImportIssueRow[],
|
|
): Promise<void> => {
|
|
if (rows.length === 0) return;
|
|
const isolatedWorkspacesEnabled = (
|
|
await instanceSettings.getExperimental()
|
|
).enableIsolatedWorkspaces;
|
|
await db.transaction(async (tx) => {
|
|
// Self-correcting counter: seed from max(issue_number) so a drifted
|
|
// company counter cannot mint colliding identifiers, then reserve the
|
|
// whole range in one bump instead of one-per-issue.
|
|
const [maxRow] = await tx
|
|
.select({
|
|
maxNum: sql<number>`coalesce(max(${issues.issueNumber}), 0)`,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.companyId, companyId));
|
|
const currentMax = maxRow?.maxNum ?? 0;
|
|
const [company] = await tx
|
|
.select({
|
|
issueCounter: companies.issueCounter,
|
|
issuePrefix: companies.issuePrefix,
|
|
})
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId));
|
|
if (!company) throw notFound("Target company not found");
|
|
const base = Math.max(company.issueCounter ?? 0, currentMax);
|
|
await tx
|
|
.update(companies)
|
|
.set({ issueCounter: base + rows.length })
|
|
.where(eq(companies.id, companyId));
|
|
|
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
|
const defaultGoalId = defaultCompanyGoal?.id ?? null;
|
|
|
|
// Project-scoped derivations depend only on the project, so resolve each
|
|
// distinct project once and reuse it across that project's issues.
|
|
const projectDerivedCache = new Map<
|
|
string,
|
|
{
|
|
goalId: string | null;
|
|
defaultProjectWorkspaceId: string | null;
|
|
defaultExecutionWorkspaceSettings: Record<string, unknown> | null;
|
|
}
|
|
>();
|
|
const loadProjectDerived = async (projectId: string) => {
|
|
const cached = projectDerivedCache.get(projectId);
|
|
if (cached) return cached;
|
|
const projectRow = await tx
|
|
.select({
|
|
goalId: projects.goalId,
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
|
})
|
|
.from(projects)
|
|
.where(
|
|
and(
|
|
eq(projects.id, projectId),
|
|
eq(projects.companyId, companyId),
|
|
),
|
|
)
|
|
.then((r) => r[0] ?? null);
|
|
const policy = parseProjectExecutionWorkspacePolicy(
|
|
projectRow?.executionWorkspacePolicy,
|
|
);
|
|
let defaultProjectWorkspaceId =
|
|
policy?.defaultProjectWorkspaceId ?? null;
|
|
if (!defaultProjectWorkspaceId) {
|
|
defaultProjectWorkspaceId = await tx
|
|
.select({ id: projectWorkspaces.id })
|
|
.from(projectWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(projectWorkspaces.projectId, projectId),
|
|
eq(projectWorkspaces.companyId, companyId),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(projectWorkspaces.isPrimary),
|
|
asc(projectWorkspaces.createdAt),
|
|
asc(projectWorkspaces.id),
|
|
)
|
|
.then((r) => r[0]?.id ?? null);
|
|
}
|
|
const defaultExecutionWorkspaceSettings =
|
|
defaultIssueExecutionWorkspaceSettingsForProject(
|
|
gateProjectExecutionWorkspacePolicy(
|
|
policy,
|
|
isolatedWorkspacesEnabled,
|
|
),
|
|
) as Record<string, unknown> | null;
|
|
const derived = {
|
|
goalId: projectRow?.goalId ?? null,
|
|
defaultProjectWorkspaceId,
|
|
defaultExecutionWorkspaceSettings,
|
|
};
|
|
projectDerivedCache.set(projectId, derived);
|
|
return derived;
|
|
};
|
|
|
|
const validatedAgentIds = new Set<string>();
|
|
const validatedWorkspaceKeys = new Set<string>();
|
|
const issueRows: Array<Record<string, unknown>> = [];
|
|
const labelRows: Array<{
|
|
issueId: string;
|
|
labelId: string;
|
|
companyId: string;
|
|
}> = [];
|
|
|
|
let counter = base;
|
|
for (const row of rows) {
|
|
await assertExecutionTaskParent(tx as unknown as Db, companyId, row.parentId);
|
|
counter += 1;
|
|
const issueNumber = counter;
|
|
const identifier = `${company.issuePrefix}-${issueNumber}`;
|
|
|
|
if (row.assigneeAgentId) {
|
|
if (!validatedAgentIds.has(row.assigneeAgentId)) {
|
|
await assertAssignableAgent(
|
|
tx as unknown as Db,
|
|
companyId,
|
|
row.assigneeAgentId,
|
|
{ kind: "work" },
|
|
);
|
|
validatedAgentIds.add(row.assigneeAgentId);
|
|
}
|
|
}
|
|
if (row.status === "in_progress" && !row.assigneeAgentId) {
|
|
throw unprocessable("in_progress issues require an assignee");
|
|
}
|
|
|
|
const projectId = row.projectId ?? null;
|
|
let projectWorkspaceId = row.projectWorkspaceId ?? null;
|
|
// Isolated-workspace fields are gated the same way create() gates them:
|
|
// when the experiment is off the imported settings are dropped.
|
|
let executionWorkspaceSettings = isolatedWorkspacesEnabled
|
|
? (row.executionWorkspaceSettings ?? null)
|
|
: null;
|
|
let projectGoalId: string | null = null;
|
|
if (projectId) {
|
|
const derived = await loadProjectDerived(projectId);
|
|
projectGoalId = derived.goalId;
|
|
if (!projectWorkspaceId)
|
|
projectWorkspaceId = derived.defaultProjectWorkspaceId;
|
|
if (executionWorkspaceSettings == null) {
|
|
executionWorkspaceSettings =
|
|
derived.defaultExecutionWorkspaceSettings;
|
|
}
|
|
}
|
|
if (projectWorkspaceId) {
|
|
const workspaceKey = `${projectId ?? ""}:${projectWorkspaceId}`;
|
|
if (!validatedWorkspaceKeys.has(workspaceKey)) {
|
|
await assertValidProjectWorkspace(
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
tx,
|
|
);
|
|
validatedWorkspaceKeys.add(workspaceKey);
|
|
}
|
|
}
|
|
|
|
const goalId = resolveIssueGoalId({
|
|
projectId,
|
|
goalId: null,
|
|
projectGoalId,
|
|
defaultGoalId,
|
|
});
|
|
|
|
issueRows.push({
|
|
id: row.id,
|
|
companyId,
|
|
issueNumber,
|
|
identifier,
|
|
title: row.title,
|
|
description: row.description ?? null,
|
|
assigneeAgentId: row.assigneeAgentId ?? null,
|
|
status: row.status,
|
|
priority: row.priority,
|
|
billingCode: row.billingCode ?? null,
|
|
assigneeAdapterOverrides: row.assigneeAdapterOverrides ?? null,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
executionWorkspaceSettings,
|
|
goalId,
|
|
responsibleUserId: null,
|
|
requestDepth: clampIssueRequestDepth(undefined),
|
|
originKind: "manual",
|
|
// The caller resolves parentId against ids in this same batch, so a
|
|
// parent always lands in the same insert (rows arrive parents-first).
|
|
parentId: row.parentId ?? null,
|
|
// Preserved bundle timestamps win; without them createdAt/updatedAt
|
|
// fall back to the insert time (the old defaultNow() behavior).
|
|
createdAt: row.createdAt ?? new Date(),
|
|
updatedAt: row.updatedAt ?? new Date(),
|
|
// Imported in-progress work did not start at import time; fabricating
|
|
// startedAt here would misrepresent its active episode.
|
|
// Only a bundle-carried startedAt is written.
|
|
startedAt: row.startedAt ?? null,
|
|
completedAt:
|
|
row.completedAt ?? (row.status === "done" ? new Date() : null),
|
|
cancelledAt:
|
|
row.cancelledAt ??
|
|
(row.status === "cancelled" ? new Date() : null),
|
|
monitorNotes: row.monitorNotes ?? null,
|
|
monitorScheduledBy: row.monitorScheduledBy ?? null,
|
|
});
|
|
for (const labelId of new Set(row.labelIds ?? [])) {
|
|
labelRows.push({ issueId: row.id, labelId, companyId });
|
|
}
|
|
}
|
|
|
|
await insertRowsInChunks(tx, issues, issueRows);
|
|
await insertRowsInChunks(tx, issueLabels, labelRows);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Batched comment insert for company import. Comment ids are pre-generated
|
|
* by the caller so attachments can reference them without a round-trip.
|
|
*/
|
|
addImportedComments: async (
|
|
rows: ImportIssueCommentRow[],
|
|
): Promise<void> => {
|
|
if (rows.length === 0) return;
|
|
const censorUsernameInLogs = (await instanceSettings.getGeneral())
|
|
.censorUsernameInLogs;
|
|
await db.transaction(async (tx) => {
|
|
const commentRows = rows.map((row) => {
|
|
const createdAt = row.createdAt ? new Date(row.createdAt) : null;
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
authorAgentId: row.authorAgentId ?? null,
|
|
authorUserId: row.authorUserId ?? null,
|
|
authorType: row.authorType,
|
|
createdByRunId: null,
|
|
body: redactCurrentUserText(row.body, {
|
|
enabled: censorUsernameInLogs,
|
|
}),
|
|
presentation: row.presentation ?? null,
|
|
metadata: row.metadata ?? null,
|
|
sourceTrust: null,
|
|
createdAt:
|
|
createdAt && !Number.isNaN(createdAt.getTime())
|
|
? createdAt
|
|
: new Date(),
|
|
};
|
|
});
|
|
await insertRowsInChunks(tx, issueComments, commentRows);
|
|
// Mirror addComment's recency bump, once per affected issue — but never
|
|
// backwards. An issue imported with a preserved updatedAt keeps it
|
|
// unless a newer imported comment outdates it; issues without preserved
|
|
// timestamps carry an insert-time updatedAt, so GREATEST reproduces the
|
|
// old "bump to now" behavior for them.
|
|
const bumpAtByIssueId = new Map<string, Date>();
|
|
for (const row of commentRows) {
|
|
const existing = bumpAtByIssueId.get(row.issueId);
|
|
if (!existing || row.createdAt > existing)
|
|
bumpAtByIssueId.set(row.issueId, row.createdAt);
|
|
}
|
|
const bumpEntries = [...bumpAtByIssueId.entries()];
|
|
for (
|
|
let start = 0;
|
|
start < bumpEntries.length;
|
|
start += DEFAULT_INSERT_CHUNK_ROWS
|
|
) {
|
|
const chunk = bumpEntries.slice(
|
|
start,
|
|
start + DEFAULT_INSERT_CHUNK_ROWS,
|
|
);
|
|
await tx.execute(sql`
|
|
update ${issues}
|
|
set updated_at = greatest(${issues.updatedAt}, bumps.bump_at)
|
|
from (values ${sql.join(
|
|
chunk.map(
|
|
([issueId, bumpAt]) =>
|
|
sql`(${issueId}::uuid, ${bumpAt.toISOString()}::timestamptz)`,
|
|
),
|
|
sql`, `,
|
|
)}) as bumps(issue_id, bump_at)
|
|
where ${issues.id} = bumps.issue_id
|
|
`);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Batched attachment insert for company import: each row mints an asset and
|
|
* links it to its issue (and optionally comment) in two chunked statements.
|
|
*/
|
|
addImportedAttachments: async (
|
|
rows: ImportIssueAttachmentRow[],
|
|
): Promise<void> => {
|
|
if (rows.length === 0) return;
|
|
await db.transaction(async (tx) => {
|
|
const assetRows: Array<Record<string, unknown>> = [];
|
|
const attachmentRows: Array<Record<string, unknown>> = [];
|
|
for (const row of rows) {
|
|
const assetId = randomUUID();
|
|
assetRows.push({
|
|
id: assetId,
|
|
companyId: row.companyId,
|
|
provider: row.provider,
|
|
objectKey: row.objectKey,
|
|
contentType: row.contentType,
|
|
byteSize: row.byteSize,
|
|
sha256: row.sha256,
|
|
originalFilename: row.originalFilename ?? null,
|
|
createdByAgentId: row.createdByAgentId ?? null,
|
|
createdByUserId: row.createdByUserId ?? null,
|
|
});
|
|
attachmentRows.push({
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
assetId,
|
|
issueCommentId: row.issueCommentId ?? null,
|
|
});
|
|
}
|
|
await insertRowsInChunks(tx, assets, assetRows);
|
|
await insertRowsInChunks(tx, issueAttachments, attachmentRows);
|
|
});
|
|
},
|
|
|
|
update: async (
|
|
id: string,
|
|
data: Partial<typeof issues.$inferInsert> & {
|
|
labelIds?: string[];
|
|
blockedByIssueIds?: string[];
|
|
actorAgentId?: string | null;
|
|
actorRunId?: string | null;
|
|
actorRunStopId?: string | null;
|
|
actorUserId?: string | null;
|
|
companyGuard?: string;
|
|
},
|
|
dbOrTx: any = db,
|
|
postCommitActivityPublications?: ActivityPublication[],
|
|
postCommitActions?: IssuePostCommitAction[],
|
|
options: { bindRuntimeSharedWorkspace?: boolean } = {},
|
|
) => {
|
|
const ownedActivityPublications: ActivityPublication[] = [];
|
|
const activityPublications =
|
|
postCommitActivityPublications ?? ownedActivityPublications;
|
|
const ownedPostCommitActions: IssuePostCommitAction[] = [];
|
|
const queuedPostCommitActions =
|
|
postCommitActions ?? ownedPostCommitActions;
|
|
// A caller that supplies `companyGuard` gets the company added to
|
|
// every read, lock, and write predicate below. A check before this
|
|
// call is not a boundary: `issues.company_id` can change between
|
|
// that check and this write, so the predicate must carry the
|
|
// company itself.
|
|
const idPredicate =
|
|
data.companyGuard !== undefined
|
|
? and(eq(issues.id, id), eq(issues.companyId, data.companyGuard))
|
|
: eq(issues.id, id);
|
|
const existing = await dbOrTx
|
|
.select()
|
|
.from(issues)
|
|
.where(idPredicate)
|
|
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
|
|
await assertExecutionTaskParent(dbOrTx, existing.companyId, data.parentId);
|
|
}
|
|
if (existing.conversationAgentId) {
|
|
if ((data.assigneeAgentId !== undefined && data.assigneeAgentId !== existing.conversationAgentId)
|
|
|| data.assigneeUserId || data.conversationAgentId !== undefined || data.conversationUserId !== undefined
|
|
|| data.conversationState !== undefined || data.conversationSessionGeneration !== undefined
|
|
|| data.conversationBoundaryCommentId !== undefined || data.status === "done" || data.status === "cancelled") {
|
|
throw unprocessable("Conversation identity is fixed; finish the reply instead of completing or reassigning the conversation");
|
|
}
|
|
}
|
|
|
|
const {
|
|
labelIds: nextLabelIds,
|
|
blockedByIssueIds,
|
|
actorAgentId,
|
|
actorRunId,
|
|
actorRunStopId,
|
|
actorUserId,
|
|
companyGuard,
|
|
...issueData
|
|
} = data;
|
|
// An explicit edit claims the title, even if it keeps the same text.
|
|
if (issueData.title !== undefined) issueData.titleNeedsGeneration = false;
|
|
if (
|
|
issueData.assigneeAgentId !== undefined &&
|
|
issueData.assigneeAgentId !== existing.assigneeAgentId
|
|
) {
|
|
const externalBinding = await dbOrTx
|
|
.select({ id: chatConversations.id })
|
|
.from(chatConversations)
|
|
.where(
|
|
and(
|
|
eq(chatConversations.companyId, existing.companyId),
|
|
eq(chatConversations.issueId, existing.id),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
|
|
if (externalBinding) {
|
|
throw conflict(
|
|
"Agent assignment cannot change while this task is bound to an external channel",
|
|
{
|
|
code: "chat_binding_agent_locked",
|
|
conversationId: externalBinding.id,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
const isolatedWorkspacesEnabled = (
|
|
await instanceSettings.getExperimental()
|
|
).enableIsolatedWorkspaces;
|
|
if (options.bindRuntimeSharedWorkspace) {
|
|
const workspaceId = issueData.executionWorkspaceId ?? existing.executionWorkspaceId;
|
|
if (!workspaceId) {
|
|
throw unprocessable("Runtime workspace binding requires an existing shared workspace");
|
|
}
|
|
const [workspace] = await dbOrTx
|
|
.select({ mode: executionWorkspaces.mode })
|
|
.from(executionWorkspaces)
|
|
.where(and(
|
|
eq(executionWorkspaces.id, workspaceId),
|
|
eq(executionWorkspaces.companyId, existing.companyId),
|
|
));
|
|
if (
|
|
workspace?.mode !== "shared_workspace" ||
|
|
(issueData.executionWorkspacePreference ?? existing.executionWorkspacePreference) !== "reuse_existing" ||
|
|
(issueData.executionWorkspaceSettings ?? existing.executionWorkspaceSettings)?.mode !== "shared_workspace"
|
|
) {
|
|
throw unprocessable("Runtime workspace binding requires an existing shared workspace");
|
|
}
|
|
}
|
|
// Warm sandbox continuity is runtime bookkeeping, independent of the
|
|
// opt-in UI for creating isolated worktrees. Public updates still obey
|
|
// the feature gate; only the internal shared-workspace binding bypasses it.
|
|
if (!isolatedWorkspacesEnabled && !options.bindRuntimeSharedWorkspace) {
|
|
delete issueData.executionWorkspaceId;
|
|
delete issueData.executionWorkspacePreference;
|
|
delete issueData.executionWorkspaceSettings;
|
|
}
|
|
|
|
if (issueData.status) {
|
|
assertTransition(existing.status, issueData.status);
|
|
}
|
|
|
|
const patch: Partial<typeof issues.$inferInsert> = {
|
|
...issueData,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (existing.status !== "blocked" && issueData.status === "blocked") {
|
|
patch.blockedTransitionAt = patch.updatedAt;
|
|
patch.blockedOwnerNotifiedAt = null;
|
|
} else if (
|
|
existing.status === "blocked" &&
|
|
issueData.status &&
|
|
issueData.status !== "blocked"
|
|
) {
|
|
patch.unblockDescriptor = null;
|
|
patch.blockedTransitionAt = null;
|
|
patch.blockedOwnerNotifiedAt = null;
|
|
}
|
|
if (issueData.requestDepth !== undefined) {
|
|
patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth);
|
|
}
|
|
|
|
const nextAssigneeAgentId =
|
|
issueData.assigneeAgentId !== undefined
|
|
? issueData.assigneeAgentId
|
|
: existing.assigneeAgentId;
|
|
const nextAssigneeUserId =
|
|
issueData.assigneeUserId !== undefined
|
|
? issueData.assigneeUserId
|
|
: existing.assigneeUserId;
|
|
|
|
if (nextAssigneeAgentId && nextAssigneeUserId) {
|
|
throw unprocessable("Issue can only have one assignee");
|
|
}
|
|
if (
|
|
patch.status === "in_progress" &&
|
|
!nextAssigneeAgentId &&
|
|
!nextAssigneeUserId
|
|
) {
|
|
throw unprocessable("in_progress issues require an assignee");
|
|
}
|
|
if (patch.status === "in_progress") {
|
|
const dependencyReadiness =
|
|
blockedByIssueIds === undefined
|
|
? (
|
|
await listIssueDependencyReadinessMap(
|
|
dbOrTx,
|
|
existing.companyId,
|
|
[id],
|
|
)
|
|
).get(id)
|
|
: null;
|
|
const unresolvedBlockerIssueIds =
|
|
blockedByIssueIds !== undefined
|
|
? await listUnresolvedBlockerIssueIds(
|
|
dbOrTx,
|
|
existing.companyId,
|
|
blockedByIssueIds,
|
|
)
|
|
: (dependencyReadiness?.unresolvedBlockerIssueIds ?? []);
|
|
if (unresolvedBlockerIssueIds.length > 0) {
|
|
const unresolvedBlockers = await listUnresolvedBlockerDetails(
|
|
dbOrTx,
|
|
existing.companyId,
|
|
unresolvedBlockerIssueIds,
|
|
dependencyReadiness?.pendingFinalizeBlockerIssueIds,
|
|
);
|
|
throw unprocessable("Issue is blocked by unresolved blockers", {
|
|
unresolvedBlockerIssueIds,
|
|
unresolvedBlockers,
|
|
});
|
|
}
|
|
}
|
|
const shouldValidateNextAssignee =
|
|
Boolean(nextAssigneeAgentId) &&
|
|
(issueData.assigneeAgentId !== undefined ||
|
|
patch.status === "in_progress");
|
|
if (shouldValidateNextAssignee) {
|
|
await assertAssignableAgent(
|
|
dbOrTx as Db,
|
|
existing.companyId,
|
|
nextAssigneeAgentId,
|
|
{ kind: "work" },
|
|
);
|
|
}
|
|
if (issueData.assigneeUserId) {
|
|
await assertAssignableUser(
|
|
existing.companyId,
|
|
issueData.assigneeUserId,
|
|
);
|
|
}
|
|
let nextProjectId =
|
|
issueData.projectId !== undefined
|
|
? issueData.projectId
|
|
: existing.projectId;
|
|
const nextProjectWorkspaceId =
|
|
issueData.projectWorkspaceId !== undefined
|
|
? issueData.projectWorkspaceId
|
|
: existing.projectWorkspaceId;
|
|
const nextExecutionWorkspaceId =
|
|
issueData.executionWorkspaceId !== undefined
|
|
? issueData.executionWorkspaceId
|
|
: existing.executionWorkspaceId;
|
|
const nextExecutionWorkspacePreference =
|
|
issueData.executionWorkspacePreference !== undefined
|
|
? issueData.executionWorkspacePreference
|
|
: existing.executionWorkspacePreference;
|
|
const nextExecutionWorkspaceSettings =
|
|
issueData.executionWorkspaceSettings !== undefined
|
|
? parseIssueExecutionWorkspaceSettings(
|
|
issueData.executionWorkspaceSettings,
|
|
)
|
|
: parseIssueExecutionWorkspaceSettings(
|
|
existing.executionWorkspaceSettings,
|
|
);
|
|
if (issueData.executionWorkspaceSettings !== undefined) {
|
|
patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings
|
|
? { ...nextExecutionWorkspaceSettings }
|
|
: null;
|
|
}
|
|
let validatedProjectWorkspace: { projectId: string } | null = null;
|
|
let validatedExecutionWorkspace: { projectId: string } | null = null;
|
|
if (!nextProjectId && nextProjectWorkspaceId) {
|
|
const workspace = await assertValidProjectWorkspace(
|
|
existing.companyId,
|
|
null,
|
|
nextProjectWorkspaceId,
|
|
);
|
|
validatedProjectWorkspace = workspace;
|
|
nextProjectId = workspace.projectId;
|
|
patch.projectId = workspace.projectId;
|
|
}
|
|
if (!nextProjectId && nextExecutionWorkspaceId) {
|
|
const workspace = await assertValidExecutionWorkspace(
|
|
existing.companyId,
|
|
null,
|
|
nextExecutionWorkspaceId,
|
|
);
|
|
validatedExecutionWorkspace = workspace;
|
|
nextProjectId = workspace.projectId;
|
|
patch.projectId = workspace.projectId;
|
|
}
|
|
if (nextProjectWorkspaceId) {
|
|
if (!validatedProjectWorkspace) {
|
|
await assertValidProjectWorkspace(
|
|
existing.companyId,
|
|
nextProjectId,
|
|
nextProjectWorkspaceId,
|
|
);
|
|
}
|
|
}
|
|
if (nextExecutionWorkspaceId) {
|
|
if (!validatedExecutionWorkspace) {
|
|
await assertValidExecutionWorkspace(
|
|
existing.companyId,
|
|
nextProjectId,
|
|
nextExecutionWorkspaceId,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
isolatedWorkspacesEnabled &&
|
|
issueData.executionWorkspaceSettings !== undefined
|
|
) {
|
|
assertExplicitPinnedWorktreeIssueRunnable({
|
|
projectId: nextProjectId ?? null,
|
|
projectWorkspaceId: nextProjectWorkspaceId ?? null,
|
|
executionWorkspaceId: nextExecutionWorkspaceId ?? null,
|
|
executionWorkspacePreference:
|
|
nextExecutionWorkspacePreference ?? null,
|
|
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
|
|
});
|
|
}
|
|
|
|
applyStatusSideEffects(issueData.status, patch);
|
|
if (issueData.status && issueData.status !== "done") {
|
|
patch.completedAt = null;
|
|
}
|
|
if (issueData.status && issueData.status !== "cancelled") {
|
|
patch.cancelledAt = null;
|
|
}
|
|
if (issueData.status && issueData.status !== "in_progress") {
|
|
patch.checkoutRunId = null;
|
|
patch.executionRunId = null;
|
|
patch.executionAgentNameKey = null;
|
|
patch.executionLockedAt = null;
|
|
}
|
|
if (
|
|
(issueData.assigneeAgentId !== undefined &&
|
|
issueData.assigneeAgentId !== existing.assigneeAgentId) ||
|
|
(issueData.assigneeUserId !== undefined &&
|
|
issueData.assigneeUserId !== existing.assigneeUserId)
|
|
) {
|
|
patch.checkoutRunId = null;
|
|
patch.executionRunId = null;
|
|
patch.executionAgentNameKey = null;
|
|
patch.executionLockedAt = null;
|
|
}
|
|
|
|
const runUpdate = async (tx: any) => {
|
|
// The receipt baseline must be read under the same row lock as the
|
|
// write. Otherwise a concurrent update can be mistaken for a change
|
|
// made by this request.
|
|
const receiptExisting = await tx
|
|
.select()
|
|
.from(issues)
|
|
.where(idPredicate)
|
|
.for("update")
|
|
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
|
if (!receiptExisting) return null;
|
|
if (actorAgentId && actorRunId) {
|
|
// Recheck under a run lock: a request admitted before Stop must not
|
|
// commit a late Done after cancellation revoked its credentials.
|
|
await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, {
|
|
agentId: actorAgentId, runId: actorRunId, stopId: actorRunStopId,
|
|
});
|
|
}
|
|
if (actorAgentId && patch.status === "done") {
|
|
const [review] = await tx.select({ id: toolActionRequests.id }).from(toolActionRequests).where(and(eq(toolActionRequests.companyId, existing.companyId), eq(toolActionRequests.issueId, id), inArray(toolActionRequests.status, ["pending", "approved", "executing"]))).limit(1);
|
|
if (review) throw conflict("This task is waiting for a connection review. Finish unrelated work, then yield in_review without retrying the governed call.", { code: "tool_review_pending", actionRequestId: review.id });
|
|
}
|
|
|
|
const [previousLabelsByIssueId, previousRelationSummaries] = await Promise.all([
|
|
nextLabelIds !== undefined
|
|
? labelMapForIssues(tx, [id])
|
|
: Promise.resolve(new Map<string, IssueLabelRow[]>()),
|
|
blockedByIssueIds !== undefined
|
|
? getIssueRelationSummaryMap(existing.companyId, [id], tx)
|
|
: Promise.resolve(new Map<string, IssueRelationSummaryMap>()),
|
|
]);
|
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
|
|
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
|
|
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
|
|
getProjectDefaultGoalId(
|
|
tx,
|
|
existing.companyId,
|
|
issueData.projectId !== undefined
|
|
? issueData.projectId
|
|
: existing.projectId,
|
|
),
|
|
]);
|
|
|
|
patch.goalId = resolveNextIssueGoalId({
|
|
currentProjectId: existing.projectId,
|
|
currentGoalId: existing.goalId,
|
|
currentProjectGoalId,
|
|
projectId: issueData.projectId,
|
|
goalId: issueData.goalId,
|
|
projectGoalId: nextProjectGoalId,
|
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
|
});
|
|
// Ownership changes invalidate observed handoff versions even if status
|
|
// stays the same, including an A -> B -> A assignment race.
|
|
if ((issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== receiptExisting.assigneeAgentId)
|
|
|| (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== receiptExisting.assigneeUserId)) {
|
|
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
|
|
// Invalidate human direction at the common assignment boundary, including
|
|
// plugin/service writes that do not go through HTTP run cancellation.
|
|
// Keep the requester attribution for audit; cancellation revokes its use.
|
|
await tx.update(agentWakeupRequests).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
|
|
.where(and(eq(agentWakeupRequests.companyId, receiptExisting.companyId),
|
|
eq(agentWakeupRequests.requestedByActorType, "user"),
|
|
sql`coalesce(${agentWakeupRequests.payload}->>'issueId', ${agentWakeupRequests.payload}->>'taskId',
|
|
${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'issueId',
|
|
${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'taskId') = ${id}`));
|
|
}
|
|
// Reasserting Blocked or changing its blockers is a fresh decision even
|
|
// when the status string stays the same. Invalidate recovery's prior
|
|
// status receipt without treating comment recency as blocking intent.
|
|
if (receiptExisting.status === "blocked" &&
|
|
(issueData.status === "blocked" ||
|
|
(issueData.status === undefined &&
|
|
(blockedByIssueIds !== undefined || issueData.unblockDescriptor !== undefined)))) {
|
|
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
|
|
}
|
|
const updated = await tx
|
|
.update(issues)
|
|
.set(patch)
|
|
.where(idPredicate)
|
|
.returning()
|
|
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
|
if (!updated) return null;
|
|
await recordChatCompletion(tx, receiptExisting, updated);
|
|
// An operator explicitly choosing a disposition owns that decision,
|
|
// including choosing In Review while the conversation is Idle.
|
|
if (actorUserId && issueData.status !== undefined) {
|
|
await tx.update(chatConversations).set({ state: "active", updatedAt: new Date() })
|
|
.where(and(eq(chatConversations.companyId, updated.companyId), eq(chatConversations.issueId, updated.id),
|
|
eq(chatConversations.state, "waiting"), sql`exists (select 1 from chat_endpoints e
|
|
where e.id = ${chatConversations.endpointId} and e.company_id = ${chatConversations.companyId} and e.provider = 'slack')`));
|
|
}
|
|
if (updated.assigneeAgentId !== existing.assigneeAgentId || updated.assigneeUserId !== existing.assigneeUserId) {
|
|
const { issueThreadInteractionService } = await import("./issue-thread-interactions.js");
|
|
await issueThreadInteractionService(tx).expireConnectionIntentsForOwnershipChange(updated);
|
|
}
|
|
if (existing.status !== updated.status) {
|
|
if (
|
|
(existing.status === "done" || existing.status === "cancelled") &&
|
|
updated.status !== "done" &&
|
|
updated.status !== "cancelled"
|
|
) {
|
|
const terminalWorkspaces = await tx
|
|
.select({ id: executionWorkspaces.id })
|
|
.from(executionWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(executionWorkspaces.companyId, updated.companyId),
|
|
eq(executionWorkspaces.sourceIssueId, updated.id),
|
|
eq(executionWorkspaces.status, "archived"),
|
|
like(executionWorkspaces.cleanupReason, "issue_terminal%"),
|
|
),
|
|
);
|
|
for (const workspace of terminalWorkspaces) {
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: updated.companyId,
|
|
actorType: actorAgentId
|
|
? "agent"
|
|
: actorUserId
|
|
? "user"
|
|
: "system",
|
|
actorId: actorAgentId ?? actorUserId ?? "issue_service",
|
|
agentId: actorAgentId ?? null,
|
|
action: "execution_workspace.source_issue_reopened",
|
|
entityType: "execution_workspace",
|
|
entityId: workspace.id,
|
|
details: {
|
|
sourceIssueId: updated.id,
|
|
previousIssueStatus: existing.status,
|
|
nextIssueStatus: updated.status,
|
|
workspaceAction: "left_archived",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (updated.status === "done" || updated.status === "cancelled") {
|
|
await finalizeSummarySlotsForTerminalIssue(tx, updated);
|
|
// Every terminal transition funnels through here, including direct
|
|
// service callers (tree control, recovery, pipelines, status cards)
|
|
// that never touch the HTTP routes, so pending interaction cards
|
|
// cannot outlive their issue. Dynamic import breaks the module
|
|
// cycle (issue-thread-interactions.js imports issueService).
|
|
const { issueThreadInteractionService } =
|
|
await import("./issue-thread-interactions.js");
|
|
const expiredInteractions = await issueThreadInteractionService(
|
|
tx,
|
|
).expirePendingInteractionsForTerminalIssue(updated, {
|
|
agentId: actorAgentId ?? null,
|
|
userId: actorUserId ?? null,
|
|
});
|
|
const {
|
|
nativeQuestionCancellationIdentity,
|
|
requestNativeQuestionRunCancellation,
|
|
} = await import("./native-runtime/native-question-bridge.js");
|
|
for (const interaction of expiredInteractions) {
|
|
if (interaction.kind === "ask_user_questions") {
|
|
const nativeQuestion =
|
|
nativeQuestionCancellationIdentity(interaction);
|
|
if (nativeQuestion) {
|
|
if (dbOrTx !== db && !postCommitActions) {
|
|
throw new Error(
|
|
"Terminal native question updates in an external transaction require a post-commit action queue",
|
|
);
|
|
}
|
|
const runId = await requestNativeQuestionRunCancellation(
|
|
tx,
|
|
nativeQuestion,
|
|
{ kind: "issue_terminal", issueStatus: updated.status },
|
|
);
|
|
if (runId) {
|
|
queuedPostCommitActions.push({
|
|
type: "cancel_native_question_run",
|
|
runId,
|
|
issueId: updated.id,
|
|
issueStatus: updated.status,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: updated.companyId,
|
|
actorType: actorAgentId
|
|
? "agent"
|
|
: actorUserId
|
|
? "user"
|
|
: "system",
|
|
actorId: actorAgentId ?? actorUserId ?? "issue_service",
|
|
agentId: actorAgentId ?? null,
|
|
action: "issue.thread_interaction_expired",
|
|
entityType: "issue",
|
|
entityId: updated.id,
|
|
details: {
|
|
identifier: updated.identifier ?? null,
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
source: "issue.status_transition.issue_closed",
|
|
result: interaction.result ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
// A status-card generation task that goes done/cancelled/blocked stops
|
|
// making progress; release the card's generation claim so the board tile
|
|
// stops spinning and offers "Run now" again (blocked = stuck on a human).
|
|
if (
|
|
updated.status === "done" ||
|
|
updated.status === "cancelled" ||
|
|
updated.status === "blocked"
|
|
) {
|
|
await finalizeStatusCardsForStalledGeneration(tx, updated);
|
|
}
|
|
}
|
|
if (nextLabelIds !== undefined) {
|
|
await syncIssueLabels(
|
|
updated.id,
|
|
existing.companyId,
|
|
nextLabelIds,
|
|
tx,
|
|
);
|
|
}
|
|
if (blockedByIssueIds !== undefined) {
|
|
await syncBlockedByIssueIds(
|
|
updated.id,
|
|
existing.companyId,
|
|
blockedByIssueIds,
|
|
{
|
|
agentId: actorAgentId ?? null,
|
|
userId: actorUserId ?? null,
|
|
},
|
|
tx,
|
|
);
|
|
}
|
|
if (
|
|
issueData.executionWorkspaceSettings !== undefined &&
|
|
nextExecutionWorkspaceId &&
|
|
nextExecutionWorkspacePreference === "reuse_existing"
|
|
) {
|
|
const workspace = await tx
|
|
.select({
|
|
id: executionWorkspaces.id,
|
|
metadata: executionWorkspaces.metadata,
|
|
})
|
|
.from(executionWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(executionWorkspaces.id, nextExecutionWorkspaceId),
|
|
eq(executionWorkspaces.companyId, existing.companyId),
|
|
),
|
|
)
|
|
.then(
|
|
(rows: Array<{ id: string; metadata: unknown }>) =>
|
|
rows[0] ?? null,
|
|
);
|
|
if (workspace) {
|
|
await tx
|
|
.update(executionWorkspaces)
|
|
.set({
|
|
metadata: mergeExecutionWorkspaceConfig(
|
|
(workspace.metadata as Record<string, unknown> | null) ??
|
|
null,
|
|
buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
|
|
nextExecutionWorkspaceSettings,
|
|
),
|
|
),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(executionWorkspaces.id, workspace.id));
|
|
}
|
|
}
|
|
const [enriched] = await withIssueLabels(tx, [updated]);
|
|
const nextBlockedByIssueIds =
|
|
blockedByIssueIds === undefined
|
|
? undefined
|
|
: [...new Set(blockedByIssueIds)].sort();
|
|
const changes = buildIssueChanges(
|
|
receiptExisting as unknown as Record<string, unknown>,
|
|
updated as unknown as Record<string, unknown>,
|
|
{
|
|
...(nextLabelIds !== undefined
|
|
? {
|
|
labelIds: {
|
|
from: (previousLabelsByIssueId.get(id) ?? []).map(
|
|
(label) => label.id,
|
|
),
|
|
to: enriched.labelIds,
|
|
},
|
|
}
|
|
: {}),
|
|
...(nextBlockedByIssueIds !== undefined
|
|
? {
|
|
blockedByIssueIds: {
|
|
from: (
|
|
previousRelationSummaries.get(id)?.blockedBy ?? []
|
|
).map((relation) => relation.id),
|
|
to: nextBlockedByIssueIds,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
);
|
|
if (
|
|
(issueData.status === "done" || issueData.status === "cancelled") &&
|
|
existing.status !== issueData.status &&
|
|
existing.originKind ===
|
|
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation
|
|
) {
|
|
const parsedIncident = parseIssueGraphLivenessIncidentKey(
|
|
existing.originId,
|
|
);
|
|
if (
|
|
parsedIncident?.issueId &&
|
|
parsedIncident.companyId === existing.companyId
|
|
) {
|
|
await tx
|
|
.delete(issueRelations)
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, existing.companyId),
|
|
eq(issueRelations.issueId, existing.id),
|
|
eq(issueRelations.relatedIssueId, parsedIncident.issueId),
|
|
eq(issueRelations.type, "blocks"),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
actorUserId &&
|
|
receiptExisting.status !== "done" &&
|
|
updated.status === "done"
|
|
) {
|
|
if (dbOrTx !== db && !postCommitActivityPublications) {
|
|
throw new Error(
|
|
"Human completion in an external transaction requires a post-commit activity queue",
|
|
);
|
|
}
|
|
const now = new Date();
|
|
const archiveState = await archiveInbox(
|
|
updated.companyId,
|
|
updated.id,
|
|
actorUserId,
|
|
now,
|
|
undefined,
|
|
tx,
|
|
);
|
|
const { publication } = await persistActivity(tx as unknown as Db, {
|
|
companyId: updated.companyId,
|
|
actorType: "user",
|
|
actorId: actorUserId,
|
|
action: "issue.inbox_archived",
|
|
entityType: "issue",
|
|
entityId: updated.id,
|
|
details: {
|
|
userId: actorUserId,
|
|
archivedAt: archiveState.archivedAt,
|
|
targetResolvedFrom: "responsible_user",
|
|
source: "issue_status_done",
|
|
},
|
|
});
|
|
activityPublications.push(publication);
|
|
}
|
|
return {
|
|
...enriched,
|
|
...(nextBlockedByIssueIds !== undefined
|
|
? { blockedByIssueIds: nextBlockedByIssueIds }
|
|
: {}),
|
|
changes,
|
|
};
|
|
};
|
|
|
|
const result = await (dbOrTx === db
|
|
? db.transaction(runUpdate)
|
|
: runUpdate(dbOrTx));
|
|
if (dbOrTx === db && !postCommitActivityPublications) {
|
|
for (const publication of ownedActivityPublications)
|
|
publishActivity(publication);
|
|
}
|
|
if (dbOrTx === db && !postCommitActions) {
|
|
await executeIssuePostCommitActions(db, ownedPostCommitActions);
|
|
}
|
|
return result;
|
|
},
|
|
|
|
clearExecutionWorkspaceEnvironmentSelection: async (
|
|
companyId: string,
|
|
environmentId: string,
|
|
) => {
|
|
const rows = await db
|
|
.select({
|
|
id: issues.id,
|
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.companyId, companyId));
|
|
|
|
let cleared = 0;
|
|
for (const row of rows) {
|
|
const settings = parseIssueExecutionWorkspaceSettings(
|
|
row.executionWorkspaceSettings,
|
|
{ includeEnvironmentId: true },
|
|
);
|
|
if (settings?.environmentId !== environmentId) continue;
|
|
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
executionWorkspaceSettings: {
|
|
...settings,
|
|
environmentId: null,
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, row.id));
|
|
cleared += 1;
|
|
}
|
|
|
|
return cleared;
|
|
},
|
|
|
|
remove: (id: string) =>
|
|
db.transaction(async (tx) => {
|
|
const attachmentAssetIds = await tx
|
|
.select({ assetId: issueAttachments.assetId })
|
|
.from(issueAttachments)
|
|
.where(eq(issueAttachments.issueId, id));
|
|
const issueDocumentIds = await tx
|
|
.select({ documentId: issueDocuments.documentId })
|
|
.from(issueDocuments)
|
|
.where(eq(issueDocuments.issueId, id));
|
|
|
|
let removedIssue;
|
|
try {
|
|
removedIssue = await tx
|
|
.delete(issues)
|
|
.where(eq(issues.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
} catch (err) {
|
|
// A foreign key to issues.id without a delete policy blocks the delete
|
|
// and raises SQLSTATE 23503. Map it to a clear 409 instead of a bare
|
|
// 500. This also covers the decisions table, whose NOT NULL references
|
|
// to issues.id stay restricted on purpose.
|
|
if (isForeignKeyViolation(err)) {
|
|
throw conflict(
|
|
"Issue cannot be deleted because another record still references it.",
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (removedIssue && attachmentAssetIds.length > 0) {
|
|
await tx.delete(assets).where(
|
|
inArray(
|
|
assets.id,
|
|
attachmentAssetIds.map((row) => row.assetId),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (removedIssue && issueDocumentIds.length > 0) {
|
|
await tx.delete(documents).where(
|
|
inArray(
|
|
documents.id,
|
|
issueDocumentIds.map((row) => row.documentId),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!removedIssue) return null;
|
|
const [enriched] = await withIssueLabels(tx, [removedIssue]);
|
|
return enriched;
|
|
}),
|
|
|
|
checkout: async (
|
|
id: string,
|
|
agentId: string,
|
|
expectedStatuses: string[],
|
|
checkoutRunId: string | null,
|
|
) => {
|
|
const issueCompany = await db
|
|
.select({ companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issueCompany) throw notFound("Issue not found");
|
|
await assertAssignableAgent(db, issueCompany.companyId, agentId, {
|
|
kind: "work",
|
|
});
|
|
|
|
const now = new Date();
|
|
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(
|
|
issueCompany.companyId,
|
|
id,
|
|
);
|
|
if (
|
|
activePauseHold &&
|
|
!(await isTreeHoldInteractionCheckoutAllowed(
|
|
issueCompany.companyId,
|
|
checkoutRunId,
|
|
activePauseHold,
|
|
))
|
|
) {
|
|
throw conflict("Issue checkout blocked by active subtree pause hold", {
|
|
issueId: id,
|
|
holdId: activePauseHold.holdId,
|
|
rootIssueId: activePauseHold.rootIssueId,
|
|
mode: activePauseHold.mode,
|
|
securityPrinciples: [
|
|
"Complete Mediation",
|
|
"Fail Securely",
|
|
"Secure Defaults",
|
|
],
|
|
});
|
|
}
|
|
|
|
await clearExecutionRunIfTerminal(id);
|
|
await clearCheckoutRunIfTerminal(id);
|
|
|
|
const dependencyReadiness = await listIssueDependencyReadinessMap(
|
|
db,
|
|
issueCompany.companyId,
|
|
[id],
|
|
);
|
|
const readiness = dependencyReadiness.get(id);
|
|
const unresolvedBlockerIssueIds =
|
|
readiness?.unresolvedBlockerIssueIds ?? [];
|
|
if (unresolvedBlockerIssueIds.length > 0) {
|
|
const unresolvedBlockers = await listUnresolvedBlockerDetails(
|
|
db,
|
|
issueCompany.companyId,
|
|
unresolvedBlockerIssueIds,
|
|
readiness?.pendingFinalizeBlockerIssueIds,
|
|
);
|
|
throw unprocessable("Issue is blocked by unresolved blockers", {
|
|
unresolvedBlockerIssueIds,
|
|
unresolvedBlockers,
|
|
});
|
|
}
|
|
|
|
const sameRunAssigneeCondition = checkoutRunId
|
|
? and(
|
|
eq(issues.assigneeAgentId, agentId),
|
|
or(
|
|
isNull(issues.checkoutRunId),
|
|
eq(issues.checkoutRunId, checkoutRunId),
|
|
),
|
|
)
|
|
: and(
|
|
eq(issues.assigneeAgentId, agentId),
|
|
isNull(issues.checkoutRunId),
|
|
);
|
|
const executionLockCondition = checkoutRunId
|
|
? or(
|
|
isNull(issues.executionRunId),
|
|
eq(issues.executionRunId, checkoutRunId),
|
|
)
|
|
: isNull(issues.executionRunId);
|
|
const updated = await db
|
|
.update(issues)
|
|
.set({
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
checkoutRunId,
|
|
executionRunId: checkoutRunId,
|
|
status: "in_progress",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issues.id, id),
|
|
inArray(issues.status, expectedStatuses),
|
|
or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition),
|
|
executionLockCondition,
|
|
),
|
|
)
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (updated) {
|
|
const [enriched] = await withIssueLabels(db, [updated]);
|
|
return enriched;
|
|
}
|
|
|
|
const current = await db
|
|
.select({
|
|
id: issues.id,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw notFound("Issue not found");
|
|
|
|
if (
|
|
current.assigneeAgentId === agentId &&
|
|
current.status === "in_progress" &&
|
|
current.checkoutRunId == null &&
|
|
(current.executionRunId == null ||
|
|
current.executionRunId === checkoutRunId) &&
|
|
checkoutRunId
|
|
) {
|
|
const adopted = await db
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId,
|
|
executionRunId: checkoutRunId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issues.id, id),
|
|
eq(issues.status, "in_progress"),
|
|
eq(issues.assigneeAgentId, agentId),
|
|
isNull(issues.checkoutRunId),
|
|
or(
|
|
isNull(issues.executionRunId),
|
|
eq(issues.executionRunId, checkoutRunId),
|
|
),
|
|
),
|
|
)
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (adopted) return adopted;
|
|
}
|
|
|
|
if (
|
|
checkoutRunId &&
|
|
current.assigneeAgentId === agentId &&
|
|
current.status === "in_progress" &&
|
|
current.checkoutRunId &&
|
|
current.checkoutRunId !== checkoutRunId
|
|
) {
|
|
const staleAdoption = await adoptStaleCheckoutRun({
|
|
issueId: id,
|
|
actorAgentId: agentId,
|
|
actorRunId: checkoutRunId,
|
|
expectedCheckoutRunId: current.checkoutRunId,
|
|
});
|
|
if (staleAdoption.adopted) {
|
|
const row = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw notFound("Issue not found");
|
|
const [enriched] = await withIssueLabels(db, [row]);
|
|
return enriched;
|
|
}
|
|
}
|
|
|
|
// Adopt stale executionRunId — if the execution lock points to a terminal/missing run, clear it and proceed.
|
|
// Only adopts when the caller's expectedStatuses guard still holds; preserves any existing assigneeUserId
|
|
// and preserves the original startedAt when the issue is already in_progress.
|
|
if (
|
|
checkoutRunId &&
|
|
current.executionRunId &&
|
|
current.executionRunId !== checkoutRunId &&
|
|
(current.assigneeAgentId === agentId || current.assigneeAgentId == null)
|
|
) {
|
|
const executionRun = await db.select().from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, current.executionRunId)).then(rows => rows[0] ?? null);
|
|
const stale = !executionRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status);
|
|
if (stale && !isExplicitContinuationRetryClaim({ ...current, companyId: issueCompany.companyId }, executionRun)) {
|
|
const now = new Date();
|
|
const adoptionSet: Record<string, unknown> = {
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId,
|
|
executionRunId: checkoutRunId,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: now,
|
|
status: "in_progress",
|
|
updatedAt: now,
|
|
};
|
|
if (current.status !== "in_progress") {
|
|
adoptionSet.startedAt = now;
|
|
}
|
|
const adopted = await db
|
|
.update(issues)
|
|
.set(adoptionSet)
|
|
.where(
|
|
and(
|
|
eq(issues.id, id),
|
|
inArray(issues.status, expectedStatuses),
|
|
eq(issues.executionRunId, current.executionRunId),
|
|
or(
|
|
isNull(issues.assigneeAgentId),
|
|
eq(issues.assigneeAgentId, agentId),
|
|
),
|
|
),
|
|
)
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (adopted) {
|
|
const [enriched] = await withIssueLabels(db, [adopted]);
|
|
return enriched;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If this run already owns it and it's in_progress, return it (no self-409)
|
|
if (
|
|
current.assigneeAgentId === agentId &&
|
|
current.status === "in_progress" &&
|
|
sameRunLock(current.checkoutRunId, checkoutRunId)
|
|
) {
|
|
const row = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw notFound("Issue not found");
|
|
const [enriched] = await withIssueLabels(db, [row]);
|
|
return enriched;
|
|
}
|
|
|
|
throw conflict("Issue checkout conflict", {
|
|
issueId: current.id,
|
|
status: current.status,
|
|
assigneeAgentId: current.assigneeAgentId,
|
|
checkoutRunId: current.checkoutRunId,
|
|
executionRunId: current.executionRunId,
|
|
});
|
|
},
|
|
|
|
assertCheckoutOwner: async (
|
|
id: string,
|
|
actorAgentId: string,
|
|
actorRunId: string | null,
|
|
) => {
|
|
await clearExecutionRunIfTerminal(id);
|
|
await clearCheckoutRunIfTerminal(id);
|
|
const loadCurrent = () =>
|
|
db
|
|
.select({
|
|
id: issues.id,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
const current = await loadCurrent();
|
|
|
|
if (!current) throw notFound("Issue not found");
|
|
|
|
const resolveSameRunOwnership = (candidate: {
|
|
id: string;
|
|
status: string;
|
|
assigneeAgentId: string | null;
|
|
checkoutRunId: string | null;
|
|
executionRunId: string | null;
|
|
}) => {
|
|
if (
|
|
candidate.status === "in_progress" &&
|
|
candidate.assigneeAgentId === actorAgentId &&
|
|
sameRunLock(candidate.checkoutRunId, actorRunId)
|
|
) {
|
|
return { ...candidate, adoptedFromRunId: null as string | null };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const canAdoptUnownedCheckout = (candidate: {
|
|
status: string;
|
|
assigneeAgentId: string | null;
|
|
checkoutRunId: string | null;
|
|
executionRunId: string | null;
|
|
}) =>
|
|
actorRunId &&
|
|
candidate.status === "in_progress" &&
|
|
candidate.assigneeAgentId === actorAgentId &&
|
|
candidate.checkoutRunId == null &&
|
|
(candidate.executionRunId == null ||
|
|
candidate.executionRunId === actorRunId);
|
|
|
|
const resolveOwnership = async (candidate: {
|
|
id: string;
|
|
status: string;
|
|
assigneeAgentId: string | null;
|
|
checkoutRunId: string | null;
|
|
executionRunId: string | null;
|
|
}) => {
|
|
const sameRunOwnership = resolveSameRunOwnership(candidate);
|
|
if (sameRunOwnership)
|
|
return { ownership: sameRunOwnership, latest: null };
|
|
|
|
if (canAdoptUnownedCheckout(candidate)) {
|
|
const adopted = await adoptUnownedCheckoutRun({
|
|
issueId: id,
|
|
actorAgentId,
|
|
actorRunId: actorRunId!,
|
|
});
|
|
|
|
if (adopted) {
|
|
return {
|
|
ownership: {
|
|
...adopted,
|
|
adoptedFromRunId: null as string | null,
|
|
},
|
|
latest: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (
|
|
actorRunId &&
|
|
candidate.status === "in_progress" &&
|
|
candidate.assigneeAgentId === actorAgentId &&
|
|
candidate.checkoutRunId &&
|
|
candidate.checkoutRunId !== actorRunId
|
|
) {
|
|
const previousCheckoutRunId = candidate.checkoutRunId;
|
|
const staleAdoption = await adoptStaleCheckoutRun({
|
|
issueId: id,
|
|
actorAgentId,
|
|
actorRunId,
|
|
expectedCheckoutRunId: previousCheckoutRunId,
|
|
});
|
|
|
|
if (staleAdoption.adopted) {
|
|
return {
|
|
ownership: {
|
|
...staleAdoption.adopted,
|
|
adoptedFromRunId: previousCheckoutRunId,
|
|
},
|
|
latest: null,
|
|
};
|
|
}
|
|
|
|
if (staleAdoption.latest) {
|
|
const latestOwnership = resolveSameRunOwnership(
|
|
staleAdoption.latest,
|
|
);
|
|
if (latestOwnership)
|
|
return {
|
|
ownership: latestOwnership,
|
|
latest: staleAdoption.latest,
|
|
};
|
|
return { ownership: null, latest: staleAdoption.latest };
|
|
}
|
|
}
|
|
|
|
return { ownership: null, latest: null };
|
|
};
|
|
|
|
const resolved = await resolveOwnership(current);
|
|
if (resolved.ownership) return resolved.ownership;
|
|
|
|
const latest = resolved.latest ?? (await loadCurrent());
|
|
if (!latest) throw notFound("Issue not found");
|
|
const resolvedLatest = await resolveOwnership(latest);
|
|
if (resolvedLatest.ownership) return resolvedLatest.ownership;
|
|
if (resolvedLatest.latest) {
|
|
throw conflict("Issue run ownership conflict", {
|
|
issueId: resolvedLatest.latest.id,
|
|
status: resolvedLatest.latest.status,
|
|
assigneeAgentId: resolvedLatest.latest.assigneeAgentId,
|
|
checkoutRunId: resolvedLatest.latest.checkoutRunId,
|
|
executionRunId: resolvedLatest.latest.executionRunId,
|
|
actorAgentId,
|
|
actorRunId,
|
|
});
|
|
}
|
|
|
|
throw conflict("Issue run ownership conflict", {
|
|
issueId: latest.id,
|
|
status: latest.status,
|
|
assigneeAgentId: latest.assigneeAgentId,
|
|
checkoutRunId: latest.checkoutRunId,
|
|
executionRunId: latest.executionRunId,
|
|
actorAgentId,
|
|
actorRunId,
|
|
});
|
|
},
|
|
|
|
release: async (
|
|
id: string,
|
|
actorAgentId?: string,
|
|
actorRunId?: string | null,
|
|
) =>
|
|
db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${id} for update`,
|
|
);
|
|
const existing = await tx
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!existing) return null;
|
|
if (
|
|
actorAgentId &&
|
|
existing.assigneeAgentId &&
|
|
existing.assigneeAgentId !== actorAgentId
|
|
) {
|
|
throw conflict("Only assignee can release issue");
|
|
}
|
|
if (
|
|
actorAgentId &&
|
|
existing.status === "in_progress" &&
|
|
existing.assigneeAgentId === actorAgentId &&
|
|
existing.checkoutRunId &&
|
|
!sameRunLock(existing.checkoutRunId, actorRunId ?? null)
|
|
) {
|
|
const stale = await isTerminalOrMissingHeartbeatRun(
|
|
existing.checkoutRunId,
|
|
tx,
|
|
);
|
|
if (!stale) {
|
|
throw conflict("Only checkout run can release issue", {
|
|
issueId: existing.id,
|
|
assigneeAgentId: existing.assigneeAgentId,
|
|
checkoutRunId: existing.checkoutRunId,
|
|
actorRunId: actorRunId ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Terminal assignment records who owned the work, not a live execution
|
|
// claim. Cleanup must preserve it; unfinished release still relinquishes it.
|
|
const isTerminal = existing.status === "done" || existing.status === "cancelled";
|
|
const releaseStatus =
|
|
existing.status === "in_progress" ? "todo" : existing.status;
|
|
const updated = await tx
|
|
.update(issues)
|
|
.set({
|
|
status: releaseStatus,
|
|
assigneeAgentId: isTerminal ? existing.assigneeAgentId : null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!updated) return null;
|
|
const [enriched] = await withIssueLabels(tx, [updated]);
|
|
return enriched;
|
|
}),
|
|
|
|
adminForceRelease: async (
|
|
id: string,
|
|
options: { clearAssignee?: boolean } = {},
|
|
) =>
|
|
db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${id} for update`,
|
|
);
|
|
const existing = await tx
|
|
.select({
|
|
id: issues.id,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const patch: Partial<typeof issues.$inferInsert> = {
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (options.clearAssignee) {
|
|
patch.assigneeAgentId = null;
|
|
}
|
|
|
|
const updated = await tx
|
|
.update(issues)
|
|
.set(patch)
|
|
.where(eq(issues.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!updated) return null;
|
|
|
|
const [enriched] = await withIssueLabels(tx, [updated]);
|
|
return {
|
|
issue: enriched,
|
|
previous: {
|
|
checkoutRunId: existing.checkoutRunId,
|
|
executionRunId: existing.executionRunId,
|
|
},
|
|
};
|
|
}),
|
|
|
|
listLabels: (companyId: string) =>
|
|
db
|
|
.select()
|
|
.from(labels)
|
|
.where(eq(labels.companyId, companyId))
|
|
.orderBy(asc(labels.name), asc(labels.id)),
|
|
|
|
getLabelById: (id: string) =>
|
|
db
|
|
.select()
|
|
.from(labels)
|
|
.where(eq(labels.id, id))
|
|
.then((rows) => rows[0] ?? null),
|
|
|
|
createLabel: async (
|
|
companyId: string,
|
|
data: Pick<typeof labels.$inferInsert, "name" | "color">,
|
|
) => {
|
|
const [created] = await db
|
|
.insert(labels)
|
|
.values({
|
|
companyId,
|
|
name: data.name.trim(),
|
|
color: data.color,
|
|
})
|
|
.returning();
|
|
return created;
|
|
},
|
|
|
|
deleteLabel: async (id: string) =>
|
|
db
|
|
.delete(labels)
|
|
.where(eq(labels.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null),
|
|
|
|
listComments: async (
|
|
issueId: string,
|
|
opts?: {
|
|
afterCommentId?: string | null;
|
|
order?: "asc" | "desc";
|
|
limit?: number | null;
|
|
},
|
|
) => {
|
|
const order = opts?.order === "asc" ? "asc" : "desc";
|
|
const afterCommentId = opts?.afterCommentId?.trim() || null;
|
|
const limit =
|
|
opts?.limit && opts.limit > 0
|
|
? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT)
|
|
: null;
|
|
|
|
const conditions = [eq(issueComments.issueId, issueId)];
|
|
if (afterCommentId) {
|
|
// Guard: reject non-UUID cursors before hitting the DB to avoid Postgres type errors.
|
|
if (!isUuidLike(afterCommentId)) return [];
|
|
const anchor = await db
|
|
.select({
|
|
id: issueComments.id,
|
|
createdAt: issueComments.createdAt,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.issueId, issueId),
|
|
eq(issueComments.id, afterCommentId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!anchor) return [];
|
|
const anchorCreatedAt =
|
|
anchor.createdAt instanceof Date
|
|
? anchor.createdAt
|
|
: new Date(String(anchor.createdAt));
|
|
conditions.push(
|
|
order === "asc"
|
|
? or(
|
|
gt(issueComments.createdAt, anchorCreatedAt),
|
|
and(
|
|
eq(issueComments.createdAt, anchorCreatedAt),
|
|
gt(issueComments.id, anchor.id),
|
|
),
|
|
)!
|
|
: or(
|
|
lt(issueComments.createdAt, anchorCreatedAt),
|
|
and(
|
|
eq(issueComments.createdAt, anchorCreatedAt),
|
|
lt(issueComments.id, anchor.id),
|
|
),
|
|
)!,
|
|
);
|
|
}
|
|
|
|
const query = db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(and(...conditions))
|
|
.orderBy(
|
|
order === "asc"
|
|
? asc(issueComments.createdAt)
|
|
: desc(issueComments.createdAt),
|
|
order === "asc" ? asc(issueComments.id) : desc(issueComments.id),
|
|
);
|
|
|
|
const comments = limit ? await query.limit(limit) : await query;
|
|
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
|
|
const projectedComments = await projectHistoricalRunComments(comments);
|
|
const enrichedComments =
|
|
await enrichCommentsWithDerivedAgentAttribution(projectedComments);
|
|
return enrichedComments.map((comment) =>
|
|
redactIssueComment(comment, censorUsernameInLogs),
|
|
);
|
|
},
|
|
|
|
getCommentCursor: async (issueId: string) => {
|
|
const [latest, countRow] = await Promise.all([
|
|
db
|
|
.select({
|
|
latestCommentId: issueComments.id,
|
|
latestCommentAt: issueComments.createdAt,
|
|
})
|
|
.from(issueComments)
|
|
.where(eq(issueComments.issueId, issueId))
|
|
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null),
|
|
db
|
|
.select({
|
|
totalComments: sql<number>`count(*)::int`,
|
|
})
|
|
.from(issueComments)
|
|
.where(eq(issueComments.issueId, issueId))
|
|
.then((rows) => rows[0] ?? null),
|
|
]);
|
|
|
|
return {
|
|
totalComments: Number(countRow?.totalComments ?? 0),
|
|
latestCommentId: latest?.latestCommentId ?? null,
|
|
latestCommentAt: latest?.latestCommentAt ?? null,
|
|
};
|
|
},
|
|
|
|
getComment: async (commentId: string) => {
|
|
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
|
|
const comment = await db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.id, commentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!comment) return null;
|
|
const [projectedComment] = await projectHistoricalRunComments([comment]);
|
|
const [enrichedComment] = await enrichCommentsWithDerivedAgentAttribution(
|
|
[projectedComment ?? comment],
|
|
);
|
|
return redactIssueComment(
|
|
enrichedComment ?? projectedComment ?? comment,
|
|
censorUsernameInLogs,
|
|
);
|
|
},
|
|
|
|
removeComment: async (commentId: string) => {
|
|
const currentUserRedactionOptions = {
|
|
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
|
};
|
|
|
|
return db.transaction(async (tx) => {
|
|
const [comment] = await tx
|
|
.delete(issueComments)
|
|
.where(eq(issueComments.id, commentId))
|
|
.returning();
|
|
|
|
if (!comment) return null;
|
|
|
|
await tx
|
|
.update(issues)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(issues.id, comment.issueId));
|
|
|
|
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
|
|
});
|
|
},
|
|
|
|
tombstoneComment: async (
|
|
commentId: string,
|
|
actor: {
|
|
actorType: "agent" | "user";
|
|
agentId?: string | null;
|
|
userId?: string | null;
|
|
runId?: string | null;
|
|
},
|
|
options?: {
|
|
afterTombstone?: (comment: IssueComment, tx: any) => Promise<void>;
|
|
},
|
|
) => {
|
|
const currentUserRedactionOptions = {
|
|
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
|
};
|
|
|
|
return db.transaction(async (tx) => {
|
|
const now = new Date();
|
|
const [comment] = await tx
|
|
.update(issueComments)
|
|
.set({
|
|
body: DELETED_ISSUE_COMMENT_BODY,
|
|
presentation: null,
|
|
metadata: null,
|
|
deletedAt: now,
|
|
deletedByType: actor.actorType,
|
|
deletedByAgentId:
|
|
actor.actorType === "agent" ? (actor.agentId ?? null) : null,
|
|
deletedByUserId:
|
|
actor.actorType === "user" ? (actor.userId ?? null) : null,
|
|
deletedByRunId: actor.runId ?? null,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueComments.id, commentId),
|
|
isNull(issueComments.deletedAt),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!comment) return null;
|
|
|
|
await tx
|
|
.update(issues)
|
|
.set({ updatedAt: now })
|
|
.where(eq(issues.id, comment.issueId));
|
|
|
|
const redacted = redactIssueComment(
|
|
comment,
|
|
currentUserRedactionOptions.enabled,
|
|
);
|
|
await options?.afterTombstone?.(redacted, tx);
|
|
|
|
return redacted;
|
|
});
|
|
},
|
|
|
|
addComment: async function addComment(
|
|
issueId: string,
|
|
body: string,
|
|
actor: {
|
|
agentId?: string;
|
|
userId?: string;
|
|
runId?: string | null;
|
|
onBehalfOfUserId?: string | null;
|
|
},
|
|
options?: {
|
|
authorType?: IssueCommentAuthorType | null;
|
|
presentation?: IssueCommentPresentation | null;
|
|
metadata?: IssueCommentMetadata | null;
|
|
attachmentIds?: string[];
|
|
authorizationReason?: string | null;
|
|
/** Server-only final assistant response, never a tool/progress comment. */
|
|
completionReply?: boolean;
|
|
sourceTrust?: typeof issueComments.$inferInsert.sourceTrust;
|
|
createdAt?: Date | string | null;
|
|
clientRequestId?: string;
|
|
/** Server-only: authenticated Paperclip messages also belong in the Slack thread. */
|
|
mirrorToSlack?: boolean;
|
|
},
|
|
dbOrTx: any = db,
|
|
): Promise<IssueComment> {
|
|
if (dbOrTx === db && (actor.runId || actor.userId)) {
|
|
const append = () =>
|
|
db.transaction(async (tx) => {
|
|
// Serialize run-authored comments on the issue so a provider retry
|
|
// cannot publish the same visible result twice. This needs no schema
|
|
// change: the issue row is the transaction fence, and the recursive
|
|
// call below performs the lookup and insert while holding it.
|
|
await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.for("update");
|
|
return addComment(issueId, body, actor, options, tx);
|
|
});
|
|
return options?.authorizationReason ===
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
|
|
? retryNativeChatReviewPresentation(append)
|
|
: append();
|
|
}
|
|
// The query below locks human comments on caller-owned transactions too,
|
|
// sharing the fence with both question creation and Slack settlement.
|
|
const issueQuery = dbOrTx
|
|
.select({ companyId: issues.companyId, conversationAgentId: issues.conversationAgentId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId));
|
|
// Caller-owned transactions (including chat and review comments) must
|
|
// serialize with question creation before inserting the human comment.
|
|
const issue = await (actor.userId || (actor.runId && dbOrTx !== db) ? issueQuery.for("update") : issueQuery)
|
|
.then((rows: Array<{ companyId: string; conversationAgentId: string | null }>) => rows[0] ?? null);
|
|
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
if (issue.conversationAgentId && actor.userId && !(await instanceSettingsService(dbOrTx).getExperimental()).enableAgentChat) {
|
|
throw unprocessable("Agent Chat is disabled in Experimental settings");
|
|
}
|
|
const currentUserRedactionOptions = {
|
|
// Keep every read on the caller's transaction connection. Re-entering
|
|
// the outer pool here can deadlock when concurrent transactions fill
|
|
// the pool while waiting on the same issue or delivery row.
|
|
enabled: (await instanceSettings.getGeneral({ db: dbOrTx }))
|
|
.censorUsernameInLogs,
|
|
};
|
|
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
|
|
if (actor.userId && options?.clientRequestId) {
|
|
const [existing] = await dbOrTx.select().from(issueComments).where(and(eq(issueComments.issueId, issueId),
|
|
eq(issueComments.authorUserId, actor.userId), eq(issueComments.clientRequestId, options.clientRequestId)));
|
|
if (existing) {
|
|
if (existing.body !== redactedBody) throw conflict("Message request ID was already used for different content");
|
|
return existing;
|
|
}
|
|
}
|
|
if (issue.conversationAgentId && actor.runId) {
|
|
const [run] = await dbOrTx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, actor.runId));
|
|
const [current] = await dbOrTx.select().from(issues).where(eq(issues.id, issueId));
|
|
if (run?.status === "cancelled") throw conflict("This conversation turn was cancelled; it cannot post a reply");
|
|
if (run?.contextSnapshot?.conversationSessionGeneration !== current.conversationSessionGeneration) {
|
|
throw conflict("Conversation session changed; this reply belongs to an earlier session");
|
|
}
|
|
}
|
|
if (options?.completionReply && actor.agentId && actor.runId) {
|
|
const delivered = await existingChatCompletionReply(dbOrTx, actor.runId, issueId);
|
|
if (delivered) return redactIssueComment(delivered, currentUserRedactionOptions.enabled);
|
|
}
|
|
const authorType = issueCommentAuthorTypeSchema.parse(
|
|
options?.authorType ??
|
|
(actor.agentId ? "agent" : actor.userId ? "user" : "system"),
|
|
);
|
|
assertIssueCommentAuthorTypeAllowed(actor, authorType);
|
|
const presentation = issueCommentPresentationSchema
|
|
.nullable()
|
|
.parse(options?.presentation ?? null);
|
|
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
|
|
const validCreatedAt =
|
|
createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt : null;
|
|
// Use one statement timestamp for both columns when the caller did not
|
|
// supply a valid historical timestamp. This keeps the comment's
|
|
// recency fields equal even when the surrounding transaction started
|
|
// earlier, while preserving imported timestamps and the normal default
|
|
// updatedAt behavior for those historical rows.
|
|
const currentInsertTimestamp = validCreatedAt
|
|
? null
|
|
: sql`statement_timestamp()`;
|
|
// Invalid/stale run ids must not 500 the insert — null out unknowns.
|
|
const createdByRun = await resolveCommentCreatedByRun(
|
|
dbOrTx,
|
|
issue.companyId,
|
|
actor.runId,
|
|
);
|
|
const createdByRunId = createdByRun?.id ?? null;
|
|
if (
|
|
createdByRunId &&
|
|
options?.authorizationReason === CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
|
|
) {
|
|
const [presentationRun] = await dbOrTx
|
|
.select({ resultJson: heartbeatRuns.resultJson })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, createdByRunId),
|
|
eq(heartbeatRuns.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (
|
|
presentationRun?.resultJson?.finalizationReasonCode ===
|
|
"governed_response_waiting" &&
|
|
(parseObject(presentationRun.resultJson.nativeResult).summary !== body ||
|
|
!(await authorizeNativeChatReviewPresentation(
|
|
dbOrTx,
|
|
{
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
runId: createdByRunId,
|
|
resultJson: presentationRun.resultJson,
|
|
},
|
|
"nonblocking",
|
|
)))
|
|
) {
|
|
throw conflict(
|
|
"This chat response is no longer authorized for external presentation",
|
|
{ code: "chat_review_response_presentation_denied" },
|
|
);
|
|
}
|
|
}
|
|
if (actor.runId && !createdByRunId) {
|
|
logger.warn(
|
|
{ issueId, companyId: issue.companyId, runId: actor.runId },
|
|
"dropping invalid createdByRunId for issue comment insert",
|
|
);
|
|
}
|
|
const attachmentIds = [...new Set(options?.attachmentIds ?? [])];
|
|
if (actor.agentId && attachmentIds.length > 0) {
|
|
if (!createdByRunId) {
|
|
throw unprocessable(
|
|
"Agent comment attachments require the current registered run",
|
|
);
|
|
}
|
|
// Routes commonly supply an existing transaction for attachment
|
|
// comments, so the outer addComment wrapper is not always responsible
|
|
// for serialization. Lock both stable parents here before selecting or
|
|
// binding files. Concurrent helper calls for the same run then observe
|
|
// one another's committed selection count instead of both admitting a
|
|
// twenty-first attachment.
|
|
const [lockedIssue] = await dbOrTx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, issueId),
|
|
eq(issues.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!lockedIssue) throw notFound("Issue not found");
|
|
const [lockedRun] = await dbOrTx
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, createdByRunId),
|
|
eq(heartbeatRuns.companyId, issue.companyId),
|
|
eq(heartbeatRuns.agentId, actor.agentId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!lockedRun) {
|
|
throw unprocessable(
|
|
"Agent comment attachments require the current registered run",
|
|
);
|
|
}
|
|
}
|
|
const onBehalfOfUserId = actor.agentId
|
|
? await resolveCommentResponsibleUserId(
|
|
dbOrTx,
|
|
issue.companyId,
|
|
createdByRunId,
|
|
actor.onBehalfOfUserId,
|
|
)
|
|
: null;
|
|
const metadata = issueCommentMetadataSchema
|
|
.nullable()
|
|
.parse(
|
|
actor.agentId
|
|
? withAgentCommentAuthorizationMetadata(
|
|
options?.metadata ?? null,
|
|
options?.authorizationReason,
|
|
)
|
|
: (options?.metadata ?? null),
|
|
);
|
|
let comment: typeof issueComments.$inferSelect | null = null;
|
|
if (createdByRunId) {
|
|
const existing = await dbOrTx
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, issue.companyId),
|
|
eq(issueComments.issueId, issueId),
|
|
eq(issueComments.createdByRunId, createdByRunId),
|
|
eq(issueComments.authorType, authorType),
|
|
actor.agentId
|
|
? eq(issueComments.authorAgentId, actor.agentId)
|
|
: isNull(issueComments.authorAgentId),
|
|
eq(issueComments.body, redactedBody),
|
|
isNull(issueComments.deletedAt),
|
|
),
|
|
)
|
|
.orderBy(issueComments.createdAt, issueComments.id)
|
|
.limit(1)
|
|
.then(
|
|
(rows: Array<typeof issueComments.$inferSelect>) => rows[0] ?? null,
|
|
);
|
|
if (existing) {
|
|
// Heartbeat's presentation resolver can intentionally select the
|
|
// exact same prose that the agent already wrote while handling an
|
|
// external-chat continuation. The first write is kept internal so
|
|
// lifecycle/tool chatter cannot escape; the later, narrowly
|
|
// authorized presentation must upgrade that one durable comment
|
|
// instead of returning early and letting the generic completion
|
|
// milestone win the provider reply slot.
|
|
const shouldUpgradeExternalAuthorization =
|
|
authorType === "agent" &&
|
|
metadata?.authorizationReason ===
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON &&
|
|
existing.metadata?.authorizationReason !==
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON;
|
|
const shouldBindAttachments =
|
|
(options?.attachmentIds?.length ?? 0) > 0;
|
|
const shouldUpgradeAttachmentAuthorization =
|
|
shouldBindAttachments &&
|
|
isExplicitExternalAgentComment(metadata) &&
|
|
!isExplicitExternalAgentComment(existing.metadata);
|
|
if (
|
|
!shouldUpgradeExternalAuthorization &&
|
|
!shouldUpgradeAttachmentAuthorization &&
|
|
!shouldBindAttachments
|
|
) {
|
|
if (options?.completionReply && actor.agentId && createdByRunId) await acknowledgeChatCompletionReply(dbOrTx, createdByRunId, existing.id);
|
|
return redactIssueComment(
|
|
existing,
|
|
currentUserRedactionOptions.enabled,
|
|
);
|
|
}
|
|
comment =
|
|
shouldUpgradeExternalAuthorization ||
|
|
shouldUpgradeAttachmentAuthorization
|
|
? await dbOrTx
|
|
.update(issueComments)
|
|
.set({
|
|
// Preserve any structured provenance the provisional comment
|
|
// carried; the presentation pass only elevates its narrowly
|
|
// resolved external-publication authorization.
|
|
metadata: {
|
|
...(existing.metadata ?? {}),
|
|
...(metadata ?? {}),
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueComments.id, existing.id),
|
|
eq(issueComments.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.returning()
|
|
.then(
|
|
(rows: Array<typeof issueComments.$inferSelect>) =>
|
|
rows[0] ?? null,
|
|
)
|
|
: existing;
|
|
}
|
|
}
|
|
if (!comment) {
|
|
[comment] = await dbOrTx
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
authorAgentId: actor.agentId ?? null,
|
|
authorUserId: actor.userId ?? null,
|
|
onBehalfOfUserId,
|
|
authorType,
|
|
createdByRunId,
|
|
body: redactedBody,
|
|
clientRequestId: options?.clientRequestId ?? null,
|
|
presentation,
|
|
metadata,
|
|
sourceTrust: options?.sourceTrust ?? null,
|
|
createdAt: validCreatedAt ?? currentInsertTimestamp!,
|
|
...(currentInsertTimestamp
|
|
? { updatedAt: currentInsertTimestamp }
|
|
: {}),
|
|
})
|
|
.returning();
|
|
}
|
|
if (!comment) throw new Error("Failed to create issue comment");
|
|
if (options?.completionReply && actor.agentId && createdByRunId) await acknowledgeChatCompletionReply(dbOrTx, createdByRunId, comment.id);
|
|
|
|
const boundAttachments: Array<{
|
|
id: string;
|
|
commentId: string;
|
|
originalFilename: string | null;
|
|
}> = [];
|
|
if (attachmentIds.length > 0) {
|
|
const attachmentRows = await dbOrTx
|
|
.select({
|
|
id: issueAttachments.id,
|
|
issueCommentId: issueAttachments.issueCommentId,
|
|
originatingRunId: issueAttachments.originatingRunId,
|
|
createdByAgentId: assets.createdByAgentId,
|
|
originalFilename: assets.originalFilename,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
|
.where(
|
|
and(
|
|
eq(issueAttachments.companyId, issue.companyId),
|
|
eq(issueAttachments.issueId, issueId),
|
|
inArray(issueAttachments.id, attachmentIds),
|
|
),
|
|
)
|
|
.for("update");
|
|
type CommentAttachmentRow = {
|
|
id: string;
|
|
issueCommentId: string | null;
|
|
originatingRunId: string | null;
|
|
createdByAgentId: string | null;
|
|
originalFilename: string | null;
|
|
};
|
|
const attachmentById = new Map<string, CommentAttachmentRow>(
|
|
attachmentRows.map(
|
|
(
|
|
attachment: CommentAttachmentRow,
|
|
): [string, CommentAttachmentRow] => [attachment.id, attachment],
|
|
),
|
|
);
|
|
if (attachmentById.size !== attachmentIds.length) {
|
|
throw unprocessable(
|
|
"Comment attachments must belong to the same task and company",
|
|
);
|
|
}
|
|
if (
|
|
attachmentRows.some(
|
|
(attachment: { issueCommentId: string | null }) =>
|
|
attachment.issueCommentId !== null &&
|
|
attachment.issueCommentId !== comment.id,
|
|
)
|
|
) {
|
|
throw conflict(
|
|
"Comment attachments are already bound to another comment",
|
|
{
|
|
code: "issue_comment_attachments_already_bound",
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
attachmentIds: attachmentRows
|
|
.filter(
|
|
(attachment: CommentAttachmentRow) =>
|
|
attachment.issueCommentId !== null &&
|
|
attachment.issueCommentId !== comment.id,
|
|
)
|
|
.map((attachment: CommentAttachmentRow) => attachment.id),
|
|
},
|
|
);
|
|
}
|
|
if (actor.agentId && createdByRunId) {
|
|
if (
|
|
attachmentRows.some(
|
|
(attachment: CommentAttachmentRow) =>
|
|
attachment.createdByAgentId !== actor.agentId ||
|
|
attachment.originatingRunId !== createdByRunId,
|
|
)
|
|
) {
|
|
throw unprocessable(
|
|
"Agent comment attachments must originate from the same agent and current run",
|
|
{ code: "issue_attachment_run_origin_mismatch" },
|
|
);
|
|
}
|
|
const chatBindings = await resolveChatOriginPublicationBindings(
|
|
dbOrTx,
|
|
issue.companyId,
|
|
issueId,
|
|
createdByRunId,
|
|
);
|
|
if (chatBindings.length > 0) {
|
|
const alreadySelected =
|
|
await listSelectedChatPresentationAttachments(dbOrTx, {
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
agentId: actor.agentId,
|
|
runId: createdByRunId,
|
|
});
|
|
const selectedIds = new Set(
|
|
alreadySelected.map((attachment) => attachment.id),
|
|
);
|
|
for (const attachmentId of attachmentIds) {
|
|
selectedIds.add(attachmentId);
|
|
}
|
|
if (selectedIds.size > MAX_CHAT_PRESENTATION_ATTACHMENTS) {
|
|
throw unprocessable(
|
|
`An agent run can select at most ${MAX_CHAT_PRESENTATION_ATTACHMENTS} attachments for one chat response`,
|
|
{
|
|
code: "chat_attachment_selection_limit_exceeded",
|
|
limit: MAX_CHAT_PRESENTATION_ATTACHMENTS,
|
|
selectedCount: selectedIds.size,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const unboundAttachmentIds = attachmentRows.flatMap(
|
|
(attachment: CommentAttachmentRow) =>
|
|
attachment.issueCommentId === null ? [attachment.id] : [],
|
|
);
|
|
const attached = unboundAttachmentIds.length
|
|
? await dbOrTx
|
|
.update(issueAttachments)
|
|
.set({ issueCommentId: comment.id, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(issueAttachments.companyId, issue.companyId),
|
|
eq(issueAttachments.issueId, issueId),
|
|
inArray(issueAttachments.id, unboundAttachmentIds),
|
|
isNull(issueAttachments.issueCommentId),
|
|
),
|
|
)
|
|
.returning({ id: issueAttachments.id })
|
|
: [];
|
|
if (attached.length !== unboundAttachmentIds.length) {
|
|
throw conflict(
|
|
"Comment attachments changed before they could be bound",
|
|
);
|
|
}
|
|
for (const attachmentId of attachmentIds) {
|
|
const attachment = attachmentById.get(attachmentId)!;
|
|
boundAttachments.push({
|
|
id: attachment.id,
|
|
commentId: comment.id,
|
|
originalFilename: attachment.originalFilename,
|
|
});
|
|
}
|
|
}
|
|
|
|
// A chat-origin run binds only the files it explicitly selects to an
|
|
// ordinary agent comment while it is still running. That comment stays
|
|
// internal until heartbeat chooses the final provider presentation. At
|
|
// that point, carry those already-authorized same-agent/same-run files
|
|
// forward without inferring any unbound artifact from the run.
|
|
if (
|
|
authorType === "agent" &&
|
|
actor.agentId &&
|
|
createdByRunId &&
|
|
metadata?.authorizationReason ===
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
|
|
) {
|
|
const priorRows = await listSelectedChatPresentationAttachments(
|
|
dbOrTx,
|
|
{
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
agentId: actor.agentId,
|
|
runId: createdByRunId,
|
|
},
|
|
);
|
|
const selectedIds = new Set(
|
|
boundAttachments.map((attachment) => attachment.id),
|
|
);
|
|
for (const attachment of priorRows) {
|
|
if (
|
|
selectedIds.has(attachment.id) ||
|
|
!isExplicitExternalAgentComment(attachment.commentMetadata)
|
|
) {
|
|
continue;
|
|
}
|
|
selectedIds.add(attachment.id);
|
|
boundAttachments.push({
|
|
id: attachment.id,
|
|
commentId: attachment.commentId,
|
|
originalFilename: attachment.originalFilename,
|
|
});
|
|
}
|
|
if (boundAttachments.length > MAX_CHAT_PRESENTATION_ATTACHMENTS) {
|
|
throw unprocessable(
|
|
`An agent run can select at most ${MAX_CHAT_PRESENTATION_ATTACHMENTS} attachments for one chat response`,
|
|
{
|
|
code: "chat_attachment_selection_limit_exceeded",
|
|
limit: MAX_CHAT_PRESENTATION_ATTACHMENTS,
|
|
selectedCount: boundAttachments.length,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (issue.conversationAgentId && actor.userId) {
|
|
await dbOrTx.update(issues).set({ conversationState: "active" }).where(eq(issues.id, issueId));
|
|
}
|
|
if (options?.mirrorToSlack && actor.userId && authorType === "user") {
|
|
await mirrorSlackBoardComment(dbOrTx, comment, { attachmentIds: options.attachmentIds });
|
|
}
|
|
if (authorType === "user" || actor.userId) {
|
|
await resumeSlackConversation(dbOrTx, issue.companyId, issueId);
|
|
}
|
|
// Update issue's updatedAt so comment activity is reflected in recency sorting
|
|
await dbOrTx
|
|
.update(issues)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(issues.id, issueId));
|
|
|
|
// Only an explicitly authored comment from the run causally woken by an
|
|
// inbound chat message is automatically publishable. Presentation,
|
|
// recovery, automation, and ordinary internal agent comments stay in
|
|
// Paperclip even while a bound conversation is active.
|
|
if (authorType === "agent" && isExplicitExternalAgentComment(metadata)) {
|
|
// An external-chat run may perform ordinary Paperclip lifecycle or
|
|
// bookkeeping writes before its adapter result is finalized. Those
|
|
// writes remain internal: only heartbeat's selected final presentation
|
|
// may consume this provider response slot. Explicit board "Send to
|
|
// channel" publications use the separate publication path.
|
|
const chatFinalOwnsProviderReply =
|
|
createdByRun !== null &&
|
|
isExternalChatPresentationContext(
|
|
createdByRun.contextSnapshot,
|
|
readStringFromRecord(createdByRun.contextSnapshot, "source") === "tool_action_review" &&
|
|
(await resolveChatOriginPublicationBindings(
|
|
dbOrTx, issue.companyId, issueId, createdByRunId,
|
|
)).length > 0,
|
|
) &&
|
|
metadata?.authorizationReason !==
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON;
|
|
const interactionOwnsProviderReply = createdByRunId
|
|
? await hasChatRunOwnedProviderInteraction(dbOrTx, {
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
runId: createdByRunId,
|
|
})
|
|
: false;
|
|
const bindings =
|
|
interactionOwnsProviderReply || chatFinalOwnsProviderReply
|
|
? []
|
|
: await resolveChatOriginPublicationBindings(
|
|
dbOrTx,
|
|
issue.companyId,
|
|
issueId,
|
|
createdByRunId,
|
|
);
|
|
const publicationCreatedAt = new Date();
|
|
for (const binding of bindings) {
|
|
await dbOrTx
|
|
.insert(chatPublications)
|
|
.values({
|
|
companyId: binding.companyId,
|
|
endpointId: binding.endpointId,
|
|
conversationId: binding.conversationId,
|
|
issueId,
|
|
commentId: comment.id,
|
|
idempotencyKey: `comment:${comment.id}:${binding.endpointId}`,
|
|
payload: projectSafeChatPublication({
|
|
classification: "external",
|
|
source: "agent_comment",
|
|
text: redactedBody,
|
|
}),
|
|
state: "pending",
|
|
createdAt: publicationCreatedAt,
|
|
updatedAt: publicationCreatedAt,
|
|
})
|
|
.onConflictDoNothing();
|
|
for (const [index, attachment] of boundAttachments.entries()) {
|
|
const attachmentCreatedAt = new Date(
|
|
publicationCreatedAt.getTime() + index + 1,
|
|
);
|
|
await dbOrTx
|
|
.insert(chatPublications)
|
|
.values({
|
|
companyId: binding.companyId,
|
|
endpointId: binding.endpointId,
|
|
conversationId: binding.conversationId,
|
|
issueId,
|
|
commentId: attachment.commentId,
|
|
idempotencyKey: `attachment:${attachment.id}:${binding.endpointId}`,
|
|
payload: projectSafeChatPublication({
|
|
classification: "external",
|
|
source: "agent_comment",
|
|
text: `Shared ${attachment.originalFilename ?? "a file"}.`,
|
|
attachmentIds: [attachment.id],
|
|
}),
|
|
state: "pending",
|
|
createdAt: attachmentCreatedAt,
|
|
updatedAt: attachmentCreatedAt,
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
authorType === "user" &&
|
|
actor.userId &&
|
|
actor.userId !== "board-concierge" &&
|
|
!createdByRunId
|
|
) {
|
|
const { issueThreadInteractionService } =
|
|
await import("./issue-thread-interactions.js");
|
|
const expiredInteractions = await issueThreadInteractionService(
|
|
dbOrTx,
|
|
).expireRequestConfirmationsSupersededByComment(
|
|
{ id: issueId, companyId: issue.companyId },
|
|
comment,
|
|
{ agentId: actor.agentId, userId: actor.userId },
|
|
);
|
|
for (const interaction of expiredInteractions) {
|
|
await logActivity(dbOrTx, {
|
|
companyId: issue.companyId,
|
|
actorType: "user",
|
|
actorId: actor.userId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: createdByRunId,
|
|
action: "issue.thread_interaction_expired",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
source: "issue.comment.service",
|
|
result: interaction.result ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
|
|
},
|
|
|
|
createAttachment: async (input: {
|
|
issueId: string;
|
|
issueCommentId?: string | null;
|
|
provider: string;
|
|
objectKey: string;
|
|
contentType: string;
|
|
byteSize: number;
|
|
sha256: string;
|
|
originalFilename?: string | null;
|
|
createdByAgentId?: string | null;
|
|
createdByUserId?: string | null;
|
|
createdByRunId?: string | null;
|
|
}) => {
|
|
const issue = await db
|
|
.select({ id: issues.id, companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, input.issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
return db.transaction(async (tx) => {
|
|
if (input.createdByAgentId && input.issueCommentId) {
|
|
const [lockedIssue] = await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, issue.id),
|
|
eq(issues.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!lockedIssue) throw notFound("Issue not found");
|
|
}
|
|
const registeredRun =
|
|
input.createdByRunId && isUuidLike(input.createdByRunId)
|
|
? await tx
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
status: heartbeatRuns.status,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.createdByRunId),
|
|
eq(heartbeatRuns.companyId, issue.companyId),
|
|
...(input.createdByAgentId
|
|
? [eq(heartbeatRuns.agentId, input.createdByAgentId)]
|
|
: []),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const registeredRunId = registeredRun?.id ?? null;
|
|
const parentComment = input.issueCommentId
|
|
? await tx
|
|
.select({
|
|
id: issueComments.id,
|
|
companyId: issueComments.companyId,
|
|
issueId: issueComments.issueId,
|
|
authorType: issueComments.authorType,
|
|
authorAgentId: issueComments.authorAgentId,
|
|
createdByRunId: issueComments.createdByRunId,
|
|
metadata: issueComments.metadata,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.id, input.issueCommentId),
|
|
isNull(issueComments.deletedAt),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
if (input.issueCommentId && !parentComment) {
|
|
throw notFound("Issue comment not found");
|
|
}
|
|
if (
|
|
parentComment &&
|
|
(parentComment.companyId !== issue.companyId ||
|
|
parentComment.issueId !== issue.id)
|
|
) {
|
|
throw unprocessable(
|
|
"Attachment comment must belong to same issue and company",
|
|
);
|
|
}
|
|
if (input.createdByAgentId && parentComment) {
|
|
if (
|
|
!registeredRunId ||
|
|
parentComment.authorType !== "agent" ||
|
|
parentComment.authorAgentId !== input.createdByAgentId ||
|
|
parentComment.createdByRunId !== registeredRunId
|
|
) {
|
|
throw unprocessable(
|
|
"Agent attachment comments must belong to the same agent and current run",
|
|
{ code: "issue_attachment_parent_run_mismatch" },
|
|
);
|
|
}
|
|
const chatBindings = isExplicitExternalAgentComment(
|
|
parentComment.metadata,
|
|
)
|
|
? await resolveChatOriginPublicationBindings(
|
|
tx,
|
|
issue.companyId,
|
|
issue.id,
|
|
registeredRunId,
|
|
)
|
|
: [];
|
|
if (chatBindings.length > 0) {
|
|
const alreadySelected =
|
|
await listSelectedChatPresentationAttachments(tx, {
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
agentId: input.createdByAgentId,
|
|
runId: registeredRunId,
|
|
});
|
|
if (alreadySelected.length >= MAX_CHAT_PRESENTATION_ATTACHMENTS) {
|
|
throw unprocessable(
|
|
`An agent run can select at most ${MAX_CHAT_PRESENTATION_ATTACHMENTS} attachments for one chat response`,
|
|
{
|
|
code: "chat_attachment_selection_limit_exceeded",
|
|
limit: MAX_CHAT_PRESENTATION_ATTACHMENTS,
|
|
selectedCount: alreadySelected.length + 1,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const [asset] = await tx
|
|
.insert(assets)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
provider: input.provider,
|
|
objectKey: input.objectKey,
|
|
contentType: input.contentType,
|
|
byteSize: input.byteSize,
|
|
sha256: input.sha256,
|
|
originalFilename: input.originalFilename ?? null,
|
|
createdByAgentId: input.createdByAgentId ?? null,
|
|
createdByUserId: input.createdByUserId ?? null,
|
|
})
|
|
.returning();
|
|
|
|
const [attachment] = await tx
|
|
.insert(issueAttachments)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
assetId: asset.id,
|
|
issueCommentId: input.issueCommentId ?? null,
|
|
originatingRunId: registeredRunId,
|
|
})
|
|
.returning();
|
|
const contentPath = `/api/attachments/${attachment.id}/content`;
|
|
const [artifactWorkProduct] = registeredRunId
|
|
? await tx
|
|
.insert(issueWorkProducts)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
type: "artifact",
|
|
provider: "paperclip",
|
|
externalId: attachment.id,
|
|
title: asset.originalFilename ?? "Attachment",
|
|
status: "active",
|
|
reviewState: "none",
|
|
isPrimary: false,
|
|
healthStatus: "unknown",
|
|
metadata: {
|
|
attachmentId: attachment.id,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
contentPath,
|
|
openPath: contentPath,
|
|
downloadPath: `${contentPath}?download=1`,
|
|
originalFilename: asset.originalFilename,
|
|
},
|
|
createdByRunId: registeredRunId,
|
|
})
|
|
.returning({ id: issueWorkProducts.id })
|
|
: [];
|
|
|
|
if (
|
|
input.createdByAgentId &&
|
|
registeredRunId &&
|
|
parentComment?.authorType === "agent" &&
|
|
parentComment.authorAgentId === input.createdByAgentId &&
|
|
parentComment.createdByRunId === registeredRunId &&
|
|
parentComment.metadata?.authorizationReason ===
|
|
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
|
|
) {
|
|
const bindings = await resolveChatOriginPublicationBindings(
|
|
tx,
|
|
issue.companyId,
|
|
issue.id,
|
|
registeredRunId,
|
|
);
|
|
for (const binding of bindings) {
|
|
await tx
|
|
.insert(chatPublications)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
endpointId: binding.endpointId,
|
|
conversationId: binding.conversationId,
|
|
issueId: issue.id,
|
|
commentId: parentComment.id,
|
|
idempotencyKey: `attachment:${attachment.id}:${binding.endpointId}`,
|
|
payload: projectSafeChatPublication({
|
|
classification: "external",
|
|
source: "agent_comment",
|
|
text: `Shared ${asset.originalFilename ?? "a file"}.`,
|
|
attachmentIds: [attachment.id],
|
|
}),
|
|
state: "pending",
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: attachment.id,
|
|
companyId: attachment.companyId,
|
|
issueId: attachment.issueId,
|
|
issueCommentId: attachment.issueCommentId,
|
|
originatingRunId: attachment.originatingRunId,
|
|
assetId: attachment.assetId,
|
|
provider: asset.provider,
|
|
objectKey: asset.objectKey,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
sha256: asset.sha256,
|
|
originalFilename: asset.originalFilename,
|
|
createdByAgentId: asset.createdByAgentId,
|
|
createdByUserId: asset.createdByUserId,
|
|
createdAt: attachment.createdAt,
|
|
updatedAt: attachment.updatedAt,
|
|
artifactWorkProductId: artifactWorkProduct?.id ?? null,
|
|
};
|
|
});
|
|
},
|
|
|
|
listAttachments: async (issueId: string) =>
|
|
db
|
|
.select({
|
|
id: issueAttachments.id,
|
|
companyId: issueAttachments.companyId,
|
|
issueId: issueAttachments.issueId,
|
|
issueCommentId: issueAttachments.issueCommentId,
|
|
originatingRunId: issueAttachments.originatingRunId,
|
|
assetId: issueAttachments.assetId,
|
|
provider: assets.provider,
|
|
objectKey: assets.objectKey,
|
|
contentType: assets.contentType,
|
|
byteSize: assets.byteSize,
|
|
sha256: assets.sha256,
|
|
originalFilename: assets.originalFilename,
|
|
createdByAgentId: assets.createdByAgentId,
|
|
createdByUserId: assets.createdByUserId,
|
|
createdAt: issueAttachments.createdAt,
|
|
updatedAt: issueAttachments.updatedAt,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
|
.where(eq(issueAttachments.issueId, issueId))
|
|
.orderBy(desc(issueAttachments.createdAt)),
|
|
|
|
getAttachmentById: async (id: string) =>
|
|
db
|
|
.select({
|
|
id: issueAttachments.id,
|
|
companyId: issueAttachments.companyId,
|
|
issueId: issueAttachments.issueId,
|
|
issueCommentId: issueAttachments.issueCommentId,
|
|
originatingRunId: issueAttachments.originatingRunId,
|
|
assetId: issueAttachments.assetId,
|
|
provider: assets.provider,
|
|
objectKey: assets.objectKey,
|
|
contentType: assets.contentType,
|
|
byteSize: assets.byteSize,
|
|
sha256: assets.sha256,
|
|
originalFilename: assets.originalFilename,
|
|
createdByAgentId: assets.createdByAgentId,
|
|
createdByUserId: assets.createdByUserId,
|
|
createdAt: issueAttachments.createdAt,
|
|
updatedAt: issueAttachments.updatedAt,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
|
.where(eq(issueAttachments.id, id))
|
|
.then((rows) => rows[0] ?? null),
|
|
|
|
removeAttachment: async (id: string) =>
|
|
db.transaction(async (tx) => {
|
|
const existing = await tx
|
|
.select({
|
|
id: issueAttachments.id,
|
|
companyId: issueAttachments.companyId,
|
|
issueId: issueAttachments.issueId,
|
|
issueCommentId: issueAttachments.issueCommentId,
|
|
originatingRunId: issueAttachments.originatingRunId,
|
|
assetId: issueAttachments.assetId,
|
|
provider: assets.provider,
|
|
objectKey: assets.objectKey,
|
|
contentType: assets.contentType,
|
|
byteSize: assets.byteSize,
|
|
sha256: assets.sha256,
|
|
originalFilename: assets.originalFilename,
|
|
createdByAgentId: assets.createdByAgentId,
|
|
createdByUserId: assets.createdByUserId,
|
|
createdAt: issueAttachments.createdAt,
|
|
updatedAt: issueAttachments.updatedAt,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
|
.where(eq(issueAttachments.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
|
|
await tx.delete(assets).where(eq(assets.id, existing.assetId));
|
|
return existing;
|
|
}),
|
|
|
|
findMentionedAgents: async (companyId: string, body: string) => {
|
|
const explicitAgentMentionIds = extractAgentMentionIds(body);
|
|
if (explicitAgentMentionIds.length === 0) return [];
|
|
|
|
const rows = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId));
|
|
const companyAgentIds = new Set(rows.map((agent) => agent.id));
|
|
return explicitAgentMentionIds.filter((agentId) =>
|
|
companyAgentIds.has(agentId),
|
|
);
|
|
},
|
|
|
|
findMentionedProjectIds: async (
|
|
issueId: string,
|
|
opts?: { includeCommentBodies?: boolean },
|
|
) => {
|
|
const issue = await db
|
|
.select({
|
|
companyId: issues.companyId,
|
|
title: issues.title,
|
|
description: issues.description,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue) return [];
|
|
|
|
const mentionedIds = new Set<string>();
|
|
for (const source of [issue.title, issue.description ?? ""]) {
|
|
for (const projectId of extractProjectMentionIds(source)) {
|
|
mentionedIds.add(projectId);
|
|
}
|
|
}
|
|
|
|
if (opts?.includeCommentBodies !== false) {
|
|
const comments = await db
|
|
.select({ body: issueComments.body })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.issueId, issueId),
|
|
isNull(issueComments.deletedAt),
|
|
),
|
|
);
|
|
|
|
for (const comment of comments) {
|
|
for (const projectId of extractProjectMentionIds(comment.body)) {
|
|
mentionedIds.add(projectId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mentionedIds.size === 0) return [];
|
|
|
|
const rows = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(
|
|
and(
|
|
eq(projects.companyId, issue.companyId),
|
|
inArray(projects.id, [...mentionedIds]),
|
|
),
|
|
);
|
|
const valid = new Set(rows.map((row) => row.id));
|
|
return [...mentionedIds].filter((projectId) => valid.has(projectId));
|
|
},
|
|
|
|
getAncestors: async (issueId: string) => {
|
|
const raw: Array<{
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
description: string | null;
|
|
status: string;
|
|
priority: string;
|
|
assigneeAgentId: string | null;
|
|
projectId: string | null;
|
|
goalId: string | null;
|
|
}> = [];
|
|
const visited = new Set<string>([issueId]);
|
|
const start = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((r) => r[0] ?? null);
|
|
let currentId = start?.parentId ?? null;
|
|
while (currentId && !visited.has(currentId) && raw.length < 50) {
|
|
visited.add(currentId);
|
|
const parent = await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
description: issues.description,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
projectId: issues.projectId,
|
|
goalId: issues.goalId,
|
|
parentId: issues.parentId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, currentId))
|
|
.then((r) => r[0] ?? null);
|
|
if (!parent) break;
|
|
raw.push({
|
|
id: parent.id,
|
|
identifier: parent.identifier ?? null,
|
|
title: parent.title,
|
|
description: parent.description ?? null,
|
|
status: parent.status,
|
|
priority: parent.priority,
|
|
assigneeAgentId: parent.assigneeAgentId ?? null,
|
|
projectId: parent.projectId ?? null,
|
|
goalId: parent.goalId ?? null,
|
|
});
|
|
currentId = parent.parentId ?? null;
|
|
}
|
|
|
|
// Batch-fetch referenced projects and goals
|
|
const projectIds = [
|
|
...new Set(
|
|
raw.map((a) => a.projectId).filter((id): id is string => id != null),
|
|
),
|
|
];
|
|
const goalIds = [
|
|
...new Set(
|
|
raw.map((a) => a.goalId).filter((id): id is string => id != null),
|
|
),
|
|
];
|
|
|
|
const projectMap = new Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
status: string;
|
|
goalId: string | null;
|
|
workspaces: Array<{
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string;
|
|
name: string;
|
|
cwd: string | null;
|
|
repoUrl: string | null;
|
|
repoRef: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
isPrimary: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}>;
|
|
primaryWorkspace: {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string;
|
|
name: string;
|
|
cwd: string | null;
|
|
repoUrl: string | null;
|
|
repoRef: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
isPrimary: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
} | null;
|
|
}
|
|
>();
|
|
const goalMap = new Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
level: string;
|
|
status: string;
|
|
}
|
|
>();
|
|
|
|
if (projectIds.length > 0) {
|
|
const workspaceRows = await db
|
|
.select()
|
|
.from(projectWorkspaces)
|
|
.where(inArray(projectWorkspaces.projectId, projectIds))
|
|
.orderBy(
|
|
desc(projectWorkspaces.isPrimary),
|
|
asc(projectWorkspaces.createdAt),
|
|
asc(projectWorkspaces.id),
|
|
);
|
|
const workspaceMap = new Map<
|
|
string,
|
|
Array<(typeof workspaceRows)[number]>
|
|
>();
|
|
for (const workspace of workspaceRows) {
|
|
const existing = workspaceMap.get(workspace.projectId);
|
|
if (existing) existing.push(workspace);
|
|
else workspaceMap.set(workspace.projectId, [workspace]);
|
|
}
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: projects.id,
|
|
name: projects.name,
|
|
description: projects.description,
|
|
status: projects.status,
|
|
goalId: projects.goalId,
|
|
})
|
|
.from(projects)
|
|
.where(inArray(projects.id, projectIds));
|
|
for (const r of rows) {
|
|
const projectWorkspaceRows = workspaceMap.get(r.id) ?? [];
|
|
const workspaces = projectWorkspaceRows.map((workspace) => ({
|
|
id: workspace.id,
|
|
companyId: workspace.companyId,
|
|
projectId: workspace.projectId,
|
|
name: workspace.name,
|
|
cwd: workspace.cwd,
|
|
repoUrl: workspace.repoUrl ?? null,
|
|
repoRef: workspace.repoRef ?? null,
|
|
metadata:
|
|
(workspace.metadata as Record<string, unknown> | null) ?? null,
|
|
isPrimary: workspace.isPrimary,
|
|
createdAt: workspace.createdAt,
|
|
updatedAt: workspace.updatedAt,
|
|
}));
|
|
const primaryWorkspace =
|
|
workspaces.find((workspace) => workspace.isPrimary) ??
|
|
workspaces[0] ??
|
|
null;
|
|
projectMap.set(r.id, {
|
|
...r,
|
|
workspaces,
|
|
primaryWorkspace,
|
|
});
|
|
// Also collect goalIds from projects
|
|
if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId);
|
|
}
|
|
}
|
|
|
|
if (goalIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: goals.id,
|
|
title: goals.title,
|
|
description: goals.description,
|
|
level: goals.level,
|
|
status: goals.status,
|
|
})
|
|
.from(goals)
|
|
.where(inArray(goals.id, goalIds));
|
|
for (const r of rows) goalMap.set(r.id, r);
|
|
}
|
|
|
|
return raw.map((a) => ({
|
|
...a,
|
|
project: a.projectId ? (projectMap.get(a.projectId) ?? null) : null,
|
|
goal: a.goalId ? (goalMap.get(a.goalId) ?? null) : null,
|
|
}));
|
|
},
|
|
};
|
|
|
|
type IssueServiceApi = typeof service & {
|
|
updateForCompany: (
|
|
id: string,
|
|
companyId: string,
|
|
data: Parameters<typeof service.update>[1],
|
|
dbOrTx?: any,
|
|
postCommitActivityPublications?: ActivityPublication[],
|
|
postCommitActions?: IssuePostCommitAction[],
|
|
) => ReturnType<typeof service.update>;
|
|
};
|
|
const serviceApi = service as IssueServiceApi;
|
|
|
|
// A company-scoped wrapper around `update`. It passes the company as a
|
|
// guard on every read, lock, and write predicate, so a caller with only
|
|
// a company id and an issue id cannot update an issue in another company.
|
|
serviceApi.updateForCompany = async (
|
|
id,
|
|
companyId,
|
|
data,
|
|
dbOrTx = db,
|
|
postCommitActivityPublications,
|
|
postCommitActions,
|
|
) => {
|
|
return service.update(
|
|
id,
|
|
{ ...data, companyGuard: companyId },
|
|
dbOrTx,
|
|
postCommitActivityPublications,
|
|
postCommitActions,
|
|
);
|
|
};
|
|
|
|
return serviceApi;
|
|
}
|