@okrlinkhub/agent-factory 0.2.14 → 0.2.15
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/README.md +1 -4
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +0 -3
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +0 -34
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/lib.d.ts +1 -1
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +1 -1
- package/dist/component/lib.js.map +1 -1
- package/dist/component/providers/fly.d.ts +14 -0
- package/dist/component/providers/fly.d.ts.map +1 -1
- package/dist/component/providers/fly.js +35 -5
- package/dist/component/providers/fly.js.map +1 -1
- package/dist/component/queue.d.ts +5 -20
- package/dist/component/queue.d.ts.map +1 -1
- package/dist/component/queue.js +41 -107
- package/dist/component/queue.js.map +1 -1
- package/dist/component/scheduler.d.ts.map +1 -1
- package/dist/component/scheduler.js +127 -81
- package/dist/component/scheduler.js.map +1 -1
- package/dist/component/schema.d.ts +5 -13
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +0 -4
- package/dist/component/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +0 -3
- package/src/component/_generated/component.ts +0 -42
- package/src/component/lib.test.ts +348 -88
- package/src/component/lib.ts +0 -1
- package/src/component/providers/fly.ts +50 -5
- package/src/component/queue.ts +52 -135
- package/src/component/scheduler.ts +211 -96
- package/src/component/schema.ts +0 -4
|
@@ -15,21 +15,54 @@ const TEST_PROVIDER_CONFIG = {
|
|
|
15
15
|
volumeSizeGb: 10,
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
function stableHashBase36(input: string): string {
|
|
19
|
+
let hash = 2166136261;
|
|
20
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
21
|
+
hash ^= input.charCodeAt(index);
|
|
22
|
+
hash = Math.imul(hash, 16777619);
|
|
23
|
+
}
|
|
24
|
+
return (hash >>> 0).toString(36);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildDedicatedVolumeName(prefix: string, workerId: string) {
|
|
28
|
+
const sanitize = (value: string) =>
|
|
29
|
+
value
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9_]/g, "_")
|
|
32
|
+
.replace(/_+/g, "_")
|
|
33
|
+
.replace(/^_+|_+$/g, "");
|
|
34
|
+
const normalizedPrefix = sanitize(prefix) || "openclaw";
|
|
35
|
+
const normalizedWorker = sanitize(workerId) || "worker";
|
|
36
|
+
const workerHash = stableHashBase36(normalizedWorker).slice(0, 8);
|
|
37
|
+
const maxPrefixLen = 30 - 1 - workerHash.length;
|
|
38
|
+
return `${normalizedPrefix.slice(0, Math.max(1, maxPrefixLen))}_${workerHash}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
42
|
+
return new Response(JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emptyResponse(status = 204) {
|
|
49
|
+
return new Response(null, { status });
|
|
50
|
+
}
|
|
51
|
+
|
|
18
52
|
describe("component lib", () => {
|
|
19
53
|
beforeEach(async () => {
|
|
20
54
|
vi.useFakeTimers();
|
|
21
55
|
});
|
|
22
56
|
afterEach(() => {
|
|
23
57
|
vi.useRealTimers();
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
vi.unstubAllGlobals();
|
|
24
60
|
});
|
|
25
61
|
test("enqueue and claim should respect queue flow", async () => {
|
|
26
62
|
const t = initConvexTest();
|
|
27
63
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
28
64
|
agentKey: "support-agent",
|
|
29
65
|
version: "1.0.0",
|
|
30
|
-
soulMd: "# Soul",
|
|
31
|
-
clientMd: "# Client",
|
|
32
|
-
skills: ["agent-bridge"],
|
|
33
66
|
secretsRef: ["telegram.botToken"],
|
|
34
67
|
enabled: true,
|
|
35
68
|
});
|
|
@@ -158,12 +191,11 @@ describe("component lib", () => {
|
|
|
158
191
|
expect(claim?.payload.messageText).toBe("hello");
|
|
159
192
|
});
|
|
160
193
|
|
|
161
|
-
test("enqueue should fail when providerUserId is blank in
|
|
194
|
+
test("enqueue should fail when providerUserId is blank in payload", async () => {
|
|
162
195
|
const t = initConvexTest();
|
|
163
196
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
164
197
|
agentKey: "missing-provider-user-agent",
|
|
165
198
|
version: "1.0.0",
|
|
166
|
-
providerUserId: " ",
|
|
167
199
|
secretsRef: [],
|
|
168
200
|
enabled: true,
|
|
169
201
|
});
|
|
@@ -178,53 +210,7 @@ describe("component lib", () => {
|
|
|
178
210
|
messageText: "hello",
|
|
179
211
|
},
|
|
180
212
|
}),
|
|
181
|
-
).rejects.toThrow("providerUserId is required but missing");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("clearDeprecatedAgentProfileFields should remove deprecated profile fields", async () => {
|
|
185
|
-
const t = initConvexTest();
|
|
186
|
-
await t.mutation(api.queue.upsertAgentProfile, {
|
|
187
|
-
agentKey: "cleanup-agent",
|
|
188
|
-
version: "1.0.0",
|
|
189
|
-
providerUserId: "legacy-user-1",
|
|
190
|
-
soulMd: "# Legacy Soul",
|
|
191
|
-
clientMd: "# Legacy Client",
|
|
192
|
-
skills: ["agent-bridge"],
|
|
193
|
-
secretsRef: [],
|
|
194
|
-
enabled: true,
|
|
195
|
-
});
|
|
196
|
-
await t.mutation(api.queue.upsertAgentProfile, {
|
|
197
|
-
agentKey: "already-clean-agent",
|
|
198
|
-
version: "1.0.0",
|
|
199
|
-
secretsRef: [],
|
|
200
|
-
enabled: true,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
const dryRun = await t.mutation((api.lib as any).clearDeprecatedAgentProfileFields, {
|
|
204
|
-
dryRun: true,
|
|
205
|
-
});
|
|
206
|
-
expect(dryRun.dryRun).toBe(true);
|
|
207
|
-
expect(dryRun.scanned).toBe(2);
|
|
208
|
-
expect(dryRun.updated).toBe(1);
|
|
209
|
-
expect(dryRun.unchanged).toBe(1);
|
|
210
|
-
expect(dryRun.clearedProviderUserId).toBe(1);
|
|
211
|
-
expect(dryRun.clearedSoulMd).toBe(1);
|
|
212
|
-
expect(dryRun.clearedClientMd).toBe(1);
|
|
213
|
-
expect(dryRun.clearedSkills).toBe(1);
|
|
214
|
-
expect(dryRun.updatedAgentKeys).toEqual(["cleanup-agent"]);
|
|
215
|
-
|
|
216
|
-
const cleanup = await t.mutation((api.lib as any).clearDeprecatedAgentProfileFields, {});
|
|
217
|
-
expect(cleanup.dryRun).toBe(false);
|
|
218
|
-
expect(cleanup.updated).toBe(1);
|
|
219
|
-
expect(cleanup.updatedAgentKeys).toEqual(["cleanup-agent"]);
|
|
220
|
-
|
|
221
|
-
const secondPass = await t.mutation((api.lib as any).clearDeprecatedAgentProfileFields, {});
|
|
222
|
-
expect(secondPass.updated).toBe(0);
|
|
223
|
-
expect(secondPass.unchanged).toBe(2);
|
|
224
|
-
expect(secondPass.clearedProviderUserId).toBe(0);
|
|
225
|
-
expect(secondPass.clearedSoulMd).toBe(0);
|
|
226
|
-
expect(secondPass.clearedClientMd).toBe(0);
|
|
227
|
-
expect(secondPass.clearedSkills).toBe(0);
|
|
213
|
+
).rejects.toThrow("providerUserId is required but missing in payload");
|
|
228
214
|
});
|
|
229
215
|
|
|
230
216
|
test("identity binding should resolve, rebind and revoke", async () => {
|
|
@@ -232,18 +218,12 @@ describe("component lib", () => {
|
|
|
232
218
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
233
219
|
agentKey: "agent-a",
|
|
234
220
|
version: "1.0.0",
|
|
235
|
-
soulMd: "# Soul",
|
|
236
|
-
clientMd: "# Client",
|
|
237
|
-
skills: ["agent-bridge"],
|
|
238
221
|
secretsRef: [],
|
|
239
222
|
enabled: true,
|
|
240
223
|
});
|
|
241
224
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
242
225
|
agentKey: "agent-b",
|
|
243
226
|
version: "1.0.0",
|
|
244
|
-
soulMd: "# Soul",
|
|
245
|
-
clientMd: "# Client",
|
|
246
|
-
skills: ["agent-bridge"],
|
|
247
227
|
secretsRef: [],
|
|
248
228
|
enabled: true,
|
|
249
229
|
});
|
|
@@ -298,9 +278,6 @@ describe("component lib", () => {
|
|
|
298
278
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
299
279
|
agentKey: "support-agent",
|
|
300
280
|
version: "1.0.0",
|
|
301
|
-
soulMd: "# Soul",
|
|
302
|
-
clientMd: "# Client",
|
|
303
|
-
skills: ["agent-bridge"],
|
|
304
281
|
secretsRef: [],
|
|
305
282
|
enabled: true,
|
|
306
283
|
});
|
|
@@ -332,14 +309,93 @@ describe("component lib", () => {
|
|
|
332
309
|
expect(worker?.scheduledShutdownAt).toBe(now + 300_000);
|
|
333
310
|
});
|
|
334
311
|
|
|
312
|
+
test("idle shutdown should force worker to stopped and prevent reactivation", async () => {
|
|
313
|
+
const t = initConvexTest();
|
|
314
|
+
const claimTime = Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
315
|
+
vi.stubGlobal(
|
|
316
|
+
"fetch",
|
|
317
|
+
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
318
|
+
const url = String(input);
|
|
319
|
+
const method = init?.method ?? "GET";
|
|
320
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
|
|
321
|
+
return jsonResponse([]);
|
|
322
|
+
}
|
|
323
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
|
|
324
|
+
return jsonResponse([]);
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`Unexpected fetch ${method} ${url}`);
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
vi.setSystemTime(claimTime);
|
|
330
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
331
|
+
agentKey: "support-agent",
|
|
332
|
+
version: "1.0.0",
|
|
333
|
+
secretsRef: [],
|
|
334
|
+
enabled: true,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const messageId = await t.mutation(api.lib.enqueue, {
|
|
338
|
+
conversationId: "telegram:chat:forced-stop",
|
|
339
|
+
agentKey: "support-agent",
|
|
340
|
+
payload: {
|
|
341
|
+
provider: "telegram",
|
|
342
|
+
providerUserId: "u-stop",
|
|
343
|
+
messageText: "stop me",
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
const claim = await t.mutation(api.lib.claim, { workerId: "worker-stop-force-1" });
|
|
347
|
+
expect(claim?.messageId).toBe(messageId);
|
|
348
|
+
|
|
349
|
+
const completionTime = claimTime + 60_000;
|
|
350
|
+
vi.setSystemTime(completionTime);
|
|
351
|
+
await t.mutation(api.lib.complete, {
|
|
352
|
+
workerId: "worker-stop-force-1",
|
|
353
|
+
messageId,
|
|
354
|
+
leaseId: claim?.leaseId ?? "",
|
|
355
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
356
|
+
} as any);
|
|
357
|
+
|
|
358
|
+
const dueTime = claimTime + 300_001;
|
|
359
|
+
vi.setSystemTime(dueTime);
|
|
360
|
+
const shutdown = await t.action(api.scheduler.checkIdleShutdowns, {
|
|
361
|
+
nowMs: dueTime,
|
|
362
|
+
flyApiToken: "fly-token",
|
|
363
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
364
|
+
});
|
|
365
|
+
expect(shutdown.stopped).toBe(1);
|
|
366
|
+
|
|
367
|
+
const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
|
|
368
|
+
const worker = workers.find((row: { workerId: string }) => row.workerId === "worker-stop-force-1");
|
|
369
|
+
expect(worker?.status).toBe("stopped");
|
|
370
|
+
expect(worker?.stoppedAt).toBe(dueTime);
|
|
371
|
+
|
|
372
|
+
const control = await t.query(api.queue.getWorkerControlState as any, {
|
|
373
|
+
workerId: "worker-stop-force-1",
|
|
374
|
+
});
|
|
375
|
+
expect(control.shouldStop).toBe(true);
|
|
376
|
+
|
|
377
|
+
const newMessageId = await t.mutation(api.lib.enqueue, {
|
|
378
|
+
conversationId: "telegram:chat:forced-stop:2",
|
|
379
|
+
agentKey: "support-agent",
|
|
380
|
+
payload: {
|
|
381
|
+
provider: "telegram",
|
|
382
|
+
providerUserId: "u-stop",
|
|
383
|
+
messageText: "new message",
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
expect(newMessageId).toBeDefined();
|
|
387
|
+
|
|
388
|
+
const reactivatedClaim = await t.mutation(api.lib.claim, {
|
|
389
|
+
workerId: "worker-stop-force-1",
|
|
390
|
+
});
|
|
391
|
+
expect(reactivatedClaim).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
335
394
|
test("hydration bundle should include resolved agent-bridge runtime config", async () => {
|
|
336
395
|
const t = initConvexTest();
|
|
337
396
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
338
397
|
agentKey: "bridge-agent",
|
|
339
398
|
version: "1.0.0",
|
|
340
|
-
soulMd: "# Soul",
|
|
341
|
-
clientMd: "# Client",
|
|
342
|
-
skills: ["agent-bridge"],
|
|
343
399
|
secretsRef: [],
|
|
344
400
|
bridgeConfig: {
|
|
345
401
|
enabled: true,
|
|
@@ -406,14 +462,239 @@ describe("component lib", () => {
|
|
|
406
462
|
expect(controlUnknown.shouldStop).toBe(true);
|
|
407
463
|
});
|
|
408
464
|
|
|
465
|
+
test("worker control state should stop active workers past scheduled shutdown", async () => {
|
|
466
|
+
const t = initConvexTest();
|
|
467
|
+
const nowMs = Date.UTC(2026, 0, 1, 14, 0, 0);
|
|
468
|
+
vi.setSystemTime(nowMs);
|
|
469
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
470
|
+
workerId: "worker-overdue-1",
|
|
471
|
+
provider: "fly",
|
|
472
|
+
status: "active",
|
|
473
|
+
load: 0,
|
|
474
|
+
nowMs: nowMs - 60_000,
|
|
475
|
+
scheduledShutdownAt: nowMs - 1,
|
|
476
|
+
});
|
|
477
|
+
const control = await t.query(api.queue.getWorkerControlState as any, {
|
|
478
|
+
workerId: "worker-overdue-1",
|
|
479
|
+
});
|
|
480
|
+
expect(control.shouldStop).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("shutdown teardown should wait for final snapshot before deleting worker volume", async () => {
|
|
484
|
+
const t = initConvexTest();
|
|
485
|
+
const workerId = "worker-cleanup-1";
|
|
486
|
+
const machineId = "machine-cleanup-1";
|
|
487
|
+
const volumeId = "vol-cleanup-1";
|
|
488
|
+
const volumeName = buildDedicatedVolumeName(TEST_PROVIDER_CONFIG.volumeName, workerId);
|
|
489
|
+
const claimTime = Date.UTC(2026, 0, 1, 15, 0, 0);
|
|
490
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
491
|
+
const url = String(input);
|
|
492
|
+
const method = init?.method ?? "GET";
|
|
493
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
|
|
494
|
+
return jsonResponse([
|
|
495
|
+
{
|
|
496
|
+
id: machineId,
|
|
497
|
+
name: workerId,
|
|
498
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
499
|
+
state: "started",
|
|
500
|
+
config: { image: TEST_PROVIDER_CONFIG.image },
|
|
501
|
+
},
|
|
502
|
+
]);
|
|
503
|
+
}
|
|
504
|
+
if (url.endsWith(`/machines/${machineId}/cordon`) && method === "POST") {
|
|
505
|
+
return emptyResponse();
|
|
506
|
+
}
|
|
507
|
+
if (url.endsWith(`/machines/${machineId}/stop`) && method === "POST") {
|
|
508
|
+
return emptyResponse();
|
|
509
|
+
}
|
|
510
|
+
if (url.endsWith(`/machines/${machineId}`) && method === "DELETE") {
|
|
511
|
+
return emptyResponse();
|
|
512
|
+
}
|
|
513
|
+
if (url.endsWith(`/machines/${machineId}`) && method === "GET") {
|
|
514
|
+
return jsonResponse({
|
|
515
|
+
id: machineId,
|
|
516
|
+
config: {
|
|
517
|
+
mounts: [{ volume: volumeId, path: TEST_PROVIDER_CONFIG.volumePath }],
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
|
|
522
|
+
return jsonResponse([
|
|
523
|
+
{
|
|
524
|
+
id: volumeId,
|
|
525
|
+
name: volumeName,
|
|
526
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
527
|
+
},
|
|
528
|
+
]);
|
|
529
|
+
}
|
|
530
|
+
if (url.endsWith(`/volumes/${volumeId}`) && method === "DELETE") {
|
|
531
|
+
return emptyResponse();
|
|
532
|
+
}
|
|
533
|
+
throw new Error(`Unexpected fetch ${method} ${url}`);
|
|
534
|
+
});
|
|
535
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
536
|
+
vi.setSystemTime(claimTime);
|
|
537
|
+
|
|
538
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
539
|
+
agentKey: "support-agent",
|
|
540
|
+
version: "1.0.0",
|
|
541
|
+
secretsRef: [],
|
|
542
|
+
enabled: true,
|
|
543
|
+
});
|
|
544
|
+
const messageId = await t.mutation(api.lib.enqueue, {
|
|
545
|
+
conversationId: "telegram:chat:cleanup",
|
|
546
|
+
agentKey: "support-agent",
|
|
547
|
+
payload: {
|
|
548
|
+
provider: "telegram",
|
|
549
|
+
providerUserId: "u-clean",
|
|
550
|
+
messageText: "cleanup",
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
const claim = await t.mutation(api.lib.claim, { workerId });
|
|
554
|
+
expect(claim?.messageId).toBe(messageId);
|
|
555
|
+
|
|
556
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
557
|
+
workerId,
|
|
558
|
+
provider: "fly",
|
|
559
|
+
status: "active",
|
|
560
|
+
load: 1,
|
|
561
|
+
nowMs: claimTime,
|
|
562
|
+
machineId,
|
|
563
|
+
appName: TEST_PROVIDER_CONFIG.appName,
|
|
564
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const completionTime = claimTime + 60_000;
|
|
568
|
+
vi.setSystemTime(completionTime);
|
|
569
|
+
await t.mutation(api.queue.completeJob as any, {
|
|
570
|
+
workerId,
|
|
571
|
+
messageId,
|
|
572
|
+
leaseId: claim?.leaseId ?? "",
|
|
573
|
+
nowMs: completionTime,
|
|
574
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const dueTime = claimTime + 300_001;
|
|
578
|
+
vi.setSystemTime(dueTime);
|
|
579
|
+
const firstPass = await t.action(api.scheduler.checkIdleShutdowns, {
|
|
580
|
+
nowMs: dueTime,
|
|
581
|
+
flyApiToken: "fly-token",
|
|
582
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
583
|
+
});
|
|
584
|
+
expect(firstPass.stopped).toBe(1);
|
|
585
|
+
expect(firstPass.pending).toBe(1);
|
|
586
|
+
|
|
587
|
+
const prematureDeleteCalls = fetchMock.mock.calls.filter((call) =>
|
|
588
|
+
String(call[0]).includes(`/volumes/${volumeId}`),
|
|
589
|
+
);
|
|
590
|
+
expect(prematureDeleteCalls).toHaveLength(0);
|
|
591
|
+
|
|
592
|
+
const snapshot = await t.mutation(api.queue.prepareDataSnapshotUpload as any, {
|
|
593
|
+
workerId,
|
|
594
|
+
workspaceId: "default",
|
|
595
|
+
agentKey: "support-agent",
|
|
596
|
+
conversationId: "telegram:chat:cleanup",
|
|
597
|
+
reason: "drain",
|
|
598
|
+
nowMs: dueTime + 1,
|
|
599
|
+
});
|
|
600
|
+
const storageId = await t.run(async (ctx) => {
|
|
601
|
+
return await ctx.storage.store(new Blob(["snapshot-ready"]));
|
|
602
|
+
});
|
|
603
|
+
const finalized = await t.mutation(api.queue.finalizeDataSnapshotUpload as any, {
|
|
604
|
+
workerId,
|
|
605
|
+
snapshotId: snapshot.snapshotId,
|
|
606
|
+
storageId,
|
|
607
|
+
sha256: "deadbeef",
|
|
608
|
+
sizeBytes: 14,
|
|
609
|
+
nowMs: dueTime + 2,
|
|
610
|
+
});
|
|
611
|
+
expect(finalized).toBe(true);
|
|
612
|
+
|
|
613
|
+
const secondPass = await t.action(api.scheduler.checkIdleShutdowns, {
|
|
614
|
+
nowMs: dueTime + 10_000,
|
|
615
|
+
flyApiToken: "fly-token",
|
|
616
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
617
|
+
});
|
|
618
|
+
expect(secondPass.pending).toBe(0);
|
|
619
|
+
|
|
620
|
+
const deleteMachineCalls = fetchMock.mock.calls.filter(
|
|
621
|
+
(call) =>
|
|
622
|
+
String(call[0]).endsWith(`/machines/${machineId}`) &&
|
|
623
|
+
((call[1] as RequestInit | undefined)?.method ?? "GET") === "DELETE",
|
|
624
|
+
);
|
|
625
|
+
const deleteVolumeCalls = fetchMock.mock.calls.filter(
|
|
626
|
+
(call) =>
|
|
627
|
+
String(call[0]).endsWith(`/volumes/${volumeId}`) &&
|
|
628
|
+
((call[1] as RequestInit | undefined)?.method ?? "GET") === "DELETE",
|
|
629
|
+
);
|
|
630
|
+
expect(deleteMachineCalls).toHaveLength(1);
|
|
631
|
+
expect(deleteVolumeCalls).toHaveLength(1);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("cleanup should remove orphan worker volumes when the machine is already gone", async () => {
|
|
635
|
+
const t = initConvexTest();
|
|
636
|
+
const workerId = "worker-orphan-1";
|
|
637
|
+
const volumeId = "vol-orphan-1";
|
|
638
|
+
const volumeName = buildDedicatedVolumeName(TEST_PROVIDER_CONFIG.volumeName, workerId);
|
|
639
|
+
const nowMs = Date.UTC(2026, 0, 1, 16, 0, 0);
|
|
640
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
641
|
+
const url = String(input);
|
|
642
|
+
const method = init?.method ?? "GET";
|
|
643
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
|
|
644
|
+
return jsonResponse([]);
|
|
645
|
+
}
|
|
646
|
+
if (url.endsWith(`/machines/machine-orphan-1`) && method === "GET") {
|
|
647
|
+
return new Response("not found", { status: 404 });
|
|
648
|
+
}
|
|
649
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
|
|
650
|
+
return jsonResponse([
|
|
651
|
+
{
|
|
652
|
+
id: volumeId,
|
|
653
|
+
name: volumeName,
|
|
654
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
655
|
+
},
|
|
656
|
+
]);
|
|
657
|
+
}
|
|
658
|
+
if (url.endsWith(`/volumes/${volumeId}`) && method === "DELETE") {
|
|
659
|
+
return emptyResponse();
|
|
660
|
+
}
|
|
661
|
+
throw new Error(`Unexpected fetch ${method} ${url}`);
|
|
662
|
+
});
|
|
663
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
664
|
+
|
|
665
|
+
await t.mutation(internal.queue.upsertWorkerState, {
|
|
666
|
+
workerId,
|
|
667
|
+
provider: "fly",
|
|
668
|
+
status: "active",
|
|
669
|
+
load: 0,
|
|
670
|
+
nowMs,
|
|
671
|
+
scheduledShutdownAt: nowMs - 1,
|
|
672
|
+
machineId: "machine-orphan-1",
|
|
673
|
+
appName: TEST_PROVIDER_CONFIG.appName,
|
|
674
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const result = await t.action(api.scheduler.checkIdleShutdowns, {
|
|
678
|
+
nowMs,
|
|
679
|
+
flyApiToken: "fly-token",
|
|
680
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
681
|
+
});
|
|
682
|
+
expect(result.stopped).toBe(1);
|
|
683
|
+
expect(result.pending).toBe(0);
|
|
684
|
+
|
|
685
|
+
const deleteVolumeCalls = fetchMock.mock.calls.filter(
|
|
686
|
+
(call) =>
|
|
687
|
+
String(call[0]).endsWith(`/volumes/${volumeId}`) &&
|
|
688
|
+
((call[1] as RequestInit | undefined)?.method ?? "GET") === "DELETE",
|
|
689
|
+
);
|
|
690
|
+
expect(deleteVolumeCalls).toHaveLength(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
409
693
|
test("scheduler count includes queued and in-progress conversations", async () => {
|
|
410
694
|
const t = initConvexTest();
|
|
411
695
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
412
696
|
agentKey: "support-agent",
|
|
413
697
|
version: "1.0.0",
|
|
414
|
-
soulMd: "# Soul",
|
|
415
|
-
clientMd: "# Client",
|
|
416
|
-
skills: ["agent-bridge"],
|
|
417
698
|
secretsRef: [],
|
|
418
699
|
enabled: true,
|
|
419
700
|
});
|
|
@@ -491,9 +772,6 @@ describe("component lib", () => {
|
|
|
491
772
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
492
773
|
agentKey: "support-agent",
|
|
493
774
|
version: "1.0.0",
|
|
494
|
-
soulMd: "# Soul",
|
|
495
|
-
clientMd: "# Client",
|
|
496
|
-
skills: ["agent-bridge"],
|
|
497
775
|
secretsRef: [],
|
|
498
776
|
enabled: true,
|
|
499
777
|
});
|
|
@@ -564,9 +842,6 @@ describe("component lib", () => {
|
|
|
564
842
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
565
843
|
agentKey: "support-agent",
|
|
566
844
|
version: "1.0.0",
|
|
567
|
-
soulMd: "# Soul",
|
|
568
|
-
clientMd: "# Client",
|
|
569
|
-
skills: ["agent-bridge"],
|
|
570
845
|
secretsRef: [],
|
|
571
846
|
enabled: true,
|
|
572
847
|
});
|
|
@@ -637,9 +912,6 @@ describe("component lib", () => {
|
|
|
637
912
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
638
913
|
agentKey: "push-agent",
|
|
639
914
|
version: "1.0.0",
|
|
640
|
-
soulMd: "# Soul",
|
|
641
|
-
clientMd: "# Client",
|
|
642
|
-
skills: [],
|
|
643
915
|
secretsRef: [],
|
|
644
916
|
enabled: true,
|
|
645
917
|
});
|
|
@@ -692,9 +964,6 @@ describe("component lib", () => {
|
|
|
692
964
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
693
965
|
agentKey: "push-telegram-manual-agent",
|
|
694
966
|
version: "1.0.0",
|
|
695
|
-
soulMd: "# Soul",
|
|
696
|
-
clientMd: "# Client",
|
|
697
|
-
skills: [],
|
|
698
967
|
secretsRef: [],
|
|
699
968
|
enabled: true,
|
|
700
969
|
});
|
|
@@ -738,9 +1007,6 @@ describe("component lib", () => {
|
|
|
738
1007
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
739
1008
|
agentKey: "push-telegram-scheduled-agent",
|
|
740
1009
|
version: "1.0.0",
|
|
741
|
-
soulMd: "# Soul",
|
|
742
|
-
clientMd: "# Client",
|
|
743
|
-
skills: [],
|
|
744
1010
|
secretsRef: [],
|
|
745
1011
|
enabled: true,
|
|
746
1012
|
});
|
|
@@ -787,18 +1053,12 @@ describe("component lib", () => {
|
|
|
787
1053
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
788
1054
|
agentKey: "broadcast-agent-a",
|
|
789
1055
|
version: "1.0.0",
|
|
790
|
-
soulMd: "# Soul",
|
|
791
|
-
clientMd: "# Client",
|
|
792
|
-
skills: [],
|
|
793
1056
|
secretsRef: [],
|
|
794
1057
|
enabled: true,
|
|
795
1058
|
});
|
|
796
1059
|
await t.mutation(api.queue.upsertAgentProfile, {
|
|
797
1060
|
agentKey: "broadcast-agent-b",
|
|
798
1061
|
version: "1.0.0",
|
|
799
|
-
soulMd: "# Soul",
|
|
800
|
-
clientMd: "# Client",
|
|
801
|
-
skills: [],
|
|
802
1062
|
secretsRef: [],
|
|
803
1063
|
enabled: true,
|
|
804
1064
|
});
|
package/src/component/lib.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface WorkerProvider {
|
|
|
33
33
|
terminateWorker(appName: string, machineId: string): Promise<void>;
|
|
34
34
|
cordonWorker(appName: string, machineId: string): Promise<void>;
|
|
35
35
|
stopWorker(appName: string, machineId: string): Promise<void>;
|
|
36
|
+
cleanupWorkerStorage(input: {
|
|
37
|
+
appName: string;
|
|
38
|
+
workerId: string;
|
|
39
|
+
machineId?: string | null;
|
|
40
|
+
region?: string;
|
|
41
|
+
volumeName: string;
|
|
42
|
+
}): Promise<void>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
type FlyMachine = {
|
|
@@ -153,15 +160,53 @@ export class FlyMachinesProvider implements WorkerProvider {
|
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
async terminateWorker(appName: string, machineId: string): Promise<void> {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
try {
|
|
164
|
+
await this.request<void>({
|
|
165
|
+
path: `/apps/${encodeURIComponent(appName)}/machines/${encodeURIComponent(machineId)}`,
|
|
166
|
+
method: "DELETE",
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (isFlyNotFoundError(error)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async cleanupWorkerStorage(input: {
|
|
177
|
+
appName: string;
|
|
178
|
+
workerId: string;
|
|
179
|
+
machineId?: string | null;
|
|
180
|
+
region?: string;
|
|
181
|
+
volumeName: string;
|
|
182
|
+
}): Promise<void> {
|
|
183
|
+
const volumeIds = new Set<string>();
|
|
184
|
+
if (input.machineId) {
|
|
185
|
+
const machineVolumeIds = await this.getMachineVolumeIds(input.appName, input.machineId);
|
|
186
|
+
for (const volumeId of machineVolumeIds) {
|
|
187
|
+
volumeIds.add(volumeId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const expectedVolumeName = buildDedicatedVolumeName(input.volumeName, input.workerId);
|
|
192
|
+
const volumes = await this.request<Array<FlyVolume>>({
|
|
193
|
+
path: `/apps/${encodeURIComponent(input.appName)}/volumes`,
|
|
194
|
+
method: "GET",
|
|
160
195
|
});
|
|
196
|
+
for (const volume of volumes) {
|
|
197
|
+
if (volume.name !== expectedVolumeName) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (input.region && volume.region && volume.region !== input.region) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
volumeIds.add(volume.id);
|
|
204
|
+
}
|
|
205
|
+
|
|
161
206
|
for (const volumeId of volumeIds) {
|
|
162
207
|
try {
|
|
163
208
|
await this.request<void>({
|
|
164
|
-
path: `/apps/${encodeURIComponent(appName)}/volumes/${encodeURIComponent(volumeId)}`,
|
|
209
|
+
path: `/apps/${encodeURIComponent(input.appName)}/volumes/${encodeURIComponent(volumeId)}`,
|
|
165
210
|
method: "DELETE",
|
|
166
211
|
});
|
|
167
212
|
} catch (error) {
|