@frockbot/plugin-composio 0.0.0 → 0.1.1

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.
@@ -0,0 +1,136 @@
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
+ });
@@ -0,0 +1,140 @@
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./agent.js";
2
+ export * from "./backend.js";
3
+ export * from "./composio-client.js";
4
+ export * from "./connection-recovery.js";
5
+ export * from "./connections.js";
6
+ export * from "./dependency-coordination.js";
7
+ export * from "./provider-reconciliation.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,71 @@
1
+ import {
2
+ ComposioRequestError,
3
+ type ComposioClient,
4
+ type ConnectedAccountSummary,
5
+ } from "./composio-client.js";
6
+
7
+ export type ComposioProviderReconciliationRequest =
8
+ | {
9
+ operation: "link";
10
+ userId: string;
11
+ providerAlias: string;
12
+ toolkitSlug: string;
13
+ }
14
+ | {
15
+ operation: "revoke";
16
+ userId: string;
17
+ connectedAccountId: string;
18
+ };
19
+
20
+ export type ComposioProviderReconciliationResult =
21
+ | { status: "active"; account: ConnectedAccountSummary }
22
+ | { status: "failed"; account: ConnectedAccountSummary }
23
+ | { status: "revoked"; account?: ConnectedAccountSummary }
24
+ | { status: "absent" }
25
+ | { status: "pending"; account?: ConnectedAccountSummary };
26
+
27
+ export function linkReconciliationDisposition(
28
+ result: ComposioProviderReconciliationResult,
29
+ ): "ready" | "pending" | "failed" {
30
+ if (result.status === "active") return "ready";
31
+ if (result.status === "pending" || result.status === "absent") {
32
+ return "pending";
33
+ }
34
+ return "failed";
35
+ }
36
+
37
+ export async function reconcileComposioProviderConnection(
38
+ client: ComposioClient,
39
+ request: ComposioProviderReconciliationRequest,
40
+ ): Promise<ComposioProviderReconciliationResult> {
41
+ if (request.operation === "link") {
42
+ const account = (await client.listConnectedAccounts(request.userId)).find(
43
+ (candidate) =>
44
+ candidate.alias === request.providerAlias &&
45
+ candidate.toolkitSlug === request.toolkitSlug,
46
+ );
47
+ if (!account) return { status: "absent" };
48
+ if (account.status === "ACTIVE") return { status: "active", account };
49
+ if (account.status === "REVOKED") return { status: "revoked", account };
50
+ if (account.status === "FAILED" || account.status === "EXPIRED") {
51
+ return { status: "failed", account };
52
+ }
53
+ return { status: "pending", account };
54
+ }
55
+
56
+ let account: ConnectedAccountSummary;
57
+ try {
58
+ account = await client.getConnectedAccount(request.connectedAccountId);
59
+ } catch (error) {
60
+ if (error instanceof ComposioRequestError && error.status === 404) {
61
+ return { status: "absent" };
62
+ }
63
+ throw error;
64
+ }
65
+ if (account.userId !== request.userId) {
66
+ throw new Error("Composio connected account does not belong to the User");
67
+ }
68
+ return account.status === "REVOKED"
69
+ ? { status: "revoked", account }
70
+ : { status: "pending", account };
71
+ }