mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents reach their tools through the MCP tool gateway. Every run starts with `tools/list`. > - Tool discovery runs a GitHub guest sponsor probe (`githubGuestBotConnectionForSession`) before it lists any connection. > - The probe walked the run's identity chain before it knew whether the task had a guest sponsored GitHub message at all. > - When agent A works a task and the task is reassigned to agent B, B's run chain continues A's run. The chain walk refuses with a plain `HttpError`. The gateway returns 403 for `tools/list` with no audit record. B loses every tool, built-in and connected, even when the company has no GitHub endpoint. > - This pull request makes the probe look up guest sponsored GitHub messages for the task and agent first. When there are none, the probe answers `null` and does not consult provenance. > - The benefit is that a reassigned or continued task keeps its tools, while a guest sponsored task still refuses a broken chain. ## Linked Issues or Issue Description Fixes #17307 **What happened?** `tools/list` on the MCP gateway returned `403 {"error":"Chat tools cannot be inherited by another task"}` for every run whose identity chain contained a run of another agent or another task. No audit event was recorded. The agent received no tools at all, including unrelated MCP connections. The company had no GitHub chat endpoint. **Expected behavior** A run on a task with no GitHub guest sponsor lists its tools. The guest probe only decides whether the run must be limited to the sponsoring bot's connection. When the task has no guest sponsored GitHub message, the answer is "no sponsor" and discovery continues. **Steps to reproduce** 1. Let agent A run on a task. The run gets an accepted identity context. 2. Reassign the task to agent B and let B run. B's identity context has A's context as parent. 3. Call `tools/list` for B's run. Before this change the gateway returns 403 with the message above. The same happens when the parent context belongs to a run on another task (see #17307). **Call path** `handleMcpGatewayProtocol` → `listToolsForNamedGateway` → `listToolsForContext` → `buildToolsForContext` → `connectionToolsForContext` → `githubGuestBotConnectionForSession` → `chatRunSourceMessageId`, which throws `forbidden(...)`. The error is an `HttpError`, not a `ToolGatewayHttpError`, so the route sends it as a bare 403 and the discovery audit never runs. **Paperclip version or commit** `d69631b01b9c658f80bf8bed389863dce82643e1` (image `sha-d69631b`), Docker, authenticated mode with PostgreSQL. ## What Changed - `server/src/services/chat-github-tools.ts`: `githubGuestBotConnectionForSession` first selects the inbound GitHub message links on the task whose delivery carries `githubAuthority.guest = true`, for endpoints assigned to the session agent. When there are none it returns `null` without reading the run or its provenance chain. When there are some, it verifies provenance as before and returns the connection of the link that matches the verified source message. - `server/src/services/chat-channels/run-origin.ts` is unchanged. The cross-agent and cross-task refusals stay in place for Slack authority, GitHub reviews and response comments, and for guest sponsored tasks. - New test `server/src/services/chat-github-tools.test.ts` (embedded PostgreSQL, same pattern as `run-origin.test.ts`): - a run whose chain continues another agent, or another task, with no guest message → `null` (fails before this change with the 403 above, passes after); - a guest sponsored task whose chain continues another agent → still rejects with status 403; - a run sponsored by a GitHub guest → returns the bot connection; the same run with `guest: false` → `null`. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/services/chat-github-tools.test.ts`: 2 failed / 3 passed before the fix, 5 passed after. - `pnpm --filter @paperclipai/server exec vitest run src/services/chat-channels/run-origin.test.ts src/services/chat-github-tools.test.ts`: all passed. - `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server exec tsc --noEmit`: clean. - Manual: with a patched image, a run on a task that was reassigned between two agents receives its full tool list through `tools/list` and a `discovery` audit event is written. Before the patch the same kind of run received the 403 and no audit event. ## Risks - Low risk. The probe's result is unchanged for every session where it returned a value before. The only behavior change is for sessions that previously threw: when the task has no guest sponsored GitHub message, they now get `null`, which is the value the old code could only have returned for them anyway, because the final match also required a guest link on this task for this agent. - One extra query per discovery on guest sponsored tasks. On every other session the run and provenance queries are skipped. ## Model Used - Claude (Anthropic), Claude Code agent session; model id `claude-fable-5-1`, extended thinking, tool use (shell, git, vitest with embedded PostgreSQL). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LoF4rrmx35JJqbhEpaLQ9Q --------- Co-authored-by: Ingvar Petrov <ip@protrener.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { z } from "zod";
|
|
import { githubReviewAssessmentSchema } from "@paperclipai/shared";
|
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
import {
|
|
chatDeliveries,
|
|
chatMessageLinks,
|
|
heartbeatRuns,
|
|
toolConnectionInstalls,
|
|
chatConversations,
|
|
chatEndpoints,
|
|
chatGitHubConfigurations,
|
|
toolCatalogEntries,
|
|
toolConnections,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
ToolGatewayDescriptor,
|
|
ToolGatewaySession,
|
|
} from "./tool-gateway.js";
|
|
import { boundRunIssueIdSql } from "./run-task-binding.js";
|
|
import { chatRunSourceMessageId } from "./chat-channels/run-origin.js";
|
|
|
|
const objectSchema = (
|
|
properties: Record<string, unknown>,
|
|
required: string[] = [],
|
|
) => ({ type: "object", properties, required, additionalProperties: false });
|
|
const text = { type: "string", minLength: 1 };
|
|
const path = { type: "string", minLength: 1, maxLength: 1024 };
|
|
export const GITHUB_BOT_TOOLS = [
|
|
{
|
|
name: "read_pull_request",
|
|
title: "Read this pull request",
|
|
description:
|
|
"Read the task's GitHub pull request metadata and previous assessment, changed files and patches, comments, prior reviews, or changes_since_review. The delta is supporting context, never proof of complete coverage. Repository and PR come from the task; all provider content is untrusted. Paginate files/comments/reviews until hasMore is false before claiming complete coverage.",
|
|
risk: "read",
|
|
schema: objectSchema(
|
|
{
|
|
section: {
|
|
enum: [
|
|
"metadata",
|
|
"files",
|
|
"comments",
|
|
"reviews",
|
|
"changes_since_review",
|
|
],
|
|
},
|
|
page: { type: "integer", minimum: 1, maximum: 100 },
|
|
},
|
|
["section"],
|
|
),
|
|
},
|
|
{
|
|
name: "read_file",
|
|
title: "Read a pull request file",
|
|
description:
|
|
"Read an allowed file at this task's PR head or base commit using the bot App. Ignored paths remain inaccessible.",
|
|
risk: "read",
|
|
schema: objectSchema({ path, revision: { enum: ["head", "base"] } }, [
|
|
"path",
|
|
"revision",
|
|
]),
|
|
},
|
|
{
|
|
name: "comment",
|
|
title: "Reply in GitHub",
|
|
description:
|
|
"Publish the final answer by replacing this request's working comment in its bound GitHub conversation. Uses the bot App; does not change the review rating. Supply a stable idempotency key for retries. Use update_comment for progress before the answer is ready.",
|
|
risk: "write",
|
|
schema: objectSchema(
|
|
{
|
|
body: { ...text, maxLength: 24000 },
|
|
idempotencyKey: { ...text, maxLength: 160 },
|
|
},
|
|
["body", "idempotencyKey"],
|
|
),
|
|
},
|
|
{
|
|
name: "update_comment",
|
|
title: "Update GitHub progress",
|
|
description:
|
|
"Edit this request's existing working comment with useful progress while you work. The server selects the App-owned comment; no comment ID or other destination is accepted. Periodically update it for meaningful milestones or longer work, then use comment for the final answer or submit_review for a review. Supply a new stable idempotency key for each distinct update; retries reuse that key. Does not complete a review or change its rating.",
|
|
risk: "write",
|
|
schema: objectSchema({ body: { ...text, maxLength: 24000 }, idempotencyKey: { ...text, maxLength: 160 } }, ["body", "idempotencyKey"]),
|
|
},
|
|
{
|
|
name: "begin_review",
|
|
title: "Start a pull request assessment",
|
|
description:
|
|
"Start or resume this run's assessment of an exact PR head and mark its Paperclip Review check pending. Call only when the authorized request is to review/re-review the PR, before analysis. Do not call for ordinary discussion or a standalone permission check. Metadata/file reads never change the rating.",
|
|
risk: "write",
|
|
schema: objectSchema(
|
|
{ reviewedCommit: { type: "string", pattern: "^[a-fA-F0-9]{40}$" } },
|
|
["reviewedCommit"],
|
|
),
|
|
},
|
|
{
|
|
name: "submit_review",
|
|
title: "Submit a review assessment",
|
|
description:
|
|
"Submit a structured assessment for this task's exact PR head. Use begin_review before starting an explicitly requested review. Paperclip validates coverage and score, publishes allowed summary/findings, and computes the Paperclip Review check. Coverage reviewedPaths and omittedPaths name only allowed changed files from read_pull_request(files); describe additional context in the rationale. Follow the schema length limits. Incomplete analysis cannot pass. This never formally approves a PR. Transient publication failures retry automatically. A received result or failed result with receipt.retryable=true remains queued; do not claim publication yet. After a timeout, repeat the exact call to recover its durable receipt. Do not begin another review or revise the assessment just to retry publication.",
|
|
risk: "write",
|
|
// Share the input contract with server validation so discovery includes every
|
|
// length/array bound; hidden limits caused real agents to abandon publication.
|
|
schema: z.toJSONSchema(githubReviewAssessmentSchema, {
|
|
target: "draft-7",
|
|
io: "input",
|
|
}),
|
|
},
|
|
{
|
|
name: "formal_review",
|
|
title: "Submit a formal GitHub review",
|
|
description:
|
|
"Separate governed APPROVE or REQUEST_CHANGES action for the current reviewed commit. Each action must be explicitly enabled in the bot's publication policy. A 5/5 assessment never grants this permission.",
|
|
risk: "write",
|
|
schema: objectSchema(
|
|
{
|
|
event: { enum: ["APPROVE", "REQUEST_CHANGES"] },
|
|
reviewedCommit: { type: "string", pattern: "^[a-fA-F0-9]{40}$" },
|
|
body: { ...text, maxLength: 24000 },
|
|
idempotencyKey: { ...text, maxLength: 160 },
|
|
},
|
|
["event", "reviewedCommit", "body", "idempotencyKey"],
|
|
),
|
|
},
|
|
] as const;
|
|
|
|
/** Called only by explicit bot-tool configuration, within its audited transaction. */
|
|
export async function syncGitHubBotTools(
|
|
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
|
|
endpoint: typeof chatEndpoints.$inferSelect,
|
|
userId: string,
|
|
enabled: boolean,
|
|
) {
|
|
const [connection] = await tx
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, endpoint.companyId),
|
|
eq(toolConnections.id, endpoint.connectionId),
|
|
),
|
|
);
|
|
if (!connection) return;
|
|
for (const tool of GITHUB_BOT_TOOLS) {
|
|
const hash = createHash("sha256")
|
|
.update(JSON.stringify(tool))
|
|
.digest("hex");
|
|
const values = {
|
|
companyId: endpoint.companyId,
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
entryKind: "tool" as const,
|
|
name: `github_bot:${tool.name}`,
|
|
toolName: tool.name,
|
|
title: tool.title,
|
|
description: tool.description,
|
|
inputSchema: tool.schema,
|
|
riskLevel: tool.risk,
|
|
isReadOnly: tool.risk === "read",
|
|
isWrite: tool.risk === "write",
|
|
versionHash: hash,
|
|
schemaHash: hash,
|
|
status: enabled ? ("active" as const) : ("removed" as const),
|
|
reviewedByUserId: userId,
|
|
reviewedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
await tx
|
|
.insert(toolCatalogEntries)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: [toolCatalogEntries.connectionId, toolCatalogEntries.name],
|
|
set: values,
|
|
});
|
|
}
|
|
if (enabled)
|
|
await tx
|
|
.insert(toolConnectionInstalls)
|
|
.values({
|
|
companyId: endpoint.companyId,
|
|
connectionId: connection.id,
|
|
targetType: "agent",
|
|
targetId: endpoint.assignedAgentId,
|
|
createdByUserId: userId,
|
|
})
|
|
.onConflictDoNothing();
|
|
const profileKey = `github-bot:${endpoint.id}`;
|
|
const [profile] = await tx
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: endpoint.companyId,
|
|
profileKey,
|
|
name: `GitHub bot ${endpoint.id}`,
|
|
description: "Task-scoped tools using this bot's App connection",
|
|
defaultAction: "deny",
|
|
status: enabled ? "active" : "disabled",
|
|
metadata: { githubChatEndpointId: endpoint.id },
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [toolProfiles.companyId, toolProfiles.profileKey],
|
|
set: { status: enabled ? "active" : "disabled", updatedAt: new Date() },
|
|
})
|
|
.returning();
|
|
await tx
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, endpoint.companyId),
|
|
eq(toolProfileEntries.profileId, profile!.id),
|
|
),
|
|
);
|
|
await tx.insert(toolProfileEntries).values({
|
|
companyId: endpoint.companyId,
|
|
profileId: profile!.id,
|
|
selectorType: "connection",
|
|
connectionId: connection.id,
|
|
effect: "include",
|
|
});
|
|
await tx
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId: endpoint.companyId,
|
|
profileId: profile!.id,
|
|
targetType: "agent",
|
|
targetId: endpoint.assignedAgentId,
|
|
createdByUserId: userId,
|
|
metadata: { githubChatEndpointId: endpoint.id },
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
|
|
export async function githubBotToolsForSession(
|
|
db: Db,
|
|
session: Pick<
|
|
ToolGatewaySession,
|
|
"companyId" | "agentId" | "issueId" | "runId"
|
|
>,
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
if (!session.agentId || !session.issueId || !session.runId) return [];
|
|
const rows = await db
|
|
// A PR task can also own inline finding threads. They share one endpoint
|
|
// and catalog, so discovery must not emit a tool once per conversation.
|
|
.selectDistinct({
|
|
endpoint: chatEndpoints,
|
|
connection: toolConnections,
|
|
entry: toolCatalogEntries,
|
|
configuration: chatGitHubConfigurations.configuration,
|
|
})
|
|
.from(chatConversations)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
)
|
|
.innerJoin(
|
|
chatGitHubConfigurations,
|
|
eq(chatGitHubConfigurations.endpointId, chatEndpoints.id),
|
|
)
|
|
.innerJoin(
|
|
toolConnections,
|
|
eq(toolConnections.id, chatEndpoints.connectionId),
|
|
)
|
|
.innerJoin(
|
|
toolCatalogEntries,
|
|
eq(toolCatalogEntries.connectionId, toolConnections.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatConversations.companyId, session.companyId),
|
|
eq(chatConversations.issueId, session.issueId),
|
|
eq(chatEndpoints.assignedAgentId, session.agentId),
|
|
eq(chatEndpoints.provider, "github"),
|
|
eq(toolConnections.enabled, true),
|
|
eq(toolConnections.status, "active"),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
isNull(toolCatalogEntries.quarantinedAt),
|
|
),
|
|
);
|
|
// Add the scoped editing companion only to an already enabled bot with an
|
|
// active, reviewed reply capability. Existing profiles, grants, disabled
|
|
// tools and quarantine decisions are never reset during discovery.
|
|
const progress = GITHUB_BOT_TOOLS.find(tool => tool.name === "update_comment")!;
|
|
for (const row of rows) {
|
|
if (row.entry.name !== "github_bot:comment" || !row.configuration.toolsEnabled ||
|
|
!["active", "verifying"].includes(row.endpoint.status) ||
|
|
rows.some(other => other.connection.id === row.connection.id && other.entry.name === "github_bot:update_comment")) continue;
|
|
const versionHash = createHash("sha256").update(JSON.stringify(progress)).digest("hex");
|
|
const [entry] = await db.insert(toolCatalogEntries).values({
|
|
companyId: row.endpoint.companyId, applicationId: row.connection.applicationId,
|
|
connectionId: row.connection.id, entryKind: "tool", name: "github_bot:update_comment",
|
|
toolName: progress.name, title: progress.title, description: progress.description,
|
|
inputSchema: progress.schema, riskLevel: "write", isReadOnly: false, isWrite: true,
|
|
versionHash, schemaHash: versionHash, status: "active",
|
|
reviewedByUserId: row.entry.reviewedByUserId, reviewedAt: row.entry.reviewedAt,
|
|
}).onConflictDoNothing().returning();
|
|
if (entry) rows.push({ ...row, entry });
|
|
}
|
|
return rows
|
|
.filter(
|
|
(row) =>
|
|
row.configuration.toolsEnabled &&
|
|
["active", "verifying"].includes(row.endpoint.status) &&
|
|
GITHUB_BOT_TOOLS.some(
|
|
(tool) => row.entry.name === `github_bot:${tool.name}`,
|
|
),
|
|
)
|
|
.map(({ endpoint, connection, entry }) => ({
|
|
name: `github-bot.${endpoint.id}:${entry.toolName}`,
|
|
displayName: entry.title ?? entry.toolName,
|
|
description: entry.description ?? "",
|
|
parametersSchema: entry.inputSchema,
|
|
pluginId: `github-bot:${endpoint.id}`,
|
|
providerType: "paperclip_github_chat",
|
|
risk: entry.riskLevel === "read" ? "read" : "write",
|
|
applicationId: connection.applicationId,
|
|
applicationKey: "github-chat",
|
|
applicationDisplayName: "GitHub bot",
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
upstreamToolName: entry.toolName,
|
|
providerMetadata: { endpointId: endpoint.id },
|
|
}));
|
|
}
|
|
|
|
/** Only a run on the endpoint's bound task may realize its channel tools. */
|
|
export async function githubBotConnectionIdsForRun(
|
|
db: Db,
|
|
companyId: string,
|
|
agentId: string,
|
|
runId: string,
|
|
): Promise<Set<string>> {
|
|
const [run] = await db
|
|
.select({
|
|
issueId: boundRunIssueIdSql(),
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.agentId, agentId),
|
|
eq(heartbeatRuns.id, runId),
|
|
),
|
|
);
|
|
const issueId = run?.issueId;
|
|
if (typeof issueId !== "string") return new Set();
|
|
const rows = await db
|
|
.select({
|
|
connectionId: chatEndpoints.connectionId,
|
|
configuration: chatGitHubConfigurations.configuration,
|
|
})
|
|
.from(chatConversations)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
)
|
|
.innerJoin(
|
|
chatGitHubConfigurations,
|
|
eq(chatGitHubConfigurations.endpointId, chatEndpoints.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatConversations.companyId, companyId),
|
|
eq(chatConversations.issueId, issueId),
|
|
eq(chatEndpoints.assignedAgentId, agentId),
|
|
eq(chatEndpoints.provider, "github"),
|
|
),
|
|
);
|
|
return new Set(
|
|
rows
|
|
.filter((row) => row.configuration.toolsEnabled)
|
|
.map((row) => row.connectionId),
|
|
);
|
|
}
|
|
|
|
/** Admission records whether this run was sponsored. Linking later cannot lend
|
|
* a previously admitted guest the sponsor's personal connections. */
|
|
export async function githubGuestBotConnectionForSession(
|
|
db: Db,
|
|
session: Pick<
|
|
ToolGatewaySession,
|
|
"companyId" | "agentId" | "runId" | "issueId"
|
|
>,
|
|
): Promise<string | null> {
|
|
if (!session.runId || !session.agentId || !session.issueId) return null;
|
|
const guestSource = (commentId?: string) => db
|
|
.select({ connectionId: chatEndpoints.connectionId })
|
|
.from(chatMessageLinks)
|
|
.innerJoin(
|
|
chatDeliveries,
|
|
eq(chatDeliveries.id, chatMessageLinks.deliveryId),
|
|
)
|
|
.innerJoin(
|
|
chatConversations,
|
|
eq(chatConversations.id, chatMessageLinks.conversationId),
|
|
)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatMessageLinks.companyId, session.companyId),
|
|
...(commentId ? [eq(chatMessageLinks.commentId, commentId)] : []),
|
|
eq(chatConversations.issueId, session.issueId!),
|
|
eq(chatEndpoints.assignedAgentId, session.agentId!),
|
|
eq(chatEndpoints.provider, "github"),
|
|
sql`${chatDeliveries.normalizedEvent} -> 'githubAuthority' ->> 'guest' = 'true'`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!(await guestSource()).length) return null;
|
|
const [run] = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
issueId: boundRunIssueIdSql(),
|
|
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('wakeCommentId', ${heartbeatRuns.contextSnapshot}->'wakeCommentId')`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, session.companyId),
|
|
eq(heartbeatRuns.agentId, session.agentId),
|
|
eq(heartbeatRuns.id, session.runId),
|
|
),
|
|
);
|
|
const commentId = run ? await chatRunSourceMessageId(db, run, { allowLegacyWakeComment: true, provider: "github" }) : null;
|
|
if (typeof commentId !== "string") return null;
|
|
const [source] = await guestSource(commentId);
|
|
return source?.connectionId ?? null;
|
|
}
|