mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
refactor(chat): extract resource reconciliation and availability
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
90ac517e6c
commit
9557e0d4ec
3 files changed
+979
-920
No files matched your search
@@ -125,3 +125,28 @@ this slice to `paperclipai/paperclip`; subsequent extractions are not part of
|
||||
this PR.
|
||||
|
||||
Review and final verification: [PR #15823](https://github.com/paperclipai/paperclip/pull/15823).
|
||||
|
||||
## Authorized stacked continuation
|
||||
|
||||
The user requested at least five stacked PRs on 2026-10-10. Continue the four
|
||||
remaining boundaries in order, with each branch based on its predecessor. Keep
|
||||
merge and deployment outside this series. Each PR carries its own current-head
|
||||
verification and review results; do not transfer a prior head's pass to a new one.
|
||||
|
||||
### Slice 2: resource reconciliation and availability
|
||||
|
||||
The lifecycle handler also owns credential replacement, delivery claims and
|
||||
runtime shutdown. Keep those responsibilities in the service. Extract the full
|
||||
non-GitHub availability transaction, inventory reconciliation, repository rename
|
||||
handling, provider ordering, resource listing and audited selection together.
|
||||
The factory takes the database, endpoint lookup, existing credential lease
|
||||
function, generation reader and error text limit. It creates no worker or cache.
|
||||
|
||||
- Source: 38,633 lines / 1,491,422 bytes → 37,720 lines / 1,454,762 bytes.
|
||||
- Destination: 947 lines / 39,584 bytes in `resource-reconciliation.ts`.
|
||||
- Mechanical comparison: 14 declarations and the complete availability
|
||||
transaction are unchanged; all other function bodies remain unchanged.
|
||||
- Baseline: all three complete chat CI shards passed on predecessor `cbba30a526`.
|
||||
- Local baseline and after selections could not start PostgreSQL and skipped
|
||||
the suite. They are not passes. Server typecheck and module boundaries pass.
|
||||
- Require complete chat CI on this slice before starting the next extraction.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,947 @@
|
||||
import { and, asc, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { chatConversations, chatDeliveries, chatEndpointResources, chatEndpoints, toolConnections, type Db } from "@paperclipai/db";
|
||||
import type { ChatEndpointSetupState } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, notFound, unprocessable } from "../../errors.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
import { notifyChatDeliveryWork, notifyChatEndpointWork } from "../chat-work-notifications.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "../activity-log.js";
|
||||
import type { CredentialMutationLeaseGuard, chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
|
||||
import type { ChatProviderInventoryResult, ChatProviderResourceInventoryItem } from "../chat-provider-inventory.js";
|
||||
import type { ChatProviderLifecycleEffect } from "../chat-provider-lifecycle.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type InternalSetupState = ChatEndpointSetupState & { runtimeGeneration?: number };
|
||||
|
||||
type ResourceReconciliationOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
maxErrorText: number;
|
||||
};
|
||||
|
||||
export function githubRepositoryInventoryItemFromPayload(
|
||||
payload: unknown,
|
||||
): ChatProviderResourceInventoryItem | null {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
||||
return null;
|
||||
const repository = (payload as { repository?: unknown }).repository;
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository !== "object" ||
|
||||
Array.isArray(repository)
|
||||
)
|
||||
return null;
|
||||
const value = repository as {
|
||||
id?: unknown;
|
||||
full_name?: unknown;
|
||||
html_url?: unknown;
|
||||
name?: unknown;
|
||||
owner?: { id?: unknown; login?: unknown };
|
||||
private?: unknown;
|
||||
};
|
||||
const repositoryId =
|
||||
typeof value.id === "number" && Number.isSafeInteger(value.id)
|
||||
? String(value.id)
|
||||
: typeof value.id === "string" && /^\d+$/.test(value.id)
|
||||
? value.id
|
||||
: null;
|
||||
const owner =
|
||||
typeof value.owner?.login === "string" ? value.owner.login.trim() : "";
|
||||
const name = typeof value.name === "string" ? value.name.trim() : "";
|
||||
const fullName =
|
||||
typeof value.full_name === "string" && value.full_name.includes("/")
|
||||
? value.full_name.trim()
|
||||
: owner && name
|
||||
? `${owner}/${name}`
|
||||
: "";
|
||||
if (!repositoryId || !fullName) return null;
|
||||
const ownerId =
|
||||
typeof value.owner?.id === "number" && Number.isSafeInteger(value.owner.id)
|
||||
? String(value.owner.id)
|
||||
: typeof value.owner?.id === "string" && /^\d+$/.test(value.owner.id)
|
||||
? value.owner.id
|
||||
: undefined;
|
||||
return {
|
||||
providerResourceId: fullName.toLowerCase(),
|
||||
parentProviderResourceId: ownerId,
|
||||
type: "repository",
|
||||
label: fullName,
|
||||
providerUrl:
|
||||
typeof value.html_url === "string" && value.html_url.length > 0
|
||||
? value.html_url
|
||||
: `https://github.com/${fullName}`,
|
||||
metadata: {
|
||||
providerRepositoryId: repositoryId,
|
||||
fullName,
|
||||
...(owner ? { owner } : {}),
|
||||
private: value.private === true,
|
||||
source: "provider_webhook",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function baseTeamsConversationId(value: string): string {
|
||||
return value.replace(/;messageid=[^;]+/i, "");
|
||||
}
|
||||
|
||||
export function slackResourceLabelIsFallback(
|
||||
providerResourceId: string,
|
||||
label: string,
|
||||
): boolean {
|
||||
const candidate = label.trim();
|
||||
return (
|
||||
candidate === providerResourceId ||
|
||||
candidate === `slack:${providerResourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
export function telegramResourceLabelIsFallback(
|
||||
providerResourceId: string,
|
||||
label: string,
|
||||
): boolean {
|
||||
const candidate = label.trim();
|
||||
return (
|
||||
candidate === providerResourceId ||
|
||||
candidate === `telegram:${providerResourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createChatResourceReconciliation(db: Db, options: ResourceReconciliationOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT } = options;
|
||||
|
||||
function githubRepositoryStableId(
|
||||
item: ChatProviderResourceInventoryItem,
|
||||
): string | null {
|
||||
const value = item.metadata?.providerRepositoryId;
|
||||
return typeof value === "string" && /^\d+$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
async function upsertProviderResourceRow(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
item: ChatProviderResourceInventoryItem,
|
||||
) {
|
||||
const stableGitHubId =
|
||||
endpoint.provider === "github" ? githubRepositoryStableId(item) : null;
|
||||
if (stableGitHubId) {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`chat-github-repository:${endpoint.id}:${stableGitHubId}`}, 0))`,
|
||||
);
|
||||
const stableResource = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
sql`${chatEndpointResources.metadata}->>'providerRepositoryId' = ${stableGitHubId}`,
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
stableResource &&
|
||||
stableResource.providerResourceId !== item.providerResourceId
|
||||
) {
|
||||
const coordinateResource = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
item.providerResourceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (coordinateResource && coordinateResource.id !== stableResource.id) {
|
||||
const coordinateConversation = await tx
|
||||
.select({ id: chatConversations.id })
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, coordinateResource.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (coordinateConversation) {
|
||||
throw conflict(
|
||||
"GitHub repository identity is already bound to two coordinates",
|
||||
{ code: "chat_github_repository_identity_conflict" },
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.delete(chatEndpointResources)
|
||||
.where(eq(chatEndpointResources.id, coordinateResource.id));
|
||||
}
|
||||
|
||||
const oldRepository = stableResource.providerResourceId;
|
||||
const oldThreadPrefix = `github:${oldRepository}`;
|
||||
const newThreadPrefix = `github:${item.providerResourceId}`;
|
||||
const oldProviderUrl = `https://github.com/${oldRepository}`;
|
||||
const newProviderUrl = `https://github.com/${item.providerResourceId}`;
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({
|
||||
providerResourceId: item.providerResourceId,
|
||||
parentProviderResourceId: item.parentProviderResourceId ?? null,
|
||||
label: item.label,
|
||||
providerUrl: item.providerUrl ?? null,
|
||||
availability: "available",
|
||||
enabled:
|
||||
stableResource.enabled || coordinateResource?.enabled === true,
|
||||
metadata: item.metadata ?? {},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatEndpointResources.id, stableResource.id));
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
externalConversationId: sql<string>`case
|
||||
when lower(${chatConversations.externalConversationId}) = ${oldRepository} then ${item.providerResourceId}
|
||||
when lower(${chatConversations.externalConversationId}) = ${oldThreadPrefix} then ${newThreadPrefix}
|
||||
else ${chatConversations.externalConversationId}
|
||||
end`,
|
||||
externalThreadId: sql<string>`case
|
||||
when lower(${chatConversations.externalThreadId}) = ${oldThreadPrefix} then ${newThreadPrefix}
|
||||
when lower(${chatConversations.externalThreadId}) like ${`${oldThreadPrefix}:%`}
|
||||
then ${newThreadPrefix} || substring(
|
||||
${chatConversations.externalThreadId}
|
||||
from char_length(${oldThreadPrefix}) + 1
|
||||
)
|
||||
else ${chatConversations.externalThreadId}
|
||||
end`,
|
||||
externalLabel: item.label,
|
||||
providerUrl: sql<string | null>`case
|
||||
when lower(${chatConversations.providerUrl}) like ${`${oldProviderUrl}/%`}
|
||||
then ${newProviderUrl} || substring(
|
||||
${chatConversations.providerUrl}
|
||||
from char_length(${oldProviderUrl}) + 1
|
||||
)
|
||||
else ${chatConversations.providerUrl}
|
||||
end`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, stableResource.id),
|
||||
),
|
||||
);
|
||||
return { id: stableResource.id };
|
||||
}
|
||||
}
|
||||
|
||||
const [resource] = await tx
|
||||
.insert(chatEndpointResources)
|
||||
.values({
|
||||
companyId: endpoint.companyId,
|
||||
endpointId: endpoint.id,
|
||||
type: item.type,
|
||||
providerResourceId: item.providerResourceId,
|
||||
parentProviderResourceId: item.parentProviderResourceId ?? null,
|
||||
label: item.label,
|
||||
providerUrl: item.providerUrl ?? null,
|
||||
availability: "available",
|
||||
// Slack inventory contains only channels the bot has joined. Apply the
|
||||
// invitation default on insert; conflict updates preserve operator choices.
|
||||
enabled:
|
||||
endpoint.provider === "slack" &&
|
||||
item.type === "channel" &&
|
||||
item.metadata?.creator !== endpoint.botExternalId,
|
||||
metadata: item.metadata ?? {},
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatEndpointResources.endpointId,
|
||||
chatEndpointResources.type,
|
||||
chatEndpointResources.providerResourceId,
|
||||
],
|
||||
set: {
|
||||
parentProviderResourceId: item.parentProviderResourceId ?? null,
|
||||
label: item.label,
|
||||
providerUrl: item.providerUrl ?? null,
|
||||
availability: "available",
|
||||
metadata: item.metadata ?? {},
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning({ id: chatEndpointResources.id });
|
||||
return resource ?? null;
|
||||
}
|
||||
|
||||
async function reconcileGitHubWebhookRepository(
|
||||
endpoint: EndpointRow,
|
||||
payload: unknown,
|
||||
credentialLease: CredentialMutationLeaseGuard,
|
||||
) {
|
||||
const item = githubRepositoryInventoryItemFromPayload(payload);
|
||||
if (!item) return;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const resource = await upsertProviderResourceRow(tx, endpoint, item);
|
||||
if (!resource) return;
|
||||
// A correctly signed repository callback is current provider proof. Keep
|
||||
// Paperclip's enabled choice, and reopen only conversations previously
|
||||
// quarantined for provider availability loss.
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "active", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
eq(chatConversations.state, "unavailable"),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcileProviderResourceRows(
|
||||
endpoint: EndpointRow,
|
||||
inventory: ChatProviderInventoryResult,
|
||||
credentialLease: CredentialMutationLeaseGuard,
|
||||
) {
|
||||
const resourceType =
|
||||
endpoint.provider === "github" ? "repository" : "channel";
|
||||
const discovered = new Set(
|
||||
inventory.resources.map((resource) => resource.providerResourceId),
|
||||
);
|
||||
const absentAvailability =
|
||||
endpoint.provider === "github" ? "removed" : "unavailable";
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
for (const item of inventory.resources) {
|
||||
const resource = await upsertProviderResourceRow(tx, endpoint, item);
|
||||
if (resource) {
|
||||
// Resource recovery reopens only bindings that Paperclip marked
|
||||
// unavailable. Completed historical tasks stay completed.
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "active", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
eq(chatConversations.state, "unavailable"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: chatEndpointResources.id,
|
||||
providerResourceId: chatEndpointResources.providerResourceId,
|
||||
})
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, resourceType),
|
||||
),
|
||||
);
|
||||
for (const resource of existing) {
|
||||
if (discovered.has(resource.providerResourceId)) continue;
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: absentAvailability, updatedAt: new Date() })
|
||||
.where(eq(chatEndpointResources.id, resource.id));
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function listResources(endpointId: string) {
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
ne(chatEndpointResources.type, "direct_message"),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatEndpointResources.label));
|
||||
return resources.map((resource) => ({
|
||||
...resource,
|
||||
participants:
|
||||
resource.providerResourceId.startsWith("imessage-photon:") &&
|
||||
Array.isArray(resource.metadata.participants)
|
||||
? resource.metadata.participants.flatMap((participant) => {
|
||||
const address =
|
||||
participant && typeof participant === "object"
|
||||
? (participant as Record<string, unknown>).address
|
||||
: null;
|
||||
return typeof address === "string" ? [address] : [];
|
||||
})
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listGitHubRepositories(
|
||||
endpointId: string,
|
||||
query: { limit: number; offset: number; search: string },
|
||||
) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
||||
const scope = and(
|
||||
eq(chatEndpointResources.companyId, record.endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
);
|
||||
// Literal, case-insensitive substring search (%, _ and backslashes are
|
||||
// ordinary characters). Counts always describe the entire connection.
|
||||
const search = query.search
|
||||
? sql`strpos(lower(${chatEndpointResources.label}), lower(${query.search})) > 0`
|
||||
: undefined;
|
||||
const [rows, [counts]] = await Promise.all([
|
||||
db.select().from(chatEndpointResources).where(and(scope, search))
|
||||
.orderBy(asc(chatEndpointResources.label), asc(chatEndpointResources.id))
|
||||
.offset(query.offset).limit(query.limit + 1),
|
||||
db.select({
|
||||
totalCount: sql<number>`count(*)::integer`,
|
||||
enabledCount: sql<number>`count(*) filter (where ${chatEndpointResources.enabled})::integer`,
|
||||
availableCount: sql<number>`count(*) filter (where ${chatEndpointResources.availability} = 'available')::integer`,
|
||||
}).from(chatEndpointResources).where(scope),
|
||||
]);
|
||||
return {
|
||||
items: rows.slice(0, query.limit),
|
||||
nextOffset: rows.length > query.limit ? query.offset + query.limit : null,
|
||||
totalCount: counts!.totalCount,
|
||||
enabledCount: counts!.enabledCount,
|
||||
availableCount: counts!.availableCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceResources(
|
||||
endpointId: string,
|
||||
updates: Array<{ id: string; enabled: boolean }>,
|
||||
actorUserId?: string | null,
|
||||
options?: { initialGitHubImport?: boolean },
|
||||
) {
|
||||
await updateResourceSelection(endpointId, updates, actorUserId, options);
|
||||
return listResources(endpointId);
|
||||
}
|
||||
|
||||
async function toggleAllGitHubRepositories(endpointId: string, enabled: boolean, actorUserId?: string | null) {
|
||||
await updateResourceSelection(endpointId, [], actorUserId, { allGitHubRepositoriesEnabled: enabled });
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
async function updateResourceSelection(
|
||||
endpointId: string,
|
||||
updates: Array<{ id: string; enabled: boolean }>,
|
||||
actorUserId?: string | null,
|
||||
options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean },
|
||||
) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial) throw notFound("Chat endpoint not found");
|
||||
const bulk = options?.allGitHubRepositoriesEnabled !== undefined;
|
||||
if (bulk && initial.endpoint.provider !== "github") throw badRequest("Only GitHub repositories support toggle all");
|
||||
if (updates.length === 0 && initial.endpoint.provider !== "github") return;
|
||||
if (initial.endpoint.provider === "imessage-photon" && initial.endpoint.botExternalId?.startsWith("photon-project:") && updates.some((entry) => entry.enabled))
|
||||
throw unprocessable("Photon shared channels support direct messages only; groups cannot be enabled");
|
||||
await withCredentialMutationLease(
|
||||
initial.endpoint,
|
||||
async (credentialLease) => {
|
||||
const ids = updates.map((entry) => entry.id);
|
||||
const publications: ActivityPublication[] = [];
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const [endpoint] = await tx
|
||||
.select({
|
||||
companyId: chatEndpoints.companyId,
|
||||
connectionId: chatEndpoints.connectionId,
|
||||
provider: chatEndpoints.provider,
|
||||
setup: chatEndpoints.setup,
|
||||
})
|
||||
.from(chatEndpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, endpointId),
|
||||
eq(chatEndpoints.companyId, initial.endpoint.companyId),
|
||||
),
|
||||
)
|
||||
.for("no key update");
|
||||
if (!endpoint) throw notFound("Chat endpoint not found");
|
||||
if (options?.initialGitHubImport && endpoint.provider === "github" && (endpoint.setup.github?.repositorySelectionSaved || !endpoint.setup.github?.initialRepositoryImportPending)) return;
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: chatEndpointResources.id,
|
||||
availability: chatEndpointResources.availability,
|
||||
enabled: chatEndpointResources.enabled,
|
||||
})
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
bulk
|
||||
? and(
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
options!.allGitHubRepositoriesEnabled
|
||||
? eq(chatEndpointResources.availability, "available")
|
||||
: undefined,
|
||||
)
|
||||
: inArray(chatEndpointResources.id, ids),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatEndpointResources.id))
|
||||
.for("no key update");
|
||||
if (!bulk && rows.length !== new Set(ids).size)
|
||||
throw unprocessable("Every resource must belong to this endpoint");
|
||||
const selectedUpdates = bulk
|
||||
? rows.map((row) => ({ id: row.id, enabled: options!.allGitHubRepositoriesEnabled! }))
|
||||
: updates;
|
||||
const availabilityById = new Map(
|
||||
rows.map((row) => [row.id, row.availability]),
|
||||
);
|
||||
// Validate every submitted grant, including intermediate duplicate
|
||||
// entries. Netting below describes the audit, not new authority.
|
||||
const unavailable = selectedUpdates.find(
|
||||
(entry) =>
|
||||
entry.enabled && availabilityById.get(entry.id) !== "available",
|
||||
);
|
||||
if (unavailable)
|
||||
throw conflict(
|
||||
"A destination must still be available from the provider before it can be enabled",
|
||||
{ code: "chat_resource_unavailable", resourceId: unavailable.id },
|
||||
);
|
||||
const finalEnabled = new Map(
|
||||
selectedUpdates.map((entry) => [entry.id, entry.enabled]),
|
||||
);
|
||||
const changes = rows
|
||||
.filter((row) => row.enabled !== finalEnabled.get(row.id))
|
||||
.map((row) => ({
|
||||
resourceId: row.id,
|
||||
before: { enabled: row.enabled },
|
||||
after: { enabled: finalEnabled.get(row.id)! },
|
||||
}));
|
||||
if (bulk) {
|
||||
// One atomic database update, independent of pagination/search and
|
||||
// serialized with refresh, reconnect, and individual row changes.
|
||||
await tx.update(chatEndpointResources)
|
||||
.set({ enabled: options!.allGitHubRepositoriesEnabled!, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
options!.allGitHubRepositoriesEnabled
|
||||
? eq(chatEndpointResources.availability, "available")
|
||||
: undefined,
|
||||
));
|
||||
} else for (const entry of selectedUpdates)
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ enabled: entry.enabled, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.id, entry.id),
|
||||
),
|
||||
);
|
||||
if (endpoint.provider === "github")
|
||||
await tx.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || '{"repositorySelectionSaved":true,"initialRepositoryImportPending":false}'::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
|
||||
if (changes.length > 0 || (endpoint.provider === "github" && !endpoint.setup.github?.repositorySelectionSaved))
|
||||
await logActivity(
|
||||
tx as unknown as Db,
|
||||
{
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "chat_endpoint.resources_updated",
|
||||
entityType: "tool_connection",
|
||||
entityId: endpoint.connectionId,
|
||||
details: { endpointId, provider: endpoint.provider, changes },
|
||||
},
|
||||
publications,
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
// A committed reach change is factual even if the outer lease's last
|
||||
// check subsequently fails. Never publish an uncommitted activity.
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function hasNewerProcessedProviderLifecycleEffect(
|
||||
endpoint: EndpointRow,
|
||||
effect: Extract<ChatProviderLifecycleEffect, { kind: "resource" }>,
|
||||
): Promise<boolean> {
|
||||
const sequence = effect.providerOrder?.sequence;
|
||||
const occurredAt = effect.providerOrder?.occurredAt;
|
||||
if (!sequence && !occurredAt) return false;
|
||||
const providerResourceId =
|
||||
endpoint.provider === "microsoft-teams"
|
||||
? baseTeamsConversationId(effect.providerResourceId)
|
||||
: effect.providerResourceId;
|
||||
const newerOrder = sequence
|
||||
? sql`(
|
||||
${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}' ~ '^[0-9]+([.][0-9]+)?$'
|
||||
and (${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}')::numeric > cast(${sequence} as numeric)
|
||||
)`
|
||||
: sql`(
|
||||
nullif(${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,occurredAt}', '')::timestamptz > cast(${occurredAt!} as timestamptz)
|
||||
)`;
|
||||
const newer = await db
|
||||
.select({ id: chatDeliveries.id })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, endpoint.id),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
inArray(chatDeliveries.eventKind, ["installation", "uninstallation"]),
|
||||
sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,kind}' = 'resource'`,
|
||||
sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,provider}' = ${effect.provider}`,
|
||||
sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerResourceId}' = ${providerResourceId}`,
|
||||
newerOrder,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return newer.length > 0;
|
||||
}
|
||||
|
||||
async function applyProviderAvailability(input: {
|
||||
currentEndpoint: EndpointRow;
|
||||
effect: ChatProviderLifecycleEffect;
|
||||
credentialLease: CredentialMutationLeaseGuard;
|
||||
staleResourceEffect: boolean;
|
||||
claimed: Pick<typeof chatDeliveries.$inferSelect, "id">;
|
||||
candidate: Pick<typeof chatDeliveries.$inferSelect, "attempts">;
|
||||
}): Promise<void> {
|
||||
const { currentEndpoint, effect, credentialLease, staleResourceEffect, claimed, candidate } = input;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const now = new Date();
|
||||
if (staleResourceEffect) {
|
||||
// Persisted provider-native ordering wins over request arrival or
|
||||
// lease acquisition order. The older callback remains auditable,
|
||||
// but cannot roll a resource or conversation back.
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: now, updatedAt: now })
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatDeliveries.id, claimed.id));
|
||||
return;
|
||||
}
|
||||
if (effect.kind === "resource") {
|
||||
const providerResourceId =
|
||||
currentEndpoint.provider === "microsoft-teams"
|
||||
? baseTeamsConversationId(effect.providerResourceId)
|
||||
: effect.providerResourceId;
|
||||
const previousProviderResourceId =
|
||||
currentEndpoint.provider === "telegram"
|
||||
? effect.previousProviderResourceId
|
||||
: undefined;
|
||||
const previousResource = previousProviderResourceId
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
eq(chatEndpointResources.type, "chat"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
previousProviderResourceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const migratedLabel =
|
||||
previousResource &&
|
||||
telegramResourceLabelIsFallback(providerResourceId, effect.label)
|
||||
? previousResource.label
|
||||
: effect.label;
|
||||
const migratedEnabled = previousResource?.enabled === true;
|
||||
if (
|
||||
previousResource &&
|
||||
previousProviderResourceId !== providerResourceId
|
||||
) {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({
|
||||
availability: "unavailable",
|
||||
enabled: false,
|
||||
metadata: {
|
||||
...previousResource.metadata,
|
||||
source: "chat_migration",
|
||||
migratedTo: providerResourceId,
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpointResources.id, previousResource.id));
|
||||
}
|
||||
const preserveProviderLabel =
|
||||
(currentEndpoint.provider === "slack" &&
|
||||
slackResourceLabelIsFallback(
|
||||
providerResourceId,
|
||||
migratedLabel,
|
||||
)) ||
|
||||
(currentEndpoint.provider === "telegram" &&
|
||||
telegramResourceLabelIsFallback(
|
||||
providerResourceId,
|
||||
migratedLabel,
|
||||
));
|
||||
const [resource] = await tx
|
||||
.insert(chatEndpointResources)
|
||||
.values({
|
||||
companyId: currentEndpoint.companyId,
|
||||
endpointId: currentEndpoint.id,
|
||||
type: effect.resourceType,
|
||||
providerResourceId,
|
||||
parentProviderResourceId:
|
||||
effect.parentProviderResourceId ?? null,
|
||||
label: migratedLabel,
|
||||
providerUrl: effect.providerUrl ?? null,
|
||||
availability: effect.availability,
|
||||
enabled:
|
||||
migratedEnabled ||
|
||||
(currentEndpoint.provider === "slack" &&
|
||||
effect.resourceType === "channel" &&
|
||||
effect.availability === "available"),
|
||||
metadata: effect.metadata ?? {},
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatEndpointResources.endpointId,
|
||||
chatEndpointResources.type,
|
||||
chatEndpointResources.providerResourceId,
|
||||
],
|
||||
set: {
|
||||
parentProviderResourceId:
|
||||
effect.parentProviderResourceId ?? null,
|
||||
...(preserveProviderLabel ? {} : { label: migratedLabel }),
|
||||
providerUrl: effect.providerUrl ?? null,
|
||||
availability: effect.availability,
|
||||
...(previousProviderResourceId
|
||||
? {
|
||||
enabled: sql<boolean>`${chatEndpointResources.enabled} or ${migratedEnabled}`,
|
||||
}
|
||||
: {}),
|
||||
metadata: effect.metadata ?? {},
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning({ id: chatEndpointResources.id });
|
||||
if (
|
||||
resource &&
|
||||
previousResource &&
|
||||
previousProviderResourceId !== providerResourceId
|
||||
) {
|
||||
const previousThreadPrefix = `telegram:${previousProviderResourceId}`;
|
||||
const migratedThreadPrefix = `telegram:${providerResourceId}`;
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
resourceId: resource.id,
|
||||
// Telegram SDK threads use the namespaced channel id. Keep
|
||||
// the migrated row in that canonical shape so the first
|
||||
// reply from the replacement supergroup finds this same
|
||||
// task instead of creating a second conversation.
|
||||
externalConversationId: migratedThreadPrefix,
|
||||
externalThreadId: sql<string>`case
|
||||
when ${chatConversations.externalThreadId} = ${previousThreadPrefix} then ${migratedThreadPrefix}
|
||||
when ${chatConversations.externalThreadId} like ${`${previousThreadPrefix}:%`}
|
||||
then ${migratedThreadPrefix} || substring(
|
||||
${chatConversations.externalThreadId}
|
||||
from char_length(${previousThreadPrefix}) + 1
|
||||
)
|
||||
else ${chatConversations.externalThreadId}
|
||||
end`,
|
||||
externalLabel: migratedLabel,
|
||||
state: "active",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, currentEndpoint.companyId),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
eq(chatConversations.resourceId, previousResource.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (resource) {
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
state:
|
||||
effect.availability === "available"
|
||||
? "active"
|
||||
: "unavailable",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, currentEndpoint.companyId),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
effect.availability === "available"
|
||||
? eq(chatConversations.state, "unavailable")
|
||||
: inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: now, updatedAt: now })
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
} else {
|
||||
const reason = redactSensitiveText(effect.reason).slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
);
|
||||
if (effect.availability === "available") {
|
||||
if (
|
||||
currentEndpoint.status !== "paused" &&
|
||||
currentEndpoint.status !== "archived"
|
||||
) {
|
||||
const status =
|
||||
currentEndpoint.setup.step === "complete"
|
||||
? "active"
|
||||
: currentEndpoint.status === "attention"
|
||||
? "verifying"
|
||||
: currentEndpoint.status;
|
||||
if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status,
|
||||
healthMessage:
|
||||
status === "active"
|
||||
? "Connected"
|
||||
: "Waiting for a test conversation",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
}
|
||||
} else {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: effect.availability,
|
||||
healthMessage: reason,
|
||||
lastError: reason,
|
||||
lastEventAt: now,
|
||||
setup: {
|
||||
...currentEndpoint.setup,
|
||||
runtimeGeneration:
|
||||
runtimeGeneration(currentEndpoint.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus:
|
||||
effect.availability === "revoked" ? "failed" : "degraded",
|
||||
healthMessage: reason,
|
||||
lastError: reason,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
chatEndpointResources.companyId,
|
||||
currentEndpoint.companyId,
|
||||
),
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, currentEndpoint.companyId),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, claimed.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
eq(chatDeliveries.attempts, candidate.attempts + 1),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
return { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability };
|
||||
}
|
||||
Reference in new issue
Block a user