@okrlinkhub/agent-factory 0.1.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/LICENSE +201 -0
- package/README.md +345 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +320 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +509 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +44 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +694 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/config.d.ts +260 -0
- package/dist/component/config.d.ts.map +1 -0
- package/dist/component/config.js +154 -0
- package/dist/component/config.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/identity.d.ts +98 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +371 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/lib.d.ts +4 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +4 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/providers/fly.d.ts +39 -0
- package/dist/component/providers/fly.d.ts.map +1 -0
- package/dist/component/providers/fly.js +145 -0
- package/dist/component/providers/fly.js.map +1 -0
- package/dist/component/queue.d.ts +294 -0
- package/dist/component/queue.d.ts.map +1 -0
- package/dist/component/queue.js +1278 -0
- package/dist/component/queue.js.map +1 -0
- package/dist/component/scheduler.d.ts +89 -0
- package/dist/component/scheduler.d.ts.map +1 -0
- package/dist/component/scheduler.js +275 -0
- package/dist/component/scheduler.js.map +1 -0
- package/dist/component/schema.d.ts +518 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +243 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +102 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.test.ts +48 -0
- package/src/client/index.ts +586 -0
- package/src/client/setup.test.ts +26 -0
- package/src/component/_generated/api.ts +60 -0
- package/src/component/_generated/component.ts +902 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/config.ts +236 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/identity.ts +450 -0
- package/src/component/lib.test.ts +319 -0
- package/src/component/lib.ts +30 -0
- package/src/component/providers/fly.ts +226 -0
- package/src/component/queue.ts +1544 -0
- package/src/component/scheduler.ts +362 -0
- package/src/component/schema.ts +336 -0
- package/src/component/setup.test.ts +11 -0
- package/src/react/index.ts +7 -0
- package/src/test.ts +18 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { api, internal } from "./_generated/api.js";
|
|
5
|
+
import { initConvexTest } from "./setup.test.js";
|
|
6
|
+
|
|
7
|
+
describe("component lib", () => {
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
test("enqueue and claim should respect queue flow", async () => {
|
|
15
|
+
const t = initConvexTest();
|
|
16
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
17
|
+
agentKey: "support-agent",
|
|
18
|
+
version: "1.0.0",
|
|
19
|
+
soulMd: "# Soul",
|
|
20
|
+
clientMd: "# Client",
|
|
21
|
+
skills: ["agent-bridge"],
|
|
22
|
+
secretsRef: ["telegram.botToken"],
|
|
23
|
+
enabled: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const messageId = await t.mutation(api.lib.enqueue, {
|
|
27
|
+
conversationId: "telegram:chat:1",
|
|
28
|
+
agentKey: "support-agent",
|
|
29
|
+
payload: {
|
|
30
|
+
provider: "telegram",
|
|
31
|
+
providerUserId: "u1",
|
|
32
|
+
messageText: "Ciao",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(messageId).toBeDefined();
|
|
36
|
+
|
|
37
|
+
const claimed = await t.mutation(api.lib.claim, {
|
|
38
|
+
workerId: "worker-1",
|
|
39
|
+
});
|
|
40
|
+
expect(claimed).not.toBeNull();
|
|
41
|
+
expect(claimed?.conversationId).toBe("telegram:chat:1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("identity binding should resolve, rebind and revoke", async () => {
|
|
45
|
+
const t = initConvexTest();
|
|
46
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
47
|
+
agentKey: "agent-a",
|
|
48
|
+
version: "1.0.0",
|
|
49
|
+
soulMd: "# Soul",
|
|
50
|
+
clientMd: "# Client",
|
|
51
|
+
skills: ["agent-bridge"],
|
|
52
|
+
secretsRef: [],
|
|
53
|
+
enabled: true,
|
|
54
|
+
});
|
|
55
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
56
|
+
agentKey: "agent-b",
|
|
57
|
+
version: "1.0.0",
|
|
58
|
+
soulMd: "# Soul",
|
|
59
|
+
clientMd: "# Client",
|
|
60
|
+
skills: ["agent-bridge"],
|
|
61
|
+
secretsRef: [],
|
|
62
|
+
enabled: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const first = await t.mutation(api.lib.bindUserAgent, {
|
|
66
|
+
consumerUserId: "u-1",
|
|
67
|
+
agentKey: "agent-a",
|
|
68
|
+
source: "telegram_pairing",
|
|
69
|
+
telegramUserId: "tg-user-1",
|
|
70
|
+
telegramChatId: "tg-chat-1",
|
|
71
|
+
});
|
|
72
|
+
expect(first.agentKey).toBe("agent-a");
|
|
73
|
+
|
|
74
|
+
const byUser = await t.query(api.lib.resolveAgentForUser, {
|
|
75
|
+
consumerUserId: "u-1",
|
|
76
|
+
});
|
|
77
|
+
expect(byUser.agentKey).toBe("agent-a");
|
|
78
|
+
|
|
79
|
+
const byTelegram = await t.query(api.lib.resolveAgentForTelegram, {
|
|
80
|
+
telegramUserId: "tg-user-1",
|
|
81
|
+
});
|
|
82
|
+
expect(byTelegram.agentKey).toBe("agent-a");
|
|
83
|
+
|
|
84
|
+
await t.mutation(api.lib.bindUserAgent, {
|
|
85
|
+
consumerUserId: "u-1",
|
|
86
|
+
agentKey: "agent-b",
|
|
87
|
+
source: "manual",
|
|
88
|
+
telegramUserId: "tg-user-1",
|
|
89
|
+
telegramChatId: "tg-chat-1",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const rebound = await t.query(api.lib.resolveAgentForUser, {
|
|
93
|
+
consumerUserId: "u-1",
|
|
94
|
+
});
|
|
95
|
+
expect(rebound.agentKey).toBe("agent-b");
|
|
96
|
+
|
|
97
|
+
const revokeResult = await t.mutation(api.lib.revokeUserAgentBinding, {
|
|
98
|
+
consumerUserId: "u-1",
|
|
99
|
+
});
|
|
100
|
+
expect(revokeResult.revoked).toBe(1);
|
|
101
|
+
|
|
102
|
+
const afterRevoke = await t.query(api.lib.resolveAgentForTelegram, {
|
|
103
|
+
telegramChatId: "tg-chat-1",
|
|
104
|
+
});
|
|
105
|
+
expect(afterRevoke.agentKey).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("worker scheduling should set idle shutdown from completion time", async () => {
|
|
109
|
+
const t = initConvexTest();
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
vi.setSystemTime(now);
|
|
112
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
113
|
+
agentKey: "support-agent",
|
|
114
|
+
version: "1.0.0",
|
|
115
|
+
soulMd: "# Soul",
|
|
116
|
+
clientMd: "# Client",
|
|
117
|
+
skills: ["agent-bridge"],
|
|
118
|
+
secretsRef: [],
|
|
119
|
+
enabled: true,
|
|
120
|
+
});
|
|
121
|
+
const messageId = await t.mutation(api.lib.enqueue, {
|
|
122
|
+
conversationId: "telegram:chat:2",
|
|
123
|
+
agentKey: "support-agent",
|
|
124
|
+
payload: {
|
|
125
|
+
provider: "telegram",
|
|
126
|
+
providerUserId: "u2",
|
|
127
|
+
messageText: "ciao",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const claim = await t.mutation(api.lib.claim, { workerId: "worker-2" });
|
|
131
|
+
expect(claim).not.toBeNull();
|
|
132
|
+
expect(claim?.messageId).toBe(messageId);
|
|
133
|
+
|
|
134
|
+
const completionTime = now + 60_000;
|
|
135
|
+
vi.setSystemTime(completionTime);
|
|
136
|
+
const completed = await t.mutation(api.lib.complete, {
|
|
137
|
+
workerId: "worker-2",
|
|
138
|
+
messageId,
|
|
139
|
+
leaseId: claim?.leaseId ?? "",
|
|
140
|
+
});
|
|
141
|
+
expect(completed).toBe(true);
|
|
142
|
+
const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
|
|
143
|
+
const worker = workers.find((row: { workerId: string }) => row.workerId === "worker-2");
|
|
144
|
+
expect(worker?.status).toBe("active");
|
|
145
|
+
expect(worker?.load).toBe(0);
|
|
146
|
+
expect(worker?.scheduledShutdownAt).toBe(completionTime + 300_000);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("worker control state should signal stop for stopped worker", async () => {
|
|
150
|
+
const t = initConvexTest();
|
|
151
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
152
|
+
workerId: "worker-stop-1",
|
|
153
|
+
provider: "fly",
|
|
154
|
+
status: "stopped",
|
|
155
|
+
load: 0,
|
|
156
|
+
});
|
|
157
|
+
const control = await t.query(api.queue.getWorkerControlState as any, {
|
|
158
|
+
workerId: "worker-stop-1",
|
|
159
|
+
});
|
|
160
|
+
expect(control.shouldStop).toBe(true);
|
|
161
|
+
|
|
162
|
+
const controlUnknown = await t.query(api.queue.getWorkerControlState as any, {
|
|
163
|
+
workerId: "worker-nonexistent",
|
|
164
|
+
});
|
|
165
|
+
expect(controlUnknown.shouldStop).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("scheduler caps desired workers by distinct ready conversations", async () => {
|
|
169
|
+
const t = initConvexTest();
|
|
170
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
171
|
+
agentKey: "support-agent",
|
|
172
|
+
version: "1.0.0",
|
|
173
|
+
soulMd: "# Soul",
|
|
174
|
+
clientMd: "# Client",
|
|
175
|
+
skills: ["agent-bridge"],
|
|
176
|
+
secretsRef: [],
|
|
177
|
+
enabled: true,
|
|
178
|
+
});
|
|
179
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
180
|
+
secretRef: "fly.apiToken",
|
|
181
|
+
plaintextValue: "fly-token",
|
|
182
|
+
});
|
|
183
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
184
|
+
secretRef: "convex.url",
|
|
185
|
+
plaintextValue: "https://example.convex.cloud",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
189
|
+
conversationId: "telegram:chat:cap-1",
|
|
190
|
+
agentKey: "support-agent",
|
|
191
|
+
payload: {
|
|
192
|
+
provider: "telegram",
|
|
193
|
+
providerUserId: "u-cap",
|
|
194
|
+
messageText: "first",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
198
|
+
conversationId: "telegram:chat:cap-1",
|
|
199
|
+
agentKey: "support-agent",
|
|
200
|
+
payload: {
|
|
201
|
+
provider: "telegram",
|
|
202
|
+
providerUserId: "u-cap",
|
|
203
|
+
messageText: "second",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
208
|
+
workerId: "worker-cap-1",
|
|
209
|
+
provider: "fly",
|
|
210
|
+
status: "active",
|
|
211
|
+
load: 0,
|
|
212
|
+
});
|
|
213
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
214
|
+
workerId: "worker-cap-2",
|
|
215
|
+
provider: "fly",
|
|
216
|
+
status: "active",
|
|
217
|
+
load: 0,
|
|
218
|
+
});
|
|
219
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
220
|
+
workerId: "worker-cap-3",
|
|
221
|
+
provider: "fly",
|
|
222
|
+
status: "active",
|
|
223
|
+
load: 0,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const reconcile = await t.action(api.scheduler.reconcileWorkerPool, {
|
|
227
|
+
scalingPolicy: {
|
|
228
|
+
minWorkers: 0,
|
|
229
|
+
maxWorkers: 5,
|
|
230
|
+
queuePerWorkerTarget: 1,
|
|
231
|
+
spawnStep: 1,
|
|
232
|
+
idleTimeoutMs: 300_000,
|
|
233
|
+
reconcileIntervalMs: 15_000,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
expect(reconcile.desiredWorkers).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("scheduler desired workers increases with distinct ready conversations", async () => {
|
|
240
|
+
const t = initConvexTest();
|
|
241
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
242
|
+
agentKey: "support-agent",
|
|
243
|
+
version: "1.0.0",
|
|
244
|
+
soulMd: "# Soul",
|
|
245
|
+
clientMd: "# Client",
|
|
246
|
+
skills: ["agent-bridge"],
|
|
247
|
+
secretsRef: [],
|
|
248
|
+
enabled: true,
|
|
249
|
+
});
|
|
250
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
251
|
+
secretRef: "fly.apiToken",
|
|
252
|
+
plaintextValue: "fly-token",
|
|
253
|
+
});
|
|
254
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
255
|
+
secretRef: "convex.url",
|
|
256
|
+
plaintextValue: "https://example.convex.cloud",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
260
|
+
conversationId: "telegram:chat:cap-a",
|
|
261
|
+
agentKey: "support-agent",
|
|
262
|
+
payload: {
|
|
263
|
+
provider: "telegram",
|
|
264
|
+
providerUserId: "u-cap-a",
|
|
265
|
+
messageText: "hello",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
269
|
+
conversationId: "telegram:chat:cap-b",
|
|
270
|
+
agentKey: "support-agent",
|
|
271
|
+
payload: {
|
|
272
|
+
provider: "telegram",
|
|
273
|
+
providerUserId: "u-cap-b",
|
|
274
|
+
messageText: "hello",
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
279
|
+
workerId: "worker-cap-4",
|
|
280
|
+
provider: "fly",
|
|
281
|
+
status: "active",
|
|
282
|
+
load: 0,
|
|
283
|
+
});
|
|
284
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
285
|
+
workerId: "worker-cap-5",
|
|
286
|
+
provider: "fly",
|
|
287
|
+
status: "active",
|
|
288
|
+
load: 0,
|
|
289
|
+
});
|
|
290
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
291
|
+
workerId: "worker-cap-6",
|
|
292
|
+
provider: "fly",
|
|
293
|
+
status: "active",
|
|
294
|
+
load: 0,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const reconcile = await t.action(api.scheduler.reconcileWorkerPool, {
|
|
298
|
+
scalingPolicy: {
|
|
299
|
+
minWorkers: 0,
|
|
300
|
+
maxWorkers: 5,
|
|
301
|
+
queuePerWorkerTarget: 1,
|
|
302
|
+
spawnStep: 1,
|
|
303
|
+
idleTimeoutMs: 300_000,
|
|
304
|
+
reconcileIntervalMs: 15_000,
|
|
305
|
+
},
|
|
306
|
+
providerConfig: {
|
|
307
|
+
kind: "fly",
|
|
308
|
+
appName: "agent-factory-workers",
|
|
309
|
+
organizationSlug: "personal",
|
|
310
|
+
image: "registry.fly.io/agent-factory-workers:test-image",
|
|
311
|
+
region: "iad",
|
|
312
|
+
volumeName: "",
|
|
313
|
+
volumePath: "",
|
|
314
|
+
volumeSizeGb: 10,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
expect(reconcile.desiredWorkers).toBe(2);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export {
|
|
2
|
+
upsertAgentProfile as configureAgent,
|
|
3
|
+
importPlaintextSecret as importSecret,
|
|
4
|
+
getSecretsStatus as secretStatus,
|
|
5
|
+
enqueueMessage as enqueue,
|
|
6
|
+
appendConversationMessages,
|
|
7
|
+
generateMediaUploadUrl,
|
|
8
|
+
getStorageFileUrl,
|
|
9
|
+
attachMessageMetadata,
|
|
10
|
+
claimNextJob as claim,
|
|
11
|
+
heartbeatJob as heartbeat,
|
|
12
|
+
completeJob as complete,
|
|
13
|
+
failJob as fail,
|
|
14
|
+
getHydrationBundleForClaimedJob as getHydrationBundle,
|
|
15
|
+
getQueueStats as queueStats,
|
|
16
|
+
getWorkerStats as workerStats,
|
|
17
|
+
} from "./queue.js";
|
|
18
|
+
|
|
19
|
+
export { reconcileWorkerPool as reconcileWorkers } from "./scheduler.js";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
bindUserAgent,
|
|
23
|
+
revokeUserAgentBinding,
|
|
24
|
+
resolveAgentForUser,
|
|
25
|
+
resolveAgentForTelegram,
|
|
26
|
+
getUserAgentBinding,
|
|
27
|
+
createPairingCode,
|
|
28
|
+
consumePairingCode,
|
|
29
|
+
getPairingCodeStatus,
|
|
30
|
+
} from "./identity.js";
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
export type WorkerProviderStatus =
|
|
2
|
+
| "active"
|
|
3
|
+
| "stopped";
|
|
4
|
+
|
|
5
|
+
export type SpawnWorkerInput = {
|
|
6
|
+
workerId: string;
|
|
7
|
+
appName: string;
|
|
8
|
+
image: string;
|
|
9
|
+
region: string;
|
|
10
|
+
volumeName: string;
|
|
11
|
+
volumePath: string;
|
|
12
|
+
volumeSizeGb: number;
|
|
13
|
+
cpuKind?: string;
|
|
14
|
+
cpus?: number;
|
|
15
|
+
memoryMb?: number;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ProviderWorker = {
|
|
20
|
+
workerId: string;
|
|
21
|
+
machineId: string;
|
|
22
|
+
region?: string;
|
|
23
|
+
image?: string;
|
|
24
|
+
status: WorkerProviderStatus;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface WorkerProvider {
|
|
28
|
+
spawnWorker(input: SpawnWorkerInput): Promise<ProviderWorker>;
|
|
29
|
+
listWorkers(appName: string): Promise<Array<ProviderWorker>>;
|
|
30
|
+
terminateWorker(appName: string, machineId: string): Promise<void>;
|
|
31
|
+
cordonWorker(appName: string, machineId: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type FlyMachine = {
|
|
35
|
+
id: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
region?: string;
|
|
38
|
+
state?: string;
|
|
39
|
+
config?: {
|
|
40
|
+
image?: string;
|
|
41
|
+
image_ref?: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type FlyMachineMount = {
|
|
46
|
+
volume: string;
|
|
47
|
+
path: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type FlyVolume = {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
region?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class FlyMachinesProvider implements WorkerProvider {
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly apiToken: string,
|
|
59
|
+
private readonly baseUrl: string = "https://api.machines.dev/v1",
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
async spawnWorker(input: SpawnWorkerInput): Promise<ProviderWorker> {
|
|
63
|
+
const volumeId = await this.resolveOrCreateVolumeId(
|
|
64
|
+
input.appName,
|
|
65
|
+
input.volumeName,
|
|
66
|
+
input.workerId,
|
|
67
|
+
input.region,
|
|
68
|
+
input.volumeSizeGb,
|
|
69
|
+
);
|
|
70
|
+
const payload = {
|
|
71
|
+
name: input.workerId,
|
|
72
|
+
region: input.region,
|
|
73
|
+
config: {
|
|
74
|
+
image: input.image,
|
|
75
|
+
guest: {
|
|
76
|
+
cpu_kind: input.cpuKind ?? "shared",
|
|
77
|
+
cpus: input.cpus ?? 1,
|
|
78
|
+
memory_mb: input.memoryMb ?? 2048,
|
|
79
|
+
},
|
|
80
|
+
mounts: [
|
|
81
|
+
{
|
|
82
|
+
volume: volumeId,
|
|
83
|
+
path: input.volumePath,
|
|
84
|
+
} satisfies FlyMachineMount,
|
|
85
|
+
],
|
|
86
|
+
env: {
|
|
87
|
+
AGENT_FACTORY_WORKER_ID: input.workerId,
|
|
88
|
+
...input.env,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const machine = await this.request<FlyMachine>({
|
|
93
|
+
path: `/apps/${encodeURIComponent(input.appName)}/machines`,
|
|
94
|
+
method: "POST",
|
|
95
|
+
body: payload,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
workerId: input.workerId,
|
|
99
|
+
machineId: machine.id,
|
|
100
|
+
region: machine.region ?? input.region,
|
|
101
|
+
image: machine.config?.image_ref ?? machine.config?.image ?? input.image,
|
|
102
|
+
status: mapFlyStateToProviderStatus(machine.state),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async resolveOrCreateVolumeId(
|
|
107
|
+
appName: string,
|
|
108
|
+
volumeNamePrefix: string,
|
|
109
|
+
workerId: string,
|
|
110
|
+
region: string,
|
|
111
|
+
volumeSizeGb: number,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
const volumeName = buildDedicatedVolumeName(volumeNamePrefix, workerId);
|
|
114
|
+
const volumes = await this.request<Array<FlyVolume>>({
|
|
115
|
+
path: `/apps/${encodeURIComponent(appName)}/volumes`,
|
|
116
|
+
method: "GET",
|
|
117
|
+
});
|
|
118
|
+
const regionalMatch = volumes.find(
|
|
119
|
+
(volume) => volume.name === volumeName && volume.region === region,
|
|
120
|
+
);
|
|
121
|
+
if (regionalMatch) {
|
|
122
|
+
return regionalMatch.id;
|
|
123
|
+
}
|
|
124
|
+
const created = await this.request<FlyVolume>({
|
|
125
|
+
path: `/apps/${encodeURIComponent(appName)}/volumes`,
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: {
|
|
128
|
+
name: volumeName,
|
|
129
|
+
region,
|
|
130
|
+
size_gb: volumeSizeGb,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return created.id;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async listWorkers(appName: string): Promise<Array<ProviderWorker>> {
|
|
137
|
+
const machines = await this.request<Array<FlyMachine>>({
|
|
138
|
+
path: `/apps/${encodeURIComponent(appName)}/machines`,
|
|
139
|
+
method: "GET",
|
|
140
|
+
});
|
|
141
|
+
return machines.map((machine) => ({
|
|
142
|
+
workerId: machine.name ?? machine.id,
|
|
143
|
+
machineId: machine.id,
|
|
144
|
+
region: machine.region,
|
|
145
|
+
image: machine.config?.image_ref ?? machine.config?.image,
|
|
146
|
+
status: mapFlyStateToProviderStatus(machine.state),
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async terminateWorker(appName: string, machineId: string): Promise<void> {
|
|
151
|
+
await this.request<void>({
|
|
152
|
+
path: `/apps/${encodeURIComponent(appName)}/machines/${encodeURIComponent(machineId)}`,
|
|
153
|
+
method: "DELETE",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async cordonWorker(appName: string, machineId: string): Promise<void> {
|
|
158
|
+
await this.request<void>({
|
|
159
|
+
path: `/apps/${encodeURIComponent(appName)}/machines/${encodeURIComponent(machineId)}/cordon`,
|
|
160
|
+
method: "POST",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async request<T>(input: {
|
|
165
|
+
path: string;
|
|
166
|
+
method: "GET" | "POST" | "DELETE";
|
|
167
|
+
body?: unknown;
|
|
168
|
+
}): Promise<T> {
|
|
169
|
+
const response = await fetch(`${this.baseUrl}${input.path}`, {
|
|
170
|
+
method: input.method,
|
|
171
|
+
headers: {
|
|
172
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
},
|
|
175
|
+
body: input.body ? JSON.stringify(input.body) : undefined,
|
|
176
|
+
});
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const text = await response.text();
|
|
179
|
+
throw new Error(`Fly API ${input.method} ${input.path} failed: ${text}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (input.method === "DELETE") {
|
|
183
|
+
return undefined as T;
|
|
184
|
+
}
|
|
185
|
+
return (await response.json()) as T;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mapFlyStateToProviderStatus(state: string | undefined): WorkerProviderStatus {
|
|
190
|
+
switch (state) {
|
|
191
|
+
case "created":
|
|
192
|
+
case "started":
|
|
193
|
+
return "active";
|
|
194
|
+
case "stopped":
|
|
195
|
+
case "destroyed":
|
|
196
|
+
case "suspended":
|
|
197
|
+
return "stopped";
|
|
198
|
+
default:
|
|
199
|
+
return "stopped";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildDedicatedVolumeName(prefix: string, workerId: string): string {
|
|
204
|
+
const sanitize = (value: string) =>
|
|
205
|
+
value
|
|
206
|
+
.toLowerCase()
|
|
207
|
+
.replace(/[^a-z0-9_]/g, "_")
|
|
208
|
+
.replace(/_+/g, "_")
|
|
209
|
+
.replace(/^_+|_+$/g, "");
|
|
210
|
+
const normalizedPrefix = sanitize(prefix) || "openclaw";
|
|
211
|
+
const normalizedWorker = sanitize(workerId) || "worker";
|
|
212
|
+
const workerHash = stableHashBase36(normalizedWorker).slice(0, 8);
|
|
213
|
+
const maxPrefixLen = 30 - 1 - workerHash.length;
|
|
214
|
+
const trimmedPrefix = normalizedPrefix.slice(0, Math.max(1, maxPrefixLen));
|
|
215
|
+
return `${trimmedPrefix}_${workerHash}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function stableHashBase36(input: string): string {
|
|
219
|
+
let hash = 2166136261;
|
|
220
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
221
|
+
hash ^= input.charCodeAt(i);
|
|
222
|
+
hash = Math.imul(hash, 16777619);
|
|
223
|
+
}
|
|
224
|
+
const unsigned = hash >>> 0;
|
|
225
|
+
return unsigned.toString(36);
|
|
226
|
+
}
|