@frockbot/plugin-composio 0.1.2 → 0.1.4
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 +6 -6
- package/src/agent.ts +5 -7
- package/src/backend.test.ts +0 -1
- package/src/backend.ts +0 -14
- package/src/connections.ts +0 -52
- package/src/index.ts +0 -2
- package/src/user-configuration.test.ts +46 -186
- package/src/user-configuration.ts +2 -275
- package/src/connection-recovery.test.ts +0 -54
- package/src/connection-recovery.ts +0 -52
- package/src/dependency-coordination.test.ts +0 -136
- package/src/dependency-coordination.ts +0 -140
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-composio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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.
|
|
26
|
-
"@frockbot/connection-core": "0.1.
|
|
27
|
-
"@frockbot/kernel-contracts": "0.1.
|
|
28
|
-
"@frockbot/plugin-settings": "0.1.
|
|
29
|
-
"@frockbot/plugin-tools": "0.1.
|
|
25
|
+
"@frockbot/configuration-core": "0.1.4",
|
|
26
|
+
"@frockbot/connection-core": "0.1.4",
|
|
27
|
+
"@frockbot/kernel-contracts": "0.1.4",
|
|
28
|
+
"@frockbot/plugin-settings": "0.1.4",
|
|
29
|
+
"@frockbot/plugin-tools": "0.1.4",
|
|
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
|
-
|
|
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.
|
|
47
|
-
config.
|
|
48
|
-
config.
|
|
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("
|
|
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;
|
package/src/backend.test.ts
CHANGED
|
@@ -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
|
|
package/src/connections.ts
CHANGED
|
@@ -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
|
|
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("
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
862
|
+
return reconciliations;
|
|
1093
863
|
});
|
|
1094
864
|
|
|
1095
|
-
for (const reconciliation of pending
|
|
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
|
-
}
|