From 9d945fbd12637b7aa41d52221912b2c73490c1d7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 2 Nov 2026 18:42:19 -0600 Subject: [PATCH] fix: distinguish removed GitHub history from unfinished retirement Permit retained GitHub endpoints only after the ordinary removal fence, credential cleanup and lease release are complete. Keep failed retirement and service initialization visible to idle inspection until the owning runtime settles. Preserve independent delivery obligations and all other transport blockers. Co-Authored-By: Paperclip --- doc/idle-incoming-wake.md | 15 ++++ .../chat-channels.integration.test.ts | 59 ++++++++++++ .../src/__tests__/idle-incoming-wake.test.ts | 90 +++++++++++++++++++ server/src/services/chat-channels.ts | 11 +++ server/src/services/chat-runtime.ts | 5 ++ server/src/services/chat-sdk-runtime.test.ts | 27 ++++++ server/src/services/chat-sdk-runtime.ts | 5 ++ server/src/services/idle-incoming-wake.ts | 23 ++++- 8 files changed, 233 insertions(+), 2 deletions(-) diff --git a/doc/idle-incoming-wake.md b/doc/idle-incoming-wake.md index 8b4b123d5d4..05dfac779c8 100644 --- a/doc/idle-incoming-wake.md +++ b/doc/idle-incoming-wake.md @@ -101,3 +101,18 @@ separate and must include every enabled transport. The v2 API profile requires an authenticated per-sleep-cycle key projection before compute stops. The signed challenge is derived from the newly random owned idle hold and stack. In the same read-only snapshot, the app projects up to 1000 active key rows into domain-separated salted fingerprints; original key hashes and raw keys stay private. Board-key expiry is carried and enforced before wake. Unknown, duplicate, malformed or overflow projection fails closed. The complete initial and final receipts must match; changing a credential or challenge refuses stop. Cloud stores the final receipt in one internal stack column under the sleep operation lock. Unknown requests cannot consume provider-start quota. Possession of a matching snapshot key only admits a bounded wake; tenant authentication and current permissions still run on delivery. A key revoked after the snapshot can therefore receive no tenant authority, even if it once qualified for wake. Wake claims recheck the exact cycle, matching key and expiry atomically. Normal next sleep re-reads revocations. Successful wake, manual/archive/delete or a known sleep abort clears the receipt; uncertain stop and a same-cycle failed wake retain recovery. A restarted host reads the durable receipt. No global TTL strands nonexpiring saved keys during long sleep, and no periodic credential refresh worker is added. + + +Removed GitHub endpoints are retained history only after the ordinary removal +writer archives the endpoint with its timestamp and generation, archives and +disables the same-company channel connection, clears its credential references, +and releases endpoint leases. Any retained lease, incomplete registration or +malformed cleanup state still blocks. This exemption covers only GitHub; archived +persistent transports remain blocked. Actions, deliveries and publications keep +their separate completion checks even when their endpoint has been removed. + +The service also keeps its existing local-work probe active while runtime +initialization or retirement is unsettled. Both supported runtime registries +report failed retirement even after `get` and `list` hide the runtime. Successful +owner retry clears that debt; a failed full service shutdown retains the probe. +These checks read process-local state and add no polling, network or SQL. diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 4657f828989..f0b36e969ac 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -31,6 +31,10 @@ import * as githubTaskPreparation from "../services/chat-channels/github/task-pr import { budgetPolicies } from "@paperclipai/db"; import { budgetService } from "../services/budgets.js"; import { idleWorkSnapshot } from "../services/task-admission.js"; +import { readIdleLocalWork } from "../services/idle-local-work.js"; +import { incomingWakeWorkQuery } from "../services/idle-incoming-wake.js"; +import { createChatRuntime } from "../services/chat-runtime.js"; +import { createChatSdkRuntime } from "../services/chat-sdk-runtime.js"; import { readFreshWorkspaceReadiness } from "../services/fresh-workspace-continuation.js"; import { requestTaskRetry, reconcileTaskChatRetryReceipts } from "../services/task-retry.js"; import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js"; @@ -806,6 +810,8 @@ class FakeChatSdkRuntime { return this.endpoints.get(endpointId) ?? null; } + hasUnsettledLifecycle() { return false; } + async replaceEndpoint(options: CreateChatSdkEndpointRuntimeOptions) { this.replaceCount += 1; this.configurations.set(options.endpointId, options); @@ -29903,13 +29909,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }), ); await initializationEntered; + expect(runtime.hasUnsettledLifecycle()).toBe(false); // Fake registry tail is already settled. + expect(await registeredIdleProbeState()).toBe("present"); await service.configure(endpoint.id, { action: "pause" }, "owner-user"); + let shutdownSettled = false; + const shutdown = service.shutdown().then(() => { shutdownSettled = true; }); + await new Promise(resolve => setImmediate(resolve)); + expect(shutdownSettled).toBe(false); + expect(await registeredIdleProbeState()).toBe("present"); releaseInitialization(); await expect(initializingWebhook).rejects.toMatchObject({ status: 409, details: { code: "chat_endpoint_runtime_superseded" }, }); + await shutdown; + expect(shutdownSettled).toBe(true); + expect(await registeredIdleProbeState()).toBe("none"); expect(runtime.endpoints.has(endpoint.id)).toBe(false); await expect(service.get(endpoint.id)).resolves.toMatchObject({ status: "paused", @@ -49451,7 +49467,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .then((rows) => rows.length); expect(deliveryCount).toBeGreaterThan(0); + const endpointBlocksSleep = async () => (await db.execute(sql` + SELECT EXISTS (${incomingWakeWorkQuery("chat_endpoints")} AND endpoint.id = ${endpoint.id}::uuid) AS blocked`))[0].blocked; + if (provider === "github") expect(await endpointBlocksSleep()).toBe(true); await service.configure(endpoint.id, { action: "remove" }, "owner-user"); + if (provider === "github") expect(await endpointBlocksSleep()).toBe(false); + else expect(await endpointBlocksSleep()).toBe(true); // No persistent/provider coverage expansion. await expect(service.get(endpoint.id)).resolves.toMatchObject({ status: "archived", @@ -49493,6 +49514,44 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }, ); + async function registeredIdleProbeState() { + let state: string | undefined; + await readIdleLocalWork({ diagnostic: (reason, value) => { if (reason === "local.registered_probes") state = value; } }); + return state; + } + + it.each([ + { name: "SDK", create: createChatSdkRuntime }, + { name: "shared", create: createChatRuntime }, + ])("retains the service idle probe after failed $name retirement and shutdown until owner retry", async ({ create }) => { + const fixture = await seedCompany(); + const f = await configuredGitHubEndpoint(fixture); + const registry = create(); + const first = await registry.replaceEndpoint(f.runtime.configurations.get(f.endpoint.id)!); + const shutdown = vi.spyOn(first, "shutdown") + .mockRejectedValueOnce(new Error("remove retirement uncertain")) + .mockRejectedValueOnce(new Error("shutdown retirement uncertain")) + .mockResolvedValue(undefined); + const service = chatChannelService(db, { runtime: registry, fetch: f.providerFetch, publicBaseUrl: "https://paperclip.example" }); + fixtureServices.add(service); + expect(await registeredIdleProbeState()).toBe("none"); + await service.configure(f.endpoint.id, { action: "remove" }, "owner-user"); + expect(registry.get(f.endpoint.id)).toBeNull(); + expect(registry.list()).toEqual([]); + expect(registry.hasUnsettledLifecycle()).toBe(true); + const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction"); + try { + expect(await registeredIdleProbeState()).toBe("present"); + expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled(); + } finally { select.mockRestore(); transaction.mockRestore(); } + await expect(service.shutdown()).rejects.toThrow("shutdown retirement uncertain"); + expect(await registeredIdleProbeState()).toBe("present"); + await service.shutdown(); + expect(shutdown).toHaveBeenCalledTimes(3); + expect(registry.hasUnsettledLifecycle()).toBe(false); + expect(await registeredIdleProbeState()).toBe("none"); + }); + it("retries credential cleanup after archival made a remove partially complete", async () => { const fixture = await seedCompany(); const { endpoint, service } = await configuredSlackEndpoint(fixture); diff --git a/server/src/__tests__/idle-incoming-wake.test.ts b/server/src/__tests__/idle-incoming-wake.test.ts index 077e583e537..18628e4c35b 100644 --- a/server/src/__tests__/idle-incoming-wake.test.ts +++ b/server/src/__tests__/idle-incoming-wake.test.ts @@ -26,6 +26,13 @@ import { connectionGrants, chatEndpoints, chatGitHubRegistrations, + chatSlackRegistrations, + chatEndpointLeases, + chatActions, + chatDeliveries, + chatPublications, + chatConversations, + issues, type Db, } from "@paperclipai/db"; import { setCloudRuntimeConfigSnapshot } from "../services/cloud-runtime-config-snapshot.js"; @@ -42,6 +49,7 @@ import { readIdleSleepSafety } from "../services/idle-sleep-safety.js"; import { readIdleIncomingWakeProof, incomingWakeFingerprint, + incomingWakeWorkQuery, } from "../services/idle-incoming-wake.js"; import { registerConnectionEventWake } from "../services/connection-event-wake.js"; import { idleApiActivity } from "../services/idle-api-activity.js"; @@ -384,6 +392,88 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities { )[0]!.name, ).toBe("Persisted after wake"); }); + // Fault injection below supplies only malformed/incomplete persisted states; + // the positive archived transition is covered by the real configure(remove) + // producer in chat-channels.integration.test.ts, without deleting history. + async function removedGitHubFixture() { + const f = await seed(), applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID(); + await db.insert(toolApplications).values({ id: applicationId, companyId: f.companyId, name: "Removed GitHub", type: "chat" }); + await db.insert(toolConnections).values({ id: connectionId, companyId: f.companyId, applicationId, + name: "Removed GitHub", uid: randomUUID(), transport: "chat_sdk", connectionPurpose: "channel", + status: "archived", enabled: false, credentialSecretRefs: [] }); + await db.insert(chatEndpoints).values({ id: endpointId, companyId: f.companyId, connectionId, + assignedAgentId: f.agentId, provider: "github", publicId: randomUUID(), status: "archived", + archivedAt: new Date(), setup: { step: "complete", runtimeGeneration: 1 } }); + const blocked = async () => (await db.execute(sql`SELECT EXISTS ( + ${incomingWakeWorkQuery("chat_endpoints")} AND endpoint.id = ${endpointId}::uuid) AS blocked`))[0].blocked; + return { ...f, applicationId, connectionId, endpointId, blocked }; + } + it.each(["stamp", "active", "paused", "revoked", "persistent_provider", "enabled_connection", "active_connection", + "non_sdk_connection", "credentials", "malformed_credentials", "malformed_setup", "missing_generation", "fractional_generation", + "lease", "expired_lease", "pending_registration", "unconsumed_registration", "wrong_registry"])( + "keeps incomplete removed GitHub proof blocked: %s", async fault => { + const f = await removedGitHubFixture(); + if (fault === "stamp") await db.update(chatEndpoints).set({ archivedAt: null }).where(eq(chatEndpoints.id, f.endpointId)); + if (["active", "paused", "revoked"].includes(fault)) await db.update(chatEndpoints) + .set({ status: fault as "active" | "paused" | "revoked" }).where(eq(chatEndpoints.id, f.endpointId)); + if (fault === "persistent_provider") await db.update(chatEndpoints).set({ provider: "discord" }).where(eq(chatEndpoints.id, f.endpointId)); + if (fault === "enabled_connection") await db.update(toolConnections).set({ enabled: true }).where(eq(toolConnections.id, f.connectionId)); + if (fault === "active_connection") await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, f.connectionId)); + if (fault === "non_sdk_connection") await db.update(toolConnections).set({ transport: "voice" }).where(eq(toolConnections.id, f.connectionId)); + if (fault === "credentials" || fault === "malformed_credentials") await db.execute(sql`UPDATE tool_connections + SET credential_secret_refs = ${fault === "credentials" ? '[{"secretId":"retained"}]' : '{}'}::jsonb WHERE id = ${f.connectionId}::uuid`); + if (fault === "malformed_setup") await db.execute(sql`UPDATE chat_endpoints SET setup = '"unknown"'::jsonb WHERE id = ${f.endpointId}::uuid`); + if (fault === "missing_generation" || fault === "fractional_generation") await db.update(chatEndpoints) + .set({ setup: fault === "missing_generation" ? { step: "complete" } : { step: "complete", runtimeGeneration: 1.5 } }) + .where(eq(chatEndpoints.id, f.endpointId)); + if (fault === "lease" || fault === "expired_lease") await db.insert(chatEndpointLeases).values({ companyId: f.companyId, + endpointId: f.endpointId, leaseKey: "credentials", token: randomUUID(), expiresAt: new Date(fault === "lease" ? Date.now() + 60_000 : 0) }); + if (fault === "pending_registration" || fault === "unconsumed_registration") await db.insert(chatGitHubRegistrations).values({ + companyId: f.companyId, endpointId: f.endpointId, userId: f.userId, stateHash: randomUUID(), trustedOrigin: "https://tenant.example.test", + status: fault === "pending_registration" ? "pending" : "completed", expiresAt: new Date(Date.now() + 60_000) }); + if (fault === "wrong_registry") await db.insert(chatSlackRegistrations).values({ endpointId: f.endpointId, companyId: f.companyId, + requestId: randomUUID(), status: "uncertain", manifest: {}, manifestHash: "a".repeat(64), callbackUri: "https://tenant.example.test", + createdByUserId: f.userId, secretIds: { clientSecret: randomUUID() } }); + expect(await f.blocked()).toBe(true); + expect(await db.select({ id: chatEndpoints.id }).from(chatEndpoints).where(eq(chatEndpoints.id, f.endpointId))).toHaveLength(1); + }); + it("retains tenant boundaries and rejects unfamiliar archived state at the schema boundary", async () => { + const f = await removedGitHubFixture(); + await expect(db.execute(sql`UPDATE chat_endpoints SET connection_id = ${randomUUID()}::uuid WHERE id = ${f.endpointId}::uuid`)).rejects.toThrow(); + const foreignApplication = randomUUID(), foreignConnection = randomUUID(); + await db.insert(toolApplications).values({ id: foreignApplication, companyId: f.foreignId, name: "Foreign", type: "chat" }); + await db.insert(toolConnections).values({ id: foreignConnection, companyId: f.foreignId, applicationId: foreignApplication, + name: "Foreign", uid: randomUUID(), transport: "chat_sdk", connectionPurpose: "channel", status: "archived", enabled: false }); + await expect(db.execute(sql`UPDATE chat_endpoints SET connection_id = ${foreignConnection}::uuid WHERE id = ${f.endpointId}::uuid`)).rejects.toThrow(); + await expect(db.execute(sql`UPDATE chat_endpoints SET status = 'unknown' WHERE id = ${f.endpointId}::uuid`)).rejects.toThrow(); + expect((await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpointId)))[0].connectionId).toBe(f.connectionId); + }); + it("does not let removed GitHub endpoint history discharge pending delivery, publication or action obligations", async () => { + const f = await removedGitHubFixture(); + await db.insert(chatGitHubRegistrations).values({ companyId: f.companyId, endpointId: f.endpointId, userId: f.userId, + stateHash: randomUUID(), trustedOrigin: "https://tenant.example.test", status: "completed", consumedAt: new Date(), expiresAt: new Date(0) }); + expect(await f.blocked()).toBe(false); + const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Retained chat debt", status: "done" }).returning(); + const [conversation] = await db.insert(chatConversations).values({ companyId: f.companyId, endpointId: f.endpointId, + issueId: issue.id, externalConversationId: "github:retained", externalLabel: "retained", state: "endpoint_removed" }).returning(); + await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: conversation.id, + providerEventId: "retained", deduplicationKey: "retained", eventKind: "message", normalizedEvent: {}, state: "retry", nextAttemptAt: new Date() }); + await db.insert(chatPublications).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: conversation.id, + issueId: issue.id, idempotencyKey: "retained", payload: { text: "Retained" }, state: "retry", nextAttemptAt: new Date() }); + await db.insert(chatActions).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: conversation.id, + kind: "provider_effect", providerActionId: "retained", payload: {}, status: "received" }); + const report = await readIdleSleepSafety(db, hold, () => now, ownerId, async () => "none", undefined, undefined, + false, false, { incomingWake: verified(), collectDiagnostics: true }); + expect(report.backgroundWork).toBe("present"); + expect(report.diagnostics?.checks).toEqual(expect.arrayContaining([ + { reason: "chat_endpoints", state: "none" }, { reason: "chat_deliveries", state: "present" }, + { reason: "chat_publications", state: "present" }, { reason: "chat_actions", state: "present" }, + ])); + expect(await db.select().from(chatDeliveries)).toHaveLength(1); + expect(await db.select().from(chatPublications)).toHaveLength(1); + expect(await db.select().from(chatActions)).toHaveLength(1); + }); + it("requires current broker instance and exact owner binding, preserving continuity and unsupported transport blockers", async () => { const f = await seed(); const applicationId = randomUUID(), diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 804359109e5..010e9670202 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -136,6 +136,7 @@ import { notifyChatDeliveryWork, notifyChatPublicationWork } from "./chat-work-n import { voiceSessionStore } from "./voice/voice-session-store.js"; import { createChatRuntime, type ChatRuntime } from "./chat-runtime.js"; +import { registerIdleLocalWorkProbe } from "./idle-local-work.js"; import type { ChatEndpointRuntime } from "./voice/voice-runtime.js"; import { fastResponseService, fastResponseSourceCurrent } from "./fast-responses.js"; @@ -517,6 +518,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { version: string; } >(); + // SDK initialize() and the final authority read continue after the registry's + // replacement tail settles. Neither that work nor a failed retirement may + // disappear just because the archived endpoint is no longer returned by get(). + const unregisterRuntimeIdleProbe = registerIdleLocalWorkProbe(() => + runtimeInitializations.size > 0 || runtime.hasUnsettledLifecycle() ? "present" : "none"); const { recordCurrentMicrosoftTeamsRoute } = createTeamsRoutes(db, { runtime, runtimeCallbackEndpoint }); const persistence = createChatSdkStatePersistence(db); const fetchImpl = options.fetch ?? globalThis.fetch; @@ -3622,12 +3628,17 @@ return { // Stop every provider runtime before releasing leader rows. This gives a // standby an immediate takeover path without overlapping Gateway sockets. await runtime.shutdown(); + await Promise.allSettled([...runtimeInitializations.values()].map(initialization => initialization.promise)); await slackRegistration.close(); await Promise.all( ownedDiscordGateways.map((ownership) => releaseDiscordGatewayLeaseRow(ownership), ), ); + if (runtimeInitializations.size > 0 || runtime.hasUnsettledLifecycle()) { + throw new Error("Chat runtime lifecycle is still unsettled after shutdown"); + } + unregisterRuntimeIdleProbe(); }, }; } diff --git a/server/src/services/chat-runtime.ts b/server/src/services/chat-runtime.ts index b404a229e9b..881810ae54d 100644 --- a/server/src/services/chat-runtime.ts +++ b/server/src/services/chat-runtime.ts @@ -23,6 +23,11 @@ export class ChatRuntime { return [...this.endpoints.values()]; } + /** Retirement failures retain their owner even after get/list hide it. */ + hasUnsettledLifecycle(): boolean { + return this.retiringEndpoints.size > 0 || this.lifecycleTails.size > 0; + } + private enqueueLifecycle( endpointId: string, operation: () => Promise, diff --git a/server/src/services/chat-sdk-runtime.test.ts b/server/src/services/chat-sdk-runtime.test.ts index 89e9a9c3587..a956946dc6e 100644 --- a/server/src/services/chat-sdk-runtime.test.ts +++ b/server/src/services/chat-sdk-runtime.test.ts @@ -241,6 +241,8 @@ import { type CreateChatSdkEndpointRuntimeOptions, } from "./chat-sdk-runtime.js"; +import { createChatRuntime } from "./chat-runtime.js"; + const persistence: ChatSdkStatePersistence = { async compareAndSet() { return true; @@ -1152,6 +1154,31 @@ describe("Chat SDK endpoint runtime", () => { await registry.shutdown(); }); + it.each([ + { name: "SDK", create: createChatSdkRuntime }, + { name: "shared", create: createChatRuntime }, + ])("keeps $name lifecycle work visible while hidden retirement fails, until its owner retries", async ({ create }) => { + const registry = create(); + const first = await registry.replaceEndpoint(registryOptions()); + expect(registry.hasUnsettledLifecycle()).toBe(false); + const held = deferred(); + const shutdown = vi.spyOn(first, "shutdown").mockReturnValueOnce(held.promise).mockResolvedValue(undefined); + const removal = registry.removeEndpoint("endpoint-1"); + const result = removal.catch((error: Error) => error.message); + expect(registry.hasUnsettledLifecycle()).toBe(true); + await vi.waitFor(() => expect(shutdown).toHaveBeenCalledOnce()); + expect(registry.get("endpoint-1")).toBeNull(); + expect(registry.list()).toEqual([]); + expect(registry.hasUnsettledLifecycle()).toBe(true); + held.reject(new Error("retirement uncertain")); + expect(await result).toBe("retirement uncertain"); + expect(registry.hasUnsettledLifecycle()).toBe(true); + await registry.removeEndpoint("endpoint-1"); + expect(shutdown).toHaveBeenCalledTimes(2); + expect(registry.hasUnsettledLifecycle()).toBe(false); + await registry.shutdown(); + }); + it("retains failed retirement ownership and only constructs a replacement after an explicit successful retry", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); diff --git a/server/src/services/chat-sdk-runtime.ts b/server/src/services/chat-sdk-runtime.ts index cffdd09f5e4..6afd8fb844c 100644 --- a/server/src/services/chat-sdk-runtime.ts +++ b/server/src/services/chat-sdk-runtime.ts @@ -3195,6 +3195,11 @@ export class ChatSdkRuntime { return [...this.endpoints.values()]; } + /** Retirement failures retain their owner even after get/list hide it. */ + hasUnsettledLifecycle(): boolean { + return this.retiringEndpoints.size > 0 || this.lifecycleTails.size > 0; + } + private enqueueLifecycle( endpointId: string, operation: () => Promise, diff --git a/server/src/services/idle-incoming-wake.ts b/server/src/services/idle-incoming-wake.ts index cf7d4b39694..f0aa26e1b13 100644 --- a/server/src/services/idle-incoming-wake.ts +++ b/server/src/services/idle-incoming-wake.ts @@ -297,7 +297,26 @@ export function incomingWakeWorkQuery( AND (expires_at IS NULL OR expires_at > now())`; if (reason === "chat_endpoints") { const ids = proof?.endpointIds ?? []; - return sql`SELECT 1 FROM chat_endpoints endpoint WHERE ((endpoint.provider = 'slack' + // Only the supported GitHub webhook path gains a removed-history proof. + // Archival commits before cleanup, so status alone cannot discharge work. + return sql`SELECT 1 FROM chat_endpoints endpoint WHERE (((endpoint.provider = 'github' + AND endpoint.status = 'archived' AND endpoint.archived_at IS NOT NULL + AND jsonb_typeof(endpoint.setup) = 'object' + AND CASE WHEN jsonb_typeof(endpoint.setup->'runtimeGeneration') = 'number' + THEN (endpoint.setup->>'runtimeGeneration')::numeric >= 1 + AND trunc((endpoint.setup->>'runtimeGeneration')::numeric) = (endpoint.setup->>'runtimeGeneration')::numeric + ELSE false END + AND EXISTS (SELECT 1 FROM tool_connections removed_connection + WHERE removed_connection.id = endpoint.connection_id AND removed_connection.company_id = endpoint.company_id + AND removed_connection.connection_purpose = 'channel' AND removed_connection.transport = 'chat_sdk' + AND removed_connection.status = 'archived' AND removed_connection.enabled = false + AND removed_connection.credential_secret_refs = '[]'::jsonb) + AND NOT EXISTS (SELECT 1 FROM chat_endpoint_leases lease WHERE lease.endpoint_id = endpoint.id) + AND NOT EXISTS (SELECT 1 FROM chat_slack_registrations registration WHERE registration.endpoint_id = endpoint.id) + AND NOT EXISTS (SELECT 1 FROM chat_github_registrations registration WHERE registration.endpoint_id = endpoint.id + AND (registration.company_id = endpoint.company_id AND registration.status = 'completed' + AND registration.consumed_at IS NOT NULL) IS NOT TRUE)) + OR (endpoint.provider = 'slack' AND endpoint.status = 'draft' AND endpoint.setup->>'step' = 'provider_setup' AND (endpoint.setup - ARRAY['step', 'command']) = '{}'::jsonb AND (endpoint.setup->'command' IS NULL OR endpoint.setup->'command' = 'null'::jsonb @@ -310,7 +329,7 @@ export function incomingWakeWorkQuery( AND EXISTS (SELECT 1 FROM tool_connections draft_connection WHERE draft_connection.id = endpoint.connection_id AND draft_connection.company_id = endpoint.company_id AND draft_connection.connection_purpose = 'channel' AND draft_connection.transport = 'chat_sdk' - AND draft_connection.status = 'draft' AND draft_connection.enabled = false)) IS NOT TRUE) AND ${ + AND draft_connection.status = 'draft' AND draft_connection.enabled = false))) IS NOT TRUE) AND ${ ids.length ? sql`endpoint.id NOT IN (${sql.join( ids.map((id) => sql`${id}::uuid`),