mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
fix(connections): preserve pooled auth repair and quota retry safety
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c2efe3e92a
commit
9dc3049257
8 files changed
+182
-41
No files matched your search
@@ -13,6 +13,7 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
|
||||
const usageCache = new Map<string, { expires: number; value: AiConnectionUsage }>();
|
||||
const usageInFlight = new Map<string, Promise<AiConnectionUsage | undefined>>();
|
||||
const routedRuntimeKeys = new Set(["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"]);
|
||||
export function applyAiConnectionRouterTaskSettings(config: Record<string, unknown>, selection: AiConnectionRouterSelection) {
|
||||
return { ...Object.fromEntries(Object.entries(config).filter(([key]) => !routedRuntimeKeys.has(key))), ...selection.runtimeConfig };
|
||||
@@ -148,20 +149,31 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
|
||||
if (!key) return undefined;
|
||||
const cached = usageCache.get(key);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
let pending = usageInFlight.get(key);
|
||||
if (!pending) {
|
||||
let probeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
pending = Promise.race([
|
||||
aiConnectionService(db).probeUsage(companyId, userId, member.binding.connectionId, member.binding.grantId).catch(() => undefined),
|
||||
new Promise<undefined>(resolve => { probeTimer = setTimeout(() => resolve(undefined), 15_000); }),
|
||||
]).then(value => {
|
||||
if (value) {
|
||||
const resets = value.limits.map(v => Date.parse(v.resetsAt ?? "")).filter(v => Number.isFinite(v) && v > Date.now());
|
||||
if (usageCache.size > 1000) usageCache.clear();
|
||||
usageCache.set(key, { value, expires: Math.min(Date.now() + 60_000, ...resets) });
|
||||
}
|
||||
return value;
|
||||
}).finally(() => { if (probeTimer) clearTimeout(probeTimer); usageInFlight.delete(key); });
|
||||
usageInFlight.set(key, pending);
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const value = await Promise.race([
|
||||
aiConnectionService(db).probeUsage(companyId, userId, member.binding.connectionId, member.binding.grantId).catch(() => undefined),
|
||||
new Promise<undefined>((resolve) => { timer = setTimeout(() => resolve(undefined), Math.max(0, deadline - Date.now())); }),
|
||||
return await Promise.race([
|
||||
pending,
|
||||
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.max(0, deadline - Date.now())); }),
|
||||
]);
|
||||
if (value) {
|
||||
const resets = value.limits.map((v) => Date.parse(v.resetsAt ?? "")).filter((v) => Number.isFinite(v) && v > Date.now());
|
||||
if (usageCache.size > 1000) usageCache.clear();
|
||||
usageCache.set(key, { value, expires: Math.min(Date.now() + 60_000, ...resets) });
|
||||
}
|
||||
return value;
|
||||
} finally { if (timer) clearTimeout(timer); }
|
||||
}
|
||||
|
||||
async function resolve(input: { companyId: string; poolId: string; agentId: string; userId: string | null; adapterType: string; taskKey: string; overrides?: Record<string, unknown>; existingGrantId?: string; requireExisting?: boolean; persisted?: AiConnectionRouterSelection }) {
|
||||
const pinWhere = and(eq(aiConnectionTaskPins.companyId, input.companyId), eq(aiConnectionTaskPins.poolId, input.poolId), eq(aiConnectionTaskPins.agentId, input.agentId), eq(aiConnectionTaskPins.taskKey, input.taskKey));
|
||||
const validateSelection = async (selection: AiConnectionRouterSelection, client = db) => aiConnectionService(client).select({ companyId: input.companyId, agentId: input.agentId, userId: input.userId, adapterType: input.adapterType, binding: selection.binding, model: selection.runtimeConfig.model, runnerProvider: selection.runtimeConfig.provider, acpxAgent: selection.runtimeConfig.acpxAgent });
|
||||
|
||||
@@ -3,12 +3,13 @@ import { emailConnectionService } from "./email-connections.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { aiConnectionService } from "./ai-connections.js";
|
||||
import { aiConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { aiConnectionBindingSchema, aiConnectionRouterBindingSchema } from "@paperclipai/shared";
|
||||
import { aiBindingForAuthRecovery, isAiAuthenticationFailure } from "./ai-auth-failure.js";
|
||||
import { and, eq, desc, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
aiConnectionTaskPins,
|
||||
companies,
|
||||
toolConnections,
|
||||
toolCatalogEntries,
|
||||
@@ -227,11 +228,26 @@ export function connectionIntentService(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
async function managedAgent(companyId: string, agentId: string, serviceSlug: string, fallback?: AiConnectionBinding) {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
||||
const saved = aiConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
|
||||
const binding = saved ?? fallback;
|
||||
return agent && binding?.provider === serviceSlug ? { agent, binding, requiresAdoption: !saved } : null;
|
||||
async function managedAgent(companyId: string, agentId: string, serviceSlug: string, options: { fallback?: AiConnectionBinding; sourceRunId?: string | null } = {}, client = db) {
|
||||
const [agent] = await client.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
||||
if (!agent) return null;
|
||||
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||||
if (router) {
|
||||
// Repair the durable task allocation, without adopting a personal default
|
||||
// or replacing the agent's pool. A changed pool invalidates the old card.
|
||||
if (!options.sourceRunId) return null;
|
||||
const [source] = await client.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.id, options.sourceRunId)));
|
||||
const taskKey = text(source?.contextSnapshot?.aiRouterTaskKey);
|
||||
const evidence = record(source?.contextSnapshot?.aiRouterSelection);
|
||||
if (!source || !isAiAuthenticationFailure(source.errorCode) || source.status !== "failed" || evidence?.poolId !== router.connectionId || !taskKey) return null;
|
||||
const [pin] = await client.select().from(aiConnectionTaskPins).where(and(eq(aiConnectionTaskPins.companyId, companyId), eq(aiConnectionTaskPins.poolId, router.connectionId), eq(aiConnectionTaskPins.agentId, agentId), eq(aiConnectionTaskPins.taskKey, taskKey)));
|
||||
const binding = pin?.selection.binding;
|
||||
if (!pin || binding?.provider !== serviceSlug || JSON.stringify(evidence.binding) !== JSON.stringify(binding)) return null;
|
||||
return { agent, binding, adapterConfig: { ...agent.adapterConfig, ...pin.selection.runtimeConfig }, requiresAdoption: false };
|
||||
}
|
||||
const saved = aiConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||||
const binding = saved ?? options.fallback;
|
||||
return binding?.provider === serviceSlug ? { agent, binding, adapterConfig: agent.adapterConfig, requiresAdoption: !saved } : null;
|
||||
}
|
||||
|
||||
async function usableConnectionForAgent(input: {
|
||||
@@ -240,12 +256,13 @@ export function connectionIntentService(db: Db) {
|
||||
responsibleUserId: string;
|
||||
serviceSlug: string;
|
||||
purpose?: "ai" | "channel";
|
||||
sourceRunId?: string | null;
|
||||
inventory?: Awaited<ReturnType<typeof connectionInventory>>;
|
||||
}) {
|
||||
const managed = input.purpose === "ai" ? await managedAgent(input.companyId, input.agentId, input.serviceSlug) : null;
|
||||
const managed = input.purpose === "ai" ? await managedAgent(input.companyId, input.agentId, input.serviceSlug, { sourceRunId: input.sourceRunId }) : null;
|
||||
if (managed) {
|
||||
try {
|
||||
const selected = await aiConnectionService(db).select({ companyId: input.companyId, agentId: input.agentId, userId: input.responsibleUserId, adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model, runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent, binding: managed.binding });
|
||||
const selected = await aiConnectionService(db).select({ companyId: input.companyId, agentId: input.agentId, userId: input.responsibleUserId, adapterType: managed.agent.adapterType, model: managed.adapterConfig.model, runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent, binding: managed.binding });
|
||||
return access.getConnection(selected.connection.id, input.companyId);
|
||||
} catch (error) { if ([403, 404, 422].includes((error as { status?: number }).status ?? 0)) return null; throw error; }
|
||||
}
|
||||
@@ -690,6 +707,7 @@ export function connectionIntentService(db: Db) {
|
||||
responsibleUserId: context.run.responsibleUserId!,
|
||||
serviceSlug: app.slug,
|
||||
purpose: options.purpose,
|
||||
sourceRunId: context.run.id,
|
||||
});
|
||||
if (ready) {
|
||||
return {
|
||||
@@ -784,7 +802,7 @@ export function connectionIntentService(db: Db) {
|
||||
readyConnectionId: inboxes.find(inbox => inbox.id === interactionId)?.connectionId ?? null },
|
||||
};
|
||||
}
|
||||
let managed = payload.purpose === "ai" ? await managedAgent(loaded.issue.companyId, payload.requestingAgentId, app.slug) : null;
|
||||
let managed = payload.purpose === "ai" ? await managedAgent(loaded.issue.companyId, payload.requestingAgentId, app.slug, { sourceRunId: loaded.interaction.sourceRunId }) : null;
|
||||
if (payload.purpose === "ai" && !managed) {
|
||||
const [source] = await db.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, loaded.interaction.sourceRunId!), eq(heartbeatRuns.companyId, loaded.issue.companyId),
|
||||
@@ -792,21 +810,21 @@ export function connectionIntentService(db: Db) {
|
||||
));
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId)));
|
||||
if (source?.status === "failed" && isAiAuthenticationFailure(source.errorCode) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) {
|
||||
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig));
|
||||
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig) });
|
||||
}
|
||||
}
|
||||
if (payload.purpose === "ai" && !managed) throw conflict("The agent’s AI configuration changed. Start a new execution.");
|
||||
const inventory = await connectionInventory(loaded.issue.companyId);
|
||||
let usableAiConnection = managed ? await usableConnectionForAgent({
|
||||
companyId: loaded.issue.companyId, agentId: payload.requestingAgentId,
|
||||
responsibleUserId: loaded.interaction.addresseeUserId!, serviceSlug: app.slug, purpose: "ai",
|
||||
responsibleUserId: loaded.interaction.addresseeUserId!, serviceSlug: app.slug, purpose: "ai", sourceRunId: loaded.interaction.sourceRunId,
|
||||
}) : null;
|
||||
if (managed?.requiresAdoption) {
|
||||
try {
|
||||
const selected = await aiConnectionService(db).select({
|
||||
companyId: loaded.issue.companyId, agentId: managed.agent.id, userId: loaded.interaction.addresseeUserId!,
|
||||
adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model,
|
||||
runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent,
|
||||
adapterType: managed.agent.adapterType, model: managed.adapterConfig.model,
|
||||
runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent,
|
||||
binding: managed.binding, allowUninstalledPersonal: true,
|
||||
});
|
||||
usableAiConnection = await access.getConnection(selected.connection.id, loaded.issue.companyId);
|
||||
@@ -955,19 +973,19 @@ export function connectionIntentService(db: Db) {
|
||||
action: "agent.updated", entityType: "agent", entityId: agent.id,
|
||||
details: { connectionIntentId: interactionId, aiConnectionAdopted: true },
|
||||
});
|
||||
managed = { agent: updated, binding, requiresAdoption: false };
|
||||
managed = { agent: updated, binding, adapterConfig: updated.adapterConfig, requiresAdoption: false };
|
||||
} else {
|
||||
managed = await managedAgent(loaded.issue.companyId, payload.requestingAgentId, payload.serviceSlug);
|
||||
managed = await managedAgent(loaded.issue.companyId, payload.requestingAgentId, payload.serviceSlug, { sourceRunId: loaded.interaction.sourceRunId }, txDb);
|
||||
}
|
||||
if (!managed) throw conflict("Configure the agent’s AI connection before using this account");
|
||||
const service = aiConnectionService(txDb);
|
||||
if (managed.binding.mode === "responsible_user") {
|
||||
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model, runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent, binding: managed.binding, allowUninstalledPersonal: true });
|
||||
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.adapterConfig.model, runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent, binding: managed.binding, allowUninstalledPersonal: true });
|
||||
if (selected.connection.id !== selectedConnection.id) throw conflict("Choose this account as your personal default in Connections first");
|
||||
const installs = await txAccess.listConnectionInstalls(selectedConnection.id, loaded.issue.companyId);
|
||||
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: [...installs, { targetType: "agent", targetId: payload.requestingAgentId }] }, { actorType: "user", actorId: userId });
|
||||
}
|
||||
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model, runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent, binding: managed.binding });
|
||||
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.adapterConfig.model, runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent, binding: managed.binding });
|
||||
if (selected.connection.id !== selectedConnection.id) throw conflict("This is not the account selected for the agent");
|
||||
return txInteractions.resolveConnectionIntent(loaded.issue, interactionId, { version: 1, outcome: "connected", connectionId: selected.connection.id }, { userId });
|
||||
}
|
||||
@@ -1094,11 +1112,14 @@ export function connectionIntentService(db: Db) {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)));
|
||||
if (!agent) return null;
|
||||
const saved = aiConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||||
const binding = saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig);
|
||||
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||||
const selected = router ? record(run.contextSnapshot?.aiRouterSelection) : null;
|
||||
const managed = router && selected ? await managedAgent(run.companyId, run.agentId, String(record(selected.binding)?.provider), { sourceRunId: run.id }) : null;
|
||||
const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig);
|
||||
if (!binding) return null;
|
||||
const attribution = record(run.contextSnapshot?.aiConnection);
|
||||
if (attribution && attribution.provider !== binding.provider) return null;
|
||||
if (saved && attribution && typeof attribution.identity === "string" && typeof attribution.connectionId === "string" && typeof attribution.grantId === "string") {
|
||||
if ((saved || managed) && attribution && typeof attribution.identity === "string" && typeof attribution.connectionId === "string" && typeof attribution.grantId === "string") {
|
||||
await aiConnectionService(db).markAuthenticationFailed({ companyId: run.companyId, runId: run.id, agentId: run.agentId,
|
||||
runStartedAt: run.startedAt ?? run.createdAt,
|
||||
attribution: attribution as unknown as AiConnectionAttribution & { identity: string } });
|
||||
|
||||
@@ -38,7 +38,7 @@ export function legacyExecutionNeedsReconciliation(
|
||||
&& (run.resultJson?.executionCancellation as Record<string, unknown> | undefined)?.state === "acknowledged") return false;
|
||||
// Waiting for a subscription or workspace precedes provider execution. It is
|
||||
// a resource wait, not a failed provider attempt or permission to replay work.
|
||||
if (run.status === "cancelled" && run.errorCode === "ai_connection_busy" &&
|
||||
if (run.status === "cancelled" && (run.errorCode === "ai_connection_busy" || run.errorCode === "ai_connection_pool_exhausted") &&
|
||||
evidence?.kind === "ai_connection_wait" && evidence.providerWorkStarted === false) return false;
|
||||
if (run.status === "cancelled" && run.errorCode === "workspace_busy" &&
|
||||
evidence?.kind === "workspace_wait" && evidence.providerWorkStarted === false) return false;
|
||||
|
||||
Reference in new issue
Block a user