diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3fa574b7..248a51ed 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -159,7 +159,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { extractSkillMentionIds } from "@paperclipai/shared"; +import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; import { environmentService } from "./environments.js"; import { environmentRuntimeService } from "./environment-runtime.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; @@ -1777,7 +1777,7 @@ function enrichWakeContextSnapshot(input: { payload: Record | null; }) { const { contextSnapshot, reason, source, triggerDetail, payload } = input; - const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]); + const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]) ?? readNonEmptyString(payload?.["taskId"]); const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); const taskKey = deriveTaskKey(contextSnapshot, payload); const wakeCommentId = deriveCommentId(contextSnapshot, payload); @@ -3419,7 +3419,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null }, ): Promise { - const issueId = readNonEmptyString(context.issueId); + const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); const contextProjectId = readNonEmptyString(context.projectId); const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId); const issueProjectRef = issueId @@ -8606,11 +8606,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let projectId = readNonEmptyString(enrichedContextSnapshot.projectId); if (!projectId && issueId) { - projectId = await db - .select({ projectId: issues.projectId }) + // Look up by either UUID or identifier (e.g. "ENV-13"), but always scope + // by companyId so a row from another tenant can never be returned even + // when identifiers collide across companies. Guard the UUID arm because + // issues.id is a Postgres uuid column — passing "ENV-13" into eq(issues.id, …) + // would fail with an invalid-input-syntax cast error before the OR is + // evaluated. + const lookupIsUuid = isUuidLike(issueId); + const idMatch = lookupIsUuid + ? or(eq(issues.id, issueId), eq(issues.identifier, issueId.toUpperCase())) + : eq(issues.identifier, issueId.toUpperCase()); + const resolvedIssue = await db + .select({ id: issues.id, projectId: issues.projectId }) .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0]?.projectId ?? null); + .where(and(eq(issues.companyId, agent.companyId), idMatch)) + .then((rows) => rows[0] ?? null); + if (resolvedIssue) { + projectId = resolvedIssue.projectId ?? null; + // Canonicalize context to the UUID so downstream lookups always use UUID + if (resolvedIssue.id !== issueId) { + issueId = resolvedIssue.id; + enrichedContextSnapshot.issueId = issueId; + if (readNonEmptyString(enrichedContextSnapshot.taskId)) { + enrichedContextSnapshot.taskId = issueId; + } + } + } + } + // Propagate projectId into context so resolveWorkspaceForRun can bind the + // project workspace even when context.projectId wasn't set by the caller. + if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) { + enrichedContextSnapshot.projectId = projectId; } const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {