Represent direct Composio MCP with the existing events; drop the retired composio_sync label

Master retired the legacy Composio broker and toolkit child sync (#13758);
direct Composio MCP (catalog slug `composio`, method `mcp`) is the supported
flow, set up from the Apps gallery or inline task cards (#13879) through the
same server paths every other gallery connector uses. No per-app child rows
exist any more, so nothing cascades.

- Remove `composio_sync` from `setup_flow` / `change_source`: after the
  rebase it had no emission site left. No other label changed.
- Direct Composio MCP needs no new event, label, or identity rule:
  `connectorKeyForConnection` already yields `composio` from the validated
  catalog slug, and the session URL / headers never reach telemetry.
- Wire two committed lifecycle writes the trace found uncovered, both on
  the direct-MCP OAuth path and both with existing labels:
  `finalizeOAuthAccess` (draft -> active; Apps gallery and connection-intent
  task cards) as `gallery`, and `completePaperclipCloudConnectorCallback`
  as `oauth_callback`.
- Replace the child-sync integration tests with direct Composio MCP tests
  (gallery connect + finish, pause/resume, OAuth access finalization) and a
  retained-legacy-record removal test proving the raw toolkit slug and
  account id never leave the process.

Proposal-only: markers stay; nothing is registered or sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bender (Paperclip)andClaude Fable 5.1 committed 2026-09-23 18:26:05 +00:00
1 parent fb60a5ee53
commit 0f614f9abf
4 files changed
+315 -187

No files matched your search

+1 -2
View File
@@ -159,7 +159,7 @@ export function trackConnectionCreated(
connector_key: RawDimension<"custom">; connector_key: RawDimension<"custom">;
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">; transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
auth_kind: RawDimension<"oauth" | "api_key" | "none">; auth_kind: RawDimension<"oauth" | "api_key" | "none">;
setup_flow: RawDimension<"gallery" | "api" | "example" | "composio_sync">; setup_flow: RawDimension<"gallery" | "api" | "example">;
status: RawDimension<"draft" | "active" | "disabled" | "archived">; status: RawDimension<"draft" | "active" | "disabled" | "archived">;
enabled: boolean; enabled: boolean;
}, },
@@ -184,7 +184,6 @@ export function trackConnectionUpdated(
| "credential_refresh" | "credential_refresh"
| "archive" | "archive"
| "example" | "example"
| "composio_sync"
>; >;
previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">; previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">;
status: RawDimension<"draft" | "active" | "disabled" | "archived">; status: RawDimension<"draft" | "active" | "disabled" | "archived">;
@@ -1,9 +1,13 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { import {
activityLog, activityLog,
companies, companies,
companyMemberships,
companySecretBindings, companySecretBindings,
companySecretVersions, companySecretVersions,
companySecrets, companySecrets,
@@ -12,12 +16,12 @@ import {
secretAccessEvents, secretAccessEvents,
toolApplications, toolApplications,
toolCatalogEntries, toolCatalogEntries,
toolConnectionInstalls,
toolConnections, toolConnections,
toolProfileBindings, toolProfileBindings,
toolProfileEntries, toolProfileEntries,
toolProfiles, toolProfiles,
} from "@paperclipai/db"; } from "@paperclipai/db";
import type { ComposioClient } from "../services/composio.js";
import { import {
getEmbeddedPostgresTestSupport, getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase, startEmbeddedPostgresTestDatabase,
@@ -31,6 +35,7 @@ vi.mock("../telemetry.js", () => ({
const { toolAccessService } = await import("../services/tool-access.js"); const { toolAccessService } = await import("../services/tool-access.js");
const { secretService } = await import("../services/secrets.js"); const { secretService } = await import("../services/secrets.js");
const { instanceSettingsService } = await import("../services/instance-settings.js");
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported const describeEmbeddedPostgres = embeddedPostgresSupport.supported
@@ -48,7 +53,7 @@ function updatedEvents() {
} }
async function createCompany(db: Db) { async function createCompany(db: Db) {
return db const company = await db
.insert(companies) .insert(companies)
.values({ .values({
name: `Lifecycle telemetry ${randomUUID()}`, name: `Lifecycle telemetry ${randomUUID()}`,
@@ -56,16 +61,66 @@ async function createCompany(db: Db) {
}) })
.returning() .returning()
.then((rows) => rows[0]!); .then((rows) => rows[0]!);
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: actor.actorId,
status: "active",
membershipRole: "admin",
});
return company;
} }
async function createComposioParentAndChild(db: Db, companyId: string) { const actor = {
const secrets = secretService(db); actorType: "user" as const,
const apiKey = await secrets.create(companyId, { actorId: "lifecycle-telemetry-user",
name: `Composio test key ${randomUUID().slice(0, 8)}`, actorSource: "local_implicit" as const,
key: `tool_app.${randomUUID()}.credentials_apiKey`, };
provider: "local_encrypted",
value: "composio-test-key", const mcpTool = (name: string) => ({
}); name,
description: name,
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
});
/**
* Minimal remote MCP server: answers initialize, the initialized notification,
* and tools/list. Mirrors the fixture the remote-connector lifecycle tests use
* so the direct Composio MCP path here is the real gallery path, not a stub.
*/
function remoteMcpFixture() {
const requests: { url: string; headers: Headers }[] = [];
const remoteHttpRequest = async (url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body)) as { id: number; method: string };
requests.push({ url, headers: new Headers(init.headers) });
if (body.method === "initialize") {
return Response.json(
{
id: body.id,
jsonrpc: "2.0",
result: { protocolVersion: "2025-06-18", capabilities: {} },
},
{ headers: { "Mcp-Session-Id": "session" } },
);
}
if (body.method === "notifications/initialized")
return new Response(null, { status: 202 });
return Response.json({
id: body.id,
jsonrpc: "2.0",
result: { tools: [mcpTool("read"), mcpTool("ask")] },
});
};
return { requests, remoteHttpRequest };
}
/**
* Retained legacy broker records (#13758): a `rest_api` parent keyed to the
* catalog slug and a `provider: composio` child. Master keeps such rows only
* for inspection and explicit removal; they cannot execute or reconnect.
*/
async function insertRetiredComposioRecords(db: Db, companyId: string) {
const [application] = await db const [application] = await db
.insert(toolApplications) .insert(toolApplications)
.values({ companyId, name: "Composio", type: "rest_api", status: "active" }) .values({ companyId, name: "Composio", type: "rest_api", status: "active" })
@@ -75,7 +130,7 @@ async function createComposioParentAndChild(db: Db, companyId: string) {
.values({ .values({
companyId, companyId,
applicationId: application!.id, applicationId: application!.id,
name: "Composio", name: "Composio (legacy broker)",
uid: `composio/${randomUUID()}`, uid: `composio/${randomUUID()}`,
transport: "rest_api", transport: "rest_api",
authKind: "api_key", authKind: "api_key",
@@ -83,34 +138,8 @@ async function createComposioParentAndChild(db: Db, companyId: string) {
enabled: true, enabled: true,
config: { sourceTemplateKey: "composio" }, config: { sourceTemplateKey: "composio" },
transportConfig: { sourceTemplateKey: "composio" }, transportConfig: { sourceTemplateKey: "composio" },
credentialRefs: [
{
name: "credentials.apiKey",
secretId: apiKey.id,
version: "latest",
placement: "header",
key: "x-api-key",
prefix: null,
},
],
credentialSecretRefs: [
{
secretId: apiKey.id,
versionSelector: "latest",
configPath: "credentials.apiKey",
required: true,
label: "Composio API key",
},
],
}) })
.returning(); .returning();
await db.insert(companySecretBindings).values({
companyId,
secretId: apiKey.id,
targetType: "tool_connection",
targetId: parent!.id,
configPath: "credentials.apiKey",
});
const [child] = await db const [child] = await db
.insert(toolConnections) .insert(toolConnections)
.values({ .values({
@@ -134,55 +163,22 @@ async function createComposioParentAndChild(db: Db, companyId: string) {
return { parent: parent!, child: child! }; return { parent: parent!, child: child! };
} }
function fakeComposioClient(accountStatus: () => string): ComposioClient {
return {
validateApiKey: vi.fn(async () => undefined),
listToolkits: vi.fn(async () => ({
items: [{ slug: "github", name: "GitHub" }],
})),
listAuthConfigs: vi.fn(async () => ({ items: [] })),
createConnectLink: vi.fn(async () => ({
link_token: "link",
redirect_url: "https://composio.test/link",
expires_at: new Date().toISOString(),
})),
listConnectedAccounts: vi.fn(async () => ({
items: [
{
id: "account-github",
user_id: "paperclip:test",
status: accountStatus(),
toolkit: { slug: "github" },
auth_config: {
id: "auth-github",
auth_scheme: "OAUTH2",
is_composio_managed: true,
},
},
],
})),
deleteConnectedAccount: vi.fn(async () => undefined),
createSession: vi.fn(async () => ({
session_id: "session",
mcp: { url: "https://composio.test/mcp" },
})),
resumeSession: vi.fn(async () => ({
session_id: "session",
mcp: { url: "https://composio.test/mcp" },
})),
} as unknown as ComposioClient;
}
describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => { describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
let db!: Db; let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null =
null; null;
let keyDir: string | null = null;
beforeAll(async () => { beforeAll(async () => {
keyDir = await mkdtemp(join(tmpdir(), "lifecycle-telemetry-secrets-"));
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key"));
tempDb = await startEmbeddedPostgresTestDatabase( tempDb = await startEmbeddedPostgresTestDatabase(
"paperclip-lifecycle-telemetry-", "paperclip-lifecycle-telemetry-",
); );
db = createDb(tempDb.connectionString); db = createDb(tempDb.connectionString);
await instanceSettingsService(db).updateExperimental({
enableMcpAggregators: true,
});
}, 30_000); }, 30_000);
afterEach(async () => { afterEach(async () => {
@@ -193,17 +189,21 @@ describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
await db.delete(toolProfiles); await db.delete(toolProfiles);
await db.delete(toolCatalogEntries); await db.delete(toolCatalogEntries);
await db.delete(connectionGrants); await db.delete(connectionGrants);
await db.delete(toolConnectionInstalls);
await db.delete(toolConnections); await db.delete(toolConnections);
await db.delete(toolApplications); await db.delete(toolApplications);
await db.delete(secretAccessEvents); await db.delete(secretAccessEvents);
await db.delete(companySecretBindings); await db.delete(companySecretBindings);
await db.delete(companySecretVersions); await db.delete(companySecretVersions);
await db.delete(companySecrets); await db.delete(companySecrets);
await db.delete(companyMemberships);
await db.delete(companies); await db.delete(companies);
}); });
afterAll(async () => { afterAll(async () => {
await tempDb?.cleanup(); await tempDb?.cleanup();
vi.unstubAllEnvs();
if (keyDir) await rm(keyDir, { recursive: true, force: true });
}); });
function service(options: Parameters<typeof toolAccessService>[1] = {}) { function service(options: Parameters<typeof toolAccessService>[1] = {}) {
@@ -263,119 +263,231 @@ describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
expect(updatedEvents()).toHaveLength(0); expect(updatedEvents()).toHaveLength(0);
}, 30_000); }, 30_000);
it("composio parent pause emits child transitions from committed rows and stays silent on the no-op repeat", async () => { it("direct Composio MCP setup from the gallery emits catalog identity only, never the session URL", async () => {
const company = await createCompany(db); const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(db, company.id); const remote = remoteMcpFixture();
const svc = service({ const svc = service({
composioClientFactory: () => fakeComposioClient(() => "ACTIVE"), deploymentMode: "local_trusted",
deploymentExposure: "private",
remoteHttpRequest: remote.remoteHttpRequest,
}); });
// The same server path the Apps gallery and inline task cards call
await svc.updateConnection(parent.id, { enabled: false }); // (POST /companies/:companyId/tools/apps/connect): Composio Connect with an
let events = updatedEvents(); // externally configured MCP session URL.
// Parent transition (api) + child cascade (composio_sync). const connected = await svc.connectGalleryApp(
expect(events).toHaveLength(2); company.id,
const childEvent = events.find( {
([, dims]) => (dims as { change_source: string }).change_source === "composio_sync", galleryKey: "composio",
); connectionMethodKey: "mcp",
expect(childEvent?.[1]).toMatchObject({ link: "https://mcp.composio.dev/session/fixture?token=fixture-secret",
connector_key: "custom", authMode: "none",
previous_enabled: true,
enabled: false,
});
await expect(
db.select().from(toolConnections).then((rows) =>
rows.find((row) => row.id === child.id),
),
).resolves.toMatchObject({ enabled: false });
track.mockClear();
await svc.updateConnection(parent.id, { enabled: false });
// Parent and child both already disabled: nothing changed, nothing emits.
expect(updatedEvents()).toHaveLength(0);
}, 30_000);
it("a child deleted between listing and write produces no event", async () => {
const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(db, company.id);
const svc = service({
composioClientFactory: () => fakeComposioClient(() => "ACTIVE"),
beforeComposioChildLifecycleWrite: async (childId) => {
await db.delete(toolConnections).where(eq(toolConnections.id, childId));
}, },
}); actor,
await svc.updateConnection(parent.id, { enabled: false });
const events = updatedEvents();
// Only the parent's own committed transition may report.
expect(events).toHaveLength(1);
expect(
events.every(
([, dims]) =>
(dims as { change_source: string }).change_source !== "composio_sync",
),
).toBe(true);
const remaining = await db.select().from(toolConnections);
expect(remaining.find((row) => row.id === child.id)).toBeUndefined();
}, 30_000);
it("a failed child lifecycle write emits nothing for that child", async () => {
const company = await createCompany(db);
const { parent } = await createComposioParentAndChild(db, company.id);
const svc = service({
composioClientFactory: () => fakeComposioClient(() => "ACTIVE"),
beforeComposioChildLifecycleWrite: async () => {
throw new Error("forced child write failure");
},
});
await expect(
svc.updateConnection(parent.id, { enabled: false }),
).rejects.toThrow(/forced child write failure/);
expect(
updatedEvents().filter(
([, dims]) =>
(dims as { change_source: string }).change_source === "composio_sync",
),
).toHaveLength(0);
}, 30_000);
it("restore emits only for children whose committed enabled state changed", async () => {
const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(db, company.id);
let accountStatus = "ACTIVE";
const svc = service({
composioClientFactory: () => fakeComposioClient(() => accountStatus),
});
await svc.updateConnection(parent.id, { enabled: false });
track.mockClear();
// Inactive account: the child stays disabled, so no transition may report.
accountStatus = "INACTIVE";
await svc.updateConnection(parent.id, { enabled: true });
expect(
updatedEvents().filter(
([, dims]) =>
(dims as { change_source: string }).change_source === "composio_sync",
),
).toHaveLength(0);
track.mockClear();
accountStatus = "ACTIVE";
await svc.updateConnection(parent.id, { enabled: true });
const events = updatedEvents().filter(
([, dims]) =>
(dims as { change_source: string }).change_source === "composio_sync",
); );
expect(events).toHaveLength(1); expect(remote.requests.length).toBeGreaterThan(0);
expect(events[0]?.[1]).toMatchObject({ const created = createdEvents();
expect(created).toHaveLength(1);
expect(created[0]?.[1]).toMatchObject({
connector_key: "composio",
transport: "mcp_remote",
auth_kind: "none",
setup_flow: "gallery",
status: connected.connection.status,
enabled: connected.connection.enabled,
});
expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-secret");
expect(JSON.stringify(track.mock.calls)).not.toContain("mcp.composio.dev");
track.mockClear();
const finished = await svc.finishGalleryAppConnection(
company.id,
connected.connectionId,
{
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
askFirstCatalogEntryIds: [],
access: "all_agents",
},
actor,
);
// Connect commits a draft; the finish step is the committed draft -> active
// transition, so it is the one `gallery` update this setup reports.
expect(connected.connection).toMatchObject({ status: "draft", enabled: false });
expect(finished.connection).toMatchObject({ status: "active", enabled: true });
const updated = updatedEvents();
expect(updated).toHaveLength(1);
expect(updated[0]?.[1]).toMatchObject({
connector_key: "composio",
transport: "mcp_remote",
change_source: "gallery",
previous_status: "draft",
status: "active",
previous_enabled: false, previous_enabled: false,
enabled: true, enabled: true,
}); });
await expect( expect(createdEvents()).toHaveLength(0);
db.select().from(toolConnections).then((rows) => }, 30_000);
rows.find((row) => row.id === child.id),
), it("pausing and resuming a direct Composio MCP connection reports api transitions with the catalog key", async () => {
).resolves.toMatchObject({ enabled: true }); const company = await createCompany(db);
const remote = remoteMcpFixture();
const svc = service({
deploymentMode: "local_trusted",
deploymentExposure: "private",
remoteHttpRequest: remote.remoteHttpRequest,
});
const connected = await svc.connectGalleryApp(
company.id,
{
galleryKey: "composio",
connectionMethodKey: "mcp",
link: "https://mcp.composio.dev/session/fixture?token=fixture-secret",
authMode: "none",
},
actor,
);
await svc.finishGalleryAppConnection(
company.id,
connected.connectionId,
{
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
askFirstCatalogEntryIds: [],
access: "all_agents",
},
actor,
);
track.mockClear();
await svc.updateConnection(connected.connectionId, { enabled: false });
await svc.updateConnection(connected.connectionId, { enabled: true });
const updated = updatedEvents();
expect(updated).toHaveLength(2);
expect(updated.map(([, dims]) => dims)).toEqual([
expect.objectContaining({
connector_key: "composio",
transport: "mcp_remote",
change_source: "api",
previous_enabled: true,
enabled: false,
}),
expect.objectContaining({
connector_key: "composio",
change_source: "api",
previous_enabled: false,
enabled: true,
}),
]);
// No per-app child rows exist for direct MCP, so nothing else cascades.
expect(createdEvents()).toHaveLength(0);
}, 30_000);
it("finalizing OAuth access reports the committed draft -> active transition as a gallery step", async () => {
// Direct MCP aggregator OAuth (Composio Connect via DCR) from the Apps
// gallery or an inline task card: the callback stores a personal grant,
// then finalizeOAuthAccess — POST .../apps/:id/finalize-oauth-access, also
// called by connection-intent completion — activates the draft row itself
// before handing off to the finish step.
const company = await createCompany(db);
const svc = service();
const [application] = await db
.insert(toolApplications)
.values({ companyId: company.id, name: "Composio", type: "mcp_remote", status: "active" })
.returning();
const [connection] = await db
.insert(toolConnections)
.values({
companyId: company.id,
applicationId: application!.id,
name: "Composio",
uid: `composio/${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
enabled: false,
config: {
sourceTemplateKey: "composio",
connectionMethodKey: "mcp",
url: "https://connect.composio.dev/mcp",
},
transportConfig: { url: "https://connect.composio.dev/mcp" },
})
.returning();
const accessToken = await secretService(db).create(company.id, {
name: `OAuth access token ${randomUUID().slice(0, 8)}`,
key: `tool_app.${randomUUID()}.oauth_access_token`,
provider: "local_encrypted",
value: "fixture-access-token",
});
await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection!.id,
kind: "user",
subjectUserId: actor.actorId,
status: "active",
credentialSecretRefs: [
{
secretId: accessToken.id,
versionSelector: "latest",
configPath: "oauth.access_token",
required: true,
label: "OAuth access token",
},
],
});
track.mockClear();
const finished = await svc.finalizeOAuthAccess(
company.id,
connection!.id,
{ grantKind: "user" },
actor,
);
expect(finished.connection).toMatchObject({ status: "active", enabled: true });
const updated = updatedEvents();
// Exactly one transition: finalize's own write. The finish step it calls
// afterwards re-reads an already-active row and stays silent.
expect(updated).toHaveLength(1);
expect(updated[0]?.[1]).toMatchObject({
connector_key: "composio",
transport: "mcp_remote",
auth_kind: "oauth",
change_source: "gallery",
previous_status: "draft",
status: "active",
previous_enabled: false,
enabled: true,
});
expect(createdEvents()).toHaveLength(0);
expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-access-token");
}, 30_000);
it("retained legacy broker records emit only their explicit removal, distinguishable by transport", async () => {
const company = await createCompany(db);
const { parent, child } = await insertRetiredComposioRecords(db, company.id);
const svc = service();
track.mockClear();
await svc.archiveConnection(parent.id);
await svc.archiveConnection(child.id);
const updated = updatedEvents();
expect(updated).toHaveLength(2);
expect(updated[0]?.[1]).toMatchObject({
// Legacy parent: catalog slug survives, but `rest_api` marks it as the
// retired broker rather than a direct MCP connection.
connector_key: "composio",
transport: "rest_api",
change_source: "archive",
status: "archived",
});
expect(updated[1]?.[1]).toMatchObject({
// Legacy child: no catalog key; the raw toolkit slug never leaves.
connector_key: "custom",
transport: "mcp_remote",
change_source: "archive",
status: "archived",
});
expect(JSON.stringify(track.mock.calls)).not.toContain("github");
expect(JSON.stringify(track.mock.calls)).not.toContain("account-github");
expect(createdEvents()).toHaveLength(0);
}, 30_000); }, 30_000);
}); });
+2 -3
View File
@@ -12,15 +12,14 @@ import { getTelemetryClient } from "../telemetry.js";
type ToolConnectionRow = typeof toolConnections.$inferSelect; type ToolConnectionRow = typeof toolConnections.$inferSelect;
type ToolInvocationRow = typeof toolInvocations.$inferSelect; type ToolInvocationRow = typeof toolInvocations.$inferSelect;
export type ConnectorSetupFlow = "gallery" | "api" | "example" | "composio_sync"; export type ConnectorSetupFlow = "gallery" | "api" | "example";
export type ConnectorChangeSource = export type ConnectorChangeSource =
| "api" | "api"
| "gallery" | "gallery"
| "oauth_callback" | "oauth_callback"
| "credential_refresh" | "credential_refresh"
| "archive" | "archive"
| "example" | "example";
| "composio_sync";
/** /**
* Terminal `tool_invocations.status` values. `pending`, `authorized`, * Terminal `tool_invocations.status` values. `pending`, `authorized`,
+18
View File
@@ -14883,6 +14883,10 @@ export function toolAccessService(
stateRow.connectionId, stateRow.connectionId,
stateRow.companyId, stateRow.companyId,
); );
const preCloudCallbackLifecycle = {
status: connection.status,
enabled: connection.enabled,
};
// The connection lifecycle, not the incidental presence of its app profile, // The connection lifecycle, not the incidental presence of its app profile,
// distinguishes setup from reauthorization. New connections and connections // distinguishes setup from reauthorization. New connections and connections
// revived after removal are drafts until this callback completes. A profile // revived after removal are drafts until this callback completes. A profile
@@ -15244,6 +15248,10 @@ export function toolAccessService(
.where(eq(issueThreadInteractions.id, stateRow.interactionId)); .where(eq(issueThreadInteractions.id, stateRow.interactionId));
} }
}); });
// The transaction above committed the connection's lifecycle write; a
// Paperclip Cloud connector callback is the managed variant of an OAuth
// callback completion.
emitConnectionUpdated(connection, preCloudCallbackLifecycle, "oauth_callback");
if (githubMetadata) { if (githubMetadata) {
const [githubGrant] = await db const [githubGrant] = await db
.select({ id: connectionGrants.id }) .select({ id: connectionGrants.id })
@@ -16155,6 +16163,10 @@ export function toolAccessService(
if (requestingAgentId) if (requestingAgentId)
await assertAgentsInCompany(companyId, [requestingAgentId]); await assertAgentsInCompany(companyId, [requestingAgentId]);
let connection = await getConnectionRow(connectionId, companyId); let connection = await getConnectionRow(connectionId, companyId);
const preFinalizeLifecycle = {
status: connection.status,
enabled: connection.enabled,
};
if (connection.authKind !== "oauth") if (connection.authKind !== "oauth")
throw badRequest("This connection does not use browser sign-in"); throw badRequest("This connection does not use browser sign-in");
if (connection.status === "archived") if (connection.status === "archived")
@@ -16464,6 +16476,12 @@ export function toolAccessService(
for (const secretId of personalSecretIds) await secrets.remove(secretId); for (const secretId of personalSecretIds) await secrets.remove(secretId);
} }
// Both identity branches above commit their own lifecycle write (draft ->
// active) before `finishGalleryAppConnection` re-reads the row, so that
// step alone would never observe this transition. This is the OAuth
// access-finalization step of the same gallery setup flow (Apps and inline
// task cards both reach it), hence `gallery`.
emitConnectionUpdated(connection, preFinalizeLifecycle, "gallery");
const catalog = await db const catalog = await db
.select() .select()
.from(toolCatalogEntries) .from(toolCatalogEntries)