@frockbot/plugin-composio 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-composio",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,11 +22,11 @@
22
22
  "typecheck": "tsc --noEmit -p tsconfig.json"
23
23
  },
24
24
  "dependencies": {
25
- "@frockbot/configuration-core": "0.1.3",
26
- "@frockbot/connection-core": "0.1.3",
27
- "@frockbot/kernel-contracts": "0.1.3",
28
- "@frockbot/plugin-settings": "0.1.3",
29
- "@frockbot/plugin-tools": "0.1.3",
25
+ "@frockbot/configuration-core": "0.2.0",
26
+ "@frockbot/connection-core": "0.2.0",
27
+ "@frockbot/kernel-contracts": "0.2.0",
28
+ "@frockbot/plugin-settings": "0.2.0",
29
+ "@frockbot/plugin-tools": "0.2.0",
30
30
  "cordis": "4.0.0-rc.8"
31
31
  },
32
32
  "devDependencies": {
package/src/agent.ts CHANGED
@@ -32,26 +32,24 @@ export interface ComposioRouterPluginConfig {
32
32
  }
33
33
 
34
34
  export function createConfiguredComposioRuntimeContribution(config: {
35
- assignment: {
35
+ capability: {
36
36
  packageId: string;
37
37
  capabilityId: string;
38
38
  connectionId?: string;
39
- state: string;
40
39
  };
41
40
  userId: string;
42
41
  readSecret(name: string): string | undefined;
43
42
  authorizeConnection(): Promise<{ safeMetadata: Record<string, unknown> }>;
44
43
  }): Plugin.Function | undefined {
45
44
  if (
46
- config.assignment.packageId !== "composio" ||
47
- config.assignment.capabilityId !== "gmail-tools" ||
48
- config.assignment.state !== "enabled" ||
49
- !config.assignment.connectionId
45
+ config.capability.packageId !== "composio" ||
46
+ config.capability.capabilityId !== "gmail-tools" ||
47
+ !config.capability.connectionId
50
48
  ) {
51
49
  return undefined;
52
50
  }
53
51
  const apiKey = config.readSecret("COMPOSIO_API_KEY");
54
- if (!apiKey) throw new Error("Assigned Composio Connection is misconfigured");
52
+ if (!apiKey) throw new Error("Enabled Composio Connection is misconfigured");
55
53
  const authorizeEffect = async () => {
56
54
  const connection = await config.authorizeConnection();
57
55
  const connectedAccountId = connection.safeMetadata.connectedAccountId;
@@ -15,7 +15,6 @@ describe("configured Composio backend", () => {
15
15
  callbackBaseUrl: "https://bot.frockbot.com",
16
16
  readSecret: (name: string) => secrets[name],
17
17
  storeFor: () => ({}) as ComposioConnectionStore,
18
- markConnectionUnavailable: () => Promise.resolve("applied" as const),
19
18
  });
20
19
 
21
20
  test("requires a strong dedicated authorization-state secret without auth fallback", () => {
package/src/backend.ts CHANGED
@@ -76,24 +76,12 @@ export interface ComposioBackendConfig {
76
76
  connectionTypes: Record<string, ComposioConnectionTypeConfig>;
77
77
  authorizationStateSecret: string;
78
78
  storeFor(userId: string): ComposioConnectionStore;
79
- markBotUnavailable?: (
80
- userId: string,
81
- botId: string,
82
- connectionId: string,
83
- compensation: { id: string; expectedGeneration: string },
84
- ) => Promise<"applied" | "stale">;
85
79
  }
86
80
 
87
81
  export interface ComposioBackendHost {
88
82
  callbackBaseUrl: string;
89
83
  readSecret(name: string): string | undefined;
90
84
  storeFor(userId: string): ComposioConnectionStore;
91
- markConnectionUnavailable(
92
- userId: string,
93
- botId: string,
94
- connectionId: string,
95
- compensation: { id: string; expectedGeneration: string },
96
- ): Promise<"applied" | "stale">;
97
85
  }
98
86
 
99
87
  export function createConfiguredComposioBackendContribution(
@@ -126,7 +114,6 @@ export function createConfiguredComposioBackendContribution(
126
114
  toolkitSlug: "gmail",
127
115
  },
128
116
  },
129
- markBotUnavailable: host.markConnectionUnavailable,
130
117
  });
131
118
  }
132
119
 
@@ -161,7 +148,6 @@ function coordinator(
161
148
  store: config.storeFor(userId),
162
149
  callbackBaseUrl: config.callbackBaseUrl,
163
150
  connectionTypes: config.connectionTypes,
164
- markBotUnavailable: config.markBotUnavailable,
165
151
  });
166
152
  }
167
153
 
@@ -11,7 +11,6 @@ import type {
11
11
  RevokeConnectionResult,
12
12
  StartConnectionResult,
13
13
  } from "./backend-contracts.js";
14
- import { isSettledBotCompensation } from "./connection-recovery.js";
15
14
  import {
16
15
  linkReconciliationDisposition,
17
16
  reconcileComposioProviderConnection,
@@ -61,11 +60,6 @@ export interface ComposioConnectionStore {
61
60
  authorizationStateId?: string;
62
61
  },
63
62
  ): Promise<boolean>;
64
- recordAssignmentCompensated(
65
- userId: string,
66
- connectionId: string,
67
- compensationId: string,
68
- ): Promise<boolean>;
69
63
  requireConnectionReconciliation(
70
64
  userId: string,
71
65
  connectionId: string,
@@ -101,12 +95,6 @@ export interface ComposioConnectionCoordinatorConfig {
101
95
  store: ComposioConnectionStore;
102
96
  callbackBaseUrl: string;
103
97
  connectionTypes: Record<string, ComposioConnectionTypeConfig>;
104
- markBotUnavailable?: (
105
- userId: string,
106
- botId: string,
107
- connectionId: string,
108
- compensation: { id: string; expectedGeneration: string },
109
- ) => Promise<"applied" | "stale">;
110
98
  }
111
99
 
112
100
  export class DefinitiveConnectionOperationError extends Error {
@@ -720,46 +708,6 @@ export class ComposioConnectionCoordinator {
720
708
  if (refreshed?.safeMetadata.revocationProviderCompleted !== true) {
721
709
  return { schemaVersion: 1, status: "reconciliation-required" };
722
710
  }
723
- const compensations = Array.isArray(
724
- refreshed.safeMetadata.assignmentCompensations,
725
- )
726
- ? refreshed.safeMetadata.assignmentCompensations
727
- : [];
728
- if (this.config.markBotUnavailable) {
729
- for (const candidate of compensations) {
730
- if (
731
- !candidate ||
732
- typeof candidate !== "object" ||
733
- Array.isArray(candidate)
734
- ) {
735
- return { schemaVersion: 1, status: "reconciliation-required" };
736
- }
737
- const compensation = candidate as Record<string, unknown>;
738
- if (
739
- typeof compensation.botId !== "string" ||
740
- typeof compensation.id !== "string" ||
741
- typeof compensation.expectedGeneration !== "string"
742
- ) {
743
- return { schemaVersion: 1, status: "reconciliation-required" };
744
- }
745
- const result = await this.config.markBotUnavailable(
746
- userId,
747
- compensation.botId,
748
- connectionId,
749
- {
750
- id: compensation.id,
751
- expectedGeneration: compensation.expectedGeneration,
752
- },
753
- );
754
- if (isSettledBotCompensation(result)) {
755
- await this.config.store.recordAssignmentCompensated(
756
- userId,
757
- connectionId,
758
- compensation.id,
759
- );
760
- }
761
- }
762
- }
763
711
  const finished = await this.config.store.finishConnectionRevocation(
764
712
  userId,
765
713
  connectionId,
package/src/index.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  export * from "./agent.js";
2
2
  export * from "./backend.js";
3
3
  export * from "./composio-client.js";
4
- export * from "./connection-recovery.js";
5
4
  export * from "./connections.js";
6
- export * from "./dependency-coordination.js";
7
5
  export * from "./provider-reconciliation.js";
@@ -1,14 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  USER_PROFILE_PLACEHOLDER_NAME_V1,
4
- type ConnectionView,
5
4
  type UserConfigurationCommandV1,
6
5
  type UserSettingsViewV1,
7
6
  } from "@frockbot/configuration-core";
8
- import {
9
- createComposioUserBackendContribution,
10
- deriveRevocationCompensations,
11
- } from "./user-configuration.js";
7
+ import { createComposioUserBackendContribution } from "./user-configuration.js";
12
8
  import { ComposioClient } from "./composio-client.js";
13
9
  import { ComposioConnectionCoordinator } from "./connections.js";
14
10
  import { reconcileComposioProviderConnection } from "./provider-reconciliation.js";
@@ -67,7 +63,6 @@ function backendHost(
67
63
  ) {
68
64
  return {
69
65
  state: { storage } as unknown as DurableObjectState,
70
- env: {} as never,
71
66
  availablePackages: [{ packageId: "composio", version: "0.0.1" }],
72
67
  reconcileProviderConnection,
73
68
  revokeConnectedAccount,
@@ -94,19 +89,6 @@ function deferred<T>() {
94
89
  return { promise, resolve };
95
90
  }
96
91
 
97
- function connection(
98
- safeMetadata: ConnectionView["safeMetadata"],
99
- ): ConnectionView {
100
- return {
101
- connectionId: "connection-1",
102
- packageId: "composio",
103
- connectionTypeId: "gmail",
104
- displayName: "Gmail",
105
- state: "ready",
106
- safeMetadata,
107
- };
108
- }
109
-
110
92
  async function startInstalledConnection(
111
93
  contribution: ReturnType<typeof createComposioUserBackendContribution>,
112
94
  input: Parameters<
@@ -131,126 +113,7 @@ async function startInstalledConnection(
131
113
  return contribution.startConnection("user-1", input);
132
114
  }
133
115
 
134
- describe("Connection revocation dependencies", () => {
135
- test("uses only acknowledged explicit Assignments", async () => {
136
- expect(
137
- await deriveRevocationCompensations(
138
- connection({
139
- targetBotId: "oauth-initiator",
140
- dependentAssignments: [
141
- { botId: "pending", generation: "gen-pending", status: "pending" },
142
- {
143
- botId: "acknowledged",
144
- generation: "gen-acknowledged",
145
- status: "acknowledged",
146
- },
147
- ],
148
- }),
149
- ),
150
- ).toEqual([
151
- {
152
- botId: "acknowledged",
153
- id: expect.stringMatching(/^revocation-[a-f0-9]{64}$/),
154
- expectedGeneration: "gen-acknowledged",
155
- },
156
- ]);
157
- });
158
-
159
- test("keeps compensation identifiers unique and within the RPC bound", async () => {
160
- const generation = "g".repeat(128);
161
- const [first, second] = await deriveRevocationCompensations(
162
- connection({
163
- dependentAssignments: [
164
- { botId: "primary", generation, status: "acknowledged" },
165
- { botId: "secondary", generation, status: "acknowledged" },
166
- ],
167
- }),
168
- );
169
-
170
- expect(first).toMatchObject({ expectedGeneration: generation });
171
- expect(second).toMatchObject({ expectedGeneration: generation });
172
- expect(first?.id).not.toBe(second?.id);
173
- expect(first?.id.length).toBeLessThanOrEqual(128);
174
- expect(second?.id.length).toBeLessThanOrEqual(128);
175
- });
176
-
177
- test("ignores legacy Bot metadata even when it has a generation", async () => {
178
- expect(
179
- await deriveRevocationCompensations(
180
- connection({
181
- targetBotId: "legacy-bot",
182
- assignmentGeneration: "gen-legacy",
183
- }),
184
- ),
185
- ).toEqual([]);
186
- });
187
-
188
- test("retries durable Bot compensation through the versioned RPC envelope", async () => {
189
- const storage = new MemoryStorage();
190
- const requests: unknown[] = [];
191
- await storage.put({
192
- "user-id": "user-1",
193
- "user-configuration": {
194
- schemaVersion: 1,
195
- revision: 1,
196
- profile: { name: "User" },
197
- packages: [],
198
- connections: [
199
- {
200
- ...connection({
201
- connectedAccountId: "account-1",
202
- revocationProviderCompleted: true,
203
- assignmentCompensationPending: true,
204
- assignmentCompensations: [
205
- {
206
- botId: "primary",
207
- id: "compensation-1",
208
- expectedGeneration: "generation-1",
209
- },
210
- ],
211
- compensationRetryAt: 0,
212
- }),
213
- state: "revoking",
214
- },
215
- ],
216
- } satisfies UserSettingsViewV1,
217
- });
218
- const contribution = createComposioUserBackendContribution({
219
- ...backendHost(storage),
220
- env: {
221
- BOT_STATES: {
222
- idFromName: (name: string) => name,
223
- get: () => ({
224
- markConnectionUnavailable: (request: unknown) => {
225
- requests.push(request);
226
- return Promise.resolve("applied" as const);
227
- },
228
- }),
229
- },
230
- } as never,
231
- });
232
-
233
- await contribution.alarm();
234
-
235
- expect(requests).toEqual([
236
- {
237
- schemaVersion: 1,
238
- userId: "user-1",
239
- botId: "primary",
240
- connectionId: "connection-1",
241
- compensation: {
242
- id: "compensation-1",
243
- expectedGeneration: "generation-1",
244
- },
245
- },
246
- ]);
247
- expect(
248
- await contribution.getConnection("user-1", "connection-1"),
249
- ).toMatchObject({ state: "revoked" });
250
- });
251
- });
252
-
253
- describe("Connection dependency admission", () => {
116
+ describe("Connection admission", () => {
254
117
  test("replays a Package receipt before deployment availability changes", async () => {
255
118
  const storage = new MemoryStorage();
256
119
  const host = backendHost(storage);
@@ -438,7 +301,7 @@ describe("Connection dependency admission", () => {
438
301
  expect((await upgraded.read("user-1")).connections).toEqual([]);
439
302
  });
440
303
 
441
- test("atomically enforces the Package and Connection Type requirement", async () => {
304
+ test("decodes the seam and admits an enabled Package's Connection", async () => {
442
305
  const storage = new MemoryStorage();
443
306
  const contribution = createComposioUserBackendContribution({
444
307
  ...backendHost(storage),
@@ -498,55 +361,52 @@ describe("Connection dependency admission", () => {
498
361
  state: "ready",
499
362
  });
500
363
 
501
- const requirement = {
502
- schemaVersion: 1 as const,
503
- packageId: "composio",
504
- packageVersion: "0.0.1",
505
- capabilityId: "gmail-tools",
506
- connectionTypeIds: ["gmail"],
507
- };
508
- expect(
509
- await contribution.claimConnectionDependency(
510
- "user-1",
511
- "gmail-1",
512
- "primary",
513
- "generation-1",
514
- { ...requirement, packageVersion: "9.9.9" },
515
- ),
516
- ).toBe(false);
517
- expect(
518
- await contribution.claimConnectionDependency(
519
- "user-1",
520
- "gmail-1",
521
- "primary",
522
- "generation-1",
523
- { ...requirement, connectionTypeIds: ["calendar"] },
524
- ),
525
- ).toBe(false);
526
- const beforeClaim = await read();
527
- expect(beforeClaim.revision).toBe(3);
528
- expect(beforeClaim.connections[0]?.safeMetadata).not.toHaveProperty(
529
- "dependentAssignments",
530
- );
364
+ expect(await read()).toMatchObject({
365
+ revision: 3,
366
+ connections: [{ connectionId: "gmail-1", state: "ready" }],
367
+ });
368
+ });
369
+ });
531
370
 
532
- expect(
533
- await contribution.claimConnectionDependency(
534
- "user-1",
535
- "gmail-1",
536
- "primary",
537
- "generation-1",
538
- requirement,
539
- ),
540
- ).toBe(true);
541
- expect((await read()).connections[0]?.safeMetadata).toMatchObject({
542
- dependentAssignments: [
543
- {
544
- botId: "primary",
545
- generation: "generation-1",
546
- status: "pending",
371
+ describe("Connection revocation", () => {
372
+ test("disconnects without dependent bookkeeping and replays idempotently", async () => {
373
+ const storage = new MemoryStorage();
374
+ const contribution = createComposioUserBackendContribution(
375
+ backendHost(storage),
376
+ );
377
+ await startInstalledConnection(contribution, {
378
+ connectionId: "gmail-1",
379
+ packageId: "composio",
380
+ connectionTypeId: "gmail",
381
+ displayName: "Gmail",
382
+ });
383
+ await contribution.finishConnectionAuthorization("user-1", "gmail-1", {
384
+ state: "ready",
385
+ safeMetadata: { connectedAccountId: "account-1" },
386
+ });
387
+ let providerRevocations = 0;
388
+ const coordinator = new ComposioConnectionCoordinator({
389
+ client: new ComposioClient({
390
+ apiKey: "secret",
391
+ fetch: (_input, init) => {
392
+ expect(init?.method).toBe("POST");
393
+ providerRevocations += 1;
394
+ return Promise.resolve(Response.json({ success: true }));
547
395
  },
548
- ],
396
+ }),
397
+ store: contribution,
398
+ callbackBaseUrl: "https://app.example.com",
399
+ connectionTypes: {},
549
400
  });
401
+
402
+ const first = await coordinator.revoke("user-1", "gmail-1");
403
+ const replay = await coordinator.revoke("user-1", "gmail-1");
404
+
405
+ expect(first).toEqual({ schemaVersion: 1, status: "revoked" });
406
+ expect(replay).toEqual(first);
407
+ expect(providerRevocations).toBe(1);
408
+ const connection = await contribution.getConnection("user-1", "gmail-1");
409
+ expect(connection?.state).toBe("revoked");
550
410
  });
551
411
  });
552
412
 
@@ -1,7 +1,5 @@
1
1
  import {
2
- decodeConnectionDependencyRequirementV1,
3
2
  USER_PROFILE_PLACEHOLDER_NAME_V1,
4
- type ConnectionDependencyRequirementV1,
5
3
  type UserSettingsViewV1,
6
4
  } from "@frockbot/configuration-core";
7
5
  import {
@@ -11,15 +9,6 @@ import {
11
9
  // pi-lens-ignore: ts:2307
12
10
  } from "@frockbot/plugin-settings/user";
13
11
  import type { Plugin } from "cordis";
14
- import {
15
- acknowledgeDependentAssignment,
16
- claimDependentAssignment,
17
- compensateDependentAssignment,
18
- } from "./dependency-coordination.js";
19
- import {
20
- completeAssignmentCompensation,
21
- isSettledBotCompensation,
22
- } from "./connection-recovery.js";
23
12
  import {
24
13
  linkReconciliationDisposition,
25
14
  type ComposioProviderReconciliationRequest,
@@ -61,68 +50,8 @@ function readyAuthorizationMetadata(
61
50
  };
62
51
  }
63
52
 
64
- async function revocationCompensationId(
65
- botId: string,
66
- generation: string,
67
- ): Promise<string> {
68
- const digest = await crypto.subtle.digest(
69
- "SHA-256",
70
- new TextEncoder().encode(`${botId}\u0000${generation}`),
71
- );
72
- return `revocation-${Array.from(new Uint8Array(digest), (byte) =>
73
- byte.toString(16).padStart(2, "0"),
74
- ).join("")}`;
75
- }
76
-
77
- export async function deriveRevocationCompensations(
78
- connection: UserSettingsViewV1["connections"][number],
79
- ): Promise<Array<{ botId: string; id: string; expectedGeneration: string }>> {
80
- const dependencies = Array.isArray(
81
- connection.safeMetadata.dependentAssignments,
82
- )
83
- ? connection.safeMetadata.dependentAssignments
84
- : [];
85
- const dependenciesByKey = new Map<string, string>();
86
- for (const candidate of dependencies) {
87
- if (
88
- !candidate ||
89
- typeof candidate !== "object" ||
90
- Array.isArray(candidate)
91
- ) {
92
- continue;
93
- }
94
- const dependency = candidate as Record<string, unknown>;
95
- if (
96
- typeof dependency.botId === "string" &&
97
- typeof dependency.generation === "string" &&
98
- dependency.status === "acknowledged"
99
- ) {
100
- dependenciesByKey.set(
101
- `${dependency.botId}\u0000${dependency.generation}`,
102
- dependency.generation,
103
- );
104
- }
105
- }
106
- return Promise.all(
107
- [...dependenciesByKey].map(async ([key, expectedGeneration]) => {
108
- const botId = key.slice(0, key.indexOf("\u0000"));
109
- return {
110
- botId,
111
- id: await revocationCompensationId(botId, expectedGeneration),
112
- expectedGeneration,
113
- };
114
- }),
115
- );
116
- }
117
-
118
- export interface UserConfigurationEnv {
119
- BOT_STATES: DurableObjectNamespace;
120
- COMPOSIO_API_KEY?: string;
121
- }
122
-
123
53
  export interface ComposioUserBackendHost {
124
54
  state: DurableObjectState;
125
- env: UserConfigurationEnv;
126
55
  availablePackages: readonly { packageId: string; version: string }[];
127
56
  reconcileProviderConnection(
128
57
  request: ComposioProviderReconciliationRequest,
@@ -170,12 +99,6 @@ function nextConnectionAlarm(settings: UserSettingsViewV1): number | undefined {
170
99
  ) {
171
100
  values.push(metadata.reconciliationRetryAt);
172
101
  }
173
- if (
174
- metadata.assignmentCompensationPending === true &&
175
- typeof metadata.compensationRetryAt === "number"
176
- ) {
177
- values.push(metadata.compensationRetryAt);
178
- }
179
102
  return values;
180
103
  });
181
104
  return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
@@ -243,7 +166,6 @@ const initialState = (): UserSettingsViewV1 => ({
243
166
 
244
167
  export class ComposioUserBackendContribution {
245
168
  readonly ctx: DurableObjectState;
246
- readonly env: UserConfigurationEnv;
247
169
  private readonly settings: UserSettingsBackendContribution;
248
170
  private readonly availablePackages: ReadonlySet<string>;
249
171
  private readonly reconcileProviderConnection: ComposioUserBackendHost["reconcileProviderConnection"];
@@ -251,7 +173,6 @@ export class ComposioUserBackendContribution {
251
173
 
252
174
  constructor(host: ComposioUserBackendHost) {
253
175
  this.ctx = host.state;
254
- this.env = host.env;
255
176
  this.settings = createUserSettingsBackendContribution({
256
177
  storage: host.state.storage,
257
178
  availablePackages: host.availablePackages,
@@ -482,8 +403,6 @@ export class ComposioUserBackendContribution {
482
403
  throw new Error("Pending Link cannot enter cleanup");
483
404
  }
484
405
  const effectDeadlineAt = Date.now() + CONNECTION_EFFECT_ALARM_MS;
485
- const assignmentCompensations =
486
- await deriveRevocationCompensations(connection);
487
406
  const claimed = {
488
407
  ...connection,
489
408
  state: "revoking" as const,
@@ -496,11 +415,6 @@ export class ComposioUserBackendContribution {
496
415
  reconciliationOperation: "revoke",
497
416
  revocationProviderCompleted: false,
498
417
  effectDeadlineAt,
499
- assignmentCompensationPending: assignmentCompensations.length > 0,
500
- assignmentCompensations,
501
- ...(assignmentCompensations.length > 0
502
- ? { compensationRetryAt: effectDeadlineAt }
503
- : {}),
504
418
  },
505
419
  failure: "Lost Connect Link cleanup requires provider reconciliation",
506
420
  };
@@ -631,92 +545,6 @@ export class ComposioUserBackendContribution {
631
545
  });
632
546
  }
633
547
 
634
- async recordAssignmentCompensated(
635
- userId: string,
636
- connectionId: string,
637
- compensationId: string,
638
- ): Promise<boolean> {
639
- return this.transitionConnection(userId, connectionId, (connection) =>
640
- completeAssignmentCompensation(connection, compensationId),
641
- );
642
- }
643
-
644
- async claimConnectionDependency(
645
- userId: string,
646
- connectionId: string,
647
- botId: string,
648
- generation: string,
649
- requirement: ConnectionDependencyRequirementV1,
650
- ): Promise<boolean> {
651
- const decoded = decodeConnectionDependencyRequirementV1(requirement);
652
- await this.assertIdentity(userId);
653
- return this.ctx.storage.transaction(async (transaction) => {
654
- const current =
655
- (await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
656
- initialState();
657
- const installation = current.packages.find(
658
- (pkg) =>
659
- pkg.packageId === decoded.packageId &&
660
- pkg.version === decoded.packageVersion &&
661
- pkg.state === "installed",
662
- );
663
- const connection = current.connections.find(
664
- (item) => item.connectionId === connectionId,
665
- );
666
- if (
667
- !installation ||
668
- !connection ||
669
- connection.packageId !== decoded.packageId ||
670
- !decoded.connectionTypeIds.includes(connection.connectionTypeId)
671
- ) {
672
- return false;
673
- }
674
- const nextConnection = claimDependentAssignment(
675
- connection,
676
- botId,
677
- generation,
678
- );
679
- if (!nextConnection) return false;
680
- const next = {
681
- ...current,
682
- revision: current.revision + 1,
683
- connections: current.connections.map((item) =>
684
- item.connectionId === connectionId ? nextConnection : item,
685
- ),
686
- } satisfies UserSettingsViewV1;
687
- await transaction.put(STATE_KEY, next);
688
- const alarmAt = nextConnectionAlarm(next);
689
- if (alarmAt === undefined) {
690
- await transaction.deleteAlarm();
691
- } else {
692
- await transaction.setAlarm(Math.max(Date.now(), alarmAt));
693
- }
694
- return true;
695
- });
696
- }
697
-
698
- async acknowledgeConnectionDependency(
699
- userId: string,
700
- connectionId: string,
701
- botId: string,
702
- generation: string,
703
- ): Promise<boolean> {
704
- return this.transitionConnection(userId, connectionId, (connection) =>
705
- acknowledgeDependentAssignment(connection, botId, generation),
706
- );
707
- }
708
-
709
- async compensateConnectionDependency(
710
- userId: string,
711
- connectionId: string,
712
- botId: string,
713
- generation: string,
714
- ): Promise<boolean> {
715
- return this.transitionConnection(userId, connectionId, (connection) =>
716
- compensateDependentAssignment(connection, botId, generation),
717
- );
718
- }
719
-
720
548
  async requireConnectionReconciliation(
721
549
  userId: string,
722
550
  connectionId: string,
@@ -848,8 +676,6 @@ export class ComposioUserBackendContribution {
848
676
  return { phase: "pending" as const, connection: pending };
849
677
  }
850
678
  const effectDeadlineAt = Date.now() + CONNECTION_EFFECT_ALARM_MS;
851
- const assignmentCompensations =
852
- await deriveRevocationCompensations(connection);
853
679
  const claimed = {
854
680
  ...connection,
855
681
  state: "revoking" as const,
@@ -858,11 +684,6 @@ export class ComposioUserBackendContribution {
858
684
  reconciliationOperation: "revoke",
859
685
  revocationProviderCompleted: false,
860
686
  effectDeadlineAt,
861
- assignmentCompensationPending: assignmentCompensations.length > 0,
862
- assignmentCompensations,
863
- ...(assignmentCompensations.length > 0
864
- ? { compensationRetryAt: effectDeadlineAt }
865
- : {}),
866
687
  },
867
688
  failure: undefined,
868
689
  };
@@ -915,8 +736,6 @@ export class ComposioUserBackendContribution {
915
736
  return this.transitionConnection(userId, connectionId, (connection) => {
916
737
  if (
917
738
  connection.safeMetadata.revocationProviderCompleted !== true ||
918
- (Array.isArray(connection.safeMetadata.assignmentCompensations) &&
919
- connection.safeMetadata.assignmentCompensations.length > 0) ||
920
739
  (connection.state !== "revoking" &&
921
740
  !(
922
741
  connection.state === "reconciliation-required" &&
@@ -938,12 +757,6 @@ export class ComposioUserBackendContribution {
938
757
  initialState();
939
758
  const now = Date.now();
940
759
  let changed = false;
941
- const pending: Array<{
942
- connectionId: string;
943
- botId: string;
944
- compensationId: string;
945
- expectedGeneration: string;
946
- }> = [];
947
760
  const reconciliations: Array<{
948
761
  connection: UserSettingsViewV1["connections"][number];
949
762
  operation: "link" | "revoke";
@@ -1032,49 +845,6 @@ export class ComposioUserBackendContribution {
1032
845
  changed = true;
1033
846
  }
1034
847
 
1035
- if (
1036
- next.safeMetadata.assignmentCompensationPending === true &&
1037
- typeof next.safeMetadata.compensationRetryAt === "number" &&
1038
- next.safeMetadata.compensationRetryAt <= now
1039
- ) {
1040
- const stored = Array.isArray(
1041
- next.safeMetadata.assignmentCompensations,
1042
- )
1043
- ? next.safeMetadata.assignmentCompensations
1044
- : [];
1045
- for (const candidate of stored) {
1046
- if (
1047
- !candidate ||
1048
- typeof candidate !== "object" ||
1049
- Array.isArray(candidate)
1050
- ) {
1051
- continue;
1052
- }
1053
- const compensation = candidate as Record<string, unknown>;
1054
- if (
1055
- typeof compensation.botId === "string" &&
1056
- typeof compensation.id === "string" &&
1057
- typeof compensation.expectedGeneration === "string"
1058
- ) {
1059
- pending.push({
1060
- connectionId: next.connectionId,
1061
- botId: compensation.botId,
1062
- compensationId: compensation.id,
1063
- expectedGeneration: compensation.expectedGeneration,
1064
- });
1065
- }
1066
- }
1067
- if (stored.length > 0) {
1068
- next = {
1069
- ...next,
1070
- safeMetadata: {
1071
- ...next.safeMetadata,
1072
- compensationRetryAt: now + CONNECTION_EFFECT_ALARM_MS,
1073
- },
1074
- };
1075
- changed = true;
1076
- }
1077
- }
1078
848
  return next;
1079
849
  });
1080
850
  const next = {
@@ -1089,10 +859,10 @@ export class ComposioUserBackendContribution {
1089
859
  } else {
1090
860
  await transaction.setAlarm(Math.max(Date.now(), alarmAt));
1091
861
  }
1092
- return { compensations: pending, reconciliations };
862
+ return reconciliations;
1093
863
  });
1094
864
 
1095
- for (const reconciliation of pending.reconciliations) {
865
+ for (const reconciliation of pending) {
1096
866
  try {
1097
867
  const connection = reconciliation.connection;
1098
868
  if (reconciliation.operation === "link") {
@@ -1260,49 +1030,6 @@ export class ComposioUserBackendContribution {
1260
1030
  // Durable reconciliation state and its alarm deadline remain stored.
1261
1031
  }
1262
1032
  }
1263
-
1264
- for (const compensation of pending.compensations) {
1265
- try {
1266
- const id = this.env.BOT_STATES.idFromName(
1267
- `${userId}:${compensation.botId}`,
1268
- );
1269
- // SAFETY: BOT_STATES binds BotState, whose public RPC method below is
1270
- // stable; workers-types cannot infer the generated Durable Object stub.
1271
- const bot = this.env.BOT_STATES.get(id) as unknown as {
1272
- markConnectionUnavailable(request: {
1273
- schemaVersion: 1;
1274
- userId: string;
1275
- botId: string;
1276
- connectionId: string;
1277
- compensation: { id: string; expectedGeneration: string };
1278
- }): Promise<"applied" | "stale">;
1279
- };
1280
- const result = await bot.markConnectionUnavailable({
1281
- schemaVersion: 1,
1282
- userId,
1283
- botId: compensation.botId,
1284
- connectionId: compensation.connectionId,
1285
- compensation: {
1286
- id: compensation.compensationId,
1287
- expectedGeneration: compensation.expectedGeneration,
1288
- },
1289
- });
1290
- if (!isSettledBotCompensation(result)) continue;
1291
- const cleared = await this.recordAssignmentCompensated(
1292
- userId,
1293
- compensation.connectionId,
1294
- compensation.compensationId,
1295
- );
1296
- if (cleared) {
1297
- await this.finishConnectionRevocation(
1298
- userId,
1299
- compensation.connectionId,
1300
- );
1301
- }
1302
- } catch {
1303
- // Durable compensation intent and its retry deadline remain stored.
1304
- }
1305
- }
1306
1033
  }
1307
1034
 
1308
1035
  private async transitionConnection(
@@ -1,54 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { ConnectionView } from "@frockbot/configuration-core";
3
- import {
4
- completeAssignmentCompensation,
5
- isSettledBotCompensation,
6
- } from "./connection-recovery.js";
7
-
8
- function connection(
9
- state: ConnectionView["state"],
10
- safeMetadata: ConnectionView["safeMetadata"],
11
- ): ConnectionView {
12
- return {
13
- connectionId: "connection-1",
14
- packageId: "composio",
15
- connectionTypeId: "gmail",
16
- displayName: "Gmail",
17
- state,
18
- safeMetadata,
19
- };
20
- }
21
-
22
- describe("Connection recovery", () => {
23
- test("treats stale Bot generations as settled compensation", () => {
24
- expect(isSettledBotCompensation("stale")).toBe(true);
25
- expect(isSettledBotCompensation("applied")).toBe(true);
26
- });
27
-
28
- test("keeps revocation scheduled until every generation settles", () => {
29
- const revoking = connection("revoking", {
30
- assignmentCompensationPending: true,
31
- compensationRetryAt: Date.now() + 60_000,
32
- assignmentCompensations: [
33
- { botId: "primary", id: "old", expectedGeneration: "gen-old" },
34
- { botId: "primary", id: "new", expectedGeneration: "gen-new" },
35
- ],
36
- });
37
-
38
- const afterOld = completeAssignmentCompensation(revoking, "old");
39
- const afterNew = afterOld
40
- ? completeAssignmentCompensation(afterOld, "new")
41
- : undefined;
42
-
43
- expect(afterOld?.safeMetadata).toMatchObject({
44
- assignmentCompensationPending: true,
45
- assignmentCompensations: [
46
- { botId: "primary", id: "new", expectedGeneration: "gen-new" },
47
- ],
48
- });
49
- expect(afterNew?.safeMetadata.assignmentCompensations).toEqual([]);
50
- expect(afterNew?.safeMetadata).not.toHaveProperty(
51
- "assignmentCompensationPending",
52
- );
53
- });
54
- });
@@ -1,52 +0,0 @@
1
- import type { ConnectionView } from "@frockbot/configuration-core";
2
-
3
- export type BotCompensationResult = "applied" | "stale";
4
-
5
- export function isSettledBotCompensation(
6
- result: BotCompensationResult,
7
- ): boolean {
8
- return result === "applied" || result === "stale";
9
- }
10
-
11
- export function completeAssignmentCompensation(
12
- connection: ConnectionView,
13
- compensationId: string,
14
- ): ConnectionView | undefined {
15
- if (Array.isArray(connection.safeMetadata.assignmentCompensations)) {
16
- const remaining = connection.safeMetadata.assignmentCompensations.filter(
17
- (candidate) =>
18
- !candidate ||
19
- typeof candidate !== "object" ||
20
- Array.isArray(candidate) ||
21
- (candidate as Record<string, unknown>).id !== compensationId,
22
- );
23
- if (
24
- remaining.length ===
25
- connection.safeMetadata.assignmentCompensations.length
26
- ) {
27
- return undefined;
28
- }
29
- const {
30
- compensationRetryAt,
31
- assignmentCompensationPending: _,
32
- ...safeMetadata
33
- } = connection.safeMetadata;
34
- return {
35
- ...connection,
36
- safeMetadata: {
37
- ...safeMetadata,
38
- assignmentCompensations: remaining,
39
- ...(remaining.length > 0
40
- ? {
41
- assignmentCompensationPending: true,
42
- compensationRetryAt:
43
- typeof compensationRetryAt === "number"
44
- ? compensationRetryAt
45
- : Date.now() + 60_000,
46
- }
47
- : {}),
48
- },
49
- };
50
- }
51
- return undefined;
52
- }
@@ -1,136 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { ConnectionView } from "@frockbot/configuration-core";
3
- import {
4
- acknowledgeDependentAssignment,
5
- claimDependentAssignment,
6
- compensateDependentAssignment,
7
- releaseDependentAssignment,
8
- } from "./dependency-coordination.js";
9
-
10
- function assignmentConnection(): ConnectionView {
11
- return {
12
- connectionId: "connection-1",
13
- packageId: "composio",
14
- connectionTypeId: "gmail",
15
- displayName: "Gmail",
16
- state: "ready",
17
- safeMetadata: {},
18
- };
19
- }
20
-
21
- describe("Connection dependency coordination", () => {
22
- test("records intent before acknowledgement and rejects revocation races", () => {
23
- const claimed = claimDependentAssignment(
24
- assignmentConnection(),
25
- "bot-1",
26
- "generation-1",
27
- );
28
- expect(claimed?.safeMetadata.dependentAssignments).toEqual([
29
- { botId: "bot-1", generation: "generation-1", status: "pending" },
30
- ]);
31
- expect(
32
- acknowledgeDependentAssignment(claimed!, "bot-1", "generation-1")
33
- ?.safeMetadata.dependentAssignments,
34
- ).toEqual([
35
- {
36
- botId: "bot-1",
37
- generation: "generation-1",
38
- status: "acknowledged",
39
- },
40
- ]);
41
- expect(
42
- claimDependentAssignment(
43
- {
44
- ...assignmentConnection(),
45
- state: "revoking",
46
- safeMetadata: {
47
- ...assignmentConnection().safeMetadata,
48
- revocationRequested: true,
49
- },
50
- },
51
- "bot-1",
52
- "generation-1",
53
- ),
54
- ).toBeUndefined();
55
- });
56
-
57
- test("keeps two same-Bot Assignment generations independent on one Connection", () => {
58
- const acknowledged = {
59
- ...assignmentConnection(),
60
- state: "ready" as const,
61
- safeMetadata: {
62
- dependentAssignments: [
63
- {
64
- botId: "bot-1",
65
- generation: "generation-1",
66
- status: "acknowledged",
67
- },
68
- ],
69
- },
70
- };
71
- const claimed = claimDependentAssignment(
72
- acknowledged,
73
- "bot-1",
74
- "generation-2",
75
- );
76
-
77
- expect(claimed?.safeMetadata.dependentAssignments).toEqual([
78
- {
79
- botId: "bot-1",
80
- generation: "generation-1",
81
- status: "acknowledged",
82
- },
83
- { botId: "bot-1", generation: "generation-2", status: "pending" },
84
- ]);
85
- expect(
86
- compensateDependentAssignment(claimed!, "bot-1", "generation-2")
87
- ?.safeMetadata.dependentAssignments,
88
- ).toEqual([
89
- {
90
- botId: "bot-1",
91
- generation: "generation-1",
92
- status: "acknowledged",
93
- },
94
- ]);
95
- const bothAcknowledged = acknowledgeDependentAssignment(
96
- claimed!,
97
- "bot-1",
98
- "generation-2",
99
- );
100
- expect(bothAcknowledged?.safeMetadata.dependentAssignments).toEqual([
101
- {
102
- botId: "bot-1",
103
- generation: "generation-1",
104
- status: "acknowledged",
105
- },
106
- {
107
- botId: "bot-1",
108
- generation: "generation-2",
109
- status: "acknowledged",
110
- },
111
- ]);
112
-
113
- const firstReleased = releaseDependentAssignment(
114
- bothAcknowledged!,
115
- "bot-1",
116
- "generation-1",
117
- );
118
- expect(firstReleased?.safeMetadata.dependentAssignments).toEqual([
119
- {
120
- botId: "bot-1",
121
- generation: "generation-2",
122
- status: "acknowledged",
123
- },
124
- ]);
125
- expect(
126
- releaseDependentAssignment(bothAcknowledged!, "bot-1", "generation-2")
127
- ?.safeMetadata.dependentAssignments,
128
- ).toEqual([
129
- {
130
- botId: "bot-1",
131
- generation: "generation-1",
132
- status: "acknowledged",
133
- },
134
- ]);
135
- });
136
- });
@@ -1,140 +0,0 @@
1
- import type { ConnectionView } from "@frockbot/configuration-core";
2
-
3
- export function claimDependentAssignment(
4
- connection: ConnectionView,
5
- botId: string,
6
- generation: string,
7
- ): ConnectionView | undefined {
8
- if (
9
- connection.state === "revoking" ||
10
- connection.state === "revoked" ||
11
- connection.state === "failed" ||
12
- connection.safeMetadata.revocationRequested === true
13
- ) {
14
- return undefined;
15
- }
16
- if (connection.state !== "ready") {
17
- return undefined;
18
- }
19
- const existing = Array.isArray(connection.safeMetadata.dependentAssignments)
20
- ? connection.safeMetadata.dependentAssignments.filter(
21
- (candidate) =>
22
- candidate &&
23
- typeof candidate === "object" &&
24
- !Array.isArray(candidate) &&
25
- typeof (candidate as Record<string, unknown>).botId === "string",
26
- )
27
- : [];
28
- return {
29
- ...connection,
30
- safeMetadata: {
31
- ...connection.safeMetadata,
32
- dependentAssignments: [
33
- ...existing.filter(
34
- (candidate) =>
35
- (candidate as Record<string, unknown>).botId !== botId ||
36
- (candidate as Record<string, unknown>).generation !== generation,
37
- ),
38
- { botId, generation, status: "pending" },
39
- ],
40
- },
41
- };
42
- }
43
-
44
- export function acknowledgeDependentAssignment(
45
- connection: ConnectionView,
46
- botId: string,
47
- generation: string,
48
- ): ConnectionView | undefined {
49
- if (
50
- connection.state === "revoking" ||
51
- connection.state === "revoked" ||
52
- connection.safeMetadata.revocationRequested === true
53
- ) {
54
- return undefined;
55
- }
56
- const dependencies = Array.isArray(
57
- connection.safeMetadata.dependentAssignments,
58
- )
59
- ? connection.safeMetadata.dependentAssignments
60
- : [];
61
- let matched = false;
62
- const acknowledged = dependencies.map((candidate) => {
63
- if (
64
- candidate &&
65
- typeof candidate === "object" &&
66
- !Array.isArray(candidate) &&
67
- (candidate as Record<string, unknown>).botId === botId &&
68
- (candidate as Record<string, unknown>).generation === generation
69
- ) {
70
- matched = true;
71
- return { ...candidate, status: "acknowledged" };
72
- }
73
- return candidate;
74
- });
75
- if (!matched) return undefined;
76
- return {
77
- ...connection,
78
- safeMetadata: {
79
- ...connection.safeMetadata,
80
- dependentAssignments: acknowledged,
81
- },
82
- };
83
- }
84
-
85
- export function releaseDependentAssignment(
86
- connection: ConnectionView,
87
- botId: string,
88
- generation: string,
89
- ): ConnectionView | undefined {
90
- const dependencies = Array.isArray(
91
- connection.safeMetadata.dependentAssignments,
92
- )
93
- ? connection.safeMetadata.dependentAssignments
94
- : [];
95
- const remaining = dependencies.filter(
96
- (candidate) =>
97
- !candidate ||
98
- typeof candidate !== "object" ||
99
- Array.isArray(candidate) ||
100
- (candidate as Record<string, unknown>).botId !== botId ||
101
- (candidate as Record<string, unknown>).generation !== generation,
102
- );
103
- if (remaining.length === dependencies.length) return undefined;
104
- return {
105
- ...connection,
106
- safeMetadata: {
107
- ...connection.safeMetadata,
108
- dependentAssignments: remaining,
109
- },
110
- };
111
- }
112
-
113
- export function compensateDependentAssignment(
114
- connection: ConnectionView,
115
- botId: string,
116
- generation: string,
117
- ): ConnectionView | undefined {
118
- const dependencies = Array.isArray(
119
- connection.safeMetadata.dependentAssignments,
120
- )
121
- ? connection.safeMetadata.dependentAssignments
122
- : [];
123
- const remaining = dependencies.filter(
124
- (candidate) =>
125
- !candidate ||
126
- typeof candidate !== "object" ||
127
- Array.isArray(candidate) ||
128
- (candidate as Record<string, unknown>).botId !== botId ||
129
- (candidate as Record<string, unknown>).generation !== generation ||
130
- (candidate as Record<string, unknown>).status !== "pending",
131
- );
132
- if (remaining.length === dependencies.length) return undefined;
133
- return {
134
- ...connection,
135
- safeMetadata: {
136
- ...connection.safeMetadata,
137
- dependentAssignments: remaining,
138
- },
139
- };
140
- }