mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Merge scoped health refreshes into Discord scheduling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
fe1c886a3d
25 files changed
+265
-91
No files matched your search
@@ -19,8 +19,11 @@ no deadline until the settings producer enables them. Application status and
|
|||||||
company status are not new filters: this migration preserves the old policy.
|
company status are not new filters: this migration preserves the old policy.
|
||||||
|
|
||||||
An unchecked row is due immediately. Otherwise its deadline is `healthCheckedAt`
|
An unchecked row is due immediately. Otherwise its deadline is `healthCheckedAt`
|
||||||
plus 15 minutes. A company hint refreshes that company's cache. Startup and an
|
plus 15 minutes. Individual writes carry a connection ID and reload only that
|
||||||
instance feature-settings change can refresh the whole instance. Refreshes are
|
connection. Application mutations refresh only that application's connections;
|
||||||
|
company deletion invalidates the company's cache. Startup and instance feature
|
||||||
|
settings can refresh the whole instance. Inventory reads use UUID keyset pages
|
||||||
|
of at most 100 rows and observe shutdown cancellation between pages. Refreshes are
|
||||||
coalesced with a 30-second minimum spacing when no due backlog remains.
|
coalesced with a 30-second minimum spacing when no due backlog remains.
|
||||||
Batches retain at most 25 checks and continue after one second while already due
|
Batches retain at most 25 checks and continue after one second while already due
|
||||||
rows remain, so the batch limit does not cap dispatch at 50 checks per minute. There is no scheduled refresh when
|
rows remain, so the batch limit does not cap dispatch at 50 checks per minute. There is no scheduled refresh when
|
||||||
@@ -32,8 +35,8 @@ rotates across passes when more companies are due than fit in one batch.
|
|||||||
Before a provider check, the service reloads current eligibility and deadline.
|
Before a provider check, the service reloads current eligibility and deadline.
|
||||||
A saved health result, including a failed health observation, owns the next
|
A saved health result, including a failed health observation, owns the next
|
||||||
15-minute deadline. A check that cannot persist an observation retains recovery
|
15-minute deadline. A check that cannot persist an observation retains recovery
|
||||||
debt with exponential backoff from 30 seconds to 15 minutes. A failed company
|
debt with exponential backoff from 30 seconds to 15 minutes. A failed scoped
|
||||||
refresh does not prevent another company's checks. Dirty scopes arriving during
|
refresh does not prevent checks outside that connection, application, or company. Dirty scopes arriving during
|
||||||
a refresh remain pending. Drain retains them without passive database reads.
|
a refresh remain pending. Drain retains them without passive database reads.
|
||||||
Shutdown joins admitted work; it does not abandon a provider call midway.
|
Shutdown joins admitted work; it does not abandon a provider call midway.
|
||||||
|
|
||||||
@@ -134,8 +137,8 @@ selection. The two-company regression failed before the repair; 19 health-worker
|
|||||||
and three shared batching cases pass afterward. Each probe's pre/post eligibility
|
and three shared batching cases pass afterward. Each probe's pre/post eligibility
|
||||||
query already includes the connection ID, so those indexed current-state checks
|
query already includes the connection ID, so those indexed current-state checks
|
||||||
remain. A scheduled observation still emits the normal transaction signal and
|
remain. A scheduled observation still emits the normal transaction signal and
|
||||||
may cause one coalesced company refresh; this deliberately preserves uncertainty
|
now refreshes only that connection following the security repair below. This
|
||||||
and concurrent-writer recovery rather than introducing a silent-write exception.
|
preserves uncertainty and concurrent-writer recovery without a silent-write exception.
|
||||||
|
|
||||||
The real-service producer suite now proves AgentMail activation triggers its first
|
The real-service producer suite now proves AgentMail activation triggers its first
|
||||||
health probe, pause cancels the next deadline, GitHub installation events update
|
health probe, pause cancels the next deadline, GitHub installation events update
|
||||||
@@ -164,3 +167,44 @@ fully future-dated inventory has no continuation. This is scheduling concrete
|
|||||||
backlog, not a periodic idle scan. The initial deadline-only assertion passed
|
backlog, not a periodic idle scan. The initial deadline-only assertion passed
|
||||||
incorrectly because a pending transaction notification temporarily reported an
|
incorrectly because a pending transaction notification temporarily reported an
|
||||||
immediate coordinator wake; acceptance measures actual probe timestamps instead.
|
immediate coordinator wake; acceptance measures actual probe timestamps instead.
|
||||||
|
|
||||||
|
|
||||||
|
## Security review: bounded refresh scope
|
||||||
|
|
||||||
|
Security review identified amplification in the original company hints. Five
|
||||||
|
separate real `updateConnection` calls in a company with 205 eligible connections
|
||||||
|
returned 1,025 inventory rows. A regression reproduced that result. The repaired
|
||||||
|
producer contract returns exactly five rows across those updates. Saved probe
|
||||||
|
observations use the same single-connection scope, so draining a due backlog no
|
||||||
|
longer reloads its whole company after each batch.
|
||||||
|
|
||||||
|
The typed hint accepts an existing connection row (only company and connection
|
||||||
|
IDs are encoded), or an explicit application target. Company/global scopes remain
|
||||||
|
for company deletion and administrative feature changes. Smoke Lab allocates a
|
||||||
|
new connection ID before its insert so the transaction intent is registered before
|
||||||
|
the write. No schema, outer transaction ownership, or provider policy changes.
|
||||||
|
Application eligibility changes retain their own scoped refresh. Single-connection
|
||||||
|
refreshes remove an old cached deadline even when the row was deleted, disabled,
|
||||||
|
or moved to an excluded application; unrelated deadlines remain intact.
|
||||||
|
|
||||||
|
Inventory queries use 100-row UUID keyset pages. A 205-row regression initially
|
||||||
|
observed one unbounded query and now requires three pages with all 205 distinct
|
||||||
|
IDs retained. Cancellation is checked between pages. Results replace a scope only
|
||||||
|
after its complete load succeeds; failed loads retain recovery and other scopes
|
||||||
|
can proceed. Malformed hint shapes request full reconstruction instead of dropping
|
||||||
|
later scopes.
|
||||||
|
|
||||||
|
The first amplification fixture lacked its required remote endpoint and failed
|
||||||
|
validation before reaching its assertion. After adding a controlled endpoint
|
||||||
|
lookup, the baseline reproduced 1,025 versus five. The first typecheck found a
|
||||||
|
partial access-service projection without companyId; the target now supplies the
|
||||||
|
already-owned companyId explicitly. These failures are retained as evidence, not
|
||||||
|
counted as passes. Final verification and remote review are recorded below.
|
||||||
|
|
||||||
|
|
||||||
|
Final scoped-refresh verification: 26 health/batching cases, 63 email/access
|
||||||
|
cases, 56 GitHub/Smoke Lab/startup cases, 469 tool-access cases, and 92 gateway
|
||||||
|
cases pass (706 targeted tests on this repair). Server typecheck and build pass.
|
||||||
|
The full local test matrix was not rerun; prior remote Actions passed at
|
||||||
|
f8ad26f0ff, which still had the security finding. Fresh remote checks/security
|
||||||
|
review on the repaired head are required and are not inferred from those results.
|
||||||
@@ -1169,3 +1169,14 @@ before the earlier cleanup. Seven producer integrations and all six cleanup case
|
|||||||
pass on the merged branch. The 1,413 deselected broad chat cases are not passes.
|
pass on the merged branch. The 1,413 deselected broad chat cases are not passes.
|
||||||
Health `f8ad26f0ff` now has fresh Greptile 5/5 and no failed checks; Paperclip Review
|
Health `f8ad26f0ff` now has fresh Greptile 5/5 and no failed checks; Paperclip Review
|
||||||
was still queued at the last inspection. The full goal remains active.
|
was still queued at the last inspection. The full goal remains active.
|
||||||
|
|
||||||
|
|
||||||
|
Health security repair: five real connection API updates in a 205-connection
|
||||||
|
company reproduced 1,025 inventory rows. Individual writers now signal their
|
||||||
|
connection IDs; application writers signal their application IDs. Genuine broad
|
||||||
|
loads use 100-row UUID pages. The regression now reads exactly five rows, with
|
||||||
|
application invalidation and unrelated-deadline preservation verified. All 706
|
||||||
|
targeted health/producer/consumer cases and server typecheck/build pass. Publish
|
||||||
|
the repair to #17554, propagate through company-deletion #17561 into Discord
|
||||||
|
#17557, and require fresh remote gates. No PR in this stack is merged. The next
|
||||||
|
new owner remains chat runtime discovery; the full remaining ledger is unchanged.
|
||||||
@@ -163,3 +163,19 @@ the intended regression and passes. Producer/chat consumers, query-plan evidence
|
|||||||
build, and remote gates remain pending. Master was refreshed to `8415f4a679`;
|
build, and remote gates remain pending. Master was refreshed to `8415f4a679`;
|
||||||
this uncommitted health branch remains based on the open migration stack, so
|
this uncommitted health branch remains based on the open migration stack, so
|
||||||
final stack reconciliation with master is still required. No merge is authorized.
|
final stack reconciliation with master is still required. No merge is authorized.
|
||||||
|
|
||||||
|
|
||||||
|
## Security follow-up: individual refreshes
|
||||||
|
|
||||||
|
The company-hint design amplified ordinary connection writes into full company
|
||||||
|
reads. The current contract scopes individual producers (API/example creation,
|
||||||
|
setup/rollback, OAuth, gateway, health/catalog, email, voice, GitHub events, access
|
||||||
|
removal, and Smoke Lab) by company plus connection ID. Existing application
|
||||||
|
updates in example setup and Smoke Lab use application scopes. Company deletion
|
||||||
|
and instance feature-settings changes retain broad invalidation. Startup and
|
||||||
|
broad loads use bounded UUID pages. The health owner still defines eligibility
|
||||||
|
and provider policy; the shared notification/coordinator layers remain generic.
|
||||||
|
|
||||||
|
See `doc/idle-tool-connection-health.md` for the failing amplification baseline,
|
||||||
|
changed contract, scope-cancellation tests, and verification results. The older
|
||||||
|
company-hint observations in this audit describe the superseded implementation.
|
||||||
@@ -16,7 +16,7 @@ export async function startToolHealthWorker(db: Db, companyId: string, now = new
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await registerToolConnectionHealthWork(coordinator, {
|
await registerToolConnectionHealthWork(coordinator, {
|
||||||
load: scope => schedule.load(scope ?? companyId), check: schedule.check,
|
load: (scope, connectionId, applicationId, signal) => schedule.load(scope ?? companyId, connectionId, applicationId, signal), check: schedule.check,
|
||||||
}, () => true).ready;
|
}, () => true).ready;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await coordinator.stop();
|
await coordinator.stop();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
applicationId = app.id;
|
applicationId = app.id;
|
||||||
probe.mockReset(); probe.mockImplementation(async (id: string) => {
|
probe.mockReset(); probe.mockImplementation(async (id: string) => {
|
||||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, id));
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, id));
|
||||||
await writeToolConnectionHealthChange(db, connection.companyId, async tx => {
|
await writeToolConnectionHealthChange(db, connection, async tx => {
|
||||||
await tx.update(toolConnections).set({ healthCheckedAt: new Date(), healthStatus: "ok" }).where(eq(toolConnections.id, id));
|
await tx.update(toolConnections).set({ healthCheckedAt: new Date(), healthStatus: "ok" }).where(eq(toolConnections.id, id));
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
@@ -51,10 +51,11 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
});
|
});
|
||||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||||
function connection(patch: Partial<typeof toolConnections.$inferInsert> = {}, signal = true) {
|
function connection(patch: Partial<typeof toolConnections.$inferInsert> = {}, signal = true) {
|
||||||
|
const id = patch.id ?? randomUUID();
|
||||||
const insert = async (writer: typeof db) => (await writer.insert(toolConnections).values({
|
const insert = async (writer: typeof db) => (await writer.insert(toolConnections).values({
|
||||||
companyId, applicationId, name: "Check", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true, ...patch,
|
id, companyId, applicationId, name: "Check", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true, ...patch,
|
||||||
}).returning())[0];
|
}).returning())[0];
|
||||||
return signal ? writeToolConnectionHealthChange(db, companyId, insert) : insert(db);
|
return signal ? writeToolConnectionHealthChange(db, { companyId, id }, insert) : insert(db);
|
||||||
}
|
}
|
||||||
async function start(service = connectionHealthSchedule(db, probe)) {
|
async function start(service = connectionHealthSchedule(db, probe)) {
|
||||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError: error => errors.push(error) });
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError: error => errors.push(error) });
|
||||||
@@ -88,6 +89,71 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
reads.mockRestore(); writes.mockRestore();
|
reads.mockRestore(); writes.mockRestore();
|
||||||
await vi.advanceTimersByTimeAsync(due - Date.now()); await checked();
|
await vi.advanceTimersByTimeAsync(due - Date.now()); await checked();
|
||||||
});
|
});
|
||||||
|
it("refreshes only the changed connection after real API writes in a large company", async () => {
|
||||||
|
const checkedAt = new Date();
|
||||||
|
const rows = await db.insert(toolConnections).values(Array.from({ length: 205 }, (_, index) => ({
|
||||||
|
companyId, applicationId, name: `Inventory ${index}`, uid: randomUUID(), transport: "mcp_remote" as const,
|
||||||
|
config: { url: "https://health.example.test/mcp" },
|
||||||
|
status: "active" as const, enabled: true, healthCheckedAt: checkedAt,
|
||||||
|
}))).returning();
|
||||||
|
const tools = toolAccessService(db, { remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||||
|
const service = tools.scheduledHealth, load = service.load;
|
||||||
|
let returnedRows = 0;
|
||||||
|
service.load = async (...args) => { const result = await load(...args); returnedRows += result.length; return result; };
|
||||||
|
await start(service); await idleAt(checkedAt.getTime() + 900_000);
|
||||||
|
returnedRows = 0;
|
||||||
|
for (const row of rows.slice(0, 5)) {
|
||||||
|
await tools.updateConnection(row.id, { name: `${row.name} changed` });
|
||||||
|
await idleAt(checkedAt.getTime() + 900_000);
|
||||||
|
}
|
||||||
|
expect(returnedRows).toBe(5);
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("pages startup inventory while retaining every eligible deadline", async () => {
|
||||||
|
const checkedAt = new Date();
|
||||||
|
await db.insert(toolConnections).values(Array.from({ length: 205 }, (_, index) => ({
|
||||||
|
companyId, applicationId, name: `Page ${index}`, uid: randomUUID(), transport: "mcp_remote" as const,
|
||||||
|
status: "active" as const, enabled: true, healthCheckedAt: checkedAt,
|
||||||
|
})));
|
||||||
|
const reads = vi.spyOn(db, "select");
|
||||||
|
const rows = await connectionHealthSchedule(db, probe).load(companyId);
|
||||||
|
expect(rows).toHaveLength(205);
|
||||||
|
expect(new Set(rows.map(row => row.id)).size).toBe(205);
|
||||||
|
expect(reads).toHaveBeenCalledTimes(3);
|
||||||
|
reads.mockRestore();
|
||||||
|
});
|
||||||
|
it("invalidates an application's cached deadlines without removing another application's work", async () => {
|
||||||
|
const checkedAt = new Date();
|
||||||
|
const [otherApp] = await db.insert(toolApplications).values({ companyId, name: "Other app", type: "mcp_http" }).returning();
|
||||||
|
await connection({ healthCheckedAt: checkedAt });
|
||||||
|
const other = await connection({ applicationId: otherApp.id, healthCheckedAt: new Date(checkedAt.getTime() + 60_000) });
|
||||||
|
const service = connectionHealthSchedule(db, probe), load = vi.spyOn(service, "load");
|
||||||
|
await start(service); await idleAt(checkedAt.getTime() + 900_000); load.mockClear();
|
||||||
|
await writeToolConnectionHealthChange(db, { companyId, applicationId }, async tx => {
|
||||||
|
await tx.update(toolApplications).set({ type: "paperclip_plugin" }).where(eq(toolApplications.id, applicationId));
|
||||||
|
});
|
||||||
|
await idleAt(other.healthCheckedAt!.getTime() + 900_000);
|
||||||
|
expect(load).toHaveBeenCalledWith(companyId, undefined, applicationId, expect.any(AbortSignal));
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
await writeToolConnectionHealthChange(db, { companyId, applicationId }, async tx => {
|
||||||
|
await tx.update(toolApplications).set({ type: "mcp_http" }).where(eq(toolApplications.id, applicationId));
|
||||||
|
});
|
||||||
|
await idleAt(checkedAt.getTime() + 900_000);
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("keeps unrelated deadlines when a connection changes application or is deleted", async () => {
|
||||||
|
const checkedAt = new Date();
|
||||||
|
const first = await connection({ healthCheckedAt: checkedAt });
|
||||||
|
const other = await connection({ healthCheckedAt: new Date(checkedAt.getTime() + 60_000) });
|
||||||
|
const [plugin] = await db.insert(toolApplications).values({ companyId, name: "Excluded app", type: "paperclip_plugin" }).returning();
|
||||||
|
await start(); await idleAt(checkedAt.getTime() + 900_000);
|
||||||
|
await writeToolConnectionHealthChange(db, first, async tx => {
|
||||||
|
await tx.update(toolConnections).set({ applicationId: plugin.id }).where(eq(toolConnections.id, first.id));
|
||||||
|
});
|
||||||
|
await idleAt(other.healthCheckedAt!.getTime() + 900_000);
|
||||||
|
await writeToolConnectionHealthChange(db, other, async tx => { await tx.delete(toolConnections).where(eq(toolConnections.id, other.id)); });
|
||||||
|
await quiet();
|
||||||
|
});
|
||||||
it("keeps disabled, archived, and excluded transports quiet", async () => {
|
it("keeps disabled, archived, and excluded transports quiet", async () => {
|
||||||
await connection({ enabled: false }); await connection({ status: "archived" });
|
await connection({ enabled: false }); await connection({ status: "archived" });
|
||||||
await connection({ transport: "chat_sdk", connectionPurpose: "channel" });
|
await connection({ transport: "chat_sdk", connectionPurpose: "channel" });
|
||||||
@@ -189,7 +255,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
it("continues another company's checks when one scope cannot refresh", async () => {
|
it("continues another company's checks when one scope cannot refresh", async () => {
|
||||||
const service = connectionHealthSchedule(db, probe), load = service.load;
|
const service = connectionHealthSchedule(db, probe), load = service.load;
|
||||||
await start(service); await quiet();
|
await start(service); await quiet();
|
||||||
service.load = async scope => { if (scope === companyId) throw new Error("scope unavailable"); return load(scope); };
|
service.load = async (...args) => { if (args[0] === companyId) throw new Error("scope unavailable"); return load(...args); };
|
||||||
const otherId = randomUUID();
|
const otherId = randomUUID();
|
||||||
await db.insert(companies).values({ id: otherId, name: "Other", issuePrefix: `O${otherId.slice(0, 6)}` });
|
await db.insert(companies).values({ id: otherId, name: "Other", issuePrefix: `O${otherId.slice(0, 6)}` });
|
||||||
const [app] = await db.insert(toolApplications).values({ companyId: otherId, name: "Other health", type: "mcp_http" }).returning();
|
const [app] = await db.insert(toolApplications).values({ companyId: otherId, name: "Other health", type: "mcp_http" }).returning();
|
||||||
@@ -225,10 +291,10 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
it("keeps SDK-only chat mutations quiet and wakes the existing voice obligation", async () => {
|
it("keeps SDK-only chat mutations quiet and wakes the existing voice obligation", async () => {
|
||||||
const voice = await connection({ transport: "voice", connectionPurpose: "channel", status: "draft", enabled: false });
|
const voice = await connection({ transport: "voice", connectionPurpose: "channel", status: "draft", enabled: false });
|
||||||
await start(); await quiet();
|
await start(); await quiet();
|
||||||
await db.transaction(async tx => { await notifyChatConnectionHealth(tx, { companyId, provider: "slack" }); });
|
await db.transaction(async tx => { await notifyChatConnectionHealth(tx, { companyId, connectionId: voice.id, provider: "slack" }); });
|
||||||
await quiet();
|
await quiet();
|
||||||
await db.transaction(async tx => {
|
await db.transaction(async tx => {
|
||||||
await notifyChatConnectionHealth(tx, { companyId, provider: "speko" });
|
await notifyChatConnectionHealth(tx, { companyId, connectionId: voice.id, provider: "speko" });
|
||||||
await tx.update(toolConnections).set({ status: "active", enabled: true }).where(eq(toolConnections.id, voice.id));
|
await tx.update(toolConnections).set({ status: "active", enabled: true }).where(eq(toolConnections.id, voice.id));
|
||||||
});
|
});
|
||||||
await checked(); expect(probe.mock.calls[0][0]).toBe(voice.id);
|
await checked(); expect(probe.mock.calls[0][0]).toBe(voice.id);
|
||||||
@@ -237,9 +303,9 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
const service = connectionHealthSchedule(db, probe), load = service.load;
|
const service = connectionHealthSchedule(db, probe), load = service.load;
|
||||||
await start(service); await quiet();
|
await start(service); await quiet();
|
||||||
let inserted = false;
|
let inserted = false;
|
||||||
service.load = async scope => {
|
service.load = async (...args) => {
|
||||||
const rows = await load(scope);
|
const rows = await load(...args);
|
||||||
if (scope === companyId && !inserted) { inserted = true; await connection(); }
|
if (args[0] === companyId && !inserted) { inserted = true; await connection(); }
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
await connection(); await checked(2);
|
await connection(); await checked(2);
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export function accessService(db: Db) {
|
|||||||
&& grant.credentialSecretRefs.every((ref) => !connectionSecretIdsToRemove.has(ref.secretId)),
|
&& grant.credentialSecretRefs.every((ref) => !connectionSecretIdsToRemove.has(ref.secretId)),
|
||||||
);
|
);
|
||||||
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id);
|
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id);
|
||||||
if (!hasUnaffectedActiveGrant) await notifyToolConnectionHealth(tx, companyId);
|
if (!hasUnaffectedActiveGrant) await notifyToolConnectionHealth(tx, { companyId, id: connection.id });
|
||||||
await tx.update(toolConnections).set({
|
await tx.update(toolConnections).set({
|
||||||
credentialRefs,
|
credentialRefs,
|
||||||
credentialSecretRefs,
|
credentialSecretRefs,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ChatProvider } from "@paperclipai/shared";
|
|||||||
import { notifyToolConnectionHealth } from "../tool-connection-health-notifications.js";
|
import { notifyToolConnectionHealth } from "../tool-connection-health-notifications.js";
|
||||||
import { chatConnectionTransport } from "./provider-catalog.js";
|
import { chatConnectionTransport } from "./provider-catalog.js";
|
||||||
|
|
||||||
export async function notifyChatConnectionHealth(transaction: object, endpoint: { companyId: string; provider: ChatProvider }) {
|
export async function notifyChatConnectionHealth(transaction: object, endpoint: { companyId: string; connectionId: string; provider: ChatProvider }) {
|
||||||
if (chatConnectionTransport(endpoint.provider) === "chat_sdk") return;
|
if (chatConnectionTransport(endpoint.provider) === "chat_sdk") return;
|
||||||
await notifyToolConnectionHealth(transaction, endpoint.companyId);
|
await notifyToolConnectionHealth(transaction, { companyId: endpoint.companyId, id: endpoint.connectionId });
|
||||||
}
|
}
|
||||||
@@ -914,7 +914,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(eq(chatEndpoints.id, endpoint.id));
|
.where(eq(chatEndpoints.id, endpoint.id));
|
||||||
await notifyToolConnectionHealth(tx, endpoint.companyId);
|
await notifyToolConnectionHealth(tx, { companyId: endpoint.companyId, id: endpoint.connectionId });
|
||||||
await tx
|
await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({ status: "active", enabled: true, healthStatus: "ok" })
|
.set({ status: "active", enabled: true, healthStatus: "ok" })
|
||||||
@@ -1986,7 +1986,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
|||||||
.update(chatEndpoints)
|
.update(chatEndpoints)
|
||||||
.set({ lastError: null, healthMessage: "Connected" })
|
.set({ lastError: null, healthMessage: "Connected" })
|
||||||
.where(eq(chatEndpoints.id, endpoint.id));
|
.where(eq(chatEndpoints.id, endpoint.id));
|
||||||
await writeToolConnectionHealthChange(db, endpoint.companyId, tx => tx
|
await writeToolConnectionHealthChange(db, { companyId: endpoint.companyId, id: endpoint.connectionId }, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
healthStatus: "ok",
|
healthStatus: "ok",
|
||||||
@@ -2094,7 +2094,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function markError(endpoint: Endpoint, error: string) {
|
async function markError(endpoint: Endpoint, error: string) {
|
||||||
await writeToolConnectionHealthChange(db, endpoint.companyId, tx => tx
|
await writeToolConnectionHealthChange(db, { companyId: endpoint.companyId, id: endpoint.connectionId }, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
healthStatus: "degraded",
|
healthStatus: "degraded",
|
||||||
@@ -2364,7 +2364,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
|||||||
.update(chatEndpoints)
|
.update(chatEndpoints)
|
||||||
.set({ status: "paused" })
|
.set({ status: "paused" })
|
||||||
.where(eq(chatEndpoints.id, endpoint.id));
|
.where(eq(chatEndpoints.id, endpoint.id));
|
||||||
await notifyToolConnectionHealth(tx, endpoint.companyId);
|
await notifyToolConnectionHealth(tx, { companyId: endpoint.companyId, id: endpoint.connectionId });
|
||||||
await tx
|
await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({ enabled: false, status: "disabled" })
|
.set({ enabled: false, status: "disabled" })
|
||||||
@@ -2458,7 +2458,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(chatEndpoints.id, id));
|
.where(eq(chatEndpoints.id, id));
|
||||||
await notifyToolConnectionHealth(tx, endpoint.companyId);
|
await notifyToolConnectionHealth(tx, { companyId: endpoint.companyId, id: endpoint.connectionId });
|
||||||
await tx
|
await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export function emailConnectionService(
|
|||||||
actorSource: actor.localImplicit ? "local_implicit" : "session",
|
actorSource: actor.localImplicit ? "local_implicit" : "session",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await writeToolConnectionHealthChange(db, companyId, tx => tx
|
await writeToolConnectionHealthChange(db, connection, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
uid: `agentmail-account-${input.idempotencyKey}`,
|
uid: `agentmail-account-${input.idempotencyKey}`,
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export function githubConnectionEventService(
|
|||||||
};
|
};
|
||||||
await database.update(connectionGrants).set({ providerTenant, updatedAt: now() })
|
await database.update(connectionGrants).set({ providerTenant, updatedAt: now() })
|
||||||
.where(and(eq(connectionGrants.id, binding.grantId), eq(connectionGrants.companyId, binding.companyId)));
|
.where(and(eq(connectionGrants.id, binding.grantId), eq(connectionGrants.companyId, binding.companyId)));
|
||||||
await notifyToolConnectionHealth(database, binding.companyId);
|
await notifyToolConnectionHealth(database, { companyId: binding.companyId, id: binding.connectionId });
|
||||||
await database.update(toolConnections).set({
|
await database.update(toolConnections).set({
|
||||||
healthStatus: unavailable ? "failed" : "ok",
|
healthStatus: unavailable ? "failed" : "ok",
|
||||||
healthMessage: unavailable
|
healthMessage: unavailable
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ export function smokeLabService(db: Db, options: {
|
|||||||
));
|
));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await writeToolConnectionHealthChange(db, input.companyId, tx => tx.update(toolApplications).set({
|
const [updated] = await writeToolConnectionHealthChange(db, { companyId: input.companyId, applicationId: existing.id }, tx => tx.update(toolApplications).set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
@@ -743,7 +743,8 @@ export function smokeLabService(db: Db, options: {
|
|||||||
lastHealthAt: now,
|
lastHealthAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
return writeToolConnectionHealthChange(db, input.companyId, async tx => {
|
const connectionId = existing?.id ?? randomUUID();
|
||||||
|
return writeToolConnectionHealthChange(db, { companyId: input.companyId, id: connectionId }, async tx => {
|
||||||
const ensureDefaultOrganizationGrant = async (connectionId: string) => {
|
const ensureDefaultOrganizationGrant = async (connectionId: string) => {
|
||||||
const [existingGrant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
|
const [existingGrant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
|
||||||
eq(connectionGrants.companyId, input.companyId),
|
eq(connectionGrants.companyId, input.companyId),
|
||||||
@@ -769,6 +770,7 @@ export function smokeLabService(db: Db, options: {
|
|||||||
return { row: updated ?? existing, created: false };
|
return { row: updated ?? existing, created: false };
|
||||||
}
|
}
|
||||||
const [created] = await tx.insert(toolConnections).values({
|
const [created] = await tx.insert(toolConnections).values({
|
||||||
|
id: connectionId,
|
||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
uid: `smoke-lab/${input.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
uid: `smoke-lab/${input.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
||||||
@@ -968,7 +970,7 @@ export function smokeLabService(db: Db, options: {
|
|||||||
if (!connection || !httpSidecar) return;
|
if (!connection || !httpSidecar) return;
|
||||||
const sidecarUrl = httpSidecar.url;
|
const sidecarUrl = httpSidecar.url;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await writeToolConnectionHealthChange(db, companyId, tx => tx.update(toolConnections).set({
|
await writeToolConnectionHealthChange(db, connection, tx => tx.update(toolConnections).set({
|
||||||
config: {
|
config: {
|
||||||
...asRecord(connection.config),
|
...asRecord(connection.config),
|
||||||
url: `${sidecarUrl}/mcp`,
|
url: `${sidecarUrl}/mcp`,
|
||||||
|
|||||||
@@ -1219,7 +1219,7 @@ export function toolAccessService(
|
|||||||
safeDefault: true,
|
safeDefault: true,
|
||||||
};
|
};
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await writeToolConnectionHealthChange(db, companyId, tx => tx
|
const [updated] = await writeToolConnectionHealthChange(db, { companyId, applicationId: existing.id }, tx => tx
|
||||||
.update(toolApplications)
|
.update(toolApplications)
|
||||||
.set({
|
.set({
|
||||||
name: definition.applicationName,
|
name: definition.applicationName,
|
||||||
@@ -1262,7 +1262,7 @@ export function toolAccessService(
|
|||||||
quarantineNewEntries: true,
|
quarantineNewEntries: true,
|
||||||
};
|
};
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updated = await writeToolConnectionHealthChange(db, companyId, async tx => {
|
const updated = await writeToolConnectionHealthChange(db, existing, async tx => {
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
@@ -1288,7 +1288,7 @@ export function toolAccessService(
|
|||||||
return { row: updated, created: false };
|
return { row: updated, created: false };
|
||||||
}
|
}
|
||||||
const connectionId = randomUUID();
|
const connectionId = randomUUID();
|
||||||
const created = await writeToolConnectionHealthChange(db, companyId, async tx => {
|
const created = await writeToolConnectionHealthChange(db, { companyId, id: connectionId }, async tx => {
|
||||||
const [created] = await tx
|
const [created] = await tx
|
||||||
.insert(toolConnections)
|
.insert(toolConnections)
|
||||||
.values({
|
.values({
|
||||||
@@ -2486,7 +2486,7 @@ export function toolAccessService(
|
|||||||
personalIdentityUserId || dedicatedAgentId ? [] : credentialSecretRefs;
|
personalIdentityUserId || dedicatedAgentId ? [] : credentialSecretRefs;
|
||||||
if (revivedConnectionPrevious) {
|
if (revivedConnectionPrevious) {
|
||||||
forgetMcpHttpSessions(revivedConnectionPrevious.id);
|
forgetMcpHttpSessions(revivedConnectionPrevious.id);
|
||||||
[connectionRow] = await writeToolConnectionHealthChange(db, companyId, tx => tx
|
[connectionRow] = await writeToolConnectionHealthChange(db, revivedConnectionPrevious, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
@@ -2931,7 +2931,7 @@ export function toolAccessService(
|
|||||||
connectionSetupMutationFingerprint(attemptedConnection),
|
connectionSetupMutationFingerprint(attemptedConnection),
|
||||||
);
|
);
|
||||||
if (connectionMutationIsStillCurrent) {
|
if (connectionMutationIsStillCurrent) {
|
||||||
await notifyToolConnectionHealth(tx, companyId);
|
await notifyToolConnectionHealth(tx, revivedConnectionPrevious);
|
||||||
await tx
|
await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
@@ -3007,7 +3007,7 @@ export function toolAccessService(
|
|||||||
// surface the restoration failure instead of returning only the
|
// surface the restoration failure instead of returning only the
|
||||||
// original provider error.
|
// original provider error.
|
||||||
try {
|
try {
|
||||||
await writeToolConnectionHealthChange(db, companyId, tx => tx
|
await writeToolConnectionHealthChange(db, revivedConnectionPrevious, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
status: "draft",
|
status: "draft",
|
||||||
@@ -3033,7 +3033,7 @@ export function toolAccessService(
|
|||||||
}
|
}
|
||||||
} else if (connectionRow) {
|
} else if (connectionRow) {
|
||||||
const failedConnectionId = connectionRow.id;
|
const failedConnectionId = connectionRow.id;
|
||||||
await writeToolConnectionHealthChange(db, companyId, tx => tx
|
await writeToolConnectionHealthChange(db, { companyId, id: failedConnectionId }, tx => tx
|
||||||
.delete(toolConnections)
|
.delete(toolConnections)
|
||||||
.where(eq(toolConnections.id, failedConnectionId)))
|
.where(eq(toolConnections.id, failedConnectionId)))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -3148,7 +3148,7 @@ export function toolAccessService(
|
|||||||
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
||||||
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)))
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)))
|
||||||
.for("update");
|
.for("update");
|
||||||
await notifyToolConnectionHealth(tx, companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
||||||
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning();
|
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning();
|
||||||
await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)));
|
await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)));
|
||||||
@@ -3505,7 +3505,7 @@ export function toolAccessService(
|
|||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
await notifyToolConnectionHealth(tx, companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
const [updatedConnection] = await tx
|
const [updatedConnection] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
.set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
||||||
@@ -4526,7 +4526,7 @@ export function toolAccessService(
|
|||||||
}
|
}
|
||||||
const connectionId = randomUUID();
|
const connectionId = randomUUID();
|
||||||
const binding = actorBinding(actor);
|
const binding = actorBinding(actor);
|
||||||
const row = await writeToolConnectionHealthChange(db, companyId, async tx => {
|
const row = await writeToolConnectionHealthChange(db, { companyId, id: connectionId }, async tx => {
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.insert(toolConnections)
|
.insert(toolConnections)
|
||||||
.values({
|
.values({
|
||||||
@@ -4648,7 +4648,7 @@ export function toolAccessService(
|
|||||||
await syncCredentialBindings(rows[0]!, [], tx);
|
await syncCredentialBindings(rows[0]!, [], tx);
|
||||||
return rows;
|
return rows;
|
||||||
})
|
})
|
||||||
: await writeToolConnectionHealthChange(db, existing.companyId, async tx => {
|
: await writeToolConnectionHealthChange(db, existing, async tx => {
|
||||||
const rows = await write(tx);
|
const rows = await write(tx);
|
||||||
await syncCredentialBindings(rows[0]!, [], tx);
|
await syncCredentialBindings(rows[0]!, [], tx);
|
||||||
await createToolRuntimeSlots(tx, runtimeSupervisor).ensureRuntimeSlot(rows[0]!);
|
await createToolRuntimeSlots(tx, runtimeSupervisor).ensureRuntimeSlot(rows[0]!);
|
||||||
|
|||||||
@@ -1,38 +1,48 @@
|
|||||||
import { logger } from "../../../middleware/logger.js";
|
import { logger } from "../../../middleware/logger.js";
|
||||||
import { and, eq, ne, sql } from "drizzle-orm";
|
import { and, asc, eq, gt, ne, sql } from "drizzle-orm";
|
||||||
import { toolApplications, toolConnections, type Db } from "@paperclipai/db";
|
import { toolApplications, toolConnections, type Db } from "@paperclipai/db";
|
||||||
import { isConnectorFeatureEnabled } from "@paperclipai/shared";
|
import { isConnectorFeatureEnabled } from "@paperclipai/shared";
|
||||||
import { instanceSettingsService } from "../../instance-settings.js";
|
import { instanceSettingsService } from "../../instance-settings.js";
|
||||||
import { connectionSetupApp } from "../connections/setup-policy.js";
|
import { connectionSetupApp } from "../connections/setup-policy.js";
|
||||||
import type { ConnectionHealth } from "./health.js";
|
import type { ConnectionHealth } from "./health.js";
|
||||||
|
|
||||||
export type ConnectionHealthDeadline = { id: string; companyId: string; dueAt: number };
|
export type ConnectionHealthDeadline = { id: string; companyId: string; applicationId: string; dueAt: number };
|
||||||
const HEALTH_FRESHNESS_MS = 15 * 60_000;
|
const HEALTH_FRESHNESS_MS = 15 * 60_000;
|
||||||
|
|
||||||
export function connectionHealthSchedule(db: Db, checkHealth: ConnectionHealth["checkConnectionHealth"]) {
|
export function connectionHealthSchedule(db: Db, checkHealth: ConnectionHealth["checkConnectionHealth"]) {
|
||||||
async function load(companyId?: string, connectionId?: string): Promise<ConnectionHealthDeadline[]> {
|
async function load(companyId?: string, connectionId?: string, applicationId?: string, signal?: AbortSignal): Promise<ConnectionHealthDeadline[]> {
|
||||||
const rows = await db.select({
|
const deadlines: ConnectionHealthDeadline[] = [];
|
||||||
id: toolConnections.id,
|
let cursor: string | undefined;
|
||||||
companyId: toolConnections.companyId,
|
while (true) {
|
||||||
checkedAt: toolConnections.healthCheckedAt,
|
signal?.throwIfAborted();
|
||||||
applicationKey: toolApplications.applicationKey,
|
const rows = await db.select({
|
||||||
sourceTemplateKey: sql<string | null>`coalesce(${toolConnections.config}->>'sourceTemplateKey', ${toolConnections.transportConfig}->>'sourceTemplateKey')`,
|
id: toolConnections.id,
|
||||||
}).from(toolConnections).innerJoin(toolApplications, and(
|
companyId: toolConnections.companyId,
|
||||||
eq(toolApplications.id, toolConnections.applicationId),
|
applicationId: toolConnections.applicationId,
|
||||||
eq(toolApplications.companyId, toolConnections.companyId),
|
checkedAt: toolConnections.healthCheckedAt,
|
||||||
)).where(and(
|
applicationKey: toolApplications.applicationKey,
|
||||||
eq(toolConnections.enabled, true), eq(toolConnections.status, "active"),
|
sourceTemplateKey: sql<string | null>`coalesce(${toolConnections.config}->>'sourceTemplateKey', ${toolConnections.transportConfig}->>'sourceTemplateKey')`,
|
||||||
ne(toolConnections.transport, "chat_sdk"), ne(toolConnections.transport, "runtime_auth"),
|
}).from(toolConnections).innerJoin(toolApplications, and(
|
||||||
ne(toolApplications.type, "paperclip_plugin"),
|
eq(toolApplications.id, toolConnections.applicationId),
|
||||||
companyId === undefined ? undefined : eq(toolConnections.companyId, companyId),
|
eq(toolApplications.companyId, toolConnections.companyId),
|
||||||
connectionId === undefined ? undefined : eq(toolConnections.id, connectionId),
|
)).where(and(
|
||||||
));
|
eq(toolConnections.enabled, true), eq(toolConnections.status, "active"),
|
||||||
const definitions = rows.map(row => ({ row, app: connectionSetupApp(row.applicationKey, row.sourceTemplateKey) }));
|
ne(toolConnections.transport, "chat_sdk"), ne(toolConnections.transport, "runtime_auth"),
|
||||||
const settings = definitions.some(({ app }) => app?.experimentalFeatureKey)
|
ne(toolApplications.type, "paperclip_plugin"),
|
||||||
? await instanceSettingsService(db).getExperimental() : null;
|
companyId === undefined ? undefined : eq(toolConnections.companyId, companyId),
|
||||||
return definitions.filter(({ app }) => !app || !settings || isConnectorFeatureEnabled(app, settings))
|
connectionId === undefined ? undefined : eq(toolConnections.id, connectionId),
|
||||||
.map(({ row }) => ({ id: row.id, companyId: row.companyId,
|
applicationId === undefined ? undefined : eq(toolConnections.applicationId, applicationId),
|
||||||
dueAt: row.checkedAt === null ? 0 : row.checkedAt.getTime() + HEALTH_FRESHNESS_MS }));
|
cursor === undefined ? undefined : gt(toolConnections.id, cursor),
|
||||||
|
)).orderBy(asc(toolConnections.id)).limit(100);
|
||||||
|
const definitions = rows.map(row => ({ row, app: connectionSetupApp(row.applicationKey, row.sourceTemplateKey) }));
|
||||||
|
const settings = definitions.some(({ app }) => app?.experimentalFeatureKey)
|
||||||
|
? await instanceSettingsService(db).getExperimental() : null;
|
||||||
|
deadlines.push(...definitions.filter(({ app }) => !app || !settings || isConnectorFeatureEnabled(app, settings))
|
||||||
|
.map(({ row }) => ({ id: row.id, companyId: row.companyId, applicationId: row.applicationId,
|
||||||
|
dueAt: row.checkedAt === null ? 0 : row.checkedAt.getTime() + HEALTH_FRESHNESS_MS })));
|
||||||
|
if (rows.length < 100) return deadlines;
|
||||||
|
cursor = rows[rows.length - 1].id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function check(id: string): Promise<ConnectionHealthDeadline | null> {
|
async function check(id: string): Promise<ConnectionHealthDeadline | null> {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
|||||||
message: string | null,
|
message: string | null,
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [updated] = await writeToolConnectionHealthChange(db, connection.companyId, async tx => {
|
const [updated] = await writeToolConnectionHealthChange(db, connection, async tx => {
|
||||||
await notifyChatConnectionCommandRegistration(tx, connection.id, connection.transport);
|
await notifyChatConnectionCommandRegistration(tx, connection.id, connection.transport);
|
||||||
return tx.update(toolConnections)
|
return tx.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
|||||||
: refreshOptions.enableAllByDefault
|
: refreshOptions.enableAllByDefault
|
||||||
? { ...connection.transportConfig, quarantineNewEntries: false }
|
? { ...connection.transportConfig, quarantineNewEntries: false }
|
||||||
: connection.transportConfig;
|
: connection.transportConfig;
|
||||||
const [updatedConnection] = await writeToolConnectionHealthChange(db, connection.companyId, tx => tx
|
const [updatedConnection] = await writeToolConnectionHealthChange(db, connection, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
config: normalizedConfig,
|
config: normalizedConfig,
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ async function removeConnection(
|
|||||||
|
|
||||||
const archived = await db.transaction(async (tx) => {
|
const archived = await db.transaction(async (tx) => {
|
||||||
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id);
|
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id);
|
||||||
await notifyToolConnectionHealth(tx, connection.companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
||||||
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
|
||||||
.for("update");
|
.for("update");
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ async function finalizeOAuthAccess(
|
|||||||
) {
|
) {
|
||||||
throw conflict("This connection already uses a company identity");
|
throw conflict("This connection already uses a company identity");
|
||||||
}
|
}
|
||||||
connection = await writeToolConnectionHealthChange(db, companyId, async tx => {
|
connection = await writeToolConnectionHealthChange(db, connection, async tx => {
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
@@ -328,7 +328,7 @@ async function finalizeOAuthAccess(
|
|||||||
updatedAt: nowAt,
|
updatedAt: nowAt,
|
||||||
})
|
})
|
||||||
.where(eq(connectionGrants.id, personalGrant.id));
|
.where(eq(connectionGrants.id, personalGrant.id));
|
||||||
await notifyToolConnectionHealth(tx, companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
[connection] = await tx
|
[connection] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export function createCloudOAuthCallback(db: Db, dependencies: Dependencies) {
|
|||||||
scopes: [...profile.scopes],
|
scopes: [...profile.scopes],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await notifyToolConnectionHealth(tx, connection.companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
[connection] = await tx
|
[connection] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const oauthRefreshFlights = new Map<string, Promise<unknown>>();
|
|||||||
reauthorizationRequiredAt: now().toISOString(),
|
reauthorizationRequiredAt: now().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const [updated] = await writeToolConnectionHealthChange(db, connection.companyId, tx => tx
|
const [updated] = await writeToolConnectionHealthChange(db, connection, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
status: "draft",
|
status: "draft",
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export function createDirectOAuthCallback(db: Db, dependencies: Dependencies) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await notifyToolConnectionHealth(tx, connection.companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
const [updatedConnection] = await tx
|
const [updatedConnection] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
@@ -612,7 +612,7 @@ export function createDirectOAuthCallback(db: Db, dependencies: Dependencies) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await notifyToolConnectionHealth(tx, connection.companyId);
|
await notifyToolConnectionHealth(tx, connection);
|
||||||
const [updatedConnection] = await tx
|
const [updatedConnection] = await tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export function createOAuthGrantRefresh(db: Db, { now, store, leases, currentClo
|
|||||||
connection.id,
|
connection.id,
|
||||||
connection.companyId,
|
connection.companyId,
|
||||||
);
|
);
|
||||||
const [reauthorizationRequired] = await writeToolConnectionHealthChange(db, latestConnection.companyId, tx => tx
|
const [reauthorizationRequired] = await writeToolConnectionHealthChange(db, latestConnection, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
status: "draft",
|
status: "draft",
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export function createVercelOAuthCallback(db: Db, dependencies: Dependencies) {
|
|||||||
status: connection.status,
|
status: connection.status,
|
||||||
enabled: connection.enabled,
|
enabled: connection.enabled,
|
||||||
};
|
};
|
||||||
[connection] = await writeToolConnectionHealthChange(db, connection.companyId, tx => tx
|
[connection] = await writeToolConnectionHealthChange(db, connection, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||||
|
|
||||||
export const notifyToolConnectionHealth = (transaction: object, companyId: string) =>
|
type HealthChange = string | { companyId: string; id: string } | { companyId: string; applicationId: string };
|
||||||
notifyDeliveryWork(transaction, DELIVERY_QUEUES.toolConnectionHealth, companyId);
|
export type HealthScope = { companyId?: string; connectionId?: string; applicationId?: string };
|
||||||
|
|
||||||
export function writeToolConnectionHealthChange<T>(db: Db, companyId: string, write: (transaction: Db) => Promise<T>): Promise<T> {
|
export function healthScope(hint: string): HealthScope {
|
||||||
|
if (hint === "*") return {};
|
||||||
|
const [companyId, kind, id, extra] = hint.split(":");
|
||||||
|
if (companyId && kind === undefined) return { companyId };
|
||||||
|
if (companyId && id && extra === undefined) {
|
||||||
|
if (kind === "connection") return { companyId, connectionId: id };
|
||||||
|
if (kind === "application") return { companyId, applicationId: id };
|
||||||
|
}
|
||||||
|
throw new Error("Invalid tool connection health scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notifyToolConnectionHealth = (transaction: object, target: HealthChange) =>
|
||||||
|
notifyDeliveryWork(transaction, DELIVERY_QUEUES.toolConnectionHealth, typeof target === "string" ? target
|
||||||
|
: "id" in target ? `${target.companyId}:connection:${target.id}` : `${target.companyId}:application:${target.applicationId}`);
|
||||||
|
|
||||||
|
export function writeToolConnectionHealthChange<T>(db: Db, target: HealthChange, write: (transaction: Db) => Promise<T>): Promise<T> {
|
||||||
return db.transaction(async tx => {
|
return db.transaction(async tx => {
|
||||||
await notifyToolConnectionHealth(tx, companyId);
|
await notifyToolConnectionHealth(tx, target);
|
||||||
return write(tx as unknown as Db);
|
return write(tx as unknown as Db);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { healthScope, type HealthScope } from "./tool-connection-health-notifications.js";
|
||||||
import { takeCompanyWorkBatch } from "./company-work-batch.js";
|
import { takeCompanyWorkBatch } from "./company-work-batch.js";
|
||||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||||
@@ -6,7 +7,7 @@ import type { ConnectionHealthDeadline } from "./tool-access/catalog/health-sche
|
|||||||
export function registerToolConnectionHealthWork(
|
export function registerToolConnectionHealthWork(
|
||||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||||
service: {
|
service: {
|
||||||
load(companyId?: string): Promise<ConnectionHealthDeadline[]>;
|
load(companyId?: string, connectionId?: string, applicationId?: string, signal?: AbortSignal): Promise<ConnectionHealthDeadline[]>;
|
||||||
check(id: string): Promise<ConnectionHealthDeadline | null>;
|
check(id: string): Promise<ConnectionHealthDeadline | null>;
|
||||||
},
|
},
|
||||||
canRun: () => boolean,
|
canRun: () => boolean,
|
||||||
@@ -14,6 +15,10 @@ export function registerToolConnectionHealthWork(
|
|||||||
const dirty = new Set<string>(["*"]);
|
const dirty = new Set<string>(["*"]);
|
||||||
const deadlines = new Map<string, ConnectionHealthDeadline>();
|
const deadlines = new Map<string, ConnectionHealthDeadline>();
|
||||||
const failures = new Map<string, { count: number; retryAt: number }>();
|
const failures = new Map<string, { count: number; retryAt: number }>();
|
||||||
|
const matches = (entry: ConnectionHealthDeadline, scope: HealthScope) =>
|
||||||
|
(scope.companyId === undefined || entry.companyId === scope.companyId)
|
||||||
|
&& (scope.connectionId === undefined || entry.id === scope.connectionId)
|
||||||
|
&& (scope.applicationId === undefined || entry.applicationId === scope.applicationId);
|
||||||
const retryMs = 30_000;
|
const retryMs = 30_000;
|
||||||
let earliest = 0;
|
let earliest = 0;
|
||||||
let lastCompany: string | null = null;
|
let lastCompany: string | null = null;
|
||||||
@@ -38,21 +43,26 @@ export function registerToolConnectionHealthWork(
|
|||||||
const scopes = dirty.has("*") ? ["*"] : [...dirty];
|
const scopes = dirty.has("*") ? ["*"] : [...dirty];
|
||||||
dirty.clear();
|
dirty.clear();
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
const unavailable = new Set<string>();
|
const unavailable: HealthScope[] = [];
|
||||||
for (let index = 0; index < scopes.length; index++) {
|
for (let index = 0; index < scopes.length; index++) {
|
||||||
const scope = scopes[index];
|
const scope = scopes[index];
|
||||||
if (signal.aborted || !canRun()) { for (const remaining of scopes.slice(index)) dirty.add(remaining); return; }
|
if (signal.aborted || !canRun()) { for (const remaining of scopes.slice(index)) dirty.add(remaining); return; }
|
||||||
|
let target: HealthScope | undefined;
|
||||||
try {
|
try {
|
||||||
const rows = await service.load(scope === "*" ? undefined : scope);
|
target = healthScope(scope);
|
||||||
for (const entry of deadlines.values()) if (scope === "*" || entry.companyId === scope) deadlines.delete(entry.id);
|
const rows = await service.load(target.companyId, target.connectionId, target.applicationId, signal);
|
||||||
|
if (target.connectionId) {
|
||||||
|
const current = deadlines.get(target.connectionId);
|
||||||
|
if (current && matches(current, target)) deadlines.delete(current.id);
|
||||||
|
} else for (const entry of deadlines.values()) if (matches(entry, target)) deadlines.delete(entry.id);
|
||||||
for (const entry of rows) deadlines.set(entry.id, entry);
|
for (const entry of rows) deadlines.set(entry.id, entry);
|
||||||
for (const id of failures.keys()) {
|
} catch (error) { dirty.add(target ? scope : "*"); unavailable.push(target ?? {}); errors.push(error); }
|
||||||
const current = deadlines.get(id);
|
|
||||||
if (!current || current.dueAt > Date.now()) failures.delete(id);
|
|
||||||
}
|
|
||||||
} catch (error) { dirty.add(scope); unavailable.add(scope); errors.push(error); }
|
|
||||||
}
|
}
|
||||||
const due = [...deadlines.values()].filter(entry => !unavailable.has("*") && !unavailable.has(entry.companyId)
|
for (const id of failures.keys()) {
|
||||||
|
const current = deadlines.get(id);
|
||||||
|
if (!current || current.dueAt > Date.now()) failures.delete(id);
|
||||||
|
}
|
||||||
|
const due = [...deadlines.values()].filter(entry => !unavailable.some(scope => matches(entry, scope))
|
||||||
&& Math.max(entry.dueAt, failures.get(entry.id)?.retryAt ?? 0) <= Date.now())
|
&& Math.max(entry.dueAt, failures.get(entry.id)?.retryAt ?? 0) <= Date.now())
|
||||||
.sort((a, b) => a.dueAt - b.dueAt || a.id.localeCompare(b.id));
|
.sort((a, b) => a.dueAt - b.dueAt || a.id.localeCompare(b.id));
|
||||||
const batch = takeCompanyWorkBatch(due, 25, lastCompany);
|
const batch = takeCompanyWorkBatch(due, 25, lastCompany);
|
||||||
|
|||||||
@@ -3805,7 +3805,7 @@ export function createToolGatewayService(
|
|||||||
message: string | null,
|
message: string | null,
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await writeToolConnectionHealthChange(db, connection.companyId, tx => tx
|
await writeToolConnectionHealthChange(db, connection, tx => tx
|
||||||
.update(toolConnections)
|
.update(toolConnections)
|
||||||
.set({
|
.set({
|
||||||
healthStatus: status,
|
healthStatus: status,
|
||||||
|
|||||||
Reference in new issue
Block a user